Rudy’s OBTF Rudolf Adamkovič

Home / Emacs / Org Invoice


Lisp code

Note.  All global symbols are suffixed with + to avoid name clashes.

All code resides in the org-invoice.el file:

;;; -*- lexical-binding: t -*-

;; Author: Rudolf Adamkovic <rudolf@adamkovic.org>

;; This program is free software: you can redistribute it and/or modify it
;; under the terms of the GNU General Public License as published by the Free
;; Software Foundation, either version 3 of the License, or (at your option)
;; any later version.

;; This program is distributed in the hope that it will be useful, but WITHOUT
;; ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or
;; FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for
;; more details.

;; You should have received a copy of the GNU General Public License along with
;; this program. If not, see <https://www.gnu.org/licenses/>.

;;; Customization

;; TODO Customize elsewhere.
(defcustom my-org-invoice-work-log-id nil
  "Org ID of the section with invoice details and clocked hours"
  :type '(string)
  :group 'my-org-invoice)

;; TODO Customize elsewhere.
(defcustom my-org-invoice-list-id nil
  "Org ID of the section to which invoices are generated"
  :type '(string)
  :group 'my-org-invoice)

(defcustom my-org-invoice-title "Invoice"
  "The title used for invoices, suffixed with the invoice number."
  :type '(string)
  :group 'my-org-invoice)

(setopt my-org-invoice-work-log-id "0914DEB9-E2AD-46BF-8DAC-4B48E3F81359"
        my-org-invoice-list-id "D4B2836B-9856-425B-9527-4372A0C5AB5C")

;;; Templating functions

(defun my-org-invoice-number (&optional lastp)
  "Return the number of the next, or the last, invoice."
  (let ((last-invoice-number
         (save-window-excursion
           (org-id-goto my-org-invoice-list-id)
           (and (org-goto-first-child)
                (named-let recur ((greatest-number 0))
                  (let* ((current-number
                          (let ((heading (org-get-heading t t t t)))
                            (and (string-match
                                  (format "%s %s"
                                          (regexp-quote my-org-invoice-title)
                                          (rx (group (one-or-more digit))))
                                  heading)
                                 (string-to-number (match-string 1 heading)))))
                         (greatest-number
                          (if current-number
                              (max current-number greatest-number)
                            greatest-number)))
                    (if (outline-get-next-sibling)
                        (recur greatest-number)
                      greatest-number)))))))
    (if lastp
        last-invoice-number
      (if last-invoice-number
          (1+ last-invoice-number)
        1))))

(defun my-org-invoice-title ()
  "Return the title for the next invoice."
  (format "%s %d" my-org-invoice-title (my-org-invoice-number)))

(defun my-org-invoice-file-name ()
  "Return the file name for the next invoice."
  (format "invoice-%d" (my-org-invoice-number)))

(defun my-org-invoice-property (name &optional index)
  "Return the INDEX-th PROPERTY value, padded for templating."
  (save-window-excursion
    (org-id-goto my-org-invoice-work-log-id)
    (string-pad
     (or (nth (or index 0)
              (let ((separator "\n"))
                (when-let* ((all (let ((org-property-separators
                                        (list (cons (regexp-quote name) separator))))
                                   (org-entry-get (point) (concat "INVOICE_" name)))))
                  (string-split all separator))))
         "n/a")
     (length
      (format "%%(%s)"
              (string-join
               (append (list (symbol-name #'my-org-invoice-property))
                       (list (format "\"%s\"" name))
                       (list (if index (number-to-string index) nil)))
               " "))))))

(defun my-org-invoice-details (prefix)
  "Return the \"Details\" section as a list of strings."
  (save-window-excursion
    (org-id-goto my-org-invoice-work-log-id)
    (goto-char (org-log-beginning))
    (named-let recur ((lines '()))
      (if (eq (org-element-type (org-element-at-point-no-context)) 'clock)
          (let ((line (buffer-substring-no-properties (point) (pos-eol))))
            (forward-line)
            (recur (cons (string-replace org-clock-string prefix line) lines)))
        (string-join (reverse lines) "\n")))))

(defun my-org-invoice-total (index)
  "Return the INDEX-th value of the TOTAL section, padded for templating."
  (save-window-excursion
    (org-id-goto my-org-invoice-work-log-id)
    (when-let* ((rate-string (org-entry-get (point) "INVOICE_TOTAL_HOURLY"))
                (rate (string-to-number rate-string))
                (currency (org-entry-get (point) "INVOICE_TOTAL_CURRENCY")))
      (string-pad
       (let ((hours (/ (float (org-clock-sum-current-item)) 60)))
         (pcase index
           (0 (format "hours * (%s / hour)" currency))
           (1 (format "= %.2f * %.2f" hours rate))
           (2 (format "= %.2f %s" (* hours rate) currency))
           (_ "n/a")))
       (length
        (format "%%(%s)"
                (string-join
                 (append (list (symbol-name #'my-org-invoice-total))
                         (list (if index (number-to-string index) nil)))
                 " ")))))))

;;; Installation

(with-eval-after-load 'org-capture
  (add-to-list 'org-capture-templates
               `("i" "Invoice" entry (id ,my-org-invoice-list-id)
                 (file "org-invoice+template.org")
                 :prepend t
                 :jump-to-captured t
                 :empty-lines-after 1)))

© 2025 Rudolf Adamkovič under GNU General Public License version 3.
Made with Emacs and secret alien technologies of yesteryear.