dotemacs

My Emacs configuration
git clone git://git.entf.net/dotemacs
Log | Files | Refs | LICENSE

org-invoice.el (15549B)


      1 ;;; org-invoice.el --- Help manage client invoices in OrgMode
      2 ;;
      3 ;; Copyright (C) 2008-2014, 2021 pmade inc. (Peter Jones pjones@pmade.com)
      4 ;;
      5 ;; This file is not part of GNU Emacs.
      6 
      7 ;; This program is free software: you can redistribute it and/or modify
      8 ;; it under the terms of the GNU General Public License as published by
      9 ;; the Free Software Foundation, either version 3 of the License, or
     10 ;; (at your option) any later version.
     11 
     12 ;; This program is distributed in the hope that it will be useful,
     13 ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
     14 ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
     15 ;; GNU General Public License for more details.
     16 
     17 ;; You should have received a copy of the GNU General Public License
     18 ;; along with GNU Emacs.  If not, see <https://www.gnu.org/licenses/>.
     19 
     20 ;;; Commentary:
     21 ;;
     22 ;; Building on top of the terrific OrgMode, org-invoice tries to
     23 ;; provide functionality for managing invoices.  Currently, it does
     24 ;; this by implementing an OrgMode dynamic block where invoice
     25 ;; information is aggregated so that it can be exported.
     26 ;;
     27 ;; It also provides a library of functions that can be used to collect
     28 ;; this invoice information and use it in other ways, such as
     29 ;; submitting it to on-line invoicing tools.
     30 ;;
     31 ;; I'm already working on an elisp package to submit this invoice data
     32 ;; to the FreshBooks on-line accounting tool.
     33 ;;
     34 ;; Usage:
     35 ;;
     36 ;; In your ~/.emacs:
     37 ;; (autoload 'org-invoice-report "org-invoice")
     38 ;; (autoload 'org-dblock-write:invoice "org-invoice")
     39 ;;
     40 ;; See the documentation in the following functions:
     41 ;;
     42 ;; `org-invoice-report'
     43 ;; `org-dblock-write:invoice'
     44 ;;
     45 ;; Latest version:
     46 ;;
     47 ;; git clone git://pmade.com/elisp
     48 (eval-when-compile
     49   (require 'cl)
     50   (require 'org))
     51 
     52 (declare-function org-duration-from-minutes "org-duration" (minutes &optional fmt fractional))
     53 
     54 (defgroup org-invoice nil
     55   "OrgMode Invoice Helper"
     56   :tag "Org-Invoice" :group 'org)
     57 
     58 (defcustom org-invoice-long-date-format "%A, %B %d, %Y"
     59   "The format string for long dates."
     60   :type 'string :group 'org-invoice)
     61 
     62 (defcustom org-invoice-strip-ts t
     63   "Remove org timestamps that appear in headings."
     64   :type 'boolean :group 'org-invoice)
     65 
     66 (defcustom org-invoice-default-level 2
     67   "The heading level at which a new invoice starts.  This value
     68 is used if you don't specify a scope option to the invoice block,
     69 and when other invoice helpers are trying to find the heading
     70 that starts an invoice.
     71 
     72 The default is 2, assuming that you structure your invoices so
     73 that they fall under a single heading like below:
     74 
     75 * Invoices
     76 ** This is invoice number 1...
     77 ** This is invoice number 2...
     78 
     79 If you don't structure your invoices using those conventions,
     80 change this setting to the number that corresponds to the heading
     81 at which an invoice begins."
     82   :type 'integer :group 'org-invoice)
     83 
     84 (defcustom org-invoice-start-hook nil
     85   "Hook called when org-invoice is about to collect data from an
     86 invoice heading.  When this hook is called, point will be on the
     87 heading where the invoice begins.
     88 
     89 When called, `org-invoice-current-invoice' will be set to the
     90 alist that represents the info for this invoice."
     91   :type 'hook :group 'org-invoice)
     92 
     93   (defcustom org-invoice-heading-hook nil
     94   "Hook called when org-invoice is collecting data from a
     95 heading. You can use this hook to add additional information to
     96 the alist that represents the heading.
     97 
     98 When this hook is called, point will be on the current heading
     99 being processed, and `org-invoice-current-item' will contain the
    100 alist for the current heading.
    101 
    102 This hook is called repeatedly for each invoice item processed."
    103   :type 'hook :group 'org-invoice)
    104 
    105 (defvar org-invoice-current-invoice nil
    106   "Information about the current invoice.")
    107 
    108 (defvar org-invoice-current-item nil
    109   "Information about the current invoice item.")
    110 
    111 (defvar org-invoice-table-params nil
    112   "The table parameters currently being used.")
    113 
    114 (defvar org-invoice-total-time nil
    115   "The total invoice time for the summary line.")
    116 
    117 (defvar org-invoice-total-price nil
    118   "The total invoice price for the summary line.")
    119 
    120 (defconst org-invoice-version "1.0.0"
    121   "The org-invoice version number.")
    122 
    123 (defun org-invoice-goto-tree (&optional tree)
    124   "Move point to the heading that represents the head of the
    125 current invoice.  The heading level will be taken from
    126 `org-invoice-default-level' unless tree is set to a string that
    127 looks like tree2, where the level is 2."
    128   (let ((level org-invoice-default-level))
    129     (save-match-data
    130       (when (and tree (string-match "^tree\\([0-9]+\\)$" tree))
    131         (setq level (string-to-number (match-string 1 tree)))))
    132     (org-back-to-heading)
    133     (while (and (> (org-reduced-level (org-outline-level)) level)
    134                 (org-up-heading-safe)))))
    135 
    136 (defun org-invoice-heading-info ()
    137   "Return invoice information from the current heading."
    138   (let ((title   (org-no-properties (org-get-heading t)))
    139         (date    (org-entry-get nil "TIMESTAMP" 'selective))
    140         (work    (org-entry-get nil "WORK" nil))
    141         (rate    (or (org-entry-get nil "RATE" t) "0"))
    142         (level   (org-outline-level))
    143         raw-date long-date)
    144     (unless date (setq date (org-entry-get nil "TIMESTAMP_IA" 'selective)))
    145     (unless date (setq date (org-entry-get nil "TIMESTAMP" t)))
    146     (unless date (setq date (org-entry-get nil "TIMESTAMP_IA" t)))
    147     (unless work (setq work (org-entry-get nil "CLOCKSUM" nil)))
    148     (unless work (setq work "00:00"))
    149     (when date
    150       (setq raw-date (apply 'encode-time (org-parse-time-string date)))
    151       (setq long-date (format-time-string org-invoice-long-date-format raw-date)))
    152     (when (and org-invoice-strip-ts (string-match org-ts-regexp-both title))
    153       (setq title (replace-match "" nil nil title)))
    154     (when (string-match "^[ \t]+" title)
    155       (setq title (replace-match "" nil nil title)))
    156     (when (string-match "[ \t]+$" title)
    157       (setq title (replace-match "" nil nil title)))
    158     (setq work (org-duration-to-minutes work))
    159     (setq rate (string-to-number rate))
    160     (setq org-invoice-current-item (list (cons 'title title)
    161           (cons 'date date)
    162           (cons 'raw-date raw-date)
    163           (cons 'long-date long-date)
    164           (cons 'work work)
    165           (cons 'rate rate)
    166           (cons 'level level)
    167           (cons 'price (* rate (/ work 60.0)))))
    168     (run-hook-with-args 'org-invoice-heading-hook)
    169     org-invoice-current-item))
    170 
    171 (defun org-invoice-level-min-max (ls)
    172   "Return a list where the car is the min level, and the cdr the max."
    173   (let ((max 0) min level)
    174     (dolist (info ls)
    175       (when (cdr (assq 'date info))
    176         (setq level (cdr (assq 'level info)))
    177         (when (or (not min) (< level min)) (setq min level))
    178         (when (> level max) (setq max level))))
    179     (cons (or min 0) max)))
    180 
    181 (defun org-invoice-collapse-list (ls)
    182   "Reorganize the given list by dates."
    183   (let ((min-max (org-invoice-level-min-max ls)) new)
    184     (dolist (info ls)
    185       (let* ((date (cdr (assq 'date info)))
    186              (work (cdr (assq 'work info)))
    187              (price (cdr (assq 'price info)))
    188              (long-date (cdr (assq 'long-date info)))
    189              (level (cdr (assq 'level info)))
    190              (bucket (cdr (assoc date new))))
    191         (if (and (/= (car min-max) (cdr min-max))
    192                    (=  (car min-max) level)
    193                    (=  work 0) (not bucket) date)
    194             (progn
    195               (setq info (assq-delete-all 'work info))
    196               (push (cons 'total-work 0) info)
    197               (push (cons date (list info)) new)
    198               (setq bucket (cdr (assoc date new))))
    199           (when (and date (not bucket))
    200             (setq bucket (list (list (cons 'date date)
    201                                      (cons 'title long-date)
    202                                      (cons 'total-work 0)
    203                                      (cons 'price 0))))
    204             (push (cons date bucket) new)
    205             (setq bucket (cdr (assoc date new))))
    206           (when (and date bucket)
    207             (setcdr (assq 'total-work (car bucket))
    208                     (+ work (cdr (assq 'total-work (car bucket)))))
    209             (setcdr (assq 'price (car bucket))
    210                     (+ price (cdr (assq 'price (car bucket)))))
    211             (nconc bucket (list info))))))
    212     (nreverse new)))
    213 
    214 (defun org-invoice-info-to-table (info)
    215   "Create a single org table row from the given info alist."
    216   (let ((title (cdr (assq 'title info)))
    217         (total (cdr (assq 'total-work info)))
    218         (work  (cdr (assq 'work info)))
    219         (price (cdr (assq 'price info)))
    220         (with-price (plist-get org-invoice-table-params :price)))
    221     (unless total
    222       (setq
    223        org-invoice-total-time (+ org-invoice-total-time work)
    224        org-invoice-total-price (+ org-invoice-total-price price)))
    225     (setq total (and total (org-duration-from-minutes total)))
    226     (setq work  (and work  (org-duration-from-minutes work)))
    227     (insert-before-markers
    228      (concat "|" title
    229              (cond
    230               (total (concat "|" total))
    231               (work  (concat "|" work)))
    232              (and with-price price (concat "|" (format "%.2f" price)))
    233              "|" "\n"))))
    234 
    235 (defun org-invoice-list-to-table (ls)
    236   "Convert a list of heading info to an org table"
    237   (let ((with-price (plist-get org-invoice-table-params :price))
    238         (with-summary (plist-get org-invoice-table-params :summary))
    239         (with-header (plist-get org-invoice-table-params :headers))
    240         (org-invoice-total-time 0)
    241         (org-invoice-total-price 0))
    242     (insert-before-markers
    243      (concat "| Task / Date | Time" (and with-price "| Price") "|\n"))
    244     (dolist (info ls)
    245       (insert-before-markers "|-\n")
    246       (mapc 'org-invoice-info-to-table (if with-header (cdr info) (cdr (cdr info)))))
    247     (when with-summary
    248       (insert-before-markers
    249        (concat "|-\n|Total:|"
    250                (org-duration-from-minutes org-invoice-total-time)
    251                (and with-price (concat "|" (format "%.2f" org-invoice-total-price)))
    252                "|\n")))))
    253 
    254 (defun org-invoice-collect-invoice-data ()
    255   "Collect all the invoice data from the current OrgMode tree and
    256 return it.  Before you call this function, move point to the
    257 heading that begins the invoice data, usually using the
    258 `org-invoice-goto-tree' function."
    259   (let ((org-invoice-current-invoice
    260          (list (cons 'point (point)) (cons 'buffer (current-buffer))))
    261         (org-invoice-current-item nil))
    262     (save-restriction
    263       (org-narrow-to-subtree)
    264       (org-clock-sum)
    265       (run-hook-with-args 'org-invoice-start-hook)
    266       (cons org-invoice-current-invoice
    267             (org-invoice-collapse-list
    268              (org-map-entries 'org-invoice-heading-info t 'tree 'archive))))))
    269 
    270 (defun org-dblock-write:invoice (params)
    271   "Function called by OrgMode to write the invoice dblock.  To
    272 create an invoice dblock you can use the `org-invoice-report'
    273 function.
    274 
    275 The following parameters can be given to the invoice block (for
    276 information about dblock parameters, please see the Org manual):
    277 
    278 :scope Allows you to override the `org-invoice-default-level'
    279        variable.  The only supported values right now are ones
    280        that look like :tree1, :tree2, etc.
    281 
    282 :prices Set to nil to turn off the price column.
    283 
    284 :headers Set to nil to turn off the group headers.
    285 
    286 :summary Set to nil to turn off the final summary line."
    287   (let ((scope (plist-get params :scope))
    288         (org-invoice-table-params params)
    289         (zone (point-marker))
    290         table)
    291     (unless scope (setq scope 'default))
    292     (unless (plist-member params :price) (plist-put params :price t))
    293     (unless (plist-member params :summary) (plist-put params :summary t))
    294     (unless (plist-member params :headers) (plist-put params :headers t))
    295     (save-excursion
    296       (cond
    297        ((eq scope 'tree) (org-invoice-goto-tree "tree1"))
    298        ((eq scope 'default) (org-invoice-goto-tree))
    299        ((symbolp scope) (org-invoice-goto-tree (symbol-name scope))))
    300       (setq table (org-invoice-collect-invoice-data))
    301       (goto-char zone)
    302       (org-invoice-list-to-table (cdr table))
    303       (goto-char zone)
    304       (org-table-align)
    305       (move-marker zone nil))))
    306 
    307 (defun org-invoice-in-report-p ()
    308   "Check to see if point is inside an invoice report."
    309   (let ((pos (point)) start)
    310     (save-excursion
    311       (end-of-line 1)
    312       (and (re-search-backward "^#\\+BEGIN:[ \t]+invoice" nil t)
    313 	   (setq start (match-beginning 0))
    314 	   (re-search-forward "^#\\+END:.*" nil t)
    315 	   (>= (match-end 0) pos)
    316 	   start))))
    317 
    318 (defun org-invoice-report (&optional jump)
    319   "Create or update an invoice dblock report.  If point is inside
    320 an existing invoice report, the report is updated.  If point
    321 isn't inside an invoice report, a new report is created.
    322 
    323 When called with a prefix argument, move to the first invoice
    324 report after point and update it.
    325 
    326 For information about various settings for the invoice report,
    327 see the `org-dblock-write:invoice' function documentation.
    328 
    329 An invoice report is created by reading a heading tree and
    330 collecting information from various properties.  It is assumed
    331 that all invoices start at a second level heading, but this can
    332 be configured using the `org-invoice-default-level' variable.
    333 
    334 Here is an example, where all invoices fall under the first-level
    335 heading Invoices:
    336 
    337 * Invoices
    338 ** Client Foo (Jan 01 - Jan 15)
    339 *** [2008-01-01 Tue] Built New Server for Production
    340 *** [2008-01-02 Wed] Meeting with Team to Design New System
    341 ** Client Bar (Jan 01 - Jan 15)
    342 *** [2008-01-01 Tue] Searched for Widgets on Google
    343 *** [2008-01-02 Wed] Billed You for Taking a Nap
    344 
    345 In this layout, invoices begin at level two, and invoice
    346 items (tasks) are at level three.  You'll notice that each level
    347 three heading starts with an inactive timestamp.  The timestamp
    348 can actually go anywhere you want, either in the heading, or in
    349 the text under the heading.  But you must have a timestamp
    350 somewhere so that the invoice report can group your items by
    351 date.
    352 
    353 Properties are used to collect various bits of information for
    354 the invoice.  All properties can be set on the invoice item
    355 headings, or anywhere in the tree.  The invoice report will scan
    356 up the tree looking for each of the properties.
    357 
    358 Properties used:
    359 
    360 CLOCKSUM: You can use the Org clock-in and clock-out commands to
    361           create a CLOCKSUM property.  Also see WORK.
    362 
    363 WORK: An alternative to the CLOCKSUM property.  This property
    364       should contain the amount of work that went into this
    365       invoice item formatted as HH:MM (e.g. 01:30).
    366 
    367 RATE: Used to calculate the total price for an invoice item.
    368       Should be the price per hour that you charge (e.g. 45.00).
    369       It might make more sense to place this property higher in
    370       the hierarchy than on the invoice item headings.
    371 
    372 Using this information, a report is generated that details the
    373 items grouped by days.  For each day you will be able to see the
    374 total number of hours worked, the total price, and the items
    375 worked on.
    376 
    377 You can place the invoice report anywhere in the tree you want.
    378 I place mine under a third-level heading like so:
    379 
    380 * Invoices
    381 ** An Invoice Header
    382 *** [2008-11-25 Tue] An Invoice Item
    383 *** Invoice Report
    384 #+BEGIN: invoice
    385 #+END:"
    386   (interactive "P")
    387   (let ((report (org-invoice-in-report-p)))
    388     (when (and (not report) jump)
    389       (when (re-search-forward "^#\\+BEGIN:[ \t]+invoice" nil t)
    390         (org-show-entry)
    391         (beginning-of-line)
    392         (setq report (point))))
    393     (if report (goto-char report)
    394       (org-create-dblock (list :name "invoice")))
    395     (org-update-dblock)))
    396 
    397 (provide 'org-invoice)