AtlatestRepositorysigil-ledger
sigil-ledger / tree / srcledger.sgl
1
;;; (ledger) - hledger Journal File Reader/Writer2
;;;3
;;; Read and write hledger journal files. Parses transactions, postings,4
;;; amounts with commodities, tags, and cost notation. Supports appending5
;;; new transactions and deduplicating by reference ID.6
;;;7
;;; ## Basic Usage8
;;;9
;;; ```scheme10
;;; (import (ledger))11
;;;12
;;; ;; Read a journal file13
;;; (define txns (read-journal "main.journal"))14
;;;15
;;; ;; Write transactions to a file16
;;; (write-journal "output.journal" txns)17
;;;18
;;; ;; Append new transactions (common case for imports)19
;;; (append-transactions "main.journal" new-txns)20
;;;21
;;; ;; Deduplicate by reference ID tag22
;;; (define unique (deduplicate-transactions new-txns existing-txns))23
;;; ```25
(define-library (ledger)26
(import (sigil string)27
(sigil io)28
(sigil fs)29
(sigil struct))30
(export31
;; Records32
journal-amount33
journal-amount?34
journal-amount-quantity35
journal-amount-commodity36
journal-amount-sign-before-commodity38
journal-cost39
journal-cost?40
journal-cost-type41
journal-cost-amount43
journal-posting44
journal-posting?45
journal-posting-account46
journal-posting-amount47
journal-posting-cost48
journal-posting-comment49
journal-posting-tags50
journal-posting-balance-assertion51
journal-posting-balance-assertion-type53
journal-transaction54
journal-transaction?55
journal-transaction-date56
journal-transaction-status57
journal-transaction-code58
journal-transaction-description59
journal-transaction-comment60
journal-transaction-tags61
journal-transaction-postings63
;; Parsing64
read-journal65
parse-journal67
;; Writing68
write-journal69
format-transaction70
format-transactions72
;; Appending & deduplication73
append-transactions74
deduplicate-transactions)76
(begin78
;; ========== Records ==========80
(define-struct journal-amount81
(quantity default: 0)82
(commodity default: "")83
(sign-before-commodity default: #f)) ; #t when parsed from "-$50" form85
(define-struct journal-cost86
(type default: 'per-unit) ; 'per-unit (@) or 'total (@@)87
(amount default: #f)) ; journal-amount89
(define-struct journal-posting90
(account default: "")91
(amount default: #f) ; journal-amount or #f (inferred)92
(cost default: #f) ; journal-cost or #f93
(comment default: "")94
(tags default: '()) ; alist of (name . value)95
(balance-assertion default: #f) ; journal-amount or #f96
(balance-assertion-type default: "=")) ; "=", "==", or "=*"98
(define-struct journal-transaction99
(date default: "")100
(status default: "") ; "" (unmarked), "!" (pending), "*" (cleared)101
(code default: "") ; optional code in parentheses102
(description default: "")103
(comment default: "")104
(tags default: '()) ; alist of (name . value)105
(postings default: '())) ; list of journal-posting107
;; ========== Journal Writing ==========109
;;; Format a journal-amount as a string.110
;;;111
;;; Handles both left-symbol (e.g. "$100.00") and right-symbol112
;;; (e.g. "100.00 EUR") commodities. Symbols containing only113
;;; common currency characters are placed adjacent to the number;114
;;; alphabetic commodity codes are placed after with a space.115
(define (format-amount amt)116
(if (not amt)117
""118
(let ((qty (journal-amount-quantity amt))119
(comm (journal-amount-commodity amt))120
(sign-before (journal-amount-sign-before-commodity amt)))121
(if (string-empty? comm)122
(format-number qty)123
(if (currency-symbol? comm)124
;; Preserve original sign placement for round-trip fidelity125
(if (and sign-before (< qty 0))126
(str "-" comm (format-number (- qty)))127
(str comm (format-number qty)))128
(str (format-number qty) " " comm))))))130
;;; Check if a commodity string is a currency symbol (like $, EUR, etc.)131
;;; Currency symbols go before the number, alpha codes go after.132
(define (currency-symbol? s)133
(and (not (string-empty? s))134
(let ((c (string-ref s 0)))135
(or (char=? c #\$)136
(char=? c (integer->char 163)) ; pound137
(char=? c (integer->char 165)) ; yen138
(char=? c (integer->char 8364)) ; euro sign139
))))141
;;; Format a number for journal output.142
;;; Ensures at least 2 decimal places for currency amounts.143
(define (format-number n)144
(if (integer? n)145
(str (number->string n) ".00")146
(let ((s (number->string n)))147
;; Ensure at least 2 decimal places148
(let ((dot-pos (string-find s ".")))149
(if dot-pos150
(let ((decimals (- (string-length s) dot-pos 1)))151
(if (< decimals 2)152
(str s (string-repeat "0" (- 2 decimals)))153
s))154
(str s ".00"))))))156
;;; Format a cost notation string.157
(define (format-cost cost)158
(if (not cost)159
""160
(let ((type (journal-cost-type cost))161
(amt (journal-cost-amount cost)))162
(if (eq? type 'total)163
(str " @@ " (format-amount amt))164
(str " @ " (format-amount amt))))))166
;;; Format tags as a comment string fragment.167
;;; Returns a string like "; tag1:val1, tag2:val2" or "" if no tags.168
(define (format-tags tags)169
(if (or (not tags) (null? tags))170
""171
(string-join (map (lambda (tag)172
(if (string-empty? (cdr tag))173
(str (car tag) ":")174
(str (car tag) ": " (cdr tag))))175
tags)176
", ")))178
;;; Format a single posting as a journal line.179
(define (format-posting posting)180
(let* ((account (journal-posting-account posting))181
(amt (journal-posting-amount posting))182
(cost (journal-posting-cost posting))183
(comment (journal-posting-comment posting))184
(tags (journal-posting-tags posting))185
(bal (journal-posting-balance-assertion posting))186
(amount-str (if amt (format-amount amt) ""))187
(cost-str (format-cost cost))188
(bal-str (if bal189
(str " " (journal-posting-balance-assertion-type posting)190
" " (format-amount bal))191
""))192
;; Combine tags and comment193
(tag-str (format-tags tags))194
(comment-parts (cond195
((and (not (string-empty? comment))196
(not (string-empty? tag-str)))197
(str " ; " comment ", " tag-str))198
((not (string-empty? comment))199
(str " ; " comment))200
((not (string-empty? tag-str))201
(str " ; " tag-str))202
(else ""))))203
(if (string-empty? amount-str)204
(str " " account comment-parts)205
(str " " account " " amount-str cost-str bal-str comment-parts))))207
;;; Format a single transaction as a journal string.208
;;;209
;;; ```scheme210
;;; (format-transaction211
;;; (journal-transaction212
;;; date: "2026-03-15"213
;;; status: "*"214
;;; description: "Grocery store"215
;;; postings: (list216
;;; (journal-posting account: "expenses:food" amount: (journal-amount quantity: 47.23 commodity: "USD"))217
;;; (journal-posting account: "assets:checking"))))218
;;; ```219
(define (format-transaction txn)220
(let* ((date (journal-transaction-date txn))221
(status (journal-transaction-status txn))222
(code (journal-transaction-code txn))223
(desc (journal-transaction-description txn))224
(comment (journal-transaction-comment txn))225
(tags (journal-transaction-tags txn))226
(postings (journal-transaction-postings txn))227
;; Build header line228
(header (str date229
(if (string-empty? status) "" (str " " status))230
(if (string-empty? code) "" (str " (" code ")"))231
(if (string-empty? desc) "" (str " " desc))))232
;; Transaction-level comment/tags233
(tag-str (format-tags tags))234
(txn-comment (cond235
((and (not (string-empty? comment))236
(not (string-empty? tag-str)))237
(str "\n ; " comment ", " tag-str))238
((not (string-empty? comment))239
(str "\n ; " comment))240
((not (string-empty? tag-str))241
(str "\n ; " tag-str))242
(else "")))243
;; Format postings244
(posting-lines (map format-posting postings)))245
(string-join (cons (str header txn-comment) posting-lines) "\n")))247
;;; Format a list of transactions as a complete journal string.248
(define (format-transactions txns)249
(string-join (map format-transaction txns) "\n\n"))251
;;; Write a list of transactions to a journal file.252
;;;253
;;; Creates or overwrites the file with valid hledger journal format.254
(define (write-journal path txns)255
(write-file-string path (str (format-transactions txns) "\n")))257
;;; Append transactions to an existing journal file.258
;;;259
;;; Adds new transactions to the end of the file, separated by260
;;; blank lines. Creates the file if it doesn't exist.261
(define (append-transactions path txns)262
(if (null? txns)263
#t264
(let ((new-content (format-transactions txns)))265
(guard (exn (else (write-file-string path (str new-content "\n"))))266
(let ((existing (read-file-string path)))267
(write-file-string path268
(str (string-trim-end existing) "\n\n" new-content "\n")))))))270
;; ========== Journal Parsing ==========272
;;; Read and parse an hledger journal file.273
;;;274
;;; Returns a list of journal-transaction records.275
;;;276
;;; ```scheme277
;;; (define txns (read-journal "main.journal"))278
;;; (journal-transaction-date (car txns)) ; => "2026-03-15"279
;;; ```280
(define (read-journal path)281
(parse-journal (read-file-string path)))283
;;; Parse a journal string into a list of transactions.284
;;;285
;;; Handles dates, status flags, descriptions, postings with286
;;; amounts and commodities, comments, tags, cost notation,287
;;; and balance assertions.288
(define (parse-journal text)289
(let ((lines (string-split text "\n")))290
(parse-lines lines '())))292
;; Parse lines into transactions, accumulating results293
(define (parse-lines lines acc)294
(if (null? lines)295
(reverse acc)296
(let ((line (car lines)))297
(if (transaction-start? line)298
;; Found a transaction header — gather its lines299
(let ((result (gather-transaction-lines (cdr lines) '())))300
(let ((posting-lines (car result))301
(remaining (cdr result)))302
(let ((txn (parse-transaction line posting-lines)))303
(parse-lines remaining (cons txn acc)))))304
;; Skip non-transaction lines (comments, directives, blank)305
(parse-lines (cdr lines) acc)))))307
;; Check if a line starts a transaction (begins with a date)308
(define (transaction-start? line)309
(and (>= (string-length line) 10)310
(char-numeric? (string-ref line 0))311
(char-numeric? (string-ref line 1))312
(char-numeric? (string-ref line 2))313
(char-numeric? (string-ref line 3))314
(date-separator? (string-ref line 4))315
(char-numeric? (string-ref line 5))316
(char-numeric? (string-ref line 6))317
(date-separator? (string-ref line 7))318
(char-numeric? (string-ref line 8))319
(char-numeric? (string-ref line 9))))321
(define (date-separator? c)322
(or (char=? c #\-) (char=? c #\/) (char=? c #\.)))324
;; Gather continuation lines (indented or blank) for a transaction325
(define (gather-transaction-lines lines acc)326
(if (null? lines)327
(cons (reverse acc) '())328
(let* ((line (car lines))329
(trimmed (string-trim line)))330
(if (or (string-empty? trimmed)331
(and (> (string-length line) 0)332
(or (char=? (string-ref line 0) #\space)333
(char=? (string-ref line 0) #\tab))))334
;; Skip blank lines within transaction, collect indented lines335
(if (string-empty? trimmed)336
;; Blank line — could be end of transaction, peek ahead337
(if (and (pair? (cdr lines))338
(> (string-length (cadr lines)) 0)339
(or (char=? (string-ref (cadr lines) 0) #\space)340
(char=? (string-ref (cadr lines) 0) #\tab)))341
;; Next line is indented, continue342
(gather-transaction-lines (cdr lines) acc)343
;; End of transaction344
(cons (reverse acc) (cdr lines)))345
(gather-transaction-lines (cdr lines) (cons line acc)))346
;; Non-indented, non-blank line = new transaction or directive347
(cons (reverse acc) lines)))))349
;; Parse a transaction from its header line and posting lines350
(define (parse-transaction header-line posting-lines)351
(let* ((header (parse-transaction-header header-line))352
(txn-comment-and-tags (extract-header-comments posting-lines))353
(txn-comment (car txn-comment-and-tags))354
(txn-tags (cadr txn-comment-and-tags))355
(real-posting-lines (caddr txn-comment-and-tags))356
(postings (map parse-posting real-posting-lines)))357
(journal-transaction358
date: (car header)359
status: (cadr header)360
code: (caddr header)361
description: (cadddr header)362
comment: txn-comment363
tags: txn-tags364
postings: postings)))366
;; Extract transaction-level comments (indented ; lines before postings with accounts)367
(define (extract-header-comments lines)368
(let loop ((remaining lines) (comment "") (tags '()))369
(if (null? remaining)370
(list comment tags '())371
(let ((line (string-trim (car remaining))))372
(if (and (> (string-length line) 0)373
(char=? (string-ref line 0) #\;))374
;; Comment line375
(let* ((comment-text (string-trim (substring line 1 (string-length line))))376
(parsed-tags (parse-tags-from-comment comment-text))377
(new-comment (if (string-empty? comment)378
comment-text379
(str comment ", " comment-text)))380
(new-tags (append tags parsed-tags)))381
(loop (cdr remaining) new-comment new-tags))382
;; Not a comment, these are posting lines383
(list comment tags remaining))))))385
;; Parse the transaction header line386
;; Format: DATE [STATUS] [(CODE)] DESCRIPTION387
(define (parse-transaction-header line)388
(let* ((date (substring line 0 10))389
(rest (string-trim (substring line 10 (string-length line))))390
;; Parse status391
(status-result (parse-status rest))392
(status (car status-result))393
(rest2 (cdr status-result))394
;; Parse code395
(code-result (parse-code rest2))396
(code (car code-result))397
(rest3 (cdr code-result))398
;; Remaining is description399
(description (string-trim rest3)))400
(list date status code description)))402
;; Parse optional status flag (* or !)403
(define (parse-status text)404
(let ((s (string-trim text)))405
(if (string-empty? s)406
(cons "" s)407
(let ((c (string-ref s 0)))408
(cond409
((char=? c #\*)410
(cons "*" (string-trim (substring s 1 (string-length s)))))411
((char=? c #\!)412
(cons "!" (string-trim (substring s 1 (string-length s)))))413
(else414
(cons "" s)))))))416
;; Parse optional code in parentheses417
(define (parse-code text)418
(let ((s (string-trim text)))419
(if (and (> (string-length s) 0) (char=? (string-ref s 0) #\())420
(let ((close (string-find s ")")))421
(if close422
(cons (substring s 1 close)423
(string-trim (substring s (+ close 1) (string-length s))))424
(cons "" s)))425
(cons "" s))))427
;; Parse a posting line428
(define (parse-posting line)429
(let* ((trimmed (string-trim line))430
;; Split off inline comment431
(comment-split (split-inline-comment trimmed))432
(main-part (car comment-split))433
(comment-text (cdr comment-split))434
(posting-tags (if (string-empty? comment-text)435
'()436
(parse-tags-from-comment comment-text)))437
;; Parse the main part: account amount [cost] [= assertion]438
;; Returns (account amount cost assertion assertion-type)439
(parsed (parse-posting-parts main-part))440
(p-account (car parsed))441
(p-amount (cadr parsed))442
(p-cost (caddr parsed))443
(p-assertion (cadddr parsed))444
(p-assertion-type (list-ref parsed 4)))445
(journal-posting446
account: p-account447
amount: p-amount448
cost: p-cost449
comment: comment-text450
tags: posting-tags451
balance-assertion: p-assertion452
balance-assertion-type: p-assertion-type)))454
;; Split a line at the first inline comment (;), respecting the455
;; hledger rule that ; must be preceded by 2+ spaces456
(define (split-inline-comment text)457
(let ((len (string-length text)))458
(let loop ((i 0))459
(if (>= i len)460
(cons text "")461
(if (and (char=? (string-ref text i) #\;)462
(>= i 2)463
(char=? (string-ref text (- i 1)) #\space)464
(char=? (string-ref text (- i 2)) #\space))465
(cons (string-trim-end (substring text 0 (- i 2)))466
(string-trim (substring text (+ i 1) len)))467
(loop (+ i 1)))))))469
;; Parse tags from a comment string like "tag1:val1, tag2:val2"470
(define (parse-tags-from-comment text)471
(let ((parts (string-split text ",")))472
(let loop ((rest parts) (tags '()))473
(if (null? rest)474
(reverse tags)475
(let ((part (string-trim (car rest))))476
(let ((colon (string-find part ":")))477
(if colon478
(let ((name (string-trim (substring part 0 colon)))479
(value (string-trim (substring part (+ colon 1) (string-length part)))))480
(loop (cdr rest) (cons (cons name value) tags)))481
(loop (cdr rest) tags))))))))483
;; Parse posting parts: account, amount, cost, balance assertion484
;; The account and amount are separated by 2+ spaces485
(define (parse-posting-parts text)486
(let ((trimmed (string-trim text)))487
;; Find the split point: 2+ consecutive spaces488
(let ((split-pos (find-double-space trimmed)))489
(if (not split-pos)490
;; No amount — just an account (amount inferred)491
(list trimmed #f #f #f "=")492
(let* ((account (string-trim (substring trimmed 0 split-pos)))493
(amount-part (string-trim (substring trimmed split-pos (string-length trimmed)))))494
;; Parse amount part which may include cost and balance assertion495
(parse-amount-cost-assertion account amount-part))))))497
;; Find position of first occurrence of 2+ consecutive spaces498
(define (find-double-space text)499
(let ((len (string-length text)))500
(let loop ((i 0))501
(if (>= (+ i 1) len)502
#f503
(if (and (char=? (string-ref text i) #\space)504
(char=? (string-ref text (+ i 1)) #\space))505
i506
(loop (+ i 1)))))))508
;; Parse amount, optional cost (@/@@), and optional balance assertion (=, ==, =*)509
;; Returns (account amount cost assertion assertion-type)510
(define (parse-amount-cost-assertion account text)511
(let* (;; Check for balance assertion first512
(assertion-split (split-balance-assertion text))513
(amount-cost-part (car assertion-split))514
(assertion (cadr assertion-split))515
(assertion-type (caddr assertion-split))516
;; Check for cost notation517
(cost-split (split-cost amount-cost-part))518
(amount-str (string-trim (car cost-split)))519
(cost (cdr cost-split))520
;; Parse the amount521
(amount (parse-amount-string amount-str)))522
(list account amount cost assertion assertion-type)))524
;; Split off balance assertion (=, ==, or =* amount) from end525
;; Returns (amount-cost-part assertion-amount assertion-type)526
(define (split-balance-assertion text)527
(let ((eq-pos (find-assertion-equals text)))528
(if eq-pos529
;; Determine assertion type: ==, =*, or = and skip accordingly530
(let* ((next-char (if (< (+ eq-pos 1) (string-length text))531
(string-ref text (+ eq-pos 1))532
#f))533
(assertion-type (cond534
((and next-char (char=? next-char #\=)) "==")535
((and next-char (char=? next-char #\*)) "=*")536
(else "=")))537
(skip (if (string=? assertion-type "=") 1 2))538
(assertion-str (string-trim (substring text (+ eq-pos skip) (string-length text)))))539
(list (string-trim (substring text 0 eq-pos))540
(parse-amount-string assertion-str)541
assertion-type))542
(list text #f "="))))544
;; Find the = for balance assertion, distinguishing from ==, =* and @@/@ signs545
;; Returns position of first = or #f546
(define (find-assertion-equals text)547
(let ((len (string-length text)))548
(let loop ((i (- len 1)))549
(if (< i 0)550
#f551
(let ((c (string-ref text i)))552
(if (char=? c #\=)553
;; Check it's not part of == (strict assertion)554
(if (and (> i 0) (char=? (string-ref text (- i 1)) #\=))555
;; == strict assertion — return position of first =556
(- i 1)557
i)558
;; Check for =* — the * comes after =, so look for = before *559
(if (and (char=? c #\*)560
(> i 0)561
(char=? (string-ref text (- i 1)) #\=))562
;; =* inclusive assertion — return position of =563
(- i 1)564
(loop (- i 1)))))))))566
;; Split off cost notation (@ or @@) from amount string567
(define (split-cost text)568
(let ((total-pos (string-find text " @@ "))569
(unit-pos (string-find text " @ ")))570
(cond571
;; @@ (total cost) — check first to avoid matching @ within @@572
((and total-pos (or (not unit-pos) (<= total-pos unit-pos)))573
(let* ((amount-part (substring text 0 total-pos))574
(cost-str (string-trim (substring text (+ total-pos 4) (string-length text))))575
(cost-amount (parse-amount-string cost-str)))576
(cons amount-part577
(journal-cost type: 'total amount: cost-amount))))578
;; @ (per-unit cost)579
(unit-pos580
(let* ((amount-part (substring text 0 unit-pos))581
(cost-str (string-trim (substring text (+ unit-pos 3) (string-length text))))582
(cost-amount (parse-amount-string cost-str)))583
(cons amount-part584
(journal-cost type: 'per-unit amount: cost-amount))))585
;; No cost586
(else587
(cons text #f)))))589
;; Parse an amount string like "$100.00", "100.00 EUR", "-50", "100.00"590
(define (parse-amount-string text)591
(let ((s (string-trim text)))592
(if (string-empty? s)593
#f594
(cond595
;; Left-symbol commodity: $100.00596
((left-commodity-char? (string-ref s 0))597
(parse-left-commodity s))598
;; Negative with left-symbol: -$50599
((and (> (string-length s) 1)600
(char=? (string-ref s 0) #\-)601
(left-commodity-char? (string-ref s 1)))602
(parse-negative-left-commodity s))603
;; Number possibly followed by commodity604
(else605
(parse-right-commodity s))))))607
;; Reuse currency-symbol? — check first char of a 1-char string608
(define (left-commodity-char? c)609
(currency-symbol? (string c)))611
;; Parse "$100.00" style612
(define (parse-left-commodity text)613
(let* ((commodity (substring text 0 1))614
(num-str (strip-digit-groups (substring text 1 (string-length text))))615
(qty (string->number num-str)))616
(if qty617
(journal-amount quantity: qty commodity: commodity)618
(journal-amount quantity: 0 commodity: text))))620
;; Parse "-$100.00" style621
(define (parse-negative-left-commodity text)622
(let* ((commodity (substring text 1 2))623
(num-str (strip-digit-groups (substring text 2 (string-length text))))624
(qty (string->number num-str)))625
(if qty626
(journal-amount quantity: (- qty) commodity: commodity627
sign-before-commodity: #t)628
(journal-amount quantity: 0 commodity: text))))630
;; Parse "100.00 EUR" or just "100.00" style631
(define (parse-right-commodity text)632
(let ((s (string-trim text)))633
;; Find where number ends and commodity begins634
(let ((split (find-number-end s)))635
(if split636
(let* ((num-str (strip-digit-groups (string-trim (substring s 0 split))))637
(comm (string-trim (substring s split (string-length s))))638
(qty (string->number num-str)))639
(if qty640
(journal-amount quantity: qty commodity: comm)641
(journal-amount quantity: 0 commodity: s)))642
;; Try as plain number643
(let ((qty (string->number (strip-digit-groups s))))644
(if qty645
(journal-amount quantity: qty commodity: "")646
(journal-amount quantity: 0 commodity: s)))))))648
;; Find the index where the number portion ends in a string649
;; Numbers can contain digits, ., -, +, and , (as group separator)650
(define (find-number-end text)651
(let ((len (string-length text)))652
(let loop ((i 0) (found-digit #f))653
(if (>= i len)654
(if found-digit #f i) ; all number or no number655
(let ((c (string-ref text i)))656
(if (or (char-numeric? c)657
(char=? c #\.)658
(char=? c #\,)659
(and (char=? c #\-) (= i 0))660
(and (char=? c #\+) (= i 0)))661
(loop (+ i 1) #t)662
(if found-digit663
i ; end of number664
(loop (+ i 1) #f))))))))666
;; Strip digit group separators (commas used as thousands separators)667
(define (strip-digit-groups text)668
(string-replace text "," ""))670
;; ========== Deduplication ==========672
;;; Filter out transactions that already exist in an existing journal.673
;;;674
;;; Deduplication is based on a "ref" tag in transaction comments.675
;;; Transactions without a ref tag are always included.676
;;;677
;;; ```scheme678
;;; (define existing (read-journal "main.journal"))679
;;; (define new-txns (list ...))680
;;; (define unique (deduplicate-transactions new-txns existing))681
;;; ```682
(define (deduplicate-transactions new-txns existing-txns)683
(let ((existing-refs (collect-refs existing-txns)))684
(filter (lambda (txn)685
(let ((ref (find-ref txn)))686
(or (not ref)687
(not (member ref existing-refs)))))688
new-txns)))690
;; Collect all "ref" tag values from a list of transactions691
(define (collect-refs txns)692
(let loop ((rest txns) (refs '()))693
(if (null? rest)694
refs695
(let ((ref (find-ref (car rest))))696
(loop (cdr rest)697
(if ref (cons ref refs) refs))))))699
;; Find the "ref" tag value in a transaction (checks both txn and posting tags)700
(define (find-ref txn)701
(let ((txn-ref (assoc "ref" (journal-transaction-tags txn))))702
(if txn-ref703
(cdr txn-ref)704
;; Check posting tags705
(let loop ((postings (journal-transaction-postings txn)))706
(if (null? postings)707
#f708
(let ((posting-ref (assoc "ref" (journal-posting-tags (car postings)))))709
(if posting-ref710
(cdr posting-ref)711
(loop (cdr postings)))))))))))