Personal finances application for Emacs
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

224 lines
8.1 KiB

  1. ;;; elbank-boobank.el --- Elbank functions for importing from Boobank -*- lexical-binding: t; -*-
  2. ;; Copyright (C) 2017-2018 Nicolas Petton
  3. ;; Author: Nicolas Petton <nicolas@petton.fr>
  4. ;; This program is free software; you can redistribute it and/or modify
  5. ;; it under the terms of the GNU General Public License as published by
  6. ;; the Free Software Foundation, either version 3 of the License, or
  7. ;; (at your option) any later version.
  8. ;; This program is distributed in the hope that it will be useful,
  9. ;; but WITHOUT ANY WARRANTY; without even the implied warranty of
  10. ;; MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
  11. ;; GNU General Public License for more details.
  12. ;; You should have received a copy of the GNU General Public License
  13. ;; along with this program. If not, see <http://www.gnu.org/licenses/>.
  14. ;;; Commentary:
  15. ;;
  16. ;;; Code:
  17. (require 'seq)
  18. (require 'map)
  19. (require 'json)
  20. (require 'cl-lib)
  21. (require 'subr-x)
  22. (require 'elbank-common)
  23. ;;;###autoload
  24. (defgroup elbank-boobank nil
  25. "Elbank boobank settings."
  26. :group 'elbank)
  27. ;;;###autoload
  28. (defcustom elbank-boobank-executable "boobank"
  29. "Boobank executable."
  30. :type '(file))
  31. (defun elbank-boobank-update (&optional callback)
  32. "Update `elbank-data' from boobank and save it on file.
  33. When CALLBACK is non-nil, evaluate it when data is updated."
  34. (elbank-boobank--update-accounts
  35. (lambda ()
  36. (elbank-boobank--update-transactions
  37. (lambda ()
  38. (elbank-write-data)
  39. (when callback (funcall callback)))))))
  40. (defun elbank-boobank--update-accounts (callback)
  41. "Update the accounts data from boobank.
  42. Evaluate CALLBACK when data is updated."
  43. (elbank-boobank--fetch-then-merge
  44. #'elbank-boobank--fetch-accounts
  45. #'elbank-boobank--merge-accounts
  46. (lambda (accounts)
  47. (map-put elbank-data 'accounts accounts)
  48. (funcall callback))))
  49. (defun elbank-boobank--update-transactions (callback)
  50. "Update the transactions data from boobank.
  51. Evaluate CALLBACK when data is updated."
  52. (elbank-boobank--fetch-then-merge
  53. #'elbank-boobank--fetch-transactions
  54. #'elbank-boobank--merge-transactions
  55. (lambda (transactions)
  56. (map-put elbank-data 'transactions transactions)
  57. (funcall callback))))
  58. (defun elbank-boobank--fetch-accounts (callback)
  59. "Execute CALLBACK with all fetched accounts from boobank."
  60. (let ((command (format "%s -f json ls 2>/dev/null" (elbank-boobank--find-executable))))
  61. (message "Elbank: fetching accounts...")
  62. (elbank-boobank--shell-command command callback)))
  63. (defun elbank-boobank--fetch-transactions (callback &optional accounts acc)
  64. "Fetch all transactions from all ACCOUNTS and evaluate CALLBACK.
  65. If ACCOUNTS is nil, use all accounts from `elbank-data'.
  66. CALLBACK is called with all fetched transactions.
  67. ACC is used in recursive calls to accumulate fetched transactions."
  68. (let* ((since "1970") ; the current strategy is to always fetch all data. If
  69. ; needed, this can be optimized later on.
  70. (accounts (or accounts (map-elt elbank-data 'accounts)))
  71. (account (car accounts))
  72. (id (map-elt account 'id))
  73. ;; The backend might not support listing transactions for some
  74. ;; accounts, ignore errors.
  75. (command (format "%s -f json history %s %s 2> /dev/null"
  76. (elbank-boobank--find-executable)
  77. id
  78. since)))
  79. (message "Elbank: fetching transactions for account %s..." id)
  80. (elbank-boobank--shell-command
  81. command
  82. (lambda (data)
  83. (let* ((transactions (seq-map (lambda (datum)
  84. (elbank-boobank--make-transaction datum account))
  85. data))
  86. (all (seq-concatenate 'list acc transactions)))
  87. (if (cdr accounts)
  88. (elbank-boobank--fetch-transactions callback (cdr accounts) all)
  89. (funcall callback all)))))))
  90. (defun elbank-boobank--make-transaction (data account)
  91. "Return a transaction alist from DATA with its account set to ACCOUNT."
  92. (unless (seq-contains (map-elt elbank-data 'accounts)
  93. account
  94. #'eq)
  95. (error "Account %s not in the Elbank database" account))
  96. (let ((transaction (map-copy data)))
  97. ;; Some banks add a category to transactions, which conflicts with elbank's
  98. ;; categories, so put the category in `bank-category' instead.
  99. (map-put transaction 'bank-category (map-elt data 'category))
  100. (map-put transaction 'category nil)
  101. (map-put transaction 'account account)))
  102. (defun elbank-boobank--merge-accounts (accounts)
  103. "Merge ACCOUNTS with existing ones in `elbank-data'.
  104. Data from existing accounts are updated with new data from ACCOUNTS."
  105. (elbank-boobank--update-existing-accounts accounts)
  106. (let ((existing-accounts (map-elt elbank-data 'accounts)))
  107. (let ((new-accounts (elbank-boobank--find-new-accounts accounts)))
  108. (seq-concatenate 'list existing-accounts new-accounts))))
  109. (defun elbank-boobank--find-new-accounts (accounts)
  110. "Return accounts in ACCOUNTS that are not present in `elbank-data'."
  111. (seq-remove (lambda (acc)
  112. (elbank-account-with-id (map-elt acc 'id)))
  113. accounts))
  114. (defun elbank-boobank--update-existing-accounts (new-accounts)
  115. "Update existing accounts in `elbank-data' with the data from NEW-ACCOUNTS'.
  116. No new account is created, only existing account values are updated."
  117. (seq-do (lambda (new-acc)
  118. (when-let ((acc (elbank-account-with-id (map-elt new-acc 'id))))
  119. (map-apply (lambda (key val)
  120. (map-put acc key val))
  121. new-acc)))
  122. new-accounts))
  123. (defun elbank-boobank--merge-transactions (transactions)
  124. "Merge the transaction list from `elbank-data' and TRANSACTIONS."
  125. (let* ((existing-transactions (map-elt elbank-data 'transactions))
  126. (new-transactions (elbank-boobank--find-new-transactions transactions)))
  127. (seq-concatenate 'list existing-transactions new-transactions)))
  128. (defun elbank-boobank--find-new-transactions (transactions)
  129. "Return all transactions from TRANSACTIONS not present in `elbank-data'.
  130. When comparing transactions, ignore (manually set) categories."
  131. (apply #'seq-concatenate 'list
  132. (seq-map (lambda (trans)
  133. (let ((n (- (elbank-boobank--count-transactions-like
  134. trans transactions)
  135. (elbank-boobank--count-transactions-like
  136. trans (elbank-all-transactions t)))))
  137. (when (> n 0)
  138. (let ((result))
  139. (dotimes (_ n)
  140. (setq result (cons trans result)))
  141. result))))
  142. (seq-uniq transactions))))
  143. (defun elbank-boobank--count-transactions-like (transaction transactions)
  144. "Return the number of transactions like TRANSACTION in TRANSACTIONS."
  145. (seq-length (elbank-filter-transactions
  146. transactions
  147. :raw (map-elt transaction 'raw)
  148. :account (map-elt transaction 'account)
  149. :amount (map-elt transaction 'amount)
  150. :date (map-elt transaction 'date)
  151. :vdate (map-elt transaction 'vdate)
  152. :rdate (map-elt transaction 'rdate)
  153. :label (map-elt transaction 'label))))
  154. (defun elbank-boobank--shell-command (command callback)
  155. "Start a subprocess for COMMAND, and evaluate CALLBACK with its output."
  156. (let ((bufname "*boobank process*"))
  157. (when-let ((buf (get-buffer bufname)))
  158. (with-current-buffer buf
  159. (erase-buffer)))
  160. (make-process :name "boobank"
  161. :buffer bufname
  162. :sentinel (lambda (process event)
  163. (if (eq (process-status process) 'exit)
  164. (let ((json-array-type 'list))
  165. (with-current-buffer (process-buffer process)
  166. (goto-char (point-min))
  167. (funcall callback (json-read))))
  168. (error "Boobank fetch failed! %s" event)))
  169. :command (list shell-file-name
  170. shell-command-switch
  171. command))))
  172. (defun elbank-boobank--find-executable ()
  173. "Return the boobank executable.
  174. Signal an error if the boobank executable cannot be found."
  175. (let ((executable (executable-find elbank-boobank-executable)))
  176. (unless executable
  177. (user-error "Cannot find boobank executable (%s) in PATH" elbank-boobank-executable))
  178. executable))
  179. (defun elbank-boobank--fetch-then-merge (fetch-fn merge-fn callback)
  180. "Evaluate MERGE-FN with the result of the evaluation of FETCH-FN.
  181. FETCH-FN is an asynchronous function that take a callback
  182. function as argument.
  183. Evaluate CALLBACK with the result of the merge."
  184. (funcall fetch-fn
  185. (lambda (data)
  186. (let ((merged (funcall merge-fn data)))
  187. (funcall callback merged)))))
  188. (provide 'elbank-boobank)
  189. ;;; elbank-boobank.el ends here