# 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