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.
- checksums.yaml +4 -4
- data/.github/workflows/ruby.yml +3 -1
- data/CLAUDE.md +147 -0
- data/History.txt +16 -0
- data/bin/ydim-camt +120 -0
- data/bin/ydim-edit +3 -4
- data/lib/ydim/camt.rb +219 -0
- data/lib/ydim/config.rb +6 -2
- data/lib/ydim/reconciler.rb +223 -0
- data/lib/ydim/root_session.rb +46 -0
- data/lib/ydim/server_config.rb +6 -1
- data/lib/ydim/version.rb +1 -1
- data/lib/ydim/ydim-camt +120 -0
- data/lib/ydim/ydim-edit +3 -4
- data/readme.md +88 -3
- data/test/data/camt053.xml +215 -0
- data/test/test_camt.rb +113 -0
- data/test/test_reconciler.rb +186 -0
- data/test/test_root_session.rb +109 -0
- data/ydim.gemspec +13 -2
- metadata +40 -14
|
@@ -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
|
data/test/test_root_session.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|