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.
 
 

427 lines
14 KiB

  1. ;;; elbank-common.el --- Elbank common use functions and variables -*- 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. ;; This file defines common data structures, variables and functions used in
  16. ;; Elbank.
  17. ;;; Code:
  18. (require 'map)
  19. (require 'seq)
  20. (require 'json)
  21. (require 'subr-x)
  22. (eval-and-compile (require 'cl-lib))
  23. ;;;###autoload
  24. (defgroup elbank nil
  25. "Elbank"
  26. :prefix "elbank-"
  27. :group 'tools)
  28. ;;;###autoload
  29. (defcustom elbank-data-file (locate-user-emacs-file "elbank-data.el")
  30. "Location of the file used to store elbank data."
  31. :type '(file))
  32. ;;;###autoload
  33. (defcustom elbank-categories nil
  34. "Alist of categories of transactions.
  35. Each category has an associated list of regular expressions.
  36. A transaction's category is found by testing each regexp in order.
  37. Example of categories
  38. (setq elbank-categories
  39. \\='((\"Expenses:Groceries\" . (\"walmart\" \"city market\"))
  40. (\"Income:Salary\" . (\"paycheck\"))))"
  41. :type '(alist :key-type (string :tag "Category name")
  42. :value-type (repeat (string :tag "Regexp"))))
  43. (defface elbank-header-face '((t . (:inherit font-lock-keyword-face
  44. :height 1.3)))
  45. "Face for displaying header in elbank."
  46. :group 'elbank)
  47. (defface elbank-subheader-face '((t . (:weight bold
  48. :height 1.1)))
  49. "Face for displaying sub headers in elbank."
  50. :group 'elbank)
  51. (defface elbank-positive-amount-face '((t . (:inherit success :weight normal)))
  52. "Face for displaying positive amounts."
  53. :group 'elbank)
  54. (defface elbank-negative-amount-face '((t . (:inherit error :weight normal)))
  55. "Face for displaying positive amounts."
  56. :group 'elbank)
  57. (defface elbank-entry-face '((t . ()))
  58. "Face for displaying entries in elbank."
  59. :group 'elbank)
  60. (defvar elbank-data nil
  61. "Alist of all accounts and transactions.")
  62. (defvar elbank-report-available-columns '(date rdate label raw category account amount)
  63. "List of all available columns in reports.")
  64. (defun elbank-read-data ()
  65. "Return an alist of boobank data read from `elbank-data-file'.
  66. Data is cached to `elbank-data'."
  67. (let ((file (expand-file-name elbank-data-file)))
  68. (when (file-exists-p file)
  69. (load file t))))
  70. (defun elbank-write-data ()
  71. "Write `elbank-data' to `elbank-data-file'."
  72. (let ((print-circle t)
  73. (print-level nil)
  74. (print-length nil))
  75. (with-temp-file (expand-file-name elbank-data-file)
  76. (emacs-lisp-mode)
  77. (insert ";; This file is automatically generated by Indium.")
  78. (newline)
  79. (insert (format "(setq %s '%S)" "elbank-data" elbank-data)))))
  80. (defun elbank-account-with-id (id)
  81. "Return the account with ID, or nil."
  82. (seq-find (lambda (acc)
  83. (string= (map-elt acc 'id)
  84. id))
  85. (map-elt elbank-data 'accounts)))
  86. (defun elbank-account-name (account)
  87. "Return a human-readable name for ACCOUNT."
  88. (format "%s@%s"
  89. (elbank-account-group account)
  90. (map-elt account 'label)))
  91. (defun elbank-account-group (account)
  92. "Return the group into which ACCOUNT is classified."
  93. (cadr (split-string (map-elt account 'id) "@")))
  94. (cl-defgeneric elbank-transaction-elt (transaction key &optional default)
  95. "Return the value of TRANSACTION at KEY.
  96. If the result is nil, return DEFAULT."
  97. (map-elt transaction key default))
  98. (cl-defmethod elbank-transaction-elt (transaction (key (eql account)) &optional default)
  99. "Return the account of TRANSACTION.
  100. If TRANSACTION is a split transaction, return the account of its parent transaction."
  101. (if (elbank-sub-transaction-p transaction)
  102. (elbank-transaction-elt (elbank-transaction-elt transaction 'split-from) 'account)
  103. (cl-call-next-method transaction key default)))
  104. (cl-defmethod elbank-transaction-elt (transaction (_key (eql category)) &optional default)
  105. "Return the category of TRANSACTION.
  106. Split transactions (that hold an list of (CATEGORY . AMOUNT)
  107. elements as category) have no category.
  108. If the result is nil, return DEFAULT."
  109. (let ((case-fold-search t)
  110. (custom-category (map-elt transaction 'category)))
  111. (cond ((or (null custom-category)
  112. (and (stringp custom-category)
  113. (string-empty-p custom-category)))
  114. (seq-find #'identity
  115. (map-apply
  116. (lambda (key category)
  117. (when (seq-find
  118. (lambda (regexp)
  119. (string-match-p
  120. regexp
  121. (map-elt transaction 'raw)))
  122. category)
  123. key))
  124. elbank-categories)
  125. default))
  126. ((stringp custom-category) custom-category)
  127. (t default))))
  128. (cl-defgeneric (setf elbank-transaction-elt) (store transaction key)
  129. (if (map-contains-key transaction key)
  130. (setf (map-elt transaction key) store)
  131. ;; Always mutate transactions in place.
  132. (nconc transaction (list (cons key store)))))
  133. (defun elbank-transaction-split-p (transaction)
  134. "Return non-nil if TRANSACTION is split.
  135. Split transactions have an alist of (CATEGORY-NAME . AMOUNT) as
  136. category."
  137. (let ((category (map-elt transaction 'category)))
  138. (and (not (null category))
  139. (listp category))))
  140. (defun elbank-sub-transaction-p (transaction)
  141. "Return non-nil if TRANSACTION is a sub transaction.
  142. Sub transactions are built dynamically from
  143. `elbank-all-transactions' from split transactions."
  144. (not (null (elbank-transaction-elt transaction 'split-from))))
  145. (defmacro elbank-filter-transactions (collection &rest query)
  146. "Returned all transactions in COLLECTION that match QUERY.
  147. QUERY is a plist of the form (:KEY1 VAL1 :KEY2 VAL2...) where
  148. keys are keywords matching transaction keys.
  149. Special keys in QUERY:
  150. - `:category': Match transactions within the category name, using
  151. `elbank-transaction-in-category-p'.
  152. - `:period': The value must be a list of the form (TYPE TIME),
  153. where TYPE is either year or month.
  154. - `:account-id': Lookup an account with the queried id and match
  155. transactions for that account."
  156. (let* ((sym (make-symbol "transaction"))
  157. (query (elbank--make-queries sym query)))
  158. `(seq-filter (lambda (,sym) ,query) ,collection)))
  159. (defun elbank--make-queries (transaction queries)
  160. "Return a form for filtering TRANSACTION with QUERIES."
  161. (let ((filters (seq-map (lambda (query)
  162. (elbank--make-query transaction
  163. (car query)
  164. (cadr query)))
  165. (seq-partition queries 2))))
  166. `(and ,@filters)))
  167. (cl-defgeneric elbank--make-query (transaction key val)
  168. (unless (keywordp key)
  169. (error "Query KEY must be a keyword"))
  170. `(or (null ,val)
  171. (equal (elbank-transaction-elt ,transaction
  172. ',(intern (seq-drop (symbol-name key) 1)))
  173. ,val)))
  174. (cl-defmethod elbank--make-query (transaction (_key (eql :category)) val)
  175. `(elbank-transaction-in-category-p ,transaction ,val))
  176. (cl-defmethod elbank--make-query (transaction (_key (eql :account-id)) val)
  177. (elbank--make-query transaction :account `(elbank-account-with-id ,val)))
  178. (cl-defmethod elbank--make-query (transaction (_key (eql :period)) period)
  179. "Return a period query for TRANSACTION.
  180. PERIOD is a list of the form `(type time)', with `type' a
  181. symbol (`month' or `year'), and `time' an encoded time."
  182. (let ((period-var (make-symbol "period")))
  183. `(let ((,period-var ,period))
  184. (pcase (car ,period-var)
  185. (`year (elbank--make-period-query-format ,transaction
  186. (cadr ,period-var)
  187. "%Y"))
  188. (`month (elbank--make-period-query-format ,transaction
  189. (cadr ,period-var)
  190. "%Y-%m"))
  191. (`nil t)
  192. (_ (error "Invalid period type %S" (car ,period-var)))))))
  193. (defun elbank--make-period-query-format (transaction time format)
  194. "Return a period query for TRANSACTION and TIME.
  195. Comparison is done by formatting times using FORMAT."
  196. (string= (format-time-string format time)
  197. (format-time-string format (elbank--transaction-time transaction))))
  198. (defun elbank--transaction-time (transaction)
  199. "Return the encoded time for TRANSACTION."
  200. (apply #'encode-time
  201. (seq-map (lambda (el)
  202. (or el 0))
  203. (parse-time-string (elbank-transaction-elt transaction 'date)))))
  204. (defun elbank-transaction-in-category-p (transaction category)
  205. "Return non-nil if TRANSACTION belongs to CATEGORY."
  206. (or (null category)
  207. (string-prefix-p category (elbank-transaction-elt transaction 'category "") t)))
  208. (defun elbank-sum-transactions (transactions)
  209. "Return the sum of all TRANSACTIONS.
  210. TRANSACTIONS are expected to all use the same currency."
  211. (seq-reduce (lambda (acc transaction)
  212. (+ acc
  213. (string-to-number (elbank-transaction-elt transaction 'amount))))
  214. transactions
  215. 0))
  216. (defun elbank-transaction-years ()
  217. "Return all years for which there is a transaction."
  218. (seq-sort #'time-less-p
  219. (seq-uniq
  220. (seq-map (lambda (transaction)
  221. (encode-time 0 0 0 1 1
  222. (seq-elt (decode-time
  223. (elbank--transaction-time transaction))
  224. 5)))
  225. (elbank-all-transactions)))))
  226. (defun elbank-transaction-months ()
  227. "Return all months for which there is a transaction."
  228. (seq-sort #'time-less-p
  229. (seq-uniq
  230. (seq-map (lambda (transaction)
  231. (let ((time (decode-time
  232. (elbank--transaction-time transaction))))
  233. (encode-time 0 0 0 1 (seq-elt time 4) (seq-elt time 5))))
  234. (elbank-all-transactions)))))
  235. (defun elbank-all-transactions (&optional nosplit)
  236. "Return all transactions for all accounts.
  237. When NOSPLIT is nil, split transactions that have multiple
  238. categories into multiple transactions."
  239. (if nosplit
  240. (map-elt elbank-data 'transactions)
  241. (seq-mapcat (lambda (transaction)
  242. (if (elbank-transaction-split-p transaction)
  243. (seq-map (lambda (cat)
  244. (let ((sub-trans (copy-alist transaction)))
  245. (map-put sub-trans 'amount (cdr cat))
  246. (map-put sub-trans 'category (car cat))
  247. (map-put sub-trans 'label
  248. (format "[split] %s"
  249. (elbank-transaction-elt
  250. transaction
  251. 'label)))
  252. (map-put sub-trans 'split-from transaction)
  253. sub-trans))
  254. (map-elt transaction 'category))
  255. (list transaction)))
  256. (elbank-all-transactions t))))
  257. (defun elbank--longest-account-label ()
  258. "Return the longest account label from all accounts."
  259. (seq-reduce (lambda (label1 label2)
  260. (if (> (seq-length label1)
  261. (seq-length label2))
  262. label1
  263. label2))
  264. (seq-map (lambda (account)
  265. (map-elt account 'label))
  266. (map-elt elbank-data 'accounts))
  267. ""))
  268. (defun elbank--insert-amount (amount &optional currency)
  269. "Insert AMOUNT as a float with a precision of 2 decimals.
  270. When CURRENCY is non-nil, append it to the inserted text.
  271. AMOUNT is fontified based on whether it is negative or positive."
  272. (let ((beg (point))
  273. (number (if (numberp amount)
  274. amount
  275. (string-to-number amount))))
  276. (insert (format "%.2f %s" number (or currency "")))
  277. (put-text-property beg (point)
  278. 'face
  279. (if (< number 0)
  280. 'elbank-negative-amount-face
  281. 'elbank-positive-amount-face))))
  282. (defun elbank--propertize-amount (amount &optional currency)
  283. "Fontify AMOUNT based on whether it is positive or not.
  284. When CURRENCY is non-nil, append it to the inserted text."
  285. (with-temp-buffer
  286. (elbank--insert-amount amount currency)
  287. (buffer-string)))
  288. (defun elbank-format-period (period)
  289. "Return the string representation of PERIOD."
  290. (pcase (car period)
  291. (`year (format-time-string "Year %Y" (cadr period)))
  292. (`month (format-time-string "%B %Y" (cadr period)))
  293. (`nil "")
  294. (`_ "Invalid period")))
  295. (defun elbank-quit ()
  296. "Kill the current buffer."
  297. (interactive)
  298. (quit-window t))
  299. ;;;
  300. ;;; Common major-mode for reports
  301. ;;;
  302. (defvar elbank-report-update-hook nil
  303. "Hook run when a report update is requested.")
  304. (defvar elbank-report-period nil
  305. "Period filter used in a report buffer.")
  306. (make-variable-buffer-local 'elbank-report-period)
  307. (defvar elbank-base-report-mode-map
  308. (let ((map (make-sparse-keymap)))
  309. (define-key map (kbd "q") #'elbank-quit)
  310. (define-key map (kbd "n") #'forward-button)
  311. (define-key map (kbd "p") #'backward-button)
  312. (define-key map [tab] #'forward-button)
  313. (define-key map [backtab] #'backward-button)
  314. (define-key map (kbd "M-n") #'elbank-base-report-forward-period)
  315. (define-key map (kbd "M-p") #'elbank-base-report-backward-period)
  316. (define-key map (kbd "g") #'elbank-base-report-refresh)
  317. map)
  318. "Keymap for `elbank-base-report-mode'.")
  319. (define-derived-mode elbank-base-report-mode nil "Base elbank reports"
  320. "Base major mode for viewing a report.
  321. \\{elbank-base-report-mode-map}"
  322. (setq-local truncate-lines nil)
  323. (read-only-mode))
  324. (defun elbank-base-report-refresh ()
  325. "Request an update of the current report."
  326. (interactive)
  327. (run-hooks 'elbank-base-report-refresh-hook))
  328. (defun elbank-base-report-forward-period (&optional n)
  329. "Select the next N period and update the current report.
  330. If there is no period filter, signal an error."
  331. (interactive "p")
  332. (unless elbank-report-period
  333. (user-error "No period filter for the current report"))
  334. (let* ((periods (pcase (car elbank-report-period)
  335. (`year (elbank-transaction-years))
  336. (`month (elbank-transaction-months))))
  337. (cur-index (seq-position periods (cadr elbank-report-period)))
  338. (new-index (+ n cur-index))
  339. (period (seq-elt periods new-index)))
  340. (if period
  341. (progn
  342. (setq elbank-report-period (list (car elbank-report-period)
  343. period))
  344. (elbank-base-report-refresh))
  345. (user-error "No more periods"))))
  346. (defun elbank-base-report-backward-period (&optional n)
  347. "Select the previous N period and update the current report.
  348. If there is no period filter, signal an error."
  349. (interactive "p")
  350. (elbank-base-report-forward-period (- n)))
  351. (provide 'elbank-common)
  352. ;;; elbank-common.el ends here