ydim 1.1.4 → 1.1.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,215 @@
1
+ <?xml version="1.0" encoding="UTF-8"?>
2
+ <!-- Synthetic camt.053, shaped like the UBS Z53 downloads: a business account
3
+ and a private account in the same delivery, one statement re-sent under a
4
+ new MsgId, and the remittance information in the forms payers actually
5
+ use. All IBANs, names and numbers are made up. -->
6
+ <Document xmlns="urn:iso:std:iso:20022:tech:xsd:camt.053.001.08">
7
+ <BkToCstmrStmt>
8
+ <GrpHdr>
9
+ <MsgId>TESTMSG0001</MsgId>
10
+ <CreDtTm>2026-07-31T18:00:00+02:00</CreDtTm>
11
+ </GrpHdr>
12
+ <!-- The business account: this is the one ydim reconciles. -->
13
+ <Stmt>
14
+ <Id>TESTSTMT0001</Id>
15
+ <ElctrncSeqNb>1</ElctrncSeqNb>
16
+ <CreDtTm>2026-07-31T18:00:00+02:00</CreDtTm>
17
+ <Acct>
18
+ <Id><IBAN>CH1100000000000000001</IBAN></Id>
19
+ <Ccy>CHF</Ccy>
20
+ <Ownr><Nm>Test Business GmbH</Nm></Ownr>
21
+ </Acct>
22
+ <Bal>
23
+ <Tp><CdOrPrtry><Cd>OPBD</Cd></CdOrPrtry></Tp>
24
+ <Amt Ccy="CHF">1000.00</Amt>
25
+ <CdtDbtInd>CRDT</CdtDbtInd>
26
+ <Dt><Dt>2026-07-01</Dt></Dt>
27
+ </Bal>
28
+ <Bal>
29
+ <Tp><CdOrPrtry><Cd>CLBD</Cd></CdOrPrtry></Tp>
30
+ <Amt Ccy="CHF">4433.00</Amt>
31
+ <CdtDbtInd>CRDT</CdtDbtInd>
32
+ <Dt><Dt>2026-07-31</Dt></Dt>
33
+ </Bal>
34
+ <!-- Invoice named and the amount agrees: bookable without review. -->
35
+ <Ntry>
36
+ <Amt Ccy="CHF">500.00</Amt>
37
+ <CdtDbtInd>CRDT</CdtDbtInd>
38
+ <Sts><Cd>BOOK</Cd></Sts>
39
+ <BookgDt><Dt>2026-07-10</Dt></BookgDt>
40
+ <ValDt><Dt>2026-07-10</Dt></ValDt>
41
+ <AcctSvcrRef>REFA1</AcctSvcrRef>
42
+ <NtryDtls><TxDtls>
43
+ <RltdPties><Dbtr><Pty><Nm>Example AG</Nm></Pty></Dbtr></RltdPties>
44
+ <RmtInf><Ustrd>RG 10001 VOM 1.7.2026</Ustrd></RmtInf>
45
+ </TxDtls></NtryDtls>
46
+ <AddtlNtryInf>Credit</AddtlNtryInf>
47
+ </Ntry>
48
+ <!-- One transfer settling two invoices, with the numbers run together
49
+ exactly as the payer's software writes them. -->
50
+ <Ntry>
51
+ <Amt Ccy="CHF">300.00</Amt>
52
+ <CdtDbtInd>CRDT</CdtDbtInd>
53
+ <Sts><Cd>BOOK</Cd></Sts>
54
+ <BookgDt><Dt>2026-07-11</Dt></BookgDt>
55
+ <ValDt><Dt>2026-07-11</Dt></ValDt>
56
+ <AcctSvcrRef>REFA2</AcctSvcrRef>
57
+ <NtryDtls><TxDtls>
58
+ <RltdPties><Dbtr><Pty><Nm>Example AG</Nm></Pty></Dbtr></RltdPties>
59
+ <RmtInf><Ustrd>RG 10002 VOM 2.7.2026RG 10003 VOM 3.7.2026</Ustrd></RmtInf>
60
+ </TxDtls></NtryDtls>
61
+ <AddtlNtryInf>Credit</AddtlNtryInf>
62
+ </Ntry>
63
+ <!-- No reference at all: only the amount and the payer's name to go on. -->
64
+ <Ntry>
65
+ <Amt Ccy="CHF">250.00</Amt>
66
+ <CdtDbtInd>CRDT</CdtDbtInd>
67
+ <Sts><Cd>BOOK</Cd></Sts>
68
+ <BookgDt><Dt>2026-07-12</Dt></BookgDt>
69
+ <ValDt><Dt>2026-07-12</Dt></ValDt>
70
+ <AcctSvcrRef>REFA3</AcctSvcrRef>
71
+ <NtryDtls><TxDtls>
72
+ <RltdPties><Dbtr><Pty><Nm>Nameless AG</Nm></Pty></Dbtr></RltdPties>
73
+ </TxDtls></NtryDtls>
74
+ <AddtlNtryInf>e-banking Credit</AddtlNtryInf>
75
+ </Ntry>
76
+ <!-- TWINT: the remittance information is the payer's phone number. -->
77
+ <Ntry>
78
+ <Amt Ccy="CHF">42.00</Amt>
79
+ <CdtDbtInd>CRDT</CdtDbtInd>
80
+ <Sts><Cd>BOOK</Cd></Sts>
81
+ <BookgDt><Dt>2026-07-13</Dt></BookgDt>
82
+ <ValDt><Dt>2026-07-13</Dt></ValDt>
83
+ <AcctSvcrRef>REFA4</AcctSvcrRef>
84
+ <NtryDtls><TxDtls>
85
+ <RltdPties><Dbtr><Pty><PstlAdr><AdrLine>Twint, Payer</AdrLine></PstlAdr></Pty></Dbtr></RltdPties>
86
+ <RmtInf><Ustrd>+41791234567 TWINT-Acc.:+41790000000</Ustrd></RmtInf>
87
+ </TxDtls></NtryDtls>
88
+ <AddtlNtryInf>Credit UBS TWINT // TWINT, PAYER</AddtlNtryInf>
89
+ </Ntry>
90
+ <!-- A machine-generated hex reference that happens to contain "10004".
91
+ Reading an invoice number out of it would be an accident. -->
92
+ <Ntry>
93
+ <Amt Ccy="CHF">777.00</Amt>
94
+ <CdtDbtInd>CRDT</CdtDbtInd>
95
+ <Sts><Cd>BOOK</Cd></Sts>
96
+ <BookgDt><Dt>2026-07-14</Dt></BookgDt>
97
+ <ValDt><Dt>2026-07-14</Dt></ValDt>
98
+ <AcctSvcrRef>REFA5</AcctSvcrRef>
99
+ <NtryDtls><TxDtls>
100
+ <Refs><EndToEndId>0ebf10004f364fc394da3e2776a05643</EndToEndId></Refs>
101
+ <RltdPties><Dbtr><Pty><Nm>Hexadecimal AG</Nm></Pty></Dbtr></RltdPties>
102
+ </TxDtls></NtryDtls>
103
+ <AddtlNtryInf>e-banking Credit</AddtlNtryInf>
104
+ </Ntry>
105
+ <!-- Money going out is never a payment on an invoice we issued. -->
106
+ <Ntry>
107
+ <Amt Ccy="CHF">99.00</Amt>
108
+ <CdtDbtInd>DBIT</CdtDbtInd>
109
+ <Sts><Cd>BOOK</Cd></Sts>
110
+ <BookgDt><Dt>2026-07-15</Dt></BookgDt>
111
+ <ValDt><Dt>2026-07-15</Dt></ValDt>
112
+ <AcctSvcrRef>REFA6</AcctSvcrRef>
113
+ <NtryDtls><TxDtls>
114
+ <RltdPties><Cdtr><Pty><Nm>Supplier AG</Nm></Pty></Cdtr></RltdPties>
115
+ <RmtInf><Strd><CdtrRefInf>
116
+ <Tp><CdOrPrtry><Prtry>QRR</Prtry></CdOrPrtry></Tp>
117
+ <Ref>102316851467000225775300000</Ref>
118
+ </CdtrRefInf><AddtlRmtInf>QRR</AddtlRmtInf></Strd></RmtInf>
119
+ </TxDtls></NtryDtls>
120
+ <AddtlNtryInf>Payment Order</AddtlNtryInf>
121
+ </Ntry>
122
+ <!-- Still pending: it can be reversed, so it must not settle anything. -->
123
+ <Ntry>
124
+ <Amt Ccy="CHF">123.00</Amt>
125
+ <CdtDbtInd>CRDT</CdtDbtInd>
126
+ <Sts><Cd>PDNG</Cd></Sts>
127
+ <BookgDt><Dt>2026-07-16</Dt></BookgDt>
128
+ <ValDt><Dt>2026-07-16</Dt></ValDt>
129
+ <AcctSvcrRef>REFA7</AcctSvcrRef>
130
+ <NtryDtls><TxDtls>
131
+ <RltdPties><Dbtr><Pty><Nm>Pending AG</Nm></Pty></Dbtr></RltdPties>
132
+ <RmtInf><Ustrd>RG 10005</Ustrd></RmtInf>
133
+ </TxDtls></NtryDtls>
134
+ <AddtlNtryInf>Credit</AddtlNtryInf>
135
+ </Ntry>
136
+ <!-- Right invoice, not enough money. -->
137
+ <Ntry>
138
+ <Amt Ccy="CHF">640.00</Amt>
139
+ <CdtDbtInd>CRDT</CdtDbtInd>
140
+ <Sts><Cd>BOOK</Cd></Sts>
141
+ <BookgDt><Dt>2026-07-17</Dt></BookgDt>
142
+ <ValDt><Dt>2026-07-17</Dt></ValDt>
143
+ <AcctSvcrRef>REFA8</AcctSvcrRef>
144
+ <NtryDtls><TxDtls>
145
+ <RltdPties><Dbtr><Pty><Nm>Partial AG</Nm></Pty></Dbtr></RltdPties>
146
+ <RmtInf><Ustrd>Rechnung 10006</Ustrd></RmtInf>
147
+ </TxDtls></NtryDtls>
148
+ <AddtlNtryInf>Credit</AddtlNtryInf>
149
+ </Ntry>
150
+ <!-- Names an invoice that was settled on an earlier run. -->
151
+ <Ntry>
152
+ <Amt Ccy="CHF">1000.00</Amt>
153
+ <CdtDbtInd>CRDT</CdtDbtInd>
154
+ <Sts><Cd>BOOK</Cd></Sts>
155
+ <BookgDt><Dt>2026-07-18</Dt></BookgDt>
156
+ <ValDt><Dt>2026-07-18</Dt></ValDt>
157
+ <AcctSvcrRef>REFA9</AcctSvcrRef>
158
+ <NtryDtls><TxDtls>
159
+ <RltdPties><Dbtr><Pty><Nm>Prompt AG</Nm></Pty></Dbtr></RltdPties>
160
+ <RmtInf><Ustrd>RG 10007</Ustrd></RmtInf>
161
+ </TxDtls></NtryDtls>
162
+ <AddtlNtryInf>Credit</AddtlNtryInf>
163
+ </Ntry>
164
+ </Stmt>
165
+ <!-- The same day delivered a second time under a new statement id. -->
166
+ <Stmt>
167
+ <Id>TESTSTMT0002</Id>
168
+ <ElctrncSeqNb>2</ElctrncSeqNb>
169
+ <CreDtTm>2026-07-31T18:00:00+02:00</CreDtTm>
170
+ <Acct>
171
+ <Id><IBAN>CH1100000000000000001</IBAN></Id>
172
+ <Ccy>CHF</Ccy>
173
+ <Ownr><Nm>Test Business GmbH</Nm></Ownr>
174
+ </Acct>
175
+ <Ntry>
176
+ <Amt Ccy="CHF">500.00</Amt>
177
+ <CdtDbtInd>CRDT</CdtDbtInd>
178
+ <Sts><Cd>BOOK</Cd></Sts>
179
+ <BookgDt><Dt>2026-07-10</Dt></BookgDt>
180
+ <ValDt><Dt>2026-07-10</Dt></ValDt>
181
+ <AcctSvcrRef>REFA1</AcctSvcrRef>
182
+ <NtryDtls><TxDtls>
183
+ <RltdPties><Dbtr><Pty><Nm>Example AG</Nm></Pty></Dbtr></RltdPties>
184
+ <RmtInf><Ustrd>RG 10001 VOM 1.7.2026</Ustrd></RmtInf>
185
+ </TxDtls></NtryDtls>
186
+ <AddtlNtryInf>Credit</AddtlNtryInf>
187
+ </Ntry>
188
+ </Stmt>
189
+ <!-- A private account rides along in the same download. Nothing here is
190
+ ever a payment on a ydim invoice, however well it seems to match. -->
191
+ <Stmt>
192
+ <Id>TESTSTMT0003</Id>
193
+ <ElctrncSeqNb>3</ElctrncSeqNb>
194
+ <CreDtTm>2026-07-31T18:00:00+02:00</CreDtTm>
195
+ <Acct>
196
+ <Id><IBAN>CH2200000000000000002</IBAN></Id>
197
+ <Ccy>CHF</Ccy>
198
+ <Ownr><Nm>Private Person</Nm></Ownr>
199
+ </Acct>
200
+ <Ntry>
201
+ <Amt Ccy="CHF">500.00</Amt>
202
+ <CdtDbtInd>CRDT</CdtDbtInd>
203
+ <Sts><Cd>BOOK</Cd></Sts>
204
+ <BookgDt><Dt>2026-07-19</Dt></BookgDt>
205
+ <ValDt><Dt>2026-07-19</Dt></ValDt>
206
+ <AcctSvcrRef>REFB1</AcctSvcrRef>
207
+ <NtryDtls><TxDtls>
208
+ <RltdPties><Dbtr><Pty><Nm>Example AG</Nm></Pty></Dbtr></RltdPties>
209
+ <RmtInf><Ustrd>RG 10001 VOM 1.7.2026</Ustrd></RmtInf>
210
+ </TxDtls></NtryDtls>
211
+ <AddtlNtryInf>Credit</AddtlNtryInf>
212
+ </Ntry>
213
+ </Stmt>
214
+ </BkToCstmrStmt>
215
+ </Document>
data/test/test_camt.rb ADDED
@@ -0,0 +1,113 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+ # TestCamt -- ydim -- 09.08.2026 -- zdavatz@ywesee.com
4
+
5
+ $: << File.expand_path('../lib', File.dirname(__FILE__))
6
+ $: << File.dirname(__FILE__)
7
+
8
+ require 'minitest/autorun'
9
+ require 'ydim/camt'
10
+
11
+ module YDIM
12
+ class TestCamt < Minitest::Test
13
+ DATA = File.expand_path('data/camt053.xml', File.dirname(__FILE__))
14
+ BUSINESS = 'CH1100000000000000001'
15
+ PRIVATE = 'CH2200000000000000002'
16
+ def setup
17
+ @statements = Camt.read(DATA)
18
+ @entries = Camt.entries(DATA)
19
+ end
20
+ def entry(reference)
21
+ @entries.find { |ntry| ntry.reference == reference }
22
+ end
23
+ def test_read__statements
24
+ assert_equal(3, @statements.size)
25
+ stmt = @statements.first
26
+ assert_equal('TESTSTMT0001', stmt.id)
27
+ assert_equal(BUSINESS, stmt.account_iban)
28
+ assert_equal('CHF', stmt.currency)
29
+ assert_equal('Test Business GmbH', stmt.owner)
30
+ assert_equal(1000.00, stmt.opening_balance)
31
+ assert_equal(4433.00, stmt.closing_balance)
32
+ assert_equal(9, stmt.entries.size)
33
+ end
34
+ def test_read__entry
35
+ ntry = entry('REFA1')
36
+ assert_equal(BUSINESS, ntry.account_iban)
37
+ assert_equal(500.00, ntry.amount)
38
+ assert_equal(50000, ntry.amount_cents)
39
+ assert_equal('CHF', ntry.currency)
40
+ assert_equal(true, ntry.credit?)
41
+ assert_equal(true, ntry.booked?)
42
+ assert_equal(Date.new(2026, 7, 10), ntry.booking_date)
43
+ assert_equal(Date.new(2026, 7, 10), ntry.value_date)
44
+ assert_equal('Example AG', ntry.counterparty)
45
+ assert_equal(['RG 10001 VOM 1.7.2026'], ntry.remittance)
46
+ end
47
+ def test_read__debit
48
+ ntry = entry('REFA6')
49
+ assert_equal(false, ntry.credit?)
50
+ assert_equal('Supplier AG', ntry.counterparty)
51
+ assert_equal(['102316851467000225775300000'], ntry.creditor_references)
52
+ end
53
+ def test_read__pending_is_not_booked
54
+ assert_equal(false, entry('REFA7').booked?)
55
+ assert_equal(true, entry('REFA1').booked?)
56
+ end
57
+ # A number a payer typed is a token; one a machine generated is not.
58
+ def test_numeric_tokens__invoice_number
59
+ assert_equal(%w{10001 1 7 2026}, entry('REFA1').numeric_tokens)
60
+ end
61
+ def test_numeric_tokens__two_invoices_run_together
62
+ tokens = entry('REFA2').numeric_tokens
63
+ assert_includes(tokens, '10002')
64
+ assert_includes(tokens, '10003')
65
+ end
66
+ def test_numeric_tokens__ignores_hex_reference
67
+ # "0ebf10004f364fc394da3e2776a05643" must not offer up "10004".
68
+ assert_equal([], entry('REFA5').numeric_tokens)
69
+ end
70
+ def test_numeric_tokens__twint_phone_number
71
+ # The phone numbers are tokens, they are just not invoice numbers; what
72
+ # matters is that no shorter number is carved out of them.
73
+ assert_equal(%w{41791234567 41790000000}, entry('REFA4').numeric_tokens)
74
+ end
75
+ def test_numeric_tokens__none
76
+ assert_equal([], entry('REFA3').numeric_tokens)
77
+ end
78
+ def test_account
79
+ ntry = entry('REFA1')
80
+ assert_equal(true, ntry.account?(BUSINESS))
81
+ assert_equal(true, ntry.account?([PRIVATE, BUSINESS]))
82
+ assert_equal(false, ntry.account?(PRIVATE))
83
+ assert_equal(true, ntry.account?([]))
84
+ # Written the way a human copies it out of e-banking.
85
+ assert_equal(true, ntry.account?('ch11 0000 0000 0000 00001'))
86
+ end
87
+ def test_entries__keeps_duplicates
88
+ assert_equal(11, @entries.size)
89
+ assert_equal(2, @entries.count { |ntry| ntry.reference == 'REFA1' \
90
+ && ntry.account_iban == BUSINESS })
91
+ end
92
+ def test_dedup
93
+ unique = Camt.dedup(@entries)
94
+ assert_equal(10, unique.size)
95
+ assert_equal(1, unique.count { |ntry| ntry.reference == 'REFA1' \
96
+ && ntry.account_iban == BUSINESS })
97
+ # Same reference on a different account is a different movement.
98
+ assert_equal(1, unique.count { |ntry| ntry.account_iban == PRIVATE })
99
+ end
100
+ def test_read__directory
101
+ assert_equal(@statements.size,
102
+ Camt.read(File.dirname(DATA)).size)
103
+ end
104
+ def test_parse__namespace_is_taken_from_the_document
105
+ # camt.053.001.02 through .08 differ only in the namespace URI here.
106
+ xml = File.read(DATA).gsub('camt.053.001.08', 'camt.053.001.04')
107
+ assert_equal(3, Camt.parse(xml).size)
108
+ end
109
+ def test_parse__not_xml
110
+ assert_raises(REXML::ParseException) { Camt.parse('not xml at all') }
111
+ end
112
+ end
113
+ end
@@ -0,0 +1,186 @@
1
+ #!/usr/bin/env ruby
2
+ # encoding: utf-8
3
+ # TestReconciler -- ydim -- 09.08.2026 -- zdavatz@ywesee.com
4
+
5
+ $: << File.expand_path('../lib', File.dirname(__FILE__))
6
+ $: << File.dirname(__FILE__)
7
+
8
+ require 'minitest/autorun'
9
+ require 'ydim/reconciler'
10
+
11
+ module YDIM
12
+ class TestReconciler < Minitest::Test
13
+ DATA = File.expand_path('data/camt053.xml', File.dirname(__FILE__))
14
+ BUSINESS = 'CH1100000000000000001'
15
+ PRIVATE = 'CH2200000000000000002'
16
+ # Stands in for Invoice::Info, which needs the whole server to load.
17
+ Info = Struct.new(:unique_id, :total_brutto, :currency, :date,
18
+ :debitor_name, :payment_received, :deleted)
19
+ def invoice(id, total, name, date = '2026-07-01', paid = nil)
20
+ Info.new(id, total, 'CHF', Date.parse(date), name, paid, false)
21
+ end
22
+ # The invoice book the fixture statements are payments against.
23
+ def setup
24
+ @book = [
25
+ invoice(10001, 500.00, 'Example AG'),
26
+ invoice(10002, 100.00, 'Example AG'),
27
+ invoice(10003, 200.00, 'Example AG'),
28
+ invoice(10004, 777.00, 'Hexadecimal AG'),
29
+ invoice(10006, 800.00, 'Partial AG'),
30
+ invoice(10008, 250.00, 'Nameless AG'),
31
+ ]
32
+ # 10005 is named by a pending entry, 10007 was settled earlier; neither
33
+ # is open, so both are only reachable through the resolver.
34
+ @settled = {
35
+ '10005' => invoice(10005, 123.00, 'Pending AG'),
36
+ '10007' => invoice(10007, 1000.00, 'Prompt AG', '2026-07-01',
37
+ Date.new(2026, 7, 5)),
38
+ }
39
+ @entries = Camt.entries(DATA)
40
+ end
41
+ def reconcile(book = @book, accounts = BUSINESS)
42
+ resolver = lambda { |id| @settled[id] }
43
+ Reconciler.new(book, accounts, resolver).reconcile(@entries)
44
+ end
45
+ def match(result, reference)
46
+ result.matches.find { |m| m.entry.reference == reference }
47
+ end
48
+
49
+ def test_reconcile__requires_an_account
50
+ # Without a filter the private account in the same download would be
51
+ # matched against invoices, so this must fail rather than guess.
52
+ error = assert_raises(ArgumentError) { reconcile(@book, []) }
53
+ assert_match(/camt_accounts/, error.message)
54
+ assert_raises(ArgumentError) { reconcile(@book, nil) }
55
+ end
56
+ def test_reconcile__ignores_other_accounts
57
+ result = reconcile
58
+ assert_equal(1, result.skipped[:other_account])
59
+ assert_nil(match(result, 'REFB1'))
60
+ end
61
+ # The private account carries a credit that names invoice 10001 for the
62
+ # right amount. Reconciling that account instead would settle it.
63
+ def test_reconcile__business_and_private_do_not_mix
64
+ business = reconcile(@book, BUSINESS)
65
+ assert_equal([10001], match(business, 'REFA1').invoice_ids)
66
+ private_only = reconcile(@book, PRIVATE)
67
+ assert_equal(1, private_only.applicable.size)
68
+ assert_equal(9, private_only.skipped[:other_account])
69
+ end
70
+ def test_reconcile__counts_every_entry
71
+ result = reconcile
72
+ counted = result.matches.size + result.skipped.values.inject(0) { |a, b|
73
+ a + b }
74
+ assert_equal(@entries.size, counted)
75
+ end
76
+ def test_reconcile__skips_duplicates_debits_and_pending
77
+ result = reconcile
78
+ assert_equal(1, result.skipped[:duplicate])
79
+ assert_equal(1, result.skipped[:debit])
80
+ assert_equal(1, result.skipped[:not_booked])
81
+ assert_nil(match(result, 'REFA7'))
82
+ end
83
+
84
+ def test_match__exact
85
+ found = match(reconcile, 'REFA1')
86
+ assert_equal(:exact, found.state)
87
+ assert_equal([10001], found.invoice_ids)
88
+ assert_equal(true, found.applicable?)
89
+ end
90
+ def test_match__split_over_two_invoices
91
+ found = match(reconcile, 'REFA2')
92
+ assert_equal(:split, found.state)
93
+ assert_equal([10002, 10003], found.invoice_ids.sort)
94
+ assert_equal(true, found.applicable?)
95
+ end
96
+ def test_match__amount_and_name_only
97
+ found = match(reconcile, 'REFA3')
98
+ assert_equal(:amount_only, found.state)
99
+ assert_equal([10008], found.invoice_ids)
100
+ assert_equal(false, found.applicable?)
101
+ assert_equal(true, found.review?)
102
+ end
103
+ # The hex reference contains "10004" and invoice 10004 is over exactly this
104
+ # amount, so a looser reader would book it. It may be suggested, never
105
+ # booked.
106
+ def test_match__hex_reference_is_not_an_invoice_number
107
+ found = match(reconcile, 'REFA5')
108
+ assert_equal(:amount_only, found.state)
109
+ assert_equal(false, found.applicable?)
110
+ end
111
+ def test_match__underpaid
112
+ found = match(reconcile, 'REFA8')
113
+ assert_equal(:underpaid, found.state)
114
+ assert_equal([10006], found.invoice_ids)
115
+ assert_equal(false, found.applicable?)
116
+ assert_match(/640\.00 received, 800\.00 invoiced/, found.reason)
117
+ end
118
+ def test_match__overpaid
119
+ found = match(reconcile(@book.collect { |info|
120
+ info.unique_id == 10006 ? invoice(10006, 500.00, 'Partial AG') : info
121
+ }), 'REFA8')
122
+ assert_equal(:overpaid, found.state)
123
+ assert_match(/640\.00 received, 500\.00 invoiced/, found.reason)
124
+ end
125
+ def test_match__not_open
126
+ found = match(reconcile, 'REFA9')
127
+ assert_equal(:not_open, found.state)
128
+ assert_equal(false, found.applicable?)
129
+ assert_match(/no longer open/, found.reason)
130
+ end
131
+ def test_match__twint_phone_number_settles_nothing
132
+ found = match(reconcile, 'REFA4')
133
+ assert_equal(:unmatched, found.state)
134
+ assert_equal([], found.invoice_ids)
135
+ end
136
+ def test_match__ambiguous_amount
137
+ book = @book + [invoice(10009, 250.00, 'Nameless AG')]
138
+ found = match(reconcile(book), 'REFA3')
139
+ assert_equal(:ambiguous, found.state)
140
+ assert_equal([10008, 10009], found.invoice_ids.sort)
141
+ assert_equal(false, found.applicable?)
142
+ end
143
+ # Same amount, but only one of them is from the payer.
144
+ def test_match__debitor_name_breaks_the_tie
145
+ book = @book + [invoice(10009, 250.00, 'Unrelated AG')]
146
+ found = match(reconcile(book), 'REFA3')
147
+ assert_equal(:amount_only, found.state)
148
+ assert_equal([10008], found.invoice_ids)
149
+ end
150
+ def test_match__invoice_written_after_the_payment_is_no_candidate
151
+ book = [invoice(10008, 250.00, 'Nameless AG', '2026-07-20')]
152
+ assert_equal(:unmatched, match(reconcile(book), 'REFA3').state)
153
+ end
154
+ def test_match__currency_mismatch
155
+ book = [Info.new(10001, 500.00, 'EUR', Date.parse('2026-07-01'),
156
+ 'Example AG', nil, false)]
157
+ found = match(reconcile(book), 'REFA1')
158
+ assert_equal(:currency_mismatch, found.state)
159
+ assert_equal(false, found.applicable?)
160
+ end
161
+ def test_match__deleted_invoice_is_not_payable
162
+ book = [Info.new(10001, 500.00, 'CHF', Date.parse('2026-07-01'),
163
+ 'Example AG', nil, true)]
164
+ assert_equal(:not_open, match(reconcile(book), 'REFA1').state)
165
+ end
166
+
167
+ def test_result__groups
168
+ result = reconcile
169
+ assert_equal(%w{REFA1 REFA2},
170
+ result.applicable.collect { |m| m.entry.reference }.sort)
171
+ assert_equal(%w{REFA3 REFA5 REFA8 REFA9},
172
+ result.review.collect { |m| m.entry.reference }.sort)
173
+ assert_equal(%w{REFA4},
174
+ result.unmatched.collect { |m| m.entry.reference })
175
+ assert_equal([], result.applied)
176
+ end
177
+ def test_invoice_ref__carries_no_server_classes
178
+ # Results travel back over DRb; a client must be able to load them with
179
+ # ydim/reconciler alone.
180
+ ref = Reconciler::InvoiceRef.new(@book.first)
181
+ assert_equal(ref.to_s, Marshal.load(Marshal.dump(ref)).to_s)
182
+ assert_equal(50000, ref.total_cents)
183
+ assert_equal(true, ref.payable?)
184
+ end
185
+ end
186
+ end
@@ -342,6 +342,115 @@ module YDIM
342
342
  assert_logged(:debug, :debug) {
343
343
  @session.update_item(12, 4, {:foo => 'bar'}, :autoinvoice)
344
344
  }
