Files
dolibarr_shine_reconciliation/lib/reconciliation/reporter.rb
Kevin Bataille 4decb3cb3c Initial implementation of the reconciliation script
Standalone Ruby script reconciling GoCardless payments, Dolibarr
invoices (via API), and Shine bank statements. Three-pass engine:
GC↔Dolibarr matching, open invoice audit, payout↔bank verification.
Includes dry-run and --fix mode to auto-mark Dolibarr invoices as paid.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-26 00:23:07 +01:00

204 lines
7.4 KiB
Ruby

# frozen_string_literal: true
require "csv"
require "date"
module Reconciliation
class Reporter
def initialize(result, from:, to:)
@result = result
@from = from
@to = to
end
def print_summary
r = @result
puts ""
puts "=" * 60
puts " RECONCILIATION REPORT: #{@from} to #{@to}"
puts "=" * 60
# --- Dolibarr overview ---
total_invoices = r[:matches].filter_map(&:invoice).uniq(&:id).size +
r[:dolibarr_open_no_gc].size
open_count = r[:dolibarr_open_no_gc].size +
r[:gc_paid_dolibarr_open].size
puts ""
puts "DOLIBARR"
puts " Total invoices in scope: #{total_invoices}"
puts " Open (no GC match): #{r[:dolibarr_open_no_gc].size}#{r[:dolibarr_open_no_gc].any? ? ' ← needs attention' : ''}"
puts " Paid (GC matched): #{r[:matched].size}"
# --- GoCardless ↔ Dolibarr ---
puts ""
puts "GOCARDLESS ↔ DOLIBARR"
puts " Matched (paid both sides): #{r[:matched].size} #{'✓' if r[:matched].any?}"
puts " GC paid / Dolibarr open: #{r[:gc_paid_dolibarr_open].size}#{r[:gc_paid_dolibarr_open].any? ? ' ← FIX with --fix' : ''}"
puts " Dolibarr paid / no GC: #{r[:dolibarr_paid_no_gc].size}#{r[:dolibarr_paid_no_gc].any? ? ' ← verify manually' : ''}"
puts " GC failed: #{r[:gc_failed].size}"
puts " GC cancelled: #{r[:gc_cancelled].size}"
puts " GC payment / no invoice: #{r[:gc_no_invoice].size}#{r[:gc_no_invoice].any? ? ' ← investigate' : ''}"
# --- Shine ↔ GoCardless Payouts ---
unless @result[:payout_matches].empty?
pm = r[:payout_matches]
verified = pm.count { |p| p.flag == Engine::PAYOUT_VERIFIED }
mismatch = pm.count { |p| p.flag == Engine::PAYOUT_AMOUNT_MISMATCH }
missing = pm.count { |p| p.flag == Engine::PAYOUT_MISSING }
expected = pm.sum(&:expected_amount_cents)
actual = pm.sum(&:actual_amount_cents)
diff = actual - expected
puts ""
puts "SHINE ↔ GOCARDLESS PAYOUTS"
puts " Payouts expected: #{pm.size}"
puts " Verified: #{verified}#{verified == pm.size ? ' ✓' : ''}"
puts " Amount mismatch: #{mismatch}#{mismatch > 0 ? ' ← check GC fees' : ''}"
puts " Missing in Shine: #{missing}#{missing > 0 ? ' ← investigate' : ''}"
puts " Expected total: #{format_eur(expected)}"
puts " Actual total: #{format_eur(actual)}"
puts " Difference: #{format_eur(diff)}#{diff.zero? ? ' ✓' : ' ← investigate'}"
end
# --- Action items ---
actions = r[:gc_paid_dolibarr_open] +
r[:dolibarr_paid_no_gc] +
r[:dolibarr_open_no_gc] +
r[:gc_no_invoice]
if actions.any?
puts ""
puts "ACTIONS NEEDED (#{actions.size})"
puts "-" * 60
r[:gc_paid_dolibarr_open].each_with_index do |m, i|
puts " #{i + 1}. [GC_PAID_DOLIBARR_OPEN] " \
"#{m.invoice.ref.ljust(16)} " \
"#{format_eur(m.invoice.amount_cents)} " \
"#{display_name(m.invoice).ljust(20)} " \
"GC: #{m.payment.id} #{m.payment.charge_date} (#{m.match_type})"
end
r[:dolibarr_paid_no_gc].each_with_index do |m, i|
n = r[:gc_paid_dolibarr_open].size + i + 1
puts " #{n}. [DOLIBARR_PAID_NO_GC] " \
"#{m.invoice.ref.ljust(16)} " \
"#{format_eur(m.invoice.amount_cents)} " \
"#{display_name(m.invoice).ljust(20)} " \
"No GoCardless payment found"
end
base = r[:gc_paid_dolibarr_open].size + r[:dolibarr_paid_no_gc].size
r[:dolibarr_open_no_gc].each_with_index do |inv, i|
n = base + i + 1
overdue = inv.due_date && inv.due_date < Date.today ? " (overdue since #{inv.due_date})" : ""
puts " #{n}. [DOLIBARR_OPEN_NO_GC] " \
"#{inv.ref.ljust(16)} " \
"#{format_eur(inv.amount_cents)} " \
"#{display_name(inv).ljust(20)} " \
"Open, no GC payment#{overdue}"
end
base2 = base + r[:dolibarr_open_no_gc].size
r[:gc_no_invoice].each_with_index do |m, i|
n = base2 + i + 1
puts " #{n}. [GC_NO_INVOICE] " \
"GC: #{m.payment.id} #{format_eur(m.payment.amount_cents)} " \
"\"#{m.payment.description}\" #{m.payment.customer_name} #{m.payment.charge_date}"
end
r[:payout_matches].select { |p| p.flag == Engine::PAYOUT_MISSING }.each do |pm|
n = base2 + r[:gc_no_invoice].size + 1
puts " #{n}. [PAYOUT_MISSING] " \
"Payout #{pm.payout_id} #{format_eur(pm.expected_amount_cents)} " \
"expected #{pm.payout_date}"
end
else
puts ""
puts " All clear — no actions needed."
end
puts ""
puts "=" * 60
csv_path = write_csv
puts " Report saved to: #{csv_path}"
puts ""
end
def write_csv
dir = "tmp"
Dir.mkdir(dir) unless Dir.exist?(dir)
path = "#{dir}/reconciliation_#{@to}.csv"
CSV.open(path, "w", encoding: "UTF-8") do |csv|
csv << %w[
invoice_ref customer_name amount_eur invoice_date due_date
dolibarr_status gc_payment_id gc_status gc_charge_date
match_type flag action
]
@result[:matches].each do |m|
inv = m.invoice
pay = m.payment
csv << [
inv&.ref,
inv&.customer_name,
inv ? "%.2f" % (inv.amount_cents / 100.0) : nil,
inv&.date,
inv&.due_date,
inv ? status_label(inv.status) : nil,
pay&.id,
pay&.status,
pay&.charge_date,
m.match_type,
m.flag,
action_label(m.flag)
]
end
@result[:dolibarr_open_no_gc].each do |inv|
csv << [
inv.ref, inv.customer_name,
"%.2f" % (inv.amount_cents / 100.0),
inv.date, inv.due_date,
status_label(inv.status),
nil, nil, nil, nil,
Engine::DOLIBARR_OPEN_NO_GC,
action_label(Engine::DOLIBARR_OPEN_NO_GC)
]
end
end
path
end
private
def display_name(inv)
name = inv.customer_name.to_s.strip
name.empty? ? "client_id:#{inv.customer_id}" : name
end
def format_eur(cents)
"#{"%.2f" % (cents / 100.0)}"
end
def status_label(status)
{ 0 => "draft", 1 => "open", 2 => "paid", 3 => "cancelled" }[status] || status.to_s
end
def action_label(flag)
{
Engine::MATCHED => "none",
Engine::GC_PAID_DOLIBARR_OPEN => "mark_dolibarr_paid",
Engine::GC_FAILED => "verify",
Engine::GC_CANCELLED => "none",
Engine::GC_NO_INVOICE => "investigate",
Engine::DOLIBARR_PAID_NO_GC => "verify_manually",
Engine::DOLIBARR_OPEN_NO_GC => "follow_up"
}[flag] || flag.to_s
end
end
end