8 files changed,
141 insertions(+),
10 deletions(-)
Author:
Oleksandr Smirnov
olexsmir@gmail.com
Committed at:
2026-06-27 12:41:04 +0300
Authored at:
2026-06-26 18:10:39 +0300
Change ID:
pnutwltwsrouwvzsorrpvwpmyrlmvwox
Parent:
9444b5a
jump to
M
internal/linter/linter_test.go
··· 23 23 "duplicated-commodity": {&DuplicatedCommodity{}}, 24 24 "undeclared-commodity": {&UndeclaredCommodity{}}, 25 25 "undeclared-account": {&UndeclaredAccount{}}, 26 + "unbalanced-transaction": {&UnbalancedTransaction{}}, 26 27 } 27 28 28 29 func TestLinter(t *testing.T) {
A
internal/linter/rule_unbalanced_transaction.go
··· 1 +package linter 2 + 3 +import ( 4 + "fmt" 5 + 6 + "olexsmir.xyz/clerk/internal/decimal" 7 + "olexsmir.xyz/clerk/journal/ast" 8 + "olexsmir.xyz/clerk/journal/token" 9 +) 10 + 11 +// UnbalancedTransaction flags transactions whose postings don't balance to zero. 12 +type UnbalancedTransaction struct{} 13 + 14 +func (UnbalancedTransaction) ID() RuleID { return "unbalanced-transaction" } 15 +func (UnbalancedTransaction) Severity() Severity { return SeverityError } 16 +func (r *UnbalancedTransaction) CheckEntry(entry ast.Entry) []Find { 17 + switch e := entry.(type) { 18 + case *ast.Transaction: 19 + return r.check(e.Postings, e.Span) 20 + case *ast.PeriodicTransaction: 21 + return r.check(e.Postings, e.Span) 22 + case *ast.AutomatedTransaction: 23 + return r.check(e.Postings, e.Span) 24 + default: 25 + return nil 26 + } 27 +} 28 + 29 +func (r *UnbalancedTransaction) check(postings []*ast.Posting, span token.Span) []Find { 30 + var hasExpr, hasCost bool 31 + var realPostings []*ast.Posting 32 + autoBalancingPostings := 0 33 + 34 + for _, posting := range postings { 35 + if posting.Type != ast.PostingReal { 36 + continue 37 + } 38 + realPostings = append(realPostings, posting) 39 + if posting.Amount == nil { 40 + autoBalancingPostings++ 41 + } else { 42 + if posting.Amount.IsExpr { 43 + hasExpr = true 44 + } 45 + if posting.Cost != nil { 46 + hasCost = true 47 + } 48 + } 49 + } 50 + 51 + if len(realPostings) == 0 || 52 + autoBalancingPostings == 1 || 53 + // multiple auto-balancing postings is alreayd handled by [MultipleOmittedAmounts] 54 + autoBalancingPostings > 1 || 55 + // too complex too verify 56 + hasExpr || hasCost { 57 + return nil 58 + } 59 + 60 + // sum amounts per commodity 61 + sums := make(map[string]decimal.Decimal) 62 + for _, posting := range realPostings { 63 + if posting.Amount == nil { 64 + continue 65 + } 66 + sums[posting.Amount.Commodity] = sums[posting.Amount.Commodity].Add(posting.Amount.Quantity) 67 + } 68 + 69 + var finds []Find 70 + for commodity, sum := range sums { 71 + if !sum.IsZero() { 72 + var msg string 73 + if commodity != "" { 74 + msg = fmt.Sprintf("transaction is unbalanced; %s balance is %s", commodity, sum.String()) 75 + } else { 76 + msg = fmt.Sprintf("transaction is unbalanced; net balance is %s", sum.String()) 77 + } 78 + finds = append(finds, Find{ 79 + Code: r.ID(), 80 + Severity: r.Severity(), 81 + Span: span, 82 + Message: msg, 83 + }) 84 + } 85 + } 86 + return finds 87 +}
A
internal/linter/testdata/unbalanced-transaction.golden
··· 1 +unbalanced-transaction.journal:14:1: unbalanced-transaction: transaction is unbalanced; USD balance is 5 2 +unbalanced-transaction.journal:18:1: unbalanced-transaction: transaction is unbalanced; USD balance is 5 3 +unbalanced-transaction.journal:18:1: unbalanced-transaction: transaction is unbalanced; EUR balance is 5 4 +unbalanced-transaction.journal:32:1: unbalanced-transaction: transaction is unbalanced; USD balance is 10
A
internal/linter/testdata/unbalanced-transaction.input
··· 1 +2024-01-01 "auto-balance" 2 + expenses:food 10.00 USD 3 + assets:cash 4 + 5 +2024-01-01 "multiple-omitted" 6 + expenses:food 10.00 USD 7 + assets:cash 8 + equity:draw 9 + 10 +2024-01-01 "balanced" 11 + expenses:food 10.00 USD 12 + assets:cash -10.00 USD 13 + 14 +2024-01-01 "unbalanced" 15 + expenses:food 15.00 USD 16 + assets:cash -10.00 USD 17 + 18 +2024-01-01 "multi-currency" | partial imbalance 19 + expenses:food 10.00 USD 20 + expenses:fun 5.00 EUR 21 + assets:cash -5.00 USD 22 + 23 +2024-01-01 "expression" | skip 24 + expenses:food 10.00 USD 25 + assets:cash *-1 26 + 27 +2024-01-01 "virtual-ignored" 28 + expenses:food 10.00 USD 29 + assets:cash -10.00 USD 30 + (Budget:fun) 5.00 USD 31 + 32 +~ monthly ; unbalanced 33 + expenses:food 100.00 USD 34 + assets:cash -90.00 USD 35 + 36 +2024-01-01 "all-virtual" 37 + (Budget:fun) 10.00 USD 38 + (Budget:fun) -10.00 USD
M
journal/parser/parser.go
··· 214 214 215 215 pt := &ast.PeriodicTransaction{} 216 216 217 - pt.Span = p.span(s) 218 217 pt.Period = p.parsePeriod() 219 218 220 219 if desc := p.parseOptPeriodicDescription(); desc != "" { ··· 237 236 } 238 237 } 239 238 239 + pt.Span = p.span(s) 240 240 pt.Comment = comment 241 241 return pt 242 242 } ··· 247 247 p.skipWhitespace() 248 248 249 249 at := &ast.AutomatedTransaction{} 250 - at.Span = p.span(s) 251 250 252 251 at.Expr = p.parseDirectiveExpr() 253 252 at.Comment = p.parseOptInlineComment() ··· 266 265 } 267 266 } 268 267 268 + at.Span = p.span(s) 269 269 return at 270 270 } 271 271
M
journal/parser/testdata/automated_transaction.golden
··· 1 1 Journal 2 - AutomatedTransaction j:1:1-1:3 2 + AutomatedTransaction j:1:1-4:0 3 3 Expr: "^income" 4 4 Posting j:2:1-4:0 5 5 Type: unbalanced virtual ··· 15 15 Precision: 2 16 16 Decimal: "." 17 17 BlankLine j:4:0-4:1 18 - AutomatedTransaction j:4:1-4:3 18 + AutomatedTransaction j:4:1-8:0 19 19 Expr: "expenses:gifts" 20 20 Posting j:5:1-6:1 21 21 Account j:5:5-5:17 ··· 42 42 Precision: 0 43 43 Decimal: "." 44 44 BlankLine j:8:0-8:1 45 - AutomatedTransaction j:8:1-8:3 45 + AutomatedTransaction j:8:1-10:1 46 46 Expr: "income:salary" 47 47 Posting j:9:1-10:1 48 48 Account j:9:5-9:19
M
journal/parser/testdata/period_transaction_expressions.golden
··· 1 1 Journal 2 2 BlankLine j:2:0-2:1 3 - PeriodicTransaction j:2:1-2:3 3 + PeriodicTransaction j:2:1-6:0 4 4 Period: "monthly" 5 5 Status: "*" 6 6 Posting j:3:1-4:1 ··· 21 21 SubAccount: "checking" j:4:17-5:0 22 22 Amount: <elided> 23 23 BlankLine j:6:0-6:1 24 - PeriodicTransaction j:6:1-6:3 24 + PeriodicTransaction j:6:1-10:0 25 25 Period: "monthly from 2023-04-15 to 2023-06-16" 26 26 From: 2023-04-15 27 27 To: 2023-06-16 ··· 44 44 SubAccount: "checking" j:8:17-9:0 45 45 Amount: <elided> 46 46 BlankLine j:10:0-10:1 47 - PeriodicTransaction j:10:1-10:3 47 + PeriodicTransaction j:10:1-14:0 48 48 Period: "every 2 months" 49 49 Status: "*" 50 50 Description: "in 2023, we will review" ··· 66 66 SubAccount: "checking" j:12:17-13:0 67 67 Amount: <elided> 68 68 BlankLine j:14:0-14:1 69 - PeriodicTransaction j:14:1-14:3 69 + PeriodicTransaction j:14:1-18:0 70 70 Period: "monthly" 71 71 Status: "*" 72 72 Description: "Next year blah blah" ··· 87 87 SubAccount: "checking" j:16:12-17:0 88 88 Amount: <elided> 89 89 BlankLine j:18:0-18:1 90 - PeriodicTransaction j:18:1-18:3 90 + PeriodicTransaction j:18:1-21:1 91 91 Period: "monthly from 2018/6" 92 92 Status: "*" 93 93 Comment j:18:23-19:0