all repos

clerk @ 992e56fdade7479fc6e552c869f4cfeb5187c357

missing tooling for ledger/hledger
8 files changed, 141 insertions(+), 10 deletions(-)
linter: add unbalanced-transaction rule
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
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
        +}

      
M internal/linter/rules.go
···
        40
        40
         	&DuplicatedCommodity{},

      
        41
        41
         	&UndeclaredCommodity{},

      
        42
        42
         	&UndeclaredAccount{},

      
        
        43
        +	&UnbalancedTransaction{},

      
        43
        44
         }

      
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