all repos

clerk @ 45890c9deddb940b6a20bad3de4827a0058b8396

missing tooling for ledger/hledger

clerk/internal/linter/rule_unbalanced_transaction.go (view raw)

Oleksandr Smirnov Oleksandr Smirnov
olexsmir@gmail.com
linter: add unbalanced-transaction rule, 1 month ago
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
}