;;; money.lisp --- Monetary amounts, on top of cambl. ;;; ;;; SPDX-License-Identifier: GPL-3.0-or-later ;;; ;;; Copyright (C) 2026 Anthony Green ;;; ;;; chirp represents money with cambl (Commoditized Amounts and Balances, ;;; from the ledger ecosystem): a quantity bound to a commodity, with exact ;;; rational arithmetic and display rounding. This module is the thin, ;;; chirp-flavoured layer over cambl -- nil-tolerant folds (so an empty sum ;;; needs no typed zero), scalar multiply/divide for unit maths, and two ;;; formatters (with a currency symbol, or a bare aligned number). ;;; ;;; A "money" value here is either a CAMBL:AMOUNT or NIL (the additive ;;; identity / "nothing yet"). Unit counts are kept as plain RATIONALs and ;;; used as scalars. (in-package #:chirp) (defun parse-money (string) "Parse a ledger-formatted amount string (\"$2,000.00\", \"CAD -150.00\") into a cambl amount, or NIL for empty/blank/bare-zero input." (let ((s (string-trim '(#\Space #\Newline #\Return #\Tab) string))) (if (or (string= s "") (string= s "0") (string= s "0.00")) nil (ignore-errors (cambl:amount s))))) (defun money-commodity (m) "Commodity symbol of money M, or NIL." (when m (ignore-errors (cambl:commodity-name (cambl:amount-commodity m))))) (defun money-quantity (m) "The exact RATIONAL quantity of M (0 when NIL)." (if m (cambl:amount-quantity m) 0)) (defun money+ (a b) "Add two money values; NIL is the identity. A and B must share a commodity (or one be NIL); cambl would otherwise build a balance." (cond ((null a) b) ((null b) a) (t (cambl:add a b)))) (defun money- (a b) "Subtract money B from A; NIL means zero on either side." (cond ((null b) a) ((null a) (cambl:negate b)) (t (cambl:subtract a b)))) (defun money* (a scalar) "Multiply money A by a rational SCALAR, preserving exactness." (when a (cambl:multiply a scalar))) (defun money/ (a scalar) "Divide money A by a rational SCALAR, preserving exactness." (when (and a (not (zerop scalar))) (cambl:divide a scalar))) (defun money-neg (a) (when a (cambl:negate a))) (defun money-abs (a) (when a (cambl:value-abs a))) (defun money-zerop (a) (or (null a) (cambl:value-zerop a))) (defun money>= (a threshold) "True when money A is >= THRESHOLD (both cambl amounts, same commodity)." (and a (cambl:value-greatereqp a threshold))) ;;; --- formatting ------------------------------------------------------------ (defun money-format (m &optional base) "Format M with its currency symbol (e.g. \"$2,000.00\"). When M is NIL, show a zero in BASE (a commodity string) if given, else \"0.00\"." (cond (m (cambl:format-value m)) (base (cambl:format-value (cambl:amount (format nil "~A 0" base)))) (t "0.00"))) (defun group-thousands (digits) "Insert commas into a string of DIGITS (integer part, no sign)." (let* ((n (length digits)) (out (make-string-output-stream))) (loop for i from 0 below n do (when (and (plusp i) (zerop (mod (- n i) 3))) (write-char #\, out)) (write-char (char digits i) out)) (get-output-stream-string out))) (defun money-bare (m) "Format M as a bare number (no commodity), 2 decimals, thousands-grouped \(for columns whose header already names the currency). NIL -> 0.00." (let* ((q (money-quantity m)) (neg (minusp q)) (cents (round (* (abs q) 100))) (dollars (floor cents 100)) (frac (mod cents 100))) (format nil "~:[~;-~]~A.~2,'0D" neg (group-thousands (princ-to-string dollars)) frac)))