pta-ledger.lisp 5.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134
  1. (in-package #:pta-ledger)
  2. (defstruct amount quantity commodity)
  3. (defstruct posting
  4. status 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 ((predicate (parse (.query) query)))
  27. (unless predicate
  28. (error "bad query: ~A" query))
  29. predicate))
  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 (posting-amount p)
  49. (get-amounts p cost))))))
  50. (dolist (a sum sum)
  51. (setf (amount-quantity a)
  52. (- (amount-quantity a))))))
  53. (defun get-amounts (posting postings &optional cost)
  54. (let ((amount (posting-amount posting))
  55. (unit-price (posting-unit-price posting))
  56. (total-price (posting-total-price posting)))
  57. (if amount
  58. (if cost
  59. (if total-price (list total-price)
  60. (if unit-price
  61. (list (make-amount :commodity (amount-commodity unit-price)
  62. :quantity (* (amount-quantity amount)
  63. (amount-quantity unit-price))))
  64. (list amount)))
  65. (list amount))
  66. (complement-amounts postings cost))))
  67. (defun entries (journal &optional predicate)
  68. (remove-if (lambda (e)
  69. (when predicate
  70. (not (some (lambda (p)
  71. (funcall predicate e p))
  72. (entry-postings e)))))
  73. (mapcar #'cdr (remove :entry journal :key #'car :test-not #'eql))))
  74. (defun balance (entries &key predicate cost)
  75. (let ((balance (make-hash-table :test #'equal)))
  76. (dolist (entry entries balance)
  77. (dolist (posting (entry-postings entry))
  78. (when (or (null predicate)
  79. (funcall predicate entry posting))
  80. (let* ((account (posting-account posting))
  81. (amounts (get-amounts posting (entry-postings entry) cost)))
  82. (setf (gethash account balance)
  83. (add-amounts! (gethash account balance) amounts)
  84. (gethash "TOTALS" balance)
  85. (add-amounts! (gethash "TOTALS" balance) amounts))))))))
  86. (defun format-balance (balance)
  87. (let* ((accounts (alexandria:hash-table-keys balance)))
  88. (with-output-to-string (s)
  89. (loop for account in (sort accounts #'string<)
  90. for amounts = (remove 0 (gethash account balance) :test #'equal :key #'amount-quantity)
  91. do (loop for amount in amounts
  92. for first = t then nil
  93. for amount-text = (render amount)
  94. do (format s "~vA ~A~%"
  95. (- *posting-length*
  96. (position #\Space amount-text))
  97. (if first account "")
  98. amount-text))))))
  99. (defun journal-balance (journal &optional query)
  100. (format-balance (balance (entries journal)
  101. :predicate (when query (parse-query query)))))
  102. (defun accounts (entries &key predicate)
  103. (let ((accounts (make-hash-table :test #'equal)))
  104. (dolist (entry entries (alexandria:hash-table-keys accounts))
  105. (dolist (posting (entry-postings entry))
  106. (when (or (null predicate)
  107. (funcall predicate entry posting))
  108. (setf (gethash (posting-account posting) accounts) t))))))
  109. (defun journal-accounts (journal &optional query)
  110. (accounts (entries journal)
  111. :predicate (when query (parse-query query))))
  112. (defun journal-entries (journal &optional query)
  113. (entries journal (when query (parse-query query))))
  114. (defun journal-print (journal &optional query)
  115. (mapcar #'render (journal-entries journal query)))
  116. (defun clone-entry (entry)
  117. (parse (.entry) (render entry)))