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,223 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: utf-8
|
|
3
|
+
# YDIM::Reconciler -- ydim -- 09.08.2026 -- zdavatz@ywesee.com
|
|
4
|
+
|
|
5
|
+
require 'ydim/camt'
|
|
6
|
+
|
|
7
|
+
module YDIM
|
|
8
|
+
# Matches booked bank credits (YDIM::Camt::Entry) against invoices. Works on
|
|
9
|
+
# Invoice::Info structs rather than Invoice objects so that the result can
|
|
10
|
+
# travel over DRb, and so that it can be unit-tested without a database.
|
|
11
|
+
class Reconciler
|
|
12
|
+
# Words that say nothing about who paid, and would otherwise make two
|
|
13
|
+
# unrelated companies look like the same debitor.
|
|
14
|
+
NOISE = %w{ag gmbh sa sarl srl inc ltd co kg the und and dr med prof}
|
|
15
|
+
# Only these are safe to book without a human looking at them: the payer
|
|
16
|
+
# named the invoice and the money adds up to the cent.
|
|
17
|
+
APPLICABLE = [:exact, :split]
|
|
18
|
+
|
|
19
|
+
# The bit of an invoice reconciliation cares about. Results travel back to
|
|
20
|
+
# the client over DRb, and a client should not have to load the whole
|
|
21
|
+
# server just to unmarshal them, so matches carry these rather than
|
|
22
|
+
# Invoice::Info. Built from anything answering the same readers.
|
|
23
|
+
class InvoiceRef
|
|
24
|
+
KEYS = [:unique_id, :total_brutto, :currency, :date, :debitor_name,
|
|
25
|
+
:payment_received, :deleted]
|
|
26
|
+
attr_reader *KEYS
|
|
27
|
+
def initialize(invoice)
|
|
28
|
+
KEYS.each { |key|
|
|
29
|
+
instance_variable_set("@#{key}", invoice.send(key))
|
|
30
|
+
}
|
|
31
|
+
end
|
|
32
|
+
def payable?
|
|
33
|
+
!@payment_received && !@deleted
|
|
34
|
+
end
|
|
35
|
+
def total_cents
|
|
36
|
+
(@total_brutto.to_f * 100).round
|
|
37
|
+
end
|
|
38
|
+
def to_s
|
|
39
|
+
sprintf('%s %s %.2f %s', @unique_id, @currency, @total_brutto.to_f,
|
|
40
|
+
@debitor_name)
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
# One bank entry and the invoices it was matched to.
|
|
45
|
+
class Match
|
|
46
|
+
attr_reader :entry, :invoices, :state
|
|
47
|
+
attr_accessor :reason, :applied
|
|
48
|
+
def initialize(entry, invoices, state, reason = nil)
|
|
49
|
+
@entry = entry
|
|
50
|
+
@invoices = invoices
|
|
51
|
+
@state = state
|
|
52
|
+
@reason = reason
|
|
53
|
+
@applied = false
|
|
54
|
+
end
|
|
55
|
+
def applicable?
|
|
56
|
+
APPLICABLE.include?(@state)
|
|
57
|
+
end
|
|
58
|
+
# Matched, but not well enough to book automatically.
|
|
59
|
+
def review?
|
|
60
|
+
!applicable? && !@invoices.empty?
|
|
61
|
+
end
|
|
62
|
+
def applied?
|
|
63
|
+
@applied
|
|
64
|
+
end
|
|
65
|
+
def invoice_ids
|
|
66
|
+
@invoices.collect { |info| info.unique_id }
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
# What a reconciliation run found, including the entries that never made it
|
|
71
|
+
# as far as matching -- silently dropping those would make the report look
|
|
72
|
+
# like it had seen everything.
|
|
73
|
+
class Result
|
|
74
|
+
attr_reader :matches, :skipped
|
|
75
|
+
def initialize
|
|
76
|
+
@matches = []
|
|
77
|
+
@skipped = Hash.new(0)
|
|
78
|
+
end
|
|
79
|
+
def applicable
|
|
80
|
+
@matches.select { |match| match.applicable? }
|
|
81
|
+
end
|
|
82
|
+
def review
|
|
83
|
+
@matches.select { |match| match.review? }
|
|
84
|
+
end
|
|
85
|
+
def unmatched
|
|
86
|
+
@matches.select { |match| match.state == :unmatched }
|
|
87
|
+
end
|
|
88
|
+
def applied
|
|
89
|
+
@matches.select { |match| match.applied? }
|
|
90
|
+
end
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
attr_reader :accounts
|
|
94
|
+
# infos - Invoice::Info structs of the invoices still awaiting payment;
|
|
95
|
+
# these are the candidates for matching on amount alone.
|
|
96
|
+
# accounts - IBANs ydim owns; entries on any other account are ignored.
|
|
97
|
+
# ydim only ever sees the ywesee business account, but the
|
|
98
|
+
# e-banking download also carries the private ones.
|
|
99
|
+
# resolver - optional callable turning an invoice number into an Info, so
|
|
100
|
+
# that a payment naming an invoice that is already settled can
|
|
101
|
+
# be reported as such without loading the whole invoice book.
|
|
102
|
+
def initialize(infos, accounts = nil, resolver = nil)
|
|
103
|
+
@invoices = infos.collect { |info| InvoiceRef.new(info) }
|
|
104
|
+
@accounts = [accounts].flatten.compact
|
|
105
|
+
@resolver = resolver
|
|
106
|
+
@by_id = {}
|
|
107
|
+
@invoices.each { |ref| @by_id[ref.unique_id.to_s] = ref }
|
|
108
|
+
end
|
|
109
|
+
def reconcile(entries)
|
|
110
|
+
if @accounts.empty?
|
|
111
|
+
raise ArgumentError,
|
|
112
|
+
"no account given -- set camt_accounts to the IBAN of the ywesee " \
|
|
113
|
+
"business account, or the private accounts in the same download " \
|
|
114
|
+
"would be matched against invoices too"
|
|
115
|
+
end
|
|
116
|
+
result = Result.new
|
|
117
|
+
unique = YDIM::Camt.dedup(entries)
|
|
118
|
+
result.skipped[:duplicate] = entries.size - unique.size
|
|
119
|
+
unique.each { |entry|
|
|
120
|
+
if !entry.account?(@accounts)
|
|
121
|
+
result.skipped[:other_account] += 1
|
|
122
|
+
elsif !entry.credit?
|
|
123
|
+
result.skipped[:debit] += 1
|
|
124
|
+
elsif !entry.booked?
|
|
125
|
+
result.skipped[:not_booked] += 1
|
|
126
|
+
else
|
|
127
|
+
result.matches.push(match(entry))
|
|
128
|
+
end
|
|
129
|
+
}
|
|
130
|
+
result
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
private
|
|
134
|
+
def match(entry)
|
|
135
|
+
referenced = entry.numeric_tokens.collect { |token|
|
|
136
|
+
lookup(token.sub(/\A0+(?=\d)/, '')) || lookup(token)
|
|
137
|
+
}.compact.uniq
|
|
138
|
+
if referenced.empty?
|
|
139
|
+
match_by_amount(entry)
|
|
140
|
+
else
|
|
141
|
+
match_by_reference(entry, referenced)
|
|
142
|
+
end
|
|
143
|
+
end
|
|
144
|
+
def match_by_reference(entry, referenced)
|
|
145
|
+
open, paid = referenced.partition { |ref| ref.payable? }
|
|
146
|
+
if open.empty?
|
|
147
|
+
return Match.new(entry, paid, :not_open,
|
|
148
|
+
"invoice #{paid.collect { |ref| ref.unique_id }.join(', ')} " \
|
|
149
|
+
"is no longer open")
|
|
150
|
+
end
|
|
151
|
+
wrong = open.reject { |ref| currency?(entry, ref) }
|
|
152
|
+
unless wrong.empty?
|
|
153
|
+
return Match.new(entry, open, :currency_mismatch,
|
|
154
|
+
"paid in #{entry.currency}, invoiced in #{wrong.first.currency}")
|
|
155
|
+
end
|
|
156
|
+
total = open.inject(0) { |sum, ref| sum + ref.total_cents }
|
|
157
|
+
if total == entry.amount_cents
|
|
158
|
+
state = open.size == 1 ? :exact : :split
|
|
159
|
+
reason = open.size == 1 ? 'invoice number and amount match' \
|
|
160
|
+
: "invoice numbers found, amounts sum to #{money(entry)}"
|
|
161
|
+
reason += ", #{paid.size} already paid" unless paid.empty?
|
|
162
|
+
Match.new(entry, open, state, reason)
|
|
163
|
+
elsif entry.amount_cents < total
|
|
164
|
+
Match.new(entry, open, :underpaid,
|
|
165
|
+
"#{money(entry)} received, #{format_cents(total)} invoiced")
|
|
166
|
+
else
|
|
167
|
+
Match.new(entry, open, :overpaid,
|
|
168
|
+
"#{money(entry)} received, #{format_cents(total)} invoiced")
|
|
169
|
+
end
|
|
170
|
+
end
|
|
171
|
+
# Nothing in the remittance information named an invoice -- fall back to
|
|
172
|
+
# the amount, which is never good enough to book on its own.
|
|
173
|
+
def match_by_amount(entry)
|
|
174
|
+
candidates = @invoices.select { |ref|
|
|
175
|
+
ref.payable? && currency?(entry, ref) \
|
|
176
|
+
&& ref.total_cents == entry.amount_cents \
|
|
177
|
+
&& !later_than?(ref, entry)
|
|
178
|
+
}
|
|
179
|
+
named = candidates.select { |ref| same_party?(entry, ref) }
|
|
180
|
+
candidates = named unless named.empty?
|
|
181
|
+
case candidates.size
|
|
182
|
+
when 0
|
|
183
|
+
Match.new(entry, [], :unmatched, 'no invoice number, no amount match')
|
|
184
|
+
when 1
|
|
185
|
+
Match.new(entry, candidates, :amount_only,
|
|
186
|
+
named.empty? ? 'amount matches, no invoice number given' \
|
|
187
|
+
: 'amount and debitor name match, no invoice number given')
|
|
188
|
+
else
|
|
189
|
+
Match.new(entry, candidates, :ambiguous,
|
|
190
|
+
"#{candidates.size} open invoices over #{money(entry)}")
|
|
191
|
+
end
|
|
192
|
+
end
|
|
193
|
+
def lookup(id)
|
|
194
|
+
return @by_id[id] if @by_id.key?(id)
|
|
195
|
+
found = @resolver && @resolver.call(id)
|
|
196
|
+
@by_id[id] = found && InvoiceRef.new(found)
|
|
197
|
+
end
|
|
198
|
+
def currency?(entry, ref)
|
|
199
|
+
ref.currency.nil? || entry.currency.nil? \
|
|
200
|
+
|| ref.currency.to_s.upcase == entry.currency.to_s.upcase
|
|
201
|
+
end
|
|
202
|
+
# An invoice cannot have been paid before it was written.
|
|
203
|
+
def later_than?(ref, entry)
|
|
204
|
+
ref.date && entry.booking_date && ref.date > entry.booking_date
|
|
205
|
+
end
|
|
206
|
+
def same_party?(entry, ref)
|
|
207
|
+
left = words(entry.counterparty)
|
|
208
|
+
right = words(ref.debitor_name)
|
|
209
|
+
!left.empty? && !right.empty? && !(left & right).empty?
|
|
210
|
+
end
|
|
211
|
+
def words(str)
|
|
212
|
+
str.to_s.downcase.split(/[^[[:alpha:]]]+/).reject { |word|
|
|
213
|
+
word.size < 3 || NOISE.include?(word)
|
|
214
|
+
}
|
|
215
|
+
end
|
|
216
|
+
def money(entry)
|
|
217
|
+
sprintf('%s %.2f', entry.currency, entry.amount.to_f)
|
|
218
|
+
end
|
|
219
|
+
def format_cents(total)
|
|
220
|
+
sprintf('%.2f', total / 100.0)
|
|
221
|
+
end
|
|
222
|
+
end
|
|
223
|
+
end
|
data/lib/ydim/root_session.rb
CHANGED
|
@@ -7,6 +7,7 @@ require 'ydim/debitor'
|
|
|
7
7
|
require 'ydim/invoice'
|
|
8
8
|
require 'ydim/item'
|
|
9
9
|
require 'ydim/mail'
|
|
10
|
+
require 'ydim/reconciler'
|
|
10
11
|
require 'odba'
|
|
11
12
|
|
|
12
13
|
module YDIM
|
|
@@ -125,6 +126,51 @@ module YDIM
|
|
|
125
126
|
@serv.logger.debug(whoami) { "invoice_infos(#{status})" }
|
|
126
127
|
Invoice.search_by_status(status).collect { |inv| inv.info }
|
|
127
128
|
end
|
|
129
|
+
# The invoices a payment could still be settling. Both statuses are asked
|
|
130
|
+
# for because the status index is only refreshed once a day, so an invoice
|
|
131
|
+
# that has just fallen due may still be indexed as open.
|
|
132
|
+
def open_invoice_infos
|
|
133
|
+
@serv.logger.debug(whoami) { "open_invoice_infos" }
|
|
134
|
+
(Invoice.search_by_status('is_open') \
|
|
135
|
+
| Invoice.search_by_status('is_due')).collect { |inv| inv.info }
|
|
136
|
+
end
|
|
137
|
+
# Marks an invoice as paid. Stores the date the money was booked rather
|
|
138
|
+
# than a plain true, so that a later reconciliation run can tell an
|
|
139
|
+
# already-booked payment from a fresh one.
|
|
140
|
+
def mark_paid(invoice_id, date=Date.today)
|
|
141
|
+
@serv.logger.info(whoami) { "mark_paid(#{invoice_id}, #{date})" }
|
|
142
|
+
ODBA.transaction {
|
|
143
|
+
inv = invoice(invoice_id)
|
|
144
|
+
inv.payment_received = date
|
|
145
|
+
inv.odba_store
|
|
146
|
+
inv.info
|
|
147
|
+
}
|
|
148
|
+
end
|
|
149
|
+
# Matches booked bank credits against the invoice book. `entries` are
|
|
150
|
+
# YDIM::Camt::Entry objects, parsed client-side -- the daemon never needs
|
|
151
|
+
# to see the statement files. Read-only unless :apply is given, in which
|
|
152
|
+
# case only unambiguous matches (invoice number named *and* amount to the
|
|
153
|
+
# cent) are booked; everything else is left for a human.
|
|
154
|
+
def reconcile_camt(entries, opts={})
|
|
155
|
+
@serv.logger.info(whoami) {
|
|
156
|
+
"reconcile_camt(#{entries.size} entries, #{opts.inspect})" }
|
|
157
|
+
accounts = opts[:accounts] || @serv.config.camt_accounts
|
|
158
|
+
resolver = lambda { |id|
|
|
159
|
+
inv = Invoice.find_by_unique_id(id.to_s)
|
|
160
|
+
inv && inv.info
|
|
161
|
+
}
|
|
162
|
+
result = Reconciler.new(open_invoice_infos, accounts, resolver)\
|
|
163
|
+
.reconcile(entries)
|
|
164
|
+
if opts[:apply]
|
|
165
|
+
result.applicable.each { |match|
|
|
166
|
+
match.invoices.each { |info|
|
|
167
|
+
mark_paid(info.unique_id, match.entry.booking_date)
|
|
168
|
+
}
|
|
169
|
+
match.applied = true
|
|
170
|
+
}
|
|
171
|
+
end
|
|
172
|
+
result
|
|
173
|
+
end
|
|
128
174
|
def search_debitors(email_or_name)
|
|
129
175
|
@serv.logger.debug(whoami) { "search_debitors(#{email_or_name})" }
|
|
130
176
|
Debitor.search_by_exact_email(email_or_name) |
|
data/lib/ydim/server_config.rb
CHANGED
|
@@ -15,6 +15,11 @@ module YDIM
|
|
|
15
15
|
'autoinvoice_hour' => 1,
|
|
16
16
|
'config' => default_config_files,
|
|
17
17
|
'conf_dir' => File.join(ydim_default_dir, 'conf'),
|
|
18
|
+
# IBANs of the accounts ydim invoices are paid into. The e-banking camt
|
|
19
|
+
# download also carries the private accounts, whose entries must never be
|
|
20
|
+
# matched against invoices, so leaving this empty disables reconciliation
|
|
21
|
+
# rather than reconciling against everything.
|
|
22
|
+
'camt_accounts' => [],
|
|
18
23
|
'currencies' => ['CHF', 'EUR', 'USD'],
|
|
19
24
|
'currency_update_hour' => 2,
|
|
20
25
|
'data_dir' => File.join(ydim_default_dir, 'data'),
|
|
@@ -46,7 +51,7 @@ module YDIM
|
|
|
46
51
|
'smtp_port' => 587,
|
|
47
52
|
'smtp_server' => 'localhost',
|
|
48
53
|
'smtp_user' => 'ydim@ywesee.com',
|
|
49
|
-
'vat_rate' =>
|
|
54
|
+
'vat_rate' => 8.1,
|
|
50
55
|
}
|
|
51
56
|
CONFIG = RCLConf::RCLConf.new(ARGV, defaults)
|
|
52
57
|
def Server.config
|
data/lib/ydim/version.rb
CHANGED
data/lib/ydim/ydim-camt
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env ruby
|
|
2
|
+
# encoding: utf-8
|
|
3
|
+
# ydim-camt -- ydim -- 09.08.2026 -- zdavatz@ywesee.com
|
|
4
|
+
#
|
|
5
|
+
# Reconciles bank statements (ISO-20022 camt.053, as downloaded from UBS
|
|
6
|
+
# e-banking) against the open invoices. Reports by default; only books a
|
|
7
|
+
# payment when told to with --apply, and even then only when the payer named
|
|
8
|
+
# the invoice and the amount matches to the cent.
|
|
9
|
+
#
|
|
10
|
+
# ydim-camt ~/Downloads/statements.zip
|
|
11
|
+
# ydim-camt --apply ~/Downloads/statements.zip
|
|
12
|
+
# ydim-camt --account CH87... /var/ydim/camt/
|
|
13
|
+
|
|
14
|
+
require 'openssl'
|
|
15
|
+
require 'optparse'
|
|
16
|
+
require 'rrba/error'
|
|
17
|
+
require 'ydim/camt'
|
|
18
|
+
require 'ydim/client'
|
|
19
|
+
require 'ydim/config'
|
|
20
|
+
require 'ydim/reconciler'
|
|
21
|
+
|
|
22
|
+
options = { :apply => false, :verbose => false, :accounts => [] }
|
|
23
|
+
parser = OptionParser.new { |opt|
|
|
24
|
+
opt.banner = "Usage: ydim-camt [options] <statements.zip|directory|file.xml>"
|
|
25
|
+
opt.on('--apply', 'mark unambiguously matched invoices as paid') {
|
|
26
|
+
options[:apply] = true }
|
|
27
|
+
opt.on('--account IBAN', 'account to reconcile (repeatable); ',
|
|
28
|
+
'defaults to camt_accounts from the config') { |iban|
|
|
29
|
+
options[:accounts].push(iban) }
|
|
30
|
+
opt.on('-v', '--verbose', 'also list credits that matched nothing') {
|
|
31
|
+
options[:verbose] = true }
|
|
32
|
+
opt.on('-h', '--help') { puts opt; exit }
|
|
33
|
+
}
|
|
34
|
+
# Every ydim executable also takes its configuration on the command line, in
|
|
35
|
+
# RCLConf's "key=value" form -- those are not statements to read, they were
|
|
36
|
+
# already picked up from ARGV when ydim/config was required.
|
|
37
|
+
paths = parser.parse(ARGV.dup).reject { |arg| arg =~ /\A[\w.]+=/ }
|
|
38
|
+
if paths.empty?
|
|
39
|
+
warn parser
|
|
40
|
+
exit 1
|
|
41
|
+
end
|
|
42
|
+
missing = paths.reject { |path| File.exist?(path) }
|
|
43
|
+
unless missing.empty?
|
|
44
|
+
warn "no such file: #{missing.join(', ')}"
|
|
45
|
+
exit 1
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
entries = paths.flat_map { |path| YDIM::Camt.entries(path) }
|
|
49
|
+
if entries.empty?
|
|
50
|
+
warn "no camt entries found in #{paths.join(', ')}"
|
|
51
|
+
exit 1
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
config = YDIM::Client::CONFIG
|
|
55
|
+
accounts = options[:accounts]
|
|
56
|
+
accounts = [config.camt_accounts].flatten.compact if accounts.empty?
|
|
57
|
+
if accounts.empty?
|
|
58
|
+
warn "no account to reconcile: pass --account IBAN, or set camt_accounts " \
|
|
59
|
+
"in the config to the IBAN of the ywesee business account. The private " \
|
|
60
|
+
"accounts in the same download must not be matched against invoices."
|
|
61
|
+
exit 1
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def show(match, marker)
|
|
65
|
+
ids = match.invoice_ids
|
|
66
|
+
printf(" %-11s %10.2f %s %s %s\n", ids.first || '?',
|
|
67
|
+
match.entry.amount.to_f, match.entry.currency,
|
|
68
|
+
match.entry.booking_date, match.entry.counterparty.to_s[0, 30])
|
|
69
|
+
ids[1..-1].to_a.each { |id| printf(" %-11s\n", id) }
|
|
70
|
+
puts " #{' ' * 11} #{marker} #{match.reason}"
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
server = DRb::DRbObject.new(nil, config.server_url)
|
|
74
|
+
client = YDIM::Client.new(config)
|
|
75
|
+
key = OpenSSL::PKey::DSA.new(File.read(config.private_key))
|
|
76
|
+
|
|
77
|
+
DRb.start_service
|
|
78
|
+
client.login(server, key)
|
|
79
|
+
begin
|
|
80
|
+
result = client.reconcile_camt(entries,
|
|
81
|
+
:apply => options[:apply], :accounts => accounts)
|
|
82
|
+
|
|
83
|
+
applicable = result.applicable
|
|
84
|
+
puts(options[:apply] ? "BOOKED AS PAID" \
|
|
85
|
+
: "MATCHED (would be marked paid with --apply)")
|
|
86
|
+
if applicable.empty?
|
|
87
|
+
puts " none"
|
|
88
|
+
else
|
|
89
|
+
applicable.each { |match| show(match, match.applied? ? '+' : '~') }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
review = result.review
|
|
93
|
+
unless review.empty?
|
|
94
|
+
puts
|
|
95
|
+
puts "REVIEW (never booked automatically)"
|
|
96
|
+
review.each { |match| show(match, '?') }
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
unmatched = result.unmatched
|
|
100
|
+
if options[:verbose] && !unmatched.empty?
|
|
101
|
+
puts
|
|
102
|
+
puts "UNMATCHED CREDITS"
|
|
103
|
+
unmatched.each { |match| show(match, '-') }
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
puts
|
|
107
|
+
summary = ["#{applicable.size} matched", "#{review.size} to review",
|
|
108
|
+
"#{unmatched.size} unmatched"]
|
|
109
|
+
summary.push("#{result.applied.size} booked") if options[:apply]
|
|
110
|
+
result.skipped.sort_by { |reason, count| reason.to_s }.each { |reason, count|
|
|
111
|
+
summary.push("#{count} #{reason.to_s.tr('_', ' ')}") if count > 0
|
|
112
|
+
}
|
|
113
|
+
puts summary.join(', ')
|
|
114
|
+
unless options[:apply] || applicable.empty?
|
|
115
|
+
puts "run again with --apply to mark the #{applicable.size} matched " \
|
|
116
|
+
"invoice(s) paid"
|
|
117
|
+
end
|
|
118
|
+
ensure
|
|
119
|
+
client.logout
|
|
120
|
+
end
|
data/lib/ydim/ydim-edit
CHANGED
|
@@ -19,10 +19,9 @@ module YDIM
|
|
|
19
19
|
end
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
ydim_default_dir =
|
|
22
|
+
ydim_default_dir = '/etc/ydim'
|
|
23
23
|
default_config_files = [
|
|
24
|
-
File.join(ydim_default_dir, '
|
|
25
|
-
'/etc/ydim/ydimd.yml',
|
|
24
|
+
File.join(ydim_default_dir, 'ydim.yml'),
|
|
26
25
|
]
|
|
27
26
|
defaults = {
|
|
28
27
|
'autoinvoice_hour' => nil,
|
|
@@ -45,7 +44,7 @@ defaults = {
|
|
|
45
44
|
'root_key' => 'root_dsa',
|
|
46
45
|
'smtp_from' => '',
|
|
47
46
|
'smtp_server' => 'localhost',
|
|
48
|
-
'vat_rate' =>
|
|
47
|
+
'vat_rate' => 8.1,
|
|
49
48
|
}
|
|
50
49
|
config = RCLConf::RCLConf.new(ARGV, defaults)
|
|
51
50
|
config.load(config.config)
|
data/readme.md
CHANGED
|
@@ -4,7 +4,11 @@
|
|
|
4
4
|
|
|
5
5
|
## DESCRIPTION:
|
|
6
6
|
|
|
7
|
-
ywesee distributed invoice manager, Ruby
|
|
7
|
+
ywesee distributed invoice manager, Ruby.
|
|
8
|
+
|
|
9
|
+
A DRb daemon (`ydimd`) that keeps debitors, invoices and recurring auto-invoices in a
|
|
10
|
+
PostgreSQL database via ODBA, renders invoices as PDF and mails them out. Clients talk to
|
|
11
|
+
the daemon over `druby://` and authenticate with a DSA key.
|
|
8
12
|
|
|
9
13
|
## Install Ruby
|
|
10
14
|
|
|
@@ -12,6 +16,8 @@ ywesee distributed invoice manager, Ruby
|
|
|
12
16
|
* echo 'eval "$(~/.rbenv/bin/rbenv init - bash)"' >> ~/.bashrc
|
|
13
17
|
* git clone https://github.com/rbenv/ruby-build.git "$(rbenv root)"/plugins/ruby-build
|
|
14
18
|
|
|
19
|
+
Tested against Ruby 3.0, 3.1 and 3.2 (see `.github/workflows/ruby.yml`).
|
|
20
|
+
|
|
15
21
|
## Install Postgresql
|
|
16
22
|
```
|
|
17
23
|
* sudo apt-get install postgresql-10 postgresql-contrib-10
|
|
@@ -26,8 +32,12 @@ ywesee distributed invoice manager, Ruby
|
|
|
26
32
|
* -> check DB
|
|
27
33
|
* psql
|
|
28
34
|
* \l
|
|
29
|
-
* bzcat 22:00-postgresql_database-
|
|
35
|
+
* bzcat 22:00-postgresql_database-ydim-backup.bz2 | sudo -u postgres psql -p 5433 ydim
|
|
30
36
|
```
|
|
37
|
+
|
|
38
|
+
`set_initial_ydim_db.sql` creates the ODBA schema for an empty database. The
|
|
39
|
+
`get_db_ydim` script fetches the nightly dump from the backup host and restores it locally.
|
|
40
|
+
|
|
31
41
|
## INSTALL:
|
|
32
42
|
|
|
33
43
|
* gem install ydim
|
|
@@ -41,12 +51,87 @@ Or if you are using bundler
|
|
|
41
51
|
* bundle config build.pg --with-pg-config=/usr/local/pgsql-10.1/bin/pg_config
|
|
42
52
|
* bundle install
|
|
43
53
|
|
|
54
|
+
## CONFIGURATION:
|
|
55
|
+
|
|
56
|
+
All server side configuration is read from `/etc/ydim` (since version 1.1.4/1.1.5):
|
|
57
|
+
|
|
58
|
+
* `/etc/ydim/ydimd.yml` — the daemon (database, SMTP, VAT rate, invoice numbering, log level).
|
|
59
|
+
Defaults are in `lib/ydim/server_config.rb`.
|
|
60
|
+
* `/etc/ydim/ydim.yml` — the client (`server_url`, `private_key`, currency, payment period).
|
|
61
|
+
Defaults are in `lib/ydim/config.rb`.
|
|
62
|
+
* `camt_accounts` (in both files) — the IBANs ydim invoices are paid into, used by
|
|
63
|
+
`ydim-camt`. See *Checking payments* below.
|
|
64
|
+
* `/etc/ydim/conf/` — key material, e.g. the `root_dsa` public key used to authenticate.
|
|
65
|
+
* `~/.pdfinvoice/config.yml` or `/etc/pdfinvoice/config.yml` — creditor address, bank
|
|
66
|
+
details, logo and texts of the generated PDF. Defaults are in `lib/pdfinvoice/config.rb`,
|
|
67
|
+
and `test/data/config.yml` is a working example.
|
|
68
|
+
|
|
69
|
+
Any default may also be overridden on the command line, e.g. `--log_level DEBUG`.
|
|
70
|
+
|
|
71
|
+
## RUNNING:
|
|
72
|
+
|
|
73
|
+
* Start the daemon: `bundle exec ruby lib/ydim/ydimd` (listens on `druby://localhost:12375`)
|
|
74
|
+
* Interactive console against the live database: `bundle exec lib/ydim/ydim-edit`
|
|
75
|
+
(gives you `$server` and the Needle registry as `$needle` inside IRB)
|
|
76
|
+
* Create and send an invoice from a YAML description: `bundle exec lib/ydim/ydim-inject < invoice.yml`
|
|
77
|
+
|
|
78
|
+
The daemon additionally runs three daily jobs: generating and mailing due auto-invoices,
|
|
79
|
+
updating currency conversion rates, and refreshing the stored invoice status.
|
|
80
|
+
|
|
81
|
+
## Checking payments
|
|
82
|
+
|
|
83
|
+
`ydim-camt` matches the credits on a bank statement against the open invoices. It reads the
|
|
84
|
+
ISO-20022 camt.053 files banks provide — the zip downloaded from UBS e-banking, an unpacked
|
|
85
|
+
directory, or a single xml:
|
|
86
|
+
|
|
87
|
+
```
|
|
88
|
+
bundle exec lib/ydim/ydim-camt ~/Downloads/statements.zip # report only
|
|
89
|
+
bundle exec lib/ydim/ydim-camt --apply ~/Downloads/statements.zip # and book them
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Set `camt_accounts` to the IBAN(s) ydim invoices are paid into, or pass `--account IBAN`.
|
|
93
|
+
**This is required.** An e-banking download contains every account the login can see,
|
|
94
|
+
including private ones, and a credit there can look exactly like a payment on an invoice.
|
|
95
|
+
Rather than reconcile whatever it is given, `ydim-camt` refuses to run without being told
|
|
96
|
+
which account is the business one.
|
|
97
|
+
|
|
98
|
+
Without `--apply` nothing is modified. With it, only invoices whose number the payer wrote
|
|
99
|
+
in the remittance information **and** whose amount matches to the cent are marked paid; one
|
|
100
|
+
transfer settling several invoices counts when the amounts sum exactly. Everything else —
|
|
101
|
+
a payment with no reference, a partial payment, two open invoices over the same amount — is
|
|
102
|
+
listed under REVIEW and left to you. Duplicate deliveries of the same statement, debits and
|
|
103
|
+
pending entries are skipped and counted in the summary.
|
|
104
|
+
|
|
105
|
+
Marking a single invoice paid by hand, from `ydim-edit` or any client:
|
|
106
|
+
|
|
107
|
+
```ruby
|
|
108
|
+
$server.mark_paid(13363, Date.new(2026, 7, 10))
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## DEVELOPMENT:
|
|
112
|
+
|
|
113
|
+
```
|
|
114
|
+
bundle install
|
|
115
|
+
MT_COMPAT=1 bundle exec rake test # run the whole suite
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
`MT_COMPAT=1` is required because the tests use `flexmock/test_unit`, which needs the
|
|
119
|
+
minitest compatibility shim. A single test file or a single test case:
|
|
120
|
+
|
|
121
|
+
```
|
|
122
|
+
MT_COMPAT=1 bundle exec ruby -Ilib -Itest test/test_invoice.rb
|
|
123
|
+
MT_COMPAT=1 bundle exec ruby -Ilib -Itest test/test_invoice.rb -n test_add_item
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
`bundle exec rake` (the default task) cleans, runs the tests and builds the gem into `pkg/`.
|
|
127
|
+
The test suite needs no database — `test/stub/odba.rb` stubs out ODBA persistence.
|
|
128
|
+
|
|
44
129
|
## Migrating an old database
|
|
45
130
|
|
|
46
131
|
An old database can be migrated to UTF-8 by calling
|
|
47
132
|
|
|
48
133
|
bundle install --path vendor
|
|
49
|
-
bundle exec
|
|
134
|
+
bundle exec ./ydim_migrate_to_utf_8
|
|
50
135
|
|
|
51
136
|
## DEVELOPERS:
|
|
52
137
|
|