Add advanced reconciliation features: retry detection, partial payments, credit notes
New features: - Credit note handling: Exclude negative invoices from GC reconciliation - Retry detection: Flag invoices with multiple GC payment attempts - Partial payment detection: Track invoices where remain_to_pay > 0 - Payout fee CSV: Export detailed fee breakdown per payout - Configurable tolerances: RECONCILIATION_DATE_TOLERANCE, RECONCILIATION_PAYOUT_TOLERANCE Files: - lib/reconciliation/gocardless_payouts_parser.rb (new) - Parse GC payouts CSV - lib/reconciliation/engine.rb - Add retry_group, partial fields to Match struct - lib/reconciliation/reporter.rb - Show partial/retry in report, write payouts CSV - lib/reconciliation/dolibarr_fetcher.rb - Add is_credit_note field, filter negatives - bin/reconcile - Wire up --gc-payouts argument - README.md - Document new features and --gc-payouts usage - .env.example - Add optional tolerance settings Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
@@ -1,203 +1,269 @@
|
||||
# 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
|
||||
# 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' : ''}"
|
||||
|
||||
# Partial payment info
|
||||
partial_matches = r[:matches].select { |m| m.partial }
|
||||
if partial_matches.any?
|
||||
puts " Partial payments: #{partial_matches.size} ← check remain_to_pay"
|
||||
end
|
||||
|
||||
# Retry detection info
|
||||
retry_matches = r[:matches].select { |m| m.retry_group && m.retry_group.size > 1 }
|
||||
if retry_matches.any?
|
||||
puts " Retries detected: #{retry_matches.size} ← multiple GC attempts for same invoice"
|
||||
end
|
||||
|
||||
# --- 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 }
|
||||
total_net = pm.sum(&:expected_amount_cents)
|
||||
known_fees = pm.filter_map(&:fee_cents)
|
||||
total_fees = known_fees.sum
|
||||
|
||||
puts ""
|
||||
puts "SHINE ↔ GOCARDLESS PAYOUTS"
|
||||
puts " Payouts: #{pm.size}"
|
||||
puts " Verified: #{verified}#{verified == pm.size ? ' ✓' : ''}"
|
||||
puts " Amount mismatch: #{mismatch}#{mismatch > 0 ? ' ← check GC fees' : ''}" if mismatch > 0
|
||||
puts " Missing in Shine: #{missing} ← investigate" if missing > 0
|
||||
puts " Net received: #{format_eur(total_net)}"
|
||||
if known_fees.any?
|
||||
gross = pm.filter_map(&:gross_amount_cents).sum
|
||||
puts " Gross collected: #{format_eur(gross)}"
|
||||
puts " GC fees: #{format_eur(total_fees)}"
|
||||
# Per-payout fee detail
|
||||
pm.select { |p| p.fee_cents && p.fee_cents > 0 }.each do |p|
|
||||
puts " #{p.payout_id} #{p.payout_date} net=#{format_eur(p.expected_amount_cents)} " \
|
||||
"gross=#{format_eur(p.gross_amount_cents)} fee=#{format_eur(p.fee_cents)}"
|
||||
end
|
||||
end
|
||||
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|
|
||||
extra = []
|
||||
extra << "PARTIAL" if m.partial
|
||||
extra << "RETRY: #{m.retry_group.join(', ')}" if m.retry_group && m.retry_group.size > 1
|
||||
extra_str = extra.any? ? " [#{extra.join(', ')}]" : ""
|
||||
|
||||
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})#{extra_str}"
|
||||
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}"
|
||||
|
||||
unless @result[:payout_matches].empty?
|
||||
payout_csv_path = write_payouts_csv
|
||||
puts " Payout fees saved to: #{payout_csv_path}"
|
||||
end
|
||||
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 partial retry_group
|
||||
]
|
||||
|
||||
@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),
|
||||
m.partial ? "yes" : "no",
|
||||
m.retry_group&.join(", ")
|
||||
]
|
||||
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),
|
||||
"no",
|
||||
nil
|
||||
]
|
||||
end
|
||||
end
|
||||
|
||||
path
|
||||
end
|
||||
|
||||
def write_payouts_csv
|
||||
dir = "tmp"
|
||||
Dir.mkdir(dir) unless Dir.exist?(dir)
|
||||
path = "#{dir}/payouts_fees_#{@to}.csv"
|
||||
|
||||
CSV.open(path, "w", encoding: "UTF-8") do |csv|
|
||||
csv << %w[
|
||||
payout_id payout_date gross_amount_eur net_amount_eur fee_eur
|
||||
fee_percentage shine_reference status
|
||||
]
|
||||
|
||||
@result[:payout_matches].each do |pm|
|
||||
fee_pct = pm.gross_amount_cents && pm.gross_amount_cents > 0 \
|
||||
? ((pm.fee_cents.to_f / pm.gross_amount_cents) * 100).round(2) \
|
||||
: nil
|
||||
|
||||
csv << [
|
||||
pm.payout_id,
|
||||
pm.payout_date,
|
||||
pm.gross_amount_cents ? "%.2f" % (pm.gross_amount_cents / 100.0) : nil,
|
||||
"%.2f" % (pm.expected_amount_cents / 100.0),
|
||||
pm.fee_cents ? "%.2f" % (pm.fee_cents / 100.0) : nil,
|
||||
fee_pct ? "#{fee_pct}%" : nil,
|
||||
pm.shine_transaction&.label,
|
||||
pm.flag
|
||||
]
|
||||
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
|
||||
|
||||
Reference in New Issue
Block a user