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,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
@@ -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) |
@@ -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' => 7.7,
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
@@ -1,3 +1,3 @@
1
1
  module YDIM
2
- VERSION = '1.1.4'
2
+ VERSION = '1.1.6'
3
3
  end
@@ -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 = File.join(ENV['HOME'], '.ydim')
22
+ ydim_default_dir = '/etc/ydim'
23
23
  default_config_files = [
24
- File.join(ydim_default_dir, 'ydimd.yml'),
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' => 7.7,
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-ch_oddb-backup.bz2 | sudo -u postgres psql -p 5433 ydim
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 bin/migrate_to_utf_8
134
+ bundle exec ./ydim_migrate_to_utf_8
50
135
 
51
136
  ## DEVELOPERS:
52
137