345
+ end
346
+ def test_mark_paid
347
+ inv = flexmock('invoice')
348
+ date = Date.new(2026, 7, 10)
349
+ inv.should_receive(:payment_received=).with(date).times(1)
350
+ inv.should_receive(:odba_store).times(1)
351
+ inv.should_receive(:info).and_return('info')
352
+ flexstub(Invoice).should_receive(:find_by_unique_id)\
353
+ .with('12').and_return(inv)
354
+ res = nil
355
+ assert_logged(:info, :debug) {
356
+ res = @session.mark_paid(12, date)
357
+ }
358
+ assert_equal('info', res)
359
+ end
360
+ def test_open_invoice_infos
361
+ open = flexmock('open')
362
+ open.should_receive(:info).and_return('open info')
363
+ due = flexmock('due')
364
+ due.should_receive(:info).and_return('due info')
365
+ flexstub(Invoice).should_receive(:search_by_status)\
366
+ .with('is_open').and_return([open])
367
+ flexstub(Invoice).should_receive(:search_by_status)\
368
+ .with('is_due').and_return([due])
369
+ res = nil
370
+ assert_logged(:debug) {
371
+ res = @session.open_invoice_infos
372
+ }
373
+ assert_equal(['open info', 'due info'], res)
374
+ end
375
+ # The entries come from the client, already parsed; the daemon never reads
376
+ # the statement files itself.
377
+ def entry(amount, remittance)
378
+ ntry = YDIM::Camt::Entry.new
379
+ ntry.account_iban = 'CH1100000000000000001'
380
+ ntry.amount = amount
381
+ ntry.currency = 'CHF'
382
+ ntry.credit = true
383
+ ntry.status = 'BOOK'
384
+ ntry.booking_date = Date.new(2026, 7, 10)
385
+ ntry.reference = "REF#{amount}"
386
+ ntry.remittance.push(remittance)
387
+ ntry
388
+ end
389
+ def invoice_info(id, total, paid = nil)
390
+ info = flexmock("info#{id}")
391
+ info.should_receive(:unique_id).and_return(id)
392
+ info.should_receive(:total_brutto).and_return(total)
393
+ info.should_receive(:currency).and_return('CHF')
394
+ info.should_receive(:date).and_return(Date.new(2026, 7, 1))
395
+ info.should_receive(:debitor_name).and_return('Example AG')
396
+ info.should_receive(:payment_received).and_return(paid)
397
+ info.should_receive(:deleted).and_return(false)
398
+ info
399
+ end
400
+ def stub_invoice_book
401
+ config = flexmock('config')
402
+ config.should_receive(:camt_accounts)\
403
+ .and_return(['CH1100000000000000001'])
404
+ @serv.should_receive(:config).and_return { config }
405
+ open = flexmock('open invoice')
406
+ open.should_receive(:info).and_return(invoice_info(10001, 500.00))
407
+ flexstub(Invoice).should_receive(:search_by_status)\
408
+ .with('is_open').and_return([open])
409
+ flexstub(Invoice).should_receive(:search_by_status)\
410
+ .with('is_due').and_return([])
411
+ end
412
+ def test_reconcile_camt
413
+ stub_invoice_book
414
+ flexstub(Invoice).should_receive(:find_by_unique_id).and_return(nil)
415
+ entries = [entry(500.00, 'RG 10001'), entry(42.00, 'RG 99999')]
416
+ res = nil
417
+ assert_logged(:info, :debug) {
418
+ res = @session.reconcile_camt(entries)
419
+ }
420
+ assert_equal(1, res.applicable.size)
421
+ assert_equal([10001], res.applicable.first.invoice_ids)
422
+ assert_equal(1, res.unmatched.size)
423
+ # Read-only without :apply.
424
+ assert_equal([], res.applied)
425
+ end
426
+ def test_reconcile_camt__apply
427
+ stub_invoice_book
428
+ paid = flexmock('invoice to settle')
429
+ paid.should_receive(:payment_received=).with(Date.new(2026, 7, 10))\
430
+ .times(1)
431
+ paid.should_receive(:odba_store).times(1)
432
+ paid.should_receive(:info).and_return('settled')
433
+ flexstub(Invoice).should_receive(:find_by_unique_id)\
434
+ .with('10001').and_return(paid)
435
+ res = nil
436
+ assert_logged(:info, :debug, :info, :debug) {
437
+ res = @session.reconcile_camt([entry(500.00, 'RG 10001')],
438
+ :apply => true)
439
+ }
440
+ assert_equal(1, res.applied.size)
441
+ end
442
+ # A credit on a private account must never settle an invoice, however
443
+ # exactly it matches.
444
+ def test_reconcile_camt__other_account
445
+ stub_invoice_book
446
+ ntry = entry(500.00, 'RG 10001')
447
+ ntry.account_iban = 'CH2200000000000000002'
448
+ res = nil
449
+ assert_logged(:info, :debug) {
450
+ res = @session.reconcile_camt([ntry])
451
+ }
452
+ assert_equal([], res.applicable)
453
+ assert_equal(1, res.skipped[:other_account])
345
454
  end
