pta-ledger.lisp 7.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165
  1. (in-package #:pta-ledger)
  2. (defstruct amount quantity commodity)
  3. (defstruct posting
  4. status virtual account amount unit-price total-price comment)
  5. (defstruct entry
  6. date secondary-date status code description comment postings)
  7. (define-condition journal-failed (error)
  8. ((position :initarg :position :reader parse-error-pos)
  9. (left-data :initarg :left-data :reader parse-error-left))
  10. (:report (lambda (condition stream)
  11. (with-slots (position left-data) condition
  12. (format stream "Ledger parse failed at position ~A~@[, starting from ~A~]" position left-data)))))
  13. (defun parse-journal (str)
  14. (let ((*default-year* (car (get-date (get-universal-time)))))
  15. (multiple-value-bind (result left rest)
  16. (parse (.journal) str)
  17. (cond
  18. ((null result) (error 'journal-failed :left-data (subseq str 0 (min 200 (length str)))))
  19. ((not (string= "" left)) (error 'journal-failed
  20. :position (- (length str)
  21. (length left))
  22. :left-data (subseq left 0 (min 200 (length left)))))
  23. ((not (null rest)) (error 'journal-failed :position (length str)))
  24. (:otherwise result)))))
  25. (defun parse-query (query)
  26. (let ((options (parse (.query) query)))
  27. (unless options
  28. (error "bad query: ~A" query))
  29. options))
  30. (defun get-commodity (amount)
  31. (let ((com (amount-commodity amount)))
  32. (cond
  33. ((null com) *default-commodity*)
  34. ((equal com "$") "USD")
  35. ((equal com "€") "EUR")
  36. (t com))))
  37. (defun add-amounts! (sum amounts)
  38. (dolist (a amounts sum)
  39. (let* ((commodity (get-commodity a))
  40. (same (find commodity sum :key #'amount-commodity :test #'equal)))
  41. (unless same (setf same (car (push (make-amount :quantity 0 :commodity commodity)
  42. sum))))
  43. (incf (amount-quantity same)
  44. (amount-quantity a)))))
  45. (defun complement-amounts (postings &optional cost)
  46. (let ((sum
  47. (reduce #'add-amounts! postings :initial-value nil
  48. :key #'(lambda (p) (when (and (posting-amount p)
  49. (null (posting-virtual p)))
  50. (get-amounts p cost))))))
  51. (dolist (a sum sum)
  52. (setf (amount-quantity a)
  53. (- (amount-quantity a))))))
  54. (defun get-amounts (posting postings &optional cost)
  55. (let ((amount (posting-amount posting))
  56. (unit-price (posting-unit-price posting))
  57. (total-price (posting-total-price posting)))
  58. (if amount
  59. (if (eq cost :t)
  60. (if total-price (list total-price)
  61. (if unit-price
  62. (list (make-amount :commodity (amount-commodity unit-price)
  63. :quantity (* (amount-quantity amount)
  64. (amount-quantity unit-price))))
  65. (list amount)))
  66. (list amount))
  67. (complement-amounts postings cost))))
  68. (defun entries (journal &key predicate real &allow-other-keys)
  69. (remove-if (lambda (e)
  70. (let ((postings (entry-postings e)))
  71. (or (and predicate (not (member nil postings :key #'(lambda (p) (funcall predicate e p)) :test-not #'eql)))
  72. (and (eq real :t) (not (member nil (entry-postings e) :key #'posting-virtual)))
  73. (and (eq real :f) (not (member nil (entry-postings e) :key #'posting-virtual :test-not #'eql))))))
  74. (mapcar #'cdr (remove :entry journal :key #'car :test-not #'eql))))
  75. (defun select-postings (entry &key predicate real)
  76. (labels ((valid (posting)
  77. (and (or (null predicate)
  78. (funcall predicate entry posting))
  79. (or (null real)
  80. (ecase real
  81. (:t (null (posting-virtual posting)))
  82. (:f (posting-virtual posting)))))))
  83. (remove-if-not #'valid (entry-postings entry))))
  84. (defun account-parents (account &key tree)
  85. (append
  86. (when (eq tree :t)
  87. (loop for start = 0 then (1+ pos)
  88. for pos = (position #\: account :start start)
  89. while pos collect (subseq account 0 pos)))
  90. (list account "TOTALS")))
  91. (defun remove-extra-parents! (balance)
  92. (labels ((find-child (parent amounts)
  93. (let ((len (length parent)))
  94. (loop for child being the hash-keys in balance
  95. when (and (> (length child) (1+ len))
  96. (equal parent (subseq child 0 len))
  97. (equal (elt child len) #\:)
  98. (equalp amounts (gethash child balance)))
  99. do (return-from find-child t)))))
  100. (loop for parent being the hash-keys of balance using (hash-value amounts)
  101. when (find-child parent amounts)
  102. do (remhash parent balance))))
  103. (defun balance (entries &key predicate cost real tree)
  104. (let ((balance (make-hash-table :test #'equal)))
  105. (dolist (entry entries)
  106. (dolist (posting (select-postings entry :predicate predicate :real real))
  107. (let ((amounts (get-amounts posting (entry-postings entry) cost)))
  108. (dolist (account (account-parents (posting-account posting) :tree tree))
  109. (setf (gethash account balance)
  110. (add-amounts! (gethash account balance) amounts))))))
  111. (when (eq tree :t)
  112. (remove-extra-parents! balance))
  113. balance))
  114. (defun format-balance (balance)
  115. (let* ((accounts (alexandria:hash-table-keys balance)))
  116. (with-output-to-string (s)
  117. (loop for account in (sort accounts #'string<)
  118. for amounts = (remove 0 (gethash account balance) :test #'equal :key #'amount-quantity)
  119. do (loop for amount in amounts
  120. for first = t then nil
  121. for amount-text = (render amount)
  122. do (format s "~vA ~A~%"
  123. (- *posting-length*
  124. (position #\Space amount-text))
  125. (if first account "")
  126. amount-text))))))
  127. (defun journal-balance (journal &optional query)
  128. (let ((balance (apply #'balance (entries journal)
  129. (when query (parse-query query)))))
  130. (unless (zerop (hash-table-count balance))
  131. (format-balance balance))))
  132. (defun accounts (entries &key predicate real &allow-other-keys)
  133. (let ((accounts (make-hash-table :test #'equal)))
  134. (dolist (entry entries (alexandria:hash-table-keys accounts))
  135. (dolist (posting (select-postings entry :predicate predicate :real real))
  136. (setf (gethash (posting-account posting) accounts) t)))))
  137. (defun journal-accounts (journal &optional query)
  138. (apply #'accounts (entries journal)
  139. (when query (parse-query query))))
  140. (defun journal-entries (journal &optional query)
  141. (apply #'entries journal (when query (parse-query query))))
  142. (defun journal-print (journal &optional query)
  143. (mapcar #'render (journal-entries journal query)))
  144. (defun clone-entry (entry)
  145. (parse (.entry) (render entry)))