pta-ledger.lisp 5.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129
  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. (max-account-length (if accounts (apply #'max (mapcar #'length accounts)) 20)))
  89. (with-output-to-string (s)
  90. (loop for account in (sort accounts #'string<)
  91. for amounts = (gethash account balance)
  92. when (find 0 amounts :test-not #'= :key #'amount-quantity)
  93. do (format s "~vA ~$ ~A~{~%~va ~$ ~A~}~%" max-account-length account
  94. (amount-quantity (car amounts)) (amount-commodity (car amounts))
  95. (apply #'append (mapcar #'(lambda (a)
  96. (list max-account-length ""
  97. (amount-quantity a)
  98. (amount-commodity a)))
  99. (rest amounts))))))))
  100. (defun journal-balance (journal &optional query)
  101. (format-balance (balance (entries journal)
  102. :predicate (when query (parse-query query)))))
  103. (defun accounts (entries &key predicate)
  104. (let ((accounts (make-hash-table :test #'equal)))
  105. (dolist (entry entries (alexandria:hash-table-keys accounts))
  106. (dolist (posting (entry-postings entry))
  107. (when (or (null predicate)
  108. (funcall predicate entry posting))
  109. (setf (gethash (posting-account posting) accounts) t))))))
  110. (defun journal-accounts (journal &optional query)
  111. (accounts (entries journal)
  112. :predicate (when query (parse-query query))))
  113. (defun journal-print (journal &optional query)
  114. (mapcar #'render (entries journal (when query (parse-query query)))))