346
455
  end
347
456
  end
data/ydim.gemspec CHANGED
@@ -30,6 +30,10 @@ Gem::Specification.new do |spec|
30
30
  spec.add_dependency "rclconf"
31
31
  spec.add_dependency "needle"
32
32
  spec.add_dependency "ypdf-writer"
33
+ # ypdf-writer allows color >= 1.4.0, but color 2.0 removed the named
34
+ # constants it refers to while loading (Color::RGB::Blue), so a fresh
35
+ # install cannot even require pdf/writer and no invoice renders.
36
+ spec.add_dependency "color", "< 2"
33
37
  spec.add_dependency "rrba"
34
38
  spec.add_dependency "hpricot"
35
39
  spec.add_dependency "pkg-config"
@@ -38,9 +42,16 @@ Gem::Specification.new do |spec|
38
42
  spec.add_development_dependency "bundler"
39
43
  spec.add_development_dependency "simplecov"
40
44
  spec.add_development_dependency "rake"
41
- spec.add_development_dependency "flexmock"
45
+ # test_root_session.rb's assert_logged relies on flexmock passing the block
46
+ # to and_return as a trailing argument; flexmock 3 stopped doing that and
47
+ # every test using it dies with "undefined method `call' for nil".
48
+ spec.add_development_dependency "flexmock", "< 3"
42
49
  spec.add_development_dependency "test-unit"
43
- spec.add_development_dependency "minitest"
50
+ # The suite runs on flexmock/test_unit, which needs the
51
+ # Minitest::Unit::TestCase shim that MT_COMPAT=1 enables. Minitest 6 dropped
52
+ # it, and every test file then fails to load with "undefined method
53
+ # `teardown'".
54
+ spec.add_development_dependency "minitest", "< 6"
44
55
  spec.add_development_dependency "rspec"
45
56
  end
46
57