pta-ledger.lisp 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172
  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 entry-total-amount (entry)
  85. (labels ((negativep (e p)
  86. (declare (ignore e))
  87. (minusp (amount-quantity (posting-amount p)))))
  88. (format nil "~{~A~^, ~}"
  89. (mapcar #'render (complement-amounts (select-postings entry :predicate #'negativep))))))
  90. (defun account-parents (account &key tree)
  91. (append
  92. (when (eq tree :t)
  93. (loop for start = 0 then (1+ pos)
  94. for pos = (position #\: account :start start)
  95. while pos collect (subseq account 0 pos)))
  96. (list account "TOTALS")))
  97. (defun remove-extra-parents! (balance)
  98. (labels ((find-child (parent amounts)
  99. (let ((len (length parent)))
  100. (loop for child being the hash-keys in balance
  101. when (and (> (length child) (1+ len))
  102. (equal parent (subseq child 0 len))
  103. (equal (elt child len) #\:)
  104. (equalp amounts (gethash child balance)))
  105. do (return-from find-child t)))))
  106. (loop for parent being the hash-keys of balance using (hash-value amounts)
  107. when (find-child parent amounts)
  108. do (remhash parent balance))))
  109. (defun balance (entries &key predicate cost real tree)
  110. (let ((balance (make-hash-table :test #'equal)))
  111. (dolist (entry entries)
  112. (dolist (posting (select-postings entry :predicate predicate :real real))
  113. (let ((amounts (get-amounts posting (entry-postings entry) cost)))
  114. (dolist (account (account-parents (posting-account posting) :tree tree))
  115. (setf (gethash account balance)
  116. (add-amounts! (gethash account balance) amounts))))))
  117. (when (eq tree :t)
  118. (remove-extra-parents! balance))
  119. balance))
  120. (defun format-balance (balance)
  121. (let* ((accounts (alexandria:hash-table-keys balance)))
  122. (with-output-to-string (s)
  123. (loop for account in (sort accounts #'string<)
  124. for amounts = (remove 0 (gethash account balance) :test #'equal :key #'amount-quantity)
  125. do (loop for amount in amounts
  126. for first = t then nil
  127. for amount-text = (render amount)
  128. do (format s "~vA ~A~%"
  129. (- *posting-length*
  130. (position #\Space amount-text))
  131. (if first account "")
  132. amount-text))))))
  133. (defun journal-balance (journal &optional query)
  134. (let ((balance (apply #'balance (entries journal)
  135. (when query (parse-query query)))))
  136. (unless (zerop (hash-table-count balance))
  137. (format-balance balance))))
  138. (defun accounts (entries &key predicate real &allow-other-keys)
  139. (let ((accounts (make-hash-table :test #'equal)))
  140. (dolist (entry entries (alexandria:hash-table-keys accounts))
  141. (dolist (posting (select-postings entry :predicate predicate :real real))
  142. (setf (gethash (posting-account posting) accounts) t)))))
  143. (defun journal-accounts (journal &optional query)
  144. (apply #'accounts (entries journal)
  145. (when query (parse-query query))))
  146. (defun journal-entries (journal &optional query)
  147. (apply #'entries journal (when query (parse-query query))))
  148. (defun journal-print (journal &optional query)
  149. (mapcar #'render (journal-entries journal query)))
  150. (defun clone-entry (entry)
  151. (parse (.entry) (render entry)))