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.

439 lines
14 KiB

;;; elbank-common.el --- Elbank common use functions and variables -*- lexical-binding: t; -*-
;; Copyright (C) 2017-2018 Nicolas Petton
;; Author: Nicolas Petton <>
;; This program is free software; you can redistribute it and/or modify
;; it under the terms of the GNU General Public License as published by
;; the Free Software Foundation, either version 3 of the License, or
;; (at your option) any later version.
;; This program is distributed in the hope that it will be useful,
;; but WITHOUT ANY WARRANTY; without even the implied warranty of
;; GNU General Public License for more details.
;; You should have received a copy of the GNU General Public License
;; along with this program. If not, see <>.
;;; Commentary:
;; This file defines common data structures, variables and functions used in
;; Elbank.
;;; Code:
(require 'map)
(require 'seq)
(require 'json)
(require 'subr-x)
(eval-and-compile (require 'cl-lib))
(defgroup elbank nil
:prefix "elbank-"
:group 'tools)
(defcustom elbank-data-file (locate-user-emacs-file "elbank-data.el")
"Location of the file used to store elbank data."
:type '(file))
(defcustom elbank-categories nil
"Alist of categories of transactions.
Each category has an associated list of regular expressions.
A transaction's category is found by testing each regexp in order.
Example of categories
(setq elbank-categories
\\='((\"Expenses:Groceries\" . (\"walmart\" \"city market\"))
(\"Income:Salary\" . (\"paycheck\"))))"
:type '(alist :key-type (string :tag "Category name")
:value-type (repeat (string :tag "Regexp"))))
(defface elbank-header-face '((t . (:inherit font-lock-keyword-face
:height 1.3)))
"Face for displaying header in elbank."
:group 'elbank)
(defface elbank-subheader-face '((t . (:weight bold
:height 1.1)))
"Face for displaying sub headers in elbank."
:group 'elbank)
(defface elbank-positive-amount-face '((t . (:inherit success :weight normal)))
"Face for displaying positive amounts."
:group 'elbank)
(defface elbank-negative-amount-face '((t . (:inherit error :weight normal)))
"Face for displaying positive amounts."
:group 'elbank)
(defface elbank-entry-face '((t . ()))
"Face for displaying entries in elbank."
:group 'elbank)
(defvar elbank-data nil
"Alist of all accounts and transactions.")
(defvar elbank-report-available-columns '(date rdate label raw category account amount)
"List of all available columns in reports.")
(defun elbank-read-data ()
"Return an alist of boobank data read from `elbank-data-file'.
Data is cached to `elbank-data'."
(let ((file (expand-file-name elbank-data-file)))
(when (file-exists-p file)
(load file t))))
(defun elbank-write-data ()
"Write `elbank-data' to `elbank-data-file'."
(let ((print-circle t)
(print-level nil)
(print-length nil))
(with-temp-file (expand-file-name elbank-data-file)
(insert ";; This file is automatically generated by Indium.")
(insert (format "(setq %s '%S)" "elbank-data" elbank-data)))))
(defun elbank-account-with-id (id)
"Return the account with ID, or nil."
(seq-find (lambda (acc)
(string= (map-elt acc 'id)
(map-elt elbank-data 'accounts)))
(defun elbank-account-name (account)
"Return a human-readable name for ACCOUNT."
(format "%s@%s"
(elbank-account-group account)
(map-elt account 'label)))
(defun elbank-account-group (account)
"Return the group into which ACCOUNT is classified."
(cadr (split-string (map-elt account 'id) "@")))
(cl-defgeneric elbank-transaction-elt (transaction key &optional default)
"Return the value of TRANSACTION at KEY.
If the result is nil, return DEFAULT."
(map-elt transaction key default))
(cl-defmethod elbank-transaction-elt (transaction (_key (eql label)) &optional default)
"Return the label of TRANSACTION.
The label is defined as the value at symbol `label' if present,
the value at symbol `raw' if not.
Transactions can optionally have a `custom-label', which takes
priority when set.
If all are nil, return DEFAULT."
(or (map-elt transaction 'label nil)
(map-elt transaction 'raw default)))
(cl-defmethod elbank-transaction-elt (transaction (key (eql account)) &optional default)
"Return the account of TRANSACTION.
If TRANSACTION is a split transaction, return the account of its parent transaction."
(if (elbank-sub-transaction-p transaction)
(elbank-transaction-elt (elbank-transaction-elt transaction 'split-from) 'account)
(cl-call-next-method transaction key default)))
(cl-defmethod elbank-transaction-elt (transaction (_key (eql category)) &optional default)
"Return the category of TRANSACTION.
Split transactions (that hold an list of (CATEGORY . AMOUNT)
elements as category) have no category.
If the result is nil, return DEFAULT."
(let ((case-fold-search t)
(custom-category (map-elt transaction 'category)))
(cond ((or (null custom-category)
(and (stringp custom-category)
(string-empty-p custom-category)))
(seq-find #'identity
(lambda (key category)
(when (seq-find
(lambda (regexp)
(map-elt transaction 'raw)))
((stringp custom-category) custom-category)
(t default))))
(cl-defgeneric (setf elbank-transaction-elt) (store transaction key)
(if (map-contains-key transaction key)
(setf (map-elt transaction key) store)
;; Always mutate transactions in place.
(nconc transaction (list (cons key store)))))
(defun elbank-transaction-split-p (transaction)
"Return non-nil if TRANSACTION is split.
Split transactions have an alist of (CATEGORY-NAME . AMOUNT) as
(let ((category (map-elt transaction 'category)))
(and (not (null category))
(listp category))))
(defun elbank-sub-transaction-p (transaction)
"Return non-nil if TRANSACTION is a sub transaction.
Sub transactions are built dynamically from
`elbank-all-transactions' from split transactions."
(not (null (elbank-transaction-elt transaction 'split-from))))
(defmacro elbank-filter-transactions (collection &rest query)
"Returned all transactions in COLLECTION that match QUERY.
QUERY is a plist of the form (:KEY1 VAL1 :KEY2 VAL2...) where
keys are keywords matching transaction keys.
Special keys in QUERY:
- `:category': Match transactions within the category name, using
- `:period': The value must be a list of the form (TYPE TIME),
where TYPE is either year or month.
- `:account-id': Lookup an account with the queried id and match
transactions for that account."
(let* ((sym (make-symbol "transaction"))
(query (elbank--make-queries sym query)))
`(seq-filter (lambda (,sym) ,query) ,collection)))
(defun elbank--make-queries (transaction queries)
"Return a form for filtering TRANSACTION with QUERIES."
(let ((filters (seq-map (lambda (query)
(elbank--make-query transaction
(car query)
(cadr query)))
(seq-partition queries 2))))
`(and ,@filters)))
(cl-defgeneric elbank--make-query (transaction key val)
(unless (keywordp key)
(error "Query KEY must be a keyword"))
`(or (null ,val)
(equal (elbank-transaction-elt ,transaction
',(intern (seq-drop (symbol-name key) 1)))
(cl-defmethod elbank--make-query (transaction (_key (eql :category)) val)
`(elbank-transaction-in-category-p ,transaction ,val))
(cl-defmethod elbank--make-query (transaction (_key (eql :account-id)) val)
(elbank--make-query transaction :account `(elbank-account-with-id ,val)))
(cl-defmethod elbank--make-query (transaction (_key (eql :period)) period)
"Return a period query for TRANSACTION.
PERIOD is a list of the form `(type time)', with `type' a
symbol (`month' or `year'), and `time' an encoded time."
(let ((period-var (make-symbol "period")))
`(let ((,period-var ,period))
(pcase (car ,period-var)
(`year (elbank--make-period-query-format ,transaction
(cadr ,period-var)
(`month (elbank--make-period-query-format ,transaction
(cadr ,period-var)
(`nil t)
(_ (error "Invalid period type %S" (car ,period-var)))))))
(defun elbank--make-period-query-format (transaction time format)
"Return a period query for TRANSACTION and TIME.
Comparison is done by formatting times using FORMAT."
(string= (format-time-string format time)
(format-time-string format (elbank--transaction-time transaction))))
(defun elbank--transaction-time (transaction)
"Return the encoded time for TRANSACTION."
(apply #'encode-time
(seq-map (lambda (el)
(or el 0))
(parse-time-string (elbank-transaction-elt transaction 'date)))))
(defun elbank-transaction-in-category-p (transaction category)
"Return non-nil if TRANSACTION belongs to CATEGORY."
(or (null category)
(string-prefix-p category (elbank-transaction-elt transaction 'category "") t)))
(defun elbank-sum-transactions (transactions)
"Return the sum of all TRANSACTIONS.
TRANSACTIONS are expected to all use the same currency."
(seq-reduce (lambda (acc transaction)
(+ acc
(string-to-number (elbank-transaction-elt transaction 'amount))))
(defun elbank-transaction-years ()
"Return all years for which there is a transaction."
(seq-sort #'time-less-p
(seq-map (lambda (transaction)
(encode-time 0 0 0 1 1
(seq-elt (decode-time
(elbank--transaction-time transaction))
(defun elbank-transaction-months ()
"Return all months for which there is a transaction."
(seq-sort #'time-less-p
(seq-map (lambda (transaction)
(let ((time (decode-time
(elbank--transaction-time transaction))))
(encode-time 0 0 0 1 (seq-elt time 4) (seq-elt time 5))))
(defun elbank-all-transactions (&optional nosplit)
"Return all transactions for all accounts.
When NOSPLIT is nil, split transactions that have multiple
categories into multiple transactions."
(if nosplit
(map-elt elbank-data 'transactions)
(seq-mapcat (lambda (transaction)
(if (elbank-transaction-split-p transaction)
(seq-map (lambda (cat)
(let ((sub-trans (copy-alist transaction)))
(map-put sub-trans 'amount (cdr cat))
(map-put sub-trans 'category (car cat))
(map-put sub-trans 'label
(format "[split] %s"
(map-put sub-trans 'split-from transaction)
(map-elt transaction 'category))
(list transaction)))
(elbank-all-transactions t))))
(defun elbank--longest-account-label ()
"Return the longest account label from all accounts."
(seq-reduce (lambda (label1 label2)
(if (> (seq-length label1)
(seq-length label2))
(seq-map (lambda (account)
(map-elt account 'label))
(map-elt elbank-data 'accounts))
(defun elbank--insert-amount (amount &optional currency)
"Insert AMOUNT as a float with a precision of 2 decimals.
When CURRENCY is non-nil, append it to the inserted text.
AMOUNT is fontified based on whether it is negative or positive."
(let ((beg (point))
(number (if (numberp amount)
(string-to-number amount))))
(insert (format "%.2f %s" number (or currency "")))
(put-text-property beg (point)
(if (< number 0)
(defun elbank--propertize-amount (amount &optional currency)
"Fontify AMOUNT based on whether it is positive or not.
When CURRENCY is non-nil, append it to the inserted text."
(elbank--insert-amount amount currency)
(defun elbank-format-period (period)
"Return the string representation of PERIOD."
(pcase (car period)
(`year (format-time-string "Year %Y" (cadr period)))
(`month (format-time-string "%B %Y" (cadr period)))
(`nil "")
(`_ "Invalid period")))
(defun elbank-quit ()
"Kill the current buffer."
(quit-window t))
;;; Common major-mode for reports
(defvar elbank-report-update-hook nil
"Hook run when a report update is requested.")
(defvar elbank-report-period nil
"Period filter used in a report buffer.")
(make-variable-buffer-local 'elbank-report-period)
(defvar elbank-base-report-mode-map
(let ((map (make-sparse-keymap)))
(define-key map (kbd "q") #'elbank-quit)
(define-key map (kbd "n") #'forward-button)
(define-key map (kbd "p") #'backward-button)
(define-key map [tab] #'forward-button)
(define-key map [backtab] #'backward-button)
(define-key map (kbd "M-n") #'elbank-base-report-forward-period)
(define-key map (kbd "M-p") #'elbank-base-report-backward-period)
(define-key map (kbd "g") #'elbank-base-report-refresh)
"Keymap for `elbank-base-report-mode'.")
(define-derived-mode elbank-base-report-mode nil "Base elbank reports"
"Base major mode for viewing a report.
(setq-local truncate-lines nil)
(defun elbank-base-report-refresh ()
"Request an update of the current report."
(run-hooks 'elbank-base-report-refresh-hook))
(defun elbank-base-report-forward-period (&optional n)
"Select the next N period and update the current report.
If there is no period filter, signal an error."
(interactive "p")
(unless elbank-report-period
(user-error "No period filter for the current report"))
(let* ((periods (pcase (car elbank-report-period)
(`year (elbank-transaction-years))
(`month (elbank-transaction-months))))
(cur-index (seq-position periods (cadr elbank-report-period)))
(new-index (+ n cur-index))
(period (seq-elt periods new-index)))
(if period
(setq elbank-report-period (list (car elbank-report-period)
(user-error "No more periods"))))
(defun elbank-base-report-backward-period (&optional n)
"Select the previous N period and update the current report.
If there is no period filter, signal an error."
(interactive "p")
(elbank-base-report-forward-period (- n)))
(provide 'elbank-common)
;;; elbank-common.el ends here