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)