Implement sigil-ledger library for hledger journal files
Core (sigil ledger) module provides: - Record types for transactions, postings, amounts, and costs - Journal parser handling dates, status flags, codes, descriptions, amounts with commodities, cost notation (@/@@), balance assertions, inline comments, and tags - Journal writer producing valid hledger format - Transaction deduplication by reference ID tag - File read/write/append operations
Report module (sigil ledger report) wraps hledger CLI for: - Balance, register, and income statement reports - JSON output parsing via hledger print - Flexible query and option passing
Includes 29 tests covering formatting, parsing, round-tripping, cost notation, balance assertions, tags, and deduplication.
.gitignore | 2 +
package.sgl | 3 +-
src/sigil/ledger.sgl | 675 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
src/sigil/ledger/report.sgl | 176 +++++++++++++++++++++++++++++++++++++++++++
test/test-ledger.sgl | 387 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
5 files changed, 1242 insertions(+), 1 deletion(-).gitignoreadded
build/.mcp.jsonpackage.sglmodified
dependencies: (list (from-git url: sigil-repo package: "sigil-stdlib") (from-git url: sigil-repo package: "sigil-json") (from-git url: sigil-repo package: "sigil-log")) (from-git url: sigil-repo package: "sigil-log") (from-git url: sigil-repo package: "sigil-test")) tasks: (list (tasksrc/sigil/ledger.sgladded
;;; (sigil ledger) - hledger Journal File Reader/Writer;;;;;; Read and write hledger journal files. Parses transactions, postings,;;; amounts with commodities, tags, and cost notation. Supports appending;;; new transactions and deduplicating by reference ID.;;;;;; ## Basic Usage;;;;;; ```scheme;;; (import (sigil ledger));;;;;; ;; Read a journal file;;; (define txns (read-journal "main.journal"));;;;;; ;; Write transactions to a file;;; (write-journal "output.journal" txns);;;;;; ;; Append new transactions (common case for imports);;; (append-transactions "main.journal" new-txns);;;;;; ;; Deduplicate by reference ID tag;;; (define unique (deduplicate-transactions new-txns existing-txns));;; ```(define-library (sigil ledger) (import (sigil string) (sigil io) (sigil fs) (sigil struct)) (export ;; Records journal-amount journal-amount? journal-amount-quantity journal-amount-commodity journal-cost journal-cost? journal-cost-type journal-cost-amount journal-posting journal-posting? journal-posting-account journal-posting-amount journal-posting-cost journal-posting-comment journal-posting-tags journal-posting-balance-assertion journal-transaction journal-transaction? journal-transaction-date journal-transaction-status journal-transaction-code journal-transaction-description journal-transaction-comment journal-transaction-tags journal-transaction-postings ;; Parsing read-journal parse-journal ;; Writing write-journal format-transaction format-transactions ;; Appending & deduplication append-transactions deduplicate-transactions) (begin ;; ========== Records ========== (define-struct journal-amount (quantity default: 0) (commodity default: "")) (define-struct journal-cost (type default: 'per-unit) ; 'per-unit (@) or 'total (@@) (amount default: #f)) ; journal-amount (define-struct journal-posting (account default: "") (amount default: #f) ; journal-amount or #f (inferred) (cost default: #f) ; journal-cost or #f (comment default: "") (tags default: '()) ; alist of (name . value) (balance-assertion default: #f)) ; journal-amount or #f (define-struct journal-transaction (date default: "") (status default: "") ; "" (unmarked), "!" (pending), "*" (cleared) (code default: "") ; optional code in parentheses (description default: "") (comment default: "") (tags default: '()) ; alist of (name . value) (postings default: '())) ; list of journal-posting ;; ========== Journal Writing ========== ;;; Format a journal-amount as a string. ;;; ;;; Handles both left-symbol (e.g. "$100.00") and right-symbol ;;; (e.g. "100.00 EUR") commodities. Symbols containing only ;;; common currency characters are placed adjacent to the number; ;;; alphabetic commodity codes are placed after with a space. (define (format-amount amt) (if (not amt) "" (let ((qty (journal-amount-quantity amt)) (comm (journal-amount-commodity amt))) (if (string-empty? comm) (format-number qty) (if (currency-symbol? comm) (str comm (format-number qty)) (str (format-number qty) " " comm)))))) ;;; Check if a commodity string is a currency symbol (like $, EUR, etc.) ;;; Currency symbols go before the number, alpha codes go after. (define (currency-symbol? s) (and (not (string-empty? s)) (let ((c (string-ref s 0))) (or (char=? c #\$) (char=? c (integer->char 163)) ; pound (char=? c (integer->char 165)) ; yen (char=? c (integer->char 8364)) ; euro sign )))) ;;; Format a number for journal output. ;;; Ensures at least 2 decimal places for currency amounts. (define (format-number n) (if (integer? n) (str (number->string n) ".00") (let ((s (number->string n))) ;; Ensure at least 2 decimal places (let ((dot-pos (string-find s "."))) (if dot-pos (let ((decimals (- (string-length s) dot-pos 1))) (if (< decimals 2) (str s (string-repeat "0" (- 2 decimals))) s)) (str s ".00")))))) ;;; Format a cost notation string. (define (format-cost cost) (if (not cost) "" (let ((type (journal-cost-type cost)) (amt (journal-cost-amount cost))) (if (eq? type 'total) (str " @@ " (format-amount amt)) (str " @ " (format-amount amt)))))) ;;; Format tags as a comment string fragment. ;;; Returns a string like "; tag1:val1, tag2:val2" or "" if no tags. (define (format-tags tags) (if (or (not tags) (null? tags)) "" (string-join (map (lambda (tag) (if (string-empty? (cdr tag)) (str (car tag) ":") (str (car tag) ": " (cdr tag)))) tags) ", "))) ;;; Format a single posting as a journal line. (define (format-posting posting) (let* ((account (journal-posting-account posting)) (amt (journal-posting-amount posting)) (cost (journal-posting-cost posting)) (comment (journal-posting-comment posting)) (tags (journal-posting-tags posting)) (bal (journal-posting-balance-assertion posting)) (amount-str (if amt (format-amount amt) "")) (cost-str (format-cost cost)) (bal-str (if bal (str " = " (format-amount bal)) "")) ;; Combine tags and comment (tag-str (format-tags tags)) (comment-parts (cond ((and (not (string-empty? comment)) (not (string-empty? tag-str))) (str " ; " comment ", " tag-str)) ((not (string-empty? comment)) (str " ; " comment)) ((not (string-empty? tag-str)) (str " ; " tag-str)) (else "")))) (if (string-empty? amount-str) (str " " account comment-parts) (str " " account " " amount-str cost-str bal-str comment-parts)))) ;;; Format a single transaction as a journal string. ;;; ;;; ```scheme ;;; (format-transaction ;;; (journal-transaction ;;; date: "2026-03-15" ;;; status: "*" ;;; description: "Grocery store" ;;; postings: (list ;;; (journal-posting account: "expenses:food" amount: (journal-amount quantity: 47.23 commodity: "USD")) ;;; (journal-posting account: "assets:checking")))) ;;; ``` (define (format-transaction txn) (let* ((date (journal-transaction-date txn)) (status (journal-transaction-status txn)) (code (journal-transaction-code txn)) (desc (journal-transaction-description txn)) (comment (journal-transaction-comment txn)) (tags (journal-transaction-tags txn)) (postings (journal-transaction-postings txn)) ;; Build header line (header (str date (if (string-empty? status) "" (str " " status)) (if (string-empty? code) "" (str " (" code ")")) (if (string-empty? desc) "" (str " " desc)))) ;; Transaction-level comment/tags (tag-str (format-tags tags)) (txn-comment (cond ((and (not (string-empty? comment)) (not (string-empty? tag-str))) (str "\n ; " comment ", " tag-str)) ((not (string-empty? comment)) (str "\n ; " comment)) ((not (string-empty? tag-str)) (str "\n ; " tag-str)) (else ""))) ;; Format postings (posting-lines (map format-posting postings))) (string-join (cons (str header txn-comment) posting-lines) "\n"))) ;;; Format a list of transactions as a complete journal string. (define (format-transactions txns) (string-join (map format-transaction txns) "\n\n")) ;;; Write a list of transactions to a journal file. ;;; ;;; Creates or overwrites the file with valid hledger journal format. (define (write-journal path txns) (write-file-string path (str (format-transactions txns) "\n"))) ;;; Append transactions to an existing journal file. ;;; ;;; Adds new transactions to the end of the file, separated by ;;; blank lines. Creates the file if it doesn't exist. (define (append-transactions path txns) (if (null? txns) #t (let ((new-content (format-transactions txns))) (guard (exn (else (write-file-string path (str new-content "\n")))) (let ((existing (read-file-string path))) (write-file-string path (str (string-trim-end existing) "\n\n" new-content "\n"))))))) ;; ========== Journal Parsing ========== ;;; Read and parse an hledger journal file. ;;; ;;; Returns a list of journal-transaction records. ;;; ;;; ```scheme ;;; (define txns (read-journal "main.journal")) ;;; (journal-transaction-date (car txns)) ; => "2026-03-15" ;;; ``` (define (read-journal path) (parse-journal (read-file-string path))) ;;; Parse a journal string into a list of transactions. ;;; ;;; Handles dates, status flags, descriptions, postings with ;;; amounts and commodities, comments, tags, cost notation, ;;; and balance assertions. (define (parse-journal text) (let ((lines (string-split text "\n"))) (parse-lines lines '()))) ;; Parse lines into transactions, accumulating results (define (parse-lines lines acc) (if (null? lines) (reverse acc) (let ((line (car lines))) (if (transaction-start? line) ;; Found a transaction header — gather its lines (let ((result (gather-transaction-lines (cdr lines) '()))) (let ((posting-lines (car result)) (remaining (cdr result))) (let ((txn (parse-transaction line posting-lines))) (parse-lines remaining (cons txn acc))))) ;; Skip non-transaction lines (comments, directives, blank) (parse-lines (cdr lines) acc))))) ;; Check if a line starts a transaction (begins with a date) (define (transaction-start? line) (and (>= (string-length line) 10) (char-numeric? (string-ref line 0)) (char-numeric? (string-ref line 1)) (char-numeric? (string-ref line 2)) (char-numeric? (string-ref line 3)) (date-separator? (string-ref line 4)) (char-numeric? (string-ref line 5)) (char-numeric? (string-ref line 6)) (date-separator? (string-ref line 7)) (char-numeric? (string-ref line 8)) (char-numeric? (string-ref line 9)))) (define (date-separator? c) (or (char=? c #\-) (char=? c #\/) (char=? c #\.))) ;; Gather continuation lines (indented or blank) for a transaction (define (gather-transaction-lines lines acc) (if (null? lines) (cons (reverse acc) '()) (let* ((line (car lines)) (trimmed (string-trim line))) (if (or (string-empty? trimmed) (and (> (string-length line) 0) (or (char=? (string-ref line 0) #\space) (char=? (string-ref line 0) #\tab)))) ;; Skip blank lines within transaction, collect indented lines (if (string-empty? trimmed) ;; Blank line — could be end of transaction, peek ahead (if (and (pair? (cdr lines)) (> (string-length (cadr lines)) 0) (or (char=? (string-ref (cadr lines) 0) #\space) (char=? (string-ref (cadr lines) 0) #\tab))) ;; Next line is indented, continue (gather-transaction-lines (cdr lines) acc) ;; End of transaction (cons (reverse acc) (cdr lines))) (gather-transaction-lines (cdr lines) (cons line acc))) ;; Non-indented, non-blank line = new transaction or directive (cons (reverse acc) lines))))) ;; Parse a transaction from its header line and posting lines (define (parse-transaction header-line posting-lines) (let* ((header (parse-transaction-header header-line)) (txn-comment-and-tags (extract-header-comments posting-lines)) (txn-comment (car txn-comment-and-tags)) (txn-tags (cadr txn-comment-and-tags)) (real-posting-lines (caddr txn-comment-and-tags)) (postings (map parse-posting real-posting-lines))) (journal-transaction date: (car header) status: (cadr header) code: (caddr header) description: (cadddr header) comment: txn-comment tags: txn-tags postings: postings))) ;; Extract transaction-level comments (indented ; lines before postings with accounts) (define (extract-header-comments lines) (let loop ((remaining lines) (comment "") (tags '())) (if (null? remaining) (list comment tags '()) (let ((line (string-trim (car remaining)))) (if (and (> (string-length line) 0) (char=? (string-ref line 0) #\;)) ;; Comment line (let* ((comment-text (string-trim (substring line 1 (string-length line)))) (parsed-tags (parse-tags-from-comment comment-text)) (new-comment (if (string-empty? comment) comment-text (str comment ", " comment-text))) (new-tags (append tags parsed-tags))) (loop (cdr remaining) new-comment new-tags)) ;; Not a comment, these are posting lines (list comment tags remaining)))))) ;; Parse the transaction header line ;; Format: DATE [STATUS] [(CODE)] DESCRIPTION (define (parse-transaction-header line) (let* ((date (substring line 0 10)) (rest (string-trim (substring line 10 (string-length line)))) ;; Parse status (status-result (parse-status rest)) (status (car status-result)) (rest2 (cdr status-result)) ;; Parse code (code-result (parse-code rest2)) (code (car code-result)) (rest3 (cdr code-result)) ;; Remaining is description (description (string-trim rest3))) (list date status code description))) ;; Parse optional status flag (* or !) (define (parse-status text) (let ((s (string-trim text))) (if (string-empty? s) (cons "" s) (let ((c (string-ref s 0))) (cond ((char=? c #\*) (cons "*" (string-trim (substring s 1 (string-length s))))) ((char=? c #\!) (cons "!" (string-trim (substring s 1 (string-length s))))) (else (cons "" s))))))) ;; Parse optional code in parentheses (define (parse-code text) (let ((s (string-trim text))) (if (and (> (string-length s) 0) (char=? (string-ref s 0) #\()) (let ((close (string-find s ")"))) (if close (cons (substring s 1 close) (string-trim (substring s (+ close 1) (string-length s)))) (cons "" s))) (cons "" s)))) ;; Parse a posting line (define (parse-posting line) (let* ((trimmed (string-trim line)) ;; Split off inline comment (comment-split (split-inline-comment trimmed)) (main-part (car comment-split)) (comment-text (cdr comment-split)) (posting-tags (if (string-empty? comment-text) '() (parse-tags-from-comment comment-text))) ;; Parse the main part: account amount [cost] [= assertion] (parsed (parse-posting-parts main-part))) (journal-posting account: (car parsed) amount: (cadr parsed) cost: (caddr parsed) comment: comment-text tags: posting-tags balance-assertion: (cadddr parsed)))) ;; Split a line at the first inline comment (;), respecting the ;; hledger rule that ; must be preceded by 2+ spaces (define (split-inline-comment text) (let ((len (string-length text))) (let loop ((i 0)) (if (>= i len) (cons text "") (if (and (char=? (string-ref text i) #\;) (>= i 2) (char=? (string-ref text (- i 1)) #\space) (char=? (string-ref text (- i 2)) #\space)) (cons (string-trim-end (substring text 0 (- i 2))) (string-trim (substring text (+ i 1) len))) (loop (+ i 1))))))) ;; Parse tags from a comment string like "tag1:val1, tag2:val2" (define (parse-tags-from-comment text) (let ((parts (string-split text ","))) (let loop ((rest parts) (tags '())) (if (null? rest) (reverse tags) (let ((part (string-trim (car rest)))) (let ((colon (string-find part ":"))) (if colon (let ((name (string-trim (substring part 0 colon))) (value (string-trim (substring part (+ colon 1) (string-length part))))) (loop (cdr rest) (cons (cons name value) tags))) (loop (cdr rest) tags)))))))) ;; Parse posting parts: account, amount, cost, balance assertion ;; The account and amount are separated by 2+ spaces (define (parse-posting-parts text) (let ((trimmed (string-trim text))) ;; Find the split point: 2+ consecutive spaces (let ((split-pos (find-double-space trimmed))) (if (not split-pos) ;; No amount — just an account (amount inferred) (list trimmed #f #f #f) (let* ((account (string-trim (substring trimmed 0 split-pos))) (amount-part (string-trim (substring trimmed split-pos (string-length trimmed))))) ;; Parse amount part which may include cost and balance assertion (parse-amount-cost-assertion account amount-part)))))) ;; Find position of first occurrence of 2+ consecutive spaces (define (find-double-space text) (let ((len (string-length text))) (let loop ((i 0)) (if (>= (+ i 1) len) #f (if (and (char=? (string-ref text i) #\space) (char=? (string-ref text (+ i 1)) #\space)) i (loop (+ i 1))))))) ;; Parse amount, optional cost (@/@@), and optional balance assertion (=) (define (parse-amount-cost-assertion account text) (let* (;; Check for balance assertion first (assertion-split (split-balance-assertion text)) (amount-cost-part (car assertion-split)) (assertion (cdr assertion-split)) ;; Check for cost notation (cost-split (split-cost amount-cost-part))Showing the first 500 of 676 diff lines for this file. This diff is INCOMPLETE; read the file or clone the repository for the rest.
src/sigil/ledger/report.sgladded
;;; (sigil ledger report) - hledger CLI Report Generation;;;;;; Run hledger CLI commands and parse their output for reports.;;; Uses `--output-format json` where available for structured data.;;;;;; ## Basic Usage;;;;;; ```scheme;;; (import (sigil ledger report));;;;;; ;; Get account balances;;; (hledger-balance "main.journal" "assets");;;;;; ;; Get transaction register;;; (hledger-register "main.journal" "expenses" begin: "2026-01");;;;;; ;; Income statement;;; (hledger-income-statement "main.journal" period: "monthly");;;;;; ;; Run any hledger command;;; (hledger-command "accounts" file: "main.journal");;; ```(define-library (sigil ledger report) (import (sigil string) (sigil io) (sigil process) (sigil json)) (export hledger-available? hledger-command hledger-balance hledger-register hledger-income-statement hledger-print-json) (begin ;;; Check if hledger is available on PATH. (define (hledger-available?) (command-exists? "hledger")) (define (ensure-hledger!) (unless (hledger-available?) (error "hledger is not installed or not on PATH"))) ;;; Run an arbitrary hledger command and return its output as a string. ;;; ;;; The `file:` keyword specifies the journal file. ;;; Additional arguments are passed through to hledger. ;;; ;;; ```scheme ;;; (hledger-command "accounts" file: "main.journal") ;;; ; => "assets:checking\nassets:savings\n..." ;;; ;;; (hledger-command "bal" file: "main.journal" args: '("assets" "--tree")) ;;; ; => balance report as text ;;; ``` (define (hledger-command command (keys: (file #f) (args '()))) (ensure-hledger!) (let ((cmd-args (if file (cons command (cons "-f" (cons file args))) (cons command args)))) (apply process-output->string "hledger" cmd-args))) ;; Internal: run a report command with parsed query/option args (define (run-report command file rest) (ensure-hledger!) (let* ((query-and-opts (parse-report-args rest)) (query (car query-and-opts)) (opts (cdr query-and-opts)) (args (build-report-args command file query opts))) (apply process-output->string "hledger" args))) ;;; Run `hledger bal` and return the output. ;;; ;;; ```scheme ;;; (hledger-balance "main.journal") ;;; (hledger-balance "main.journal" "assets") ;;; (hledger-balance "main.journal" "expenses" ;;; begin: "2026-01" end: "2026-04" depth: 2 tree: #t) ;;; ``` (define (hledger-balance file . rest) (run-report "bal" file rest)) ;;; Run `hledger reg` and return the output. ;;; ;;; ```scheme ;;; (hledger-register "main.journal" "expenses:food") ;;; (hledger-register "main.journal" begin: "2026-03") ;;; ``` (define (hledger-register file . rest) (run-report "reg" file rest)) ;;; Run `hledger is` (income statement) and return the output. ;;; ;;; ```scheme ;;; (hledger-income-statement "main.journal") ;;; (hledger-income-statement "main.journal" period: "monthly") ;;; ``` (define (hledger-income-statement file . rest) (run-report "is" file rest)) ;;; Run `hledger print -O json` and return parsed JSON. ;;; ;;; Returns the hledger JSON representation of transactions, ;;; parsed into Sigil dicts/arrays. ;;; ;;; ```scheme ;;; (define txns (hledger-print-json "main.journal")) ;;; ``` (define (hledger-print-json file . rest) (ensure-hledger!) (let* ((query-and-opts (parse-report-args rest)) (query (car query-and-opts)) (opts (cdr query-and-opts)) (args (build-report-args "print" file query (cons (cons output-format: "json") opts)))) (let ((output (apply process-output->string "hledger" args))) (if (string-empty? (string-trim output)) #[] (json-decode output))))) ;; Parse variadic report arguments into (query . opts) pair (define (parse-report-args args) (let loop ((rest args) (query '()) (opts '())) (if (null? rest) (cons (reverse query) (reverse opts)) (let ((arg (car rest))) (cond ((keyword? arg) (if (null? (cdr rest)) (cons (reverse query) (reverse opts)) (loop (cddr rest) query (cons (cons arg (cadr rest)) opts)))) ((string? arg) (loop (cdr rest) (cons arg query) opts)) (else (loop (cdr rest) query opts))))))) ;; Build hledger argument list from parsed report options (define (build-report-args command file query opts) (let ((base (list command "-f" file))) (let ((with-opts (fold-right (lambda (opt acc) (let ((key (car opt)) (val (cdr opt))) (cond ((eq? key begin:) (cons "-b" (cons val acc))) ((eq? key end:) (cons "-e" (cons val acc))) ((eq? key period:) (cons "-p" (cons val acc))) ((eq? key depth:) (cons "--depth" (cons (if (number? val) (number->string val) val) acc))) ((eq? key tree:) (if val (cons "--tree" acc) acc)) ((eq? key monthly:) (if val (cons "-M" acc) acc)) ((eq? key quarterly:) (if val (cons "-Q" acc) acc)) ((eq? key yearly:) (if val (cons "-Y" acc) acc)) ((eq? key output-format:) (cons "-O" (cons val acc))) ((eq? key cost:) (if val (cons "-B" acc) acc)) ((eq? key market:) (if val (cons "-V" acc) acc)) ((eq? key exchange:) (cons "-X" (cons val acc))) (else acc)))) '() opts))) (append base with-opts query))))))test/test-ledger.sgladded
;;; Test suite for (sigil ledger)(import (sigil test) (sigil string) (sigil ledger));; ========== Transaction Formatting ==========(test-group "format-transaction - basic" (test "format simple transaction" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Grocery store" postings: (list (journal-posting account: "expenses:food:groceries" amount: (journal-amount quantity: 47.23 commodity: "USD")) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 * Grocery store\n expenses:food:groceries 47.23 USD\n assets:checking" (format-transaction txn)))) (test "format transaction with pending status" (let ((txn (journal-transaction date: "2026-03-15" status: "!" description: "Pending purchase" postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 10 commodity: "USD")) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 ! Pending purchase\n expenses:food 10.00 USD\n assets:checking" (format-transaction txn)))) (test "format transaction with no status" (let ((txn (journal-transaction date: "2026-03-15" description: "Some purchase" postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 10 commodity: "USD")) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 Some purchase\n expenses:food 10.00 USD\n assets:checking" (format-transaction txn)))))(test-group "format-transaction - code and description" (test "format transaction with code" (let ((txn (journal-transaction date: "2026-03-15" status: "*" code: "ref-001" description: "Whole Foods | groceries" postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 47.23 commodity: "$")) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 * (ref-001) Whole Foods | groceries\n expenses:food $47.23\n assets:checking" (format-transaction txn)))))(test-group "format-transaction - tags" (test "format transaction with tags" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Dinner" tags: (list (cons "trip" "hawaii")) postings: (list (journal-posting account: "expenses:meals" amount: (journal-amount quantity: 85 commodity: "$")) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking" (format-transaction txn)))) (test "format posting with inline tags" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Test" postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 50 commodity: "USD") tags: (list (cons "receipt" "scan-001"))) (journal-posting account: "assets:checking"))))) (assert-equal "2026-03-15 * Test\n expenses:food 50.00 USD ; receipt: scan-001\n assets:checking" (format-transaction txn)))))(test-group "format-transaction - cost notation" (test "format total cost (@@)" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Currency conversion" postings: (list (journal-posting account: "assets:wise:eur" amount: (journal-amount quantity: -500 commodity: "EUR")) (journal-posting account: "assets:wise:usd" amount: (journal-amount quantity: 540 commodity: "USD") cost: (journal-cost type: 'total amount: (journal-amount quantity: 500 commodity: "EUR"))))))) (assert-equal "2026-03-15 * Currency conversion\n assets:wise:eur -500.00 EUR\n assets:wise:usd 540.00 USD @@ 500.00 EUR" (format-transaction txn)))) (test "format per-unit cost (@)" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Currency conversion" postings: (list (journal-posting account: "assets:wise:eur" amount: (journal-amount quantity: 100 commodity: "EUR") cost: (journal-cost type: 'per-unit amount: (journal-amount quantity: 1.08 commodity: "USD"))) (journal-posting account: "assets:wise:usd"))))) (assert-equal "2026-03-15 * Currency conversion\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd" (format-transaction txn)))))(test-group "format-transaction - balance assertions" (test "format balance assertion" (let ((txn (journal-transaction date: "2026-03-15" status: "*" description: "Deposit" postings: (list (journal-posting account: "assets:checking" amount: (journal-amount quantity: 3000 commodity: "$") balance-assertion: (journal-amount quantity: 5000 commodity: "$")) (journal-posting account: "income:salary"))))) (assert-equal "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary" (format-transaction txn)))))(test-group "format-transactions" (test "format multiple transactions" (let ((txns (list (journal-transaction date: "2026-03-15" status: "*" description: "Groceries" postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 47.23 commodity: "USD")) (journal-posting account: "assets:checking"))) (journal-transaction date: "2026-03-16" status: "*" description: "Gas" postings: (list (journal-posting account: "expenses:transport" amount: (journal-amount quantity: 30 commodity: "USD")) (journal-posting account: "assets:checking")))))) (assert-equal "2026-03-15 * Groceries\n expenses:food 47.23 USD\n assets:checking\n\n2026-03-16 * Gas\n expenses:transport 30.00 USD\n assets:checking" (format-transactions txns)))));; ========== Journal Parsing ==========(test-group "parse-journal - basic" (test "parse simple transaction" (let* ((text "2026-03-15 * Grocery store\n expenses:food $47.23\n assets:checking\n") (txns (parse-journal text))) (assert-equal 1 (length txns)) (let ((txn (car txns))) (assert-equal "2026-03-15" (journal-transaction-date txn)) (assert-equal "*" (journal-transaction-status txn)) (assert-equal "Grocery store" (journal-transaction-description txn)) (assert-equal 2 (length (journal-transaction-postings txn)))))) (test "parse transaction with pending status" (let* ((text "2026-03-15 ! Pending purchase\n expenses:food $10.00\n assets:checking\n") (txns (parse-journal text))) (assert-equal "!" (journal-transaction-status (car txns))))) (test "parse transaction with no status" (let* ((text "2026-03-15 Some purchase\n expenses:food $10.00\n assets:checking\n") (txns (parse-journal text))) (assert-equal "" (journal-transaction-status (car txns))) (assert-equal "Some purchase" (journal-transaction-description (car txns))))) (test "parse transaction with code" (let* ((text "2026-03-15 * (ref-001) Whole Foods\n expenses:food $47.23\n assets:checking\n") (txns (parse-journal text))) (let ((txn (car txns))) (assert-equal "ref-001" (journal-transaction-code txn)) (assert-equal "Whole Foods" (journal-transaction-description txn))))) (test "parse multiple transactions" (let* ((text (str "2026-03-15 * Groceries\n" " expenses:food $47.23\n" " assets:checking\n" "\n" "2026-03-16 * Gas\n" " expenses:transport $30.00\n" " assets:checking\n")) (txns (parse-journal text))) (assert-equal 2 (length txns)) (assert-equal "Groceries" (journal-transaction-description (car txns))) (assert-equal "Gas" (journal-transaction-description (cadr txns))))))(test-group "parse-journal - amounts" (test "parse left-symbol commodity" (let* ((text "2026-03-15 * Test\n expenses:food $47.23\n assets:checking\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns))))) (assert-equal "$" (journal-amount-commodity (journal-posting-amount posting))) (assert-equal 47.23 (journal-amount-quantity (journal-posting-amount posting))))) (test "parse right-symbol commodity" (let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR\n assets:checking\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns))))) (assert-equal "EUR" (journal-amount-commodity (journal-posting-amount posting))) (assert-equal 100.0 (journal-amount-quantity (journal-posting-amount posting))))) (test "parse negative amount" (let* ((text "2026-03-15 * Test\n assets:wise:eur -500.00 EUR\n assets:checking\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns))))) (assert-equal -500.0 (journal-amount-quantity (journal-posting-amount posting))))) (test "parse inferred amount posting" (let* ((text "2026-03-15 * Test\n expenses:food $50.00\n assets:checking\n") (txns (parse-journal text)) (posting (cadr (journal-transaction-postings (car txns))))) (assert-false (journal-posting-amount posting)))))(test-group "parse-journal - cost notation" (test "parse per-unit cost" (let* ((text "2026-03-15 * Test\n assets:wise:eur 100.00 EUR @ 1.08 USD\n assets:wise:usd\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns)))) (cost (journal-posting-cost posting))) (assert-true (journal-cost? cost)) (assert-equal 'per-unit (journal-cost-type cost)) (assert-equal 1.08 (journal-amount-quantity (journal-cost-amount cost))) (assert-equal "USD" (journal-amount-commodity (journal-cost-amount cost))))) (test "parse total cost" (let* ((text "2026-03-15 * Test\n assets:wise:usd 540.00 USD @@ 500.00 EUR\n assets:wise:eur\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns)))) (cost (journal-posting-cost posting))) (assert-true (journal-cost? cost)) (assert-equal 'total (journal-cost-type cost)) (assert-equal 500.0 (journal-amount-quantity (journal-cost-amount cost))) (assert-equal "EUR" (journal-amount-commodity (journal-cost-amount cost))))))(test-group "parse-journal - comments and tags" (test "parse transaction with tag comment" (let* ((text "2026-03-15 * Dinner\n ; trip: hawaii\n expenses:meals $85.00\n assets:checking\n") (txns (parse-journal text)) (txn (car txns)) (tags (journal-transaction-tags txn))) (assert-equal 1 (length tags)) (assert-equal "trip" (caar tags)) (assert-equal "hawaii" (cdar tags)))) (test "parse posting with inline comment" (let* ((text "2026-03-15 * Test\n expenses:food $50.00 ; receipt: scan-001\n assets:checking\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns))))) (assert-equal 1 (length (journal-posting-tags posting))) (assert-equal "receipt" (caar (journal-posting-tags posting))) (assert-equal "scan-001" (cdar (journal-posting-tags posting))))))(test-group "parse-journal - balance assertions" (test "parse balance assertion" (let* ((text "2026-03-15 * Deposit\n assets:checking $3000.00 = $5000.00\n income:salary\n") (txns (parse-journal text)) (posting (car (journal-transaction-postings (car txns)))) (bal (journal-posting-balance-assertion posting))) (assert-true (journal-amount? bal)) (assert-equal 5000.0 (journal-amount-quantity bal)) (assert-equal "$" (journal-amount-commodity bal)))));; ========== Round-trip Tests ==========(test-group "round-trip" (test "parse and re-format simple transaction" (let* ((original (str "2026-03-15 * Grocery store\n" " expenses:food:groceries 47.23 USD\n" " assets:checking")) (txns (parse-journal (str original "\n"))) (formatted (format-transaction (car txns)))) (assert-equal original formatted))) (test "parse and re-format transaction with cost" (let* ((original (str "2026-03-15 * Currency conversion\n" " assets:wise:eur -500.00 EUR\n" " assets:wise:usd 540.00 USD @@ 500.00 EUR")) (txns (parse-journal (str original "\n"))) (formatted (format-transaction (car txns)))) (assert-equal original formatted))) (test "parse and re-format multiple transactions" (let* ((original (str "2026-03-15 * Groceries\n" " expenses:food 47.23 USD\n" " assets:checking\n" "\n" "2026-03-16 * Gas\n" " expenses:transport 30.00 USD\n" " assets:checking")) (txns (parse-journal (str original "\n"))) (formatted (format-transactions txns))) (assert-equal original formatted))));; ========== Deduplication ==========(test-group "deduplicate-transactions" (test "filter out duplicate by ref tag" (let* ((existing (list (journal-transaction date: "2026-03-15" status: "*" description: "Existing" tags: (list (cons "ref" "TRF-001")) postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 50 commodity: "USD")) (journal-posting account: "assets:checking"))))) (new-txns (list (journal-transaction date: "2026-03-16" status: "*" description: "Already imported" tags: (list (cons "ref" "TRF-001")) postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 50 commodity: "USD")) (journal-posting account: "assets:checking"))) (journal-transaction date: "2026-03-17" status: "*" description: "New one" tags: (list (cons "ref" "TRF-002")) postings: (list (journal-posting account: "expenses:food" amount: (journal-amount quantity: 30 commodity: "USD")) (journal-posting account: "assets:checking"))))) (result (deduplicate-transactions new-txns existing))) (assert-equal 1 (length result)) (assert-equal "New one" (journal-transaction-description (car result))))) (test "keep transactions without ref" (let* ((existing '()) (new-txns (list (journal-transaction date: "2026-03-15" description: "No ref" postings: (list (journal-posting account: "expenses:misc" amount: (journal-amount quantity: 10 commodity: "USD")) (journal-posting account: "assets:checking"))))) (result (deduplicate-transactions new-txns existing))) (assert-equal 1 (length result)))))(run-tests)