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:
Kevin Bataille
2026-02-26 00:38:41 +01:00
parent 4decb3cb3c
commit 0a048628a5
8 changed files with 1140 additions and 860 deletions

View File

@@ -6,6 +6,7 @@ require "dotenv/load"
require_relative "dolibarr/client"
require_relative "reconciliation/dolibarr_fetcher"
require_relative "reconciliation/gocardless_parser"
require_relative "reconciliation/gocardless_payouts_parser"
require_relative "reconciliation/shine_parser"
require_relative "reconciliation/engine"
require_relative "reconciliation/reporter"

View File

@@ -1,115 +1,119 @@
# frozen_string_literal: true
require "date"
module Reconciliation
class DolibarrFetcher
# Dolibarr invoice statuses
STATUS_DRAFT = 0
STATUS_VALIDATED = 1
STATUS_PAID = 2
STATUS_CANCELLED = 3
Invoice = Struct.new(
:id, # Dolibarr internal ID
:ref, # Invoice reference, e.g. "FA2407-0003"
:status, # 0=draft, 1=validated/open, 2=paid, 3=cancelled
:total_ttc, # Total amount TTC (euros, float)
:amount_cents, # total_ttc * 100, rounded (integer)
:paye, # 0=unpaid, 1=paid
:sumpayed, # Amount already paid (euros)
:remain_to_pay, # Remaining balance (euros)
:date, # Invoice date (Date)
:due_date, # Due date (Date)
:customer_id, # socid
:customer_name, # nom of third party if present
keyword_init: true
)
def initialize(client, from:, to:)
@client = client
@from = from
@to = to
end
# Fetches all validated invoices and resolves customer names.
# Note: querying status=1 returns all non-draft invoices in Dolibarr (open, paid, cancelled).
# Cancelled invoices are filtered out here.
def fetch_invoices
$stderr.puts "[DolibarrFetcher] Fetching thirdparty names..."
name_by_id = fetch_thirdparty_names
$stderr.puts "[DolibarrFetcher] Fetching invoices..."
raw_invoices = fetch_all(status: STATUS_VALIDATED)
invoices = raw_invoices
.map { |raw| parse_invoice(raw, name_by_id) }
.reject { |inv| inv.status == STATUS_CANCELLED }
open_count = invoices.count { |inv| inv.status == STATUS_VALIDATED }
paid_count = invoices.count { |inv| inv.status == STATUS_PAID }
$stderr.puts "[DolibarrFetcher] Total: #{invoices.size} invoices (#{open_count} open, #{paid_count} paid)"
invoices
rescue Dolibarr::Client::Error => e
$stderr.puts "[DolibarrFetcher] Error: #{e.message}"
raise
end
private
def fetch_all(status:, sqlfilters: nil)
invoices = []
page = 0
loop do
params = { status: status, limit: 100, page: page }
params[:sqlfilters] = sqlfilters if sqlfilters
batch = @client.get("/invoices", params)
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
invoices.concat(batch)
break if batch.size < 100
page += 1
end
invoices
end
def parse_invoice(raw, name_by_id = {})
customer_id = raw["socid"].to_i
Invoice.new(
id: raw["id"].to_i,
ref: raw["ref"].to_s.strip,
status: raw["statut"].to_i,
total_ttc: raw["total_ttc"].to_f,
amount_cents: (raw["total_ttc"].to_f * 100).round,
paye: raw["paye"].to_i,
sumpayed: raw["sumpayed"].to_f,
remain_to_pay: raw["remaintopay"].to_f,
date: parse_unix_date(raw["datef"] || raw["date"]),
due_date: parse_unix_date(raw["date_lim_reglement"]),
customer_id: customer_id,
customer_name: name_by_id[customer_id] || raw.dig("thirdparty", "name").to_s.strip
)
end
def fetch_thirdparty_names
result = {}
page = 0
loop do
batch = @client.get("/thirdparties", limit: 100, page: page)
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
batch.each { |tp| result[tp["id"].to_i] = tp["name"].to_s.strip }
break if batch.size < 100
page += 1
end
result
end
def parse_unix_date(value)
return nil if value.nil? || value.to_s.strip.empty? || value.to_i.zero?
Time.at(value.to_i).utc.to_date
rescue
nil
end
end
end
# frozen_string_literal: true
require "date"
module Reconciliation
class DolibarrFetcher
# Dolibarr invoice statuses
STATUS_DRAFT = 0
STATUS_VALIDATED = 1
STATUS_PAID = 2
STATUS_CANCELLED = 3
Invoice = Struct.new(
:id, # Dolibarr internal ID
:ref, # Invoice reference, e.g. "FA2407-0003"
:status, # 0=draft, 1=validated/open, 2=paid, 3=cancelled
:total_ttc, # Total amount TTC (euros, float)
:amount_cents, # total_ttc * 100, rounded (integer)
:paye, # 0=unpaid, 1=paid
:sumpayed, # Amount already paid (euros)
:remain_to_pay, # Remaining balance (euros)
:date, # Invoice date (Date)
:due_date, # Due date (Date)
:customer_id, # socid
:customer_name, # nom of third party if present
:is_credit_note, # true if this is a credit note (negative amount)
keyword_init: true
)
def initialize(client, from:, to:)
@client = client
@from = from
@to = to
end
# Fetches all validated invoices and resolves customer names.
# Note: querying status=1 returns all non-draft invoices in Dolibarr (open, paid, cancelled).
# Cancelled invoices are filtered out here.
def fetch_invoices
$stderr.puts "[DolibarrFetcher] Fetching thirdparty names..."
name_by_id = fetch_thirdparty_names
$stderr.puts "[DolibarrFetcher] Fetching invoices..."
raw_invoices = fetch_all(status: STATUS_VALIDATED)
invoices = raw_invoices
.map { |raw| parse_invoice(raw, name_by_id) }
.reject { |inv| inv.status == STATUS_CANCELLED }
.reject { |inv| inv.is_credit_note } # Credit notes (negative amounts) excluded from GC reconciliation
open_count = invoices.count { |inv| inv.status == STATUS_VALIDATED }
paid_count = invoices.count { |inv| inv.status == STATUS_PAID }
$stderr.puts "[DolibarrFetcher] Total: #{invoices.size} invoices (#{open_count} open, #{paid_count} paid)"
invoices
rescue Dolibarr::Client::Error => e
$stderr.puts "[DolibarrFetcher] Error: #{e.message}"
raise
end
private
def fetch_all(status:, sqlfilters: nil)
invoices = []
page = 0
loop do
params = { status: status, limit: 100, page: page }
params[:sqlfilters] = sqlfilters if sqlfilters
batch = @client.get("/invoices", params)
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
invoices.concat(batch)
break if batch.size < 100
page += 1
end
invoices
end
def parse_invoice(raw, name_by_id = {})
customer_id = raw["socid"].to_i
total_ttc = raw["total_ttc"].to_f
Invoice.new(
id: raw["id"].to_i,
ref: raw["ref"].to_s.strip,
status: raw["statut"].to_i,
total_ttc: total_ttc,
amount_cents: (total_ttc * 100).round,
paye: raw["paye"].to_i,
sumpayed: raw["sumpayed"].to_f,
remain_to_pay: raw["remaintopay"].to_f,
date: parse_unix_date(raw["datef"] || raw["date"]),
due_date: parse_unix_date(raw["date_lim_reglement"]),
customer_id: customer_id,
customer_name: name_by_id[customer_id] || raw.dig("thirdparty", "name").to_s.strip,
is_credit_note: total_ttc < 0
)
end
def fetch_thirdparty_names
result = {}
page = 0
loop do
batch = @client.get("/thirdparties", limit: 100, page: page)
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
batch.each { |tp| result[tp["id"].to_i] = tp["name"].to_s.strip }
break if batch.size < 100
page += 1
end
result
end
def parse_unix_date(value)
return nil if value.nil? || value.to_s.strip.empty? || value.to_i.zero?
Time.at(value.to_i).utc.to_date
rescue
nil
end
end
end

View File

@@ -1,243 +1,322 @@
# frozen_string_literal: true
require "date"
module Reconciliation
class Engine
# Match flags
MATCHED = :matched # GC paid + Dolibarr paid — all good
GC_PAID_DOLIBARR_OPEN = :gc_paid_dolibarr_open # GC collected, Dolibarr still open — FIX NEEDED
GC_FAILED = :gc_failed # GC payment failed
GC_CANCELLED = :gc_cancelled # GC payment cancelled (no action needed)
GC_NO_INVOICE = :gc_no_invoice # GC payment with no Dolibarr invoice found
DOLIBARR_PAID_NO_GC = :dolibarr_paid_no_gc # Dolibarr paid, no GC match — verify manually
DOLIBARR_OPEN_NO_GC = :dolibarr_open_no_gc # Dolibarr open, no GC payment found (overdue)
# Payout flags
PAYOUT_VERIFIED = :payout_verified
PAYOUT_MISSING = :payout_missing
PAYOUT_AMOUNT_MISMATCH = :payout_amount_mismatch
DATE_TOLERANCE = 7 # days for GC charge_date ↔ Dolibarr invoice date soft match
PAYOUT_DATE_TOLERANCE = 2 # days for GC payout_date ↔ Shine credit date
# Result structs
Match = Struct.new(
:flag,
:invoice, # DolibarrFetcher::Invoice or nil
:payment, # GocardlessParser::Payment or nil
:match_type, # :strong, :soft, or nil
keyword_init: true
)
PayoutMatch = Struct.new(
:flag,
:payout_id,
:payout_date,
:expected_amount_cents,
:shine_transaction, # ShineParser::Transaction or nil
:actual_amount_cents,
:gc_payment_ids,
keyword_init: true
)
def initialize(dolibarr_invoices:, gc_payments:, shine_transactions:, from:, to:)
@invoices = dolibarr_invoices
@gc_payments = gc_payments
@shine = shine_transactions
@from = from
@to = to
end
def run
matches = pass1_gc_vs_dolibarr
overdue = pass2_overdue_open_invoices(matches)
payout_matches = pass3_payouts_vs_shine
{
matches: matches,
matched: matches.select { |m| m.flag == MATCHED },
gc_paid_dolibarr_open: matches.select { |m| m.flag == GC_PAID_DOLIBARR_OPEN },
gc_failed: matches.select { |m| m.flag == GC_FAILED },
gc_cancelled: matches.select { |m| m.flag == GC_CANCELLED },
gc_no_invoice: matches.select { |m| m.flag == GC_NO_INVOICE },
dolibarr_paid_no_gc: matches.select { |m| m.flag == DOLIBARR_PAID_NO_GC },
dolibarr_open_no_gc: overdue,
payout_matches: payout_matches
}
end
private
# -------------------------------------------------------------------------
# Pass 1 — Match each GoCardless payment to a Dolibarr invoice
# -------------------------------------------------------------------------
def pass1_gc_vs_dolibarr
matched_invoice_ids = []
results = []
@gc_payments.each do |payment|
invoice, match_type = find_invoice(payment)
if invoice
matched_invoice_ids << invoice.id
end
flag = determine_flag(payment, invoice)
results << Match.new(flag: flag, invoice: invoice, payment: payment, match_type: match_type)
end
# Dolibarr paid invoices in the date range with no GC match.
# Scoped to the date range to avoid flagging every historical paid invoice.
matched_set = matched_invoice_ids.to_set
@invoices.each do |inv|
next if matched_set.include?(inv.id)
next unless inv.status == DolibarrFetcher::STATUS_PAID
# Only flag paid invoices whose date falls within the reconciliation window
next if inv.date && (inv.date < @from || inv.date > @to)
results << Match.new(
flag: DOLIBARR_PAID_NO_GC,
invoice: inv,
payment: nil,
match_type: nil
)
end
results
end
# -------------------------------------------------------------------------
# Pass 2 — Flag open Dolibarr invoices with no GC match that are overdue
# -------------------------------------------------------------------------
def pass2_overdue_open_invoices(matches)
matched_invoice_ids = matches.filter_map { |m| m.invoice&.id }.to_set
@invoices.select do |inv|
inv.status == DolibarrFetcher::STATUS_VALIDATED &&
!matched_invoice_ids.include?(inv.id)
end
end
# -------------------------------------------------------------------------
# Pass 3 — Match GoCardless payouts to Shine bank credits
# -------------------------------------------------------------------------
def pass3_payouts_vs_shine
return [] if @shine.empty?
gc_credits = ShineParser.gocardless_credits(@shine)
# Group paid_out payments by payout_id.
# Only paid_out payments are included in a payout; failed/cancelled are not.
by_payout = @gc_payments
.select { |p| p.status == "paid_out" && p.payout_id && !p.payout_id.empty? && p.payout_date }
.group_by(&:payout_id)
used_shine_ids = []
by_payout.filter_map do |payout_id, payments|
payout_date = payments.map(&:payout_date).compact.max
expected_cents = payments.sum(&:amount_cents)
shine_tx = gc_credits.find do |tx|
!used_shine_ids.include?(tx.id) &&
(tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE &&
tx.credit_cents == expected_cents
end
if shine_tx
used_shine_ids << shine_tx.id
flag = PAYOUT_VERIFIED
actual = shine_tx.credit_cents
else
# Try date match only (amount mismatch — possible GC fees)
shine_tx = gc_credits.find do |tx|
!used_shine_ids.include?(tx.id) &&
(tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE
end
if shine_tx
used_shine_ids << shine_tx.id
flag = PAYOUT_AMOUNT_MISMATCH
actual = shine_tx.credit_cents
else
flag = PAYOUT_MISSING
actual = 0
end
end
PayoutMatch.new(
flag: flag,
payout_id: payout_id,
payout_date: payout_date,
expected_amount_cents: expected_cents,
shine_transaction: shine_tx,
actual_amount_cents: actual,
gc_payment_ids: payments.map(&:id)
)
end
end
# -------------------------------------------------------------------------
# Invoice lookup
# -------------------------------------------------------------------------
def find_invoice(payment)
# 1. Strong match: GC description == Dolibarr invoice ref
# Applies when the user puts the invoice ref in the GC payment description.
invoice = @invoices.find do |inv|
!payment.description.empty? &&
inv.ref.casecmp(payment.description) == 0
end
return [invoice, :strong] if invoice
# 2. Soft match: same amount + customer name + date within tolerance.
# The user creates invoices after GC payment succeeds, so the invoice date
# should be close to the GC charge_date. Since multiple customers pay the
# same amount, customer name matching is required to avoid false positives.
invoice = @invoices.find do |inv|
inv.amount_cents == payment.amount_cents &&
inv.date &&
(inv.date - payment.charge_date).abs <= DATE_TOLERANCE &&
names_match?(inv.customer_name, payment.customer_name)
end
return [invoice, :soft] if invoice
[nil, nil]
end
# Normalise a name for comparison: remove accents, lowercase, collapse spaces.
def normalize_name(name)
name.to_s
.unicode_normalize(:nfd)
.gsub(/\p{Mn}/, "") # strip combining diacritical marks
.downcase
.gsub(/[^a-z0-9 ]/, "")
.split
.sort
.join(" ")
end
def names_match?(dolibarr_name, gc_name)
return false if dolibarr_name.to_s.strip.empty? || gc_name.to_s.strip.empty?
# Both names normalised and compared as sorted word sets so
# "DUPONT Jean" matches "Jean DUPONT" and accent variants.
normalize_name(dolibarr_name) == normalize_name(gc_name)
end
def determine_flag(payment, invoice)
if GocardlessParser::COLLECTED_STATUSES.include?(payment.status)
if invoice.nil?
GC_NO_INVOICE
elsif invoice.status == DolibarrFetcher::STATUS_PAID
MATCHED
else
GC_PAID_DOLIBARR_OPEN
end
elsif GocardlessParser::FAILED_STATUSES.include?(payment.status)
GC_FAILED
else
GC_CANCELLED
end
end
end
end
# frozen_string_literal: true
require "date"
module Reconciliation
class Engine
# Match flags
MATCHED = :matched # GC paid + Dolibarr paid — all good
GC_PAID_DOLIBARR_OPEN = :gc_paid_dolibarr_open # GC collected, Dolibarr still open — FIX NEEDED
GC_FAILED = :gc_failed # GC payment failed
GC_CANCELLED = :gc_cancelled # GC payment cancelled (no action needed)
GC_NO_INVOICE = :gc_no_invoice # GC payment with no Dolibarr invoice found
DOLIBARR_PAID_NO_GC = :dolibarr_paid_no_gc # Dolibarr paid, no GC match — verify manually
DOLIBARR_OPEN_NO_GC = :dolibarr_open_no_gc # Dolibarr open, no GC payment found (overdue)
# Payout flags
PAYOUT_VERIFIED = :payout_verified
PAYOUT_MISSING = :payout_missing
PAYOUT_AMOUNT_MISMATCH = :payout_amount_mismatch
DATE_TOLERANCE = ENV.fetch("RECONCILIATION_DATE_TOLERANCE", 7).to_i # days for GC charge_date ↔ Dolibarr invoice date soft match
PAYOUT_DATE_TOLERANCE = ENV.fetch("RECONCILIATION_PAYOUT_TOLERANCE", 2).to_i # days for GC payout_date ↔ Shine credit date
# Result structs
Match = Struct.new(
:flag,
:invoice, # DolibarrFetcher::Invoice or nil
:payment, # GocardlessParser::Payment or nil
:match_type, # :strong, :soft, or nil
:partial, # true if invoice is partially paid (remain_to_pay > 0)
:retry_group, # Array of GC payment IDs for same invoice (retry detection)
keyword_init: true
)
PayoutMatch = Struct.new(
:flag,
:payout_id,
:payout_date,
:expected_amount_cents, # Net amount (what should land in bank)
:shine_transaction, # ShineParser::Transaction or nil
:actual_amount_cents, # What was actually found in Shine
:gc_payment_ids,
:fee_cents, # GC fee deducted (nil if unknown)
:gross_amount_cents, # Sum of underlying payments (before fees)
keyword_init: true
)
def initialize(dolibarr_invoices:, gc_payments:, shine_transactions:, from:, to:, gc_payouts: [])
@invoices = dolibarr_invoices
@gc_payments = gc_payments
@gc_payouts = gc_payouts
@shine = shine_transactions
@from = from
@to = to
end
def run
matches = pass1_gc_vs_dolibarr
overdue = pass2_overdue_open_invoices(matches)
payout_matches = pass3_payouts_vs_shine
{
matches: matches,
matched: matches.select { |m| m.flag == MATCHED },
gc_paid_dolibarr_open: matches.select { |m| m.flag == GC_PAID_DOLIBARR_OPEN },
gc_failed: matches.select { |m| m.flag == GC_FAILED },
gc_cancelled: matches.select { |m| m.flag == GC_CANCELLED },
gc_no_invoice: matches.select { |m| m.flag == GC_NO_INVOICE },
dolibarr_paid_no_gc: matches.select { |m| m.flag == DOLIBARR_PAID_NO_GC },
dolibarr_open_no_gc: overdue,
payout_matches: payout_matches
}
end
private
# -------------------------------------------------------------------------
# Pass 1 — Match each GoCardless payment to a Dolibarr invoice
# -------------------------------------------------------------------------
def pass1_gc_vs_dolibarr
matched_invoice_ids = []
results = []
# Build a map of invoice ref -> payments for retry detection
payments_by_invoice_ref = @gc_payments.group_by { |p| p.description.downcase }
@gc_payments.each do |payment|
invoice, match_type = find_invoice(payment)
if invoice
matched_invoice_ids << invoice.id
end
flag = determine_flag(payment, invoice)
# Detect retries: multiple GC payments for the same invoice ref
retry_group = []
if invoice && !invoice.ref.nil?
ref_payments = payments_by_invoice_ref[invoice.ref.downcase] || []
retry_group = ref_payments.map(&:id) if ref_payments.size > 1
end
# Detect partial payments
partial = invoice && invoice.remain_to_pay > 0 && invoice.remain_to_pay < invoice.amount_cents / 100.0
results << Match.new(
flag: flag,
invoice: invoice,
payment: payment,
match_type: match_type,
partial: partial,
retry_group: retry_group
)
end
# Dolibarr paid invoices in the date range with no GC match.
# Scoped to the date range to avoid flagging every historical paid invoice.
matched_set = matched_invoice_ids.to_set
@invoices.each do |inv|
next if matched_set.include?(inv.id)
next unless inv.status == DolibarrFetcher::STATUS_PAID
# Only flag paid invoices whose date falls within the reconciliation window
next if inv.date && (inv.date < @from || inv.date > @to)
results << Match.new(
flag: DOLIBARR_PAID_NO_GC,
invoice: inv,
payment: nil,
match_type: nil,
partial: false,
retry_group: []
)
end
results
end
# -------------------------------------------------------------------------
# Pass 2 — Flag open Dolibarr invoices with no GC match that are overdue
# -------------------------------------------------------------------------
def pass2_overdue_open_invoices(matches)
matched_invoice_ids = matches.filter_map { |m| m.invoice&.id }.to_set
@invoices.select do |inv|
inv.status == DolibarrFetcher::STATUS_VALIDATED &&
!matched_invoice_ids.include?(inv.id)
end
end
# -------------------------------------------------------------------------
# Pass 3 — Match GoCardless payouts to Shine bank credits
#
# When a payouts CSV is provided (preferred): match by payout reference and
# net amount — both are exact. Fee breakdown is taken from the payouts CSV.
#
# When no payouts CSV is provided (fallback): group payments by payout_id,
# sum amounts, and match Shine by date only (amount will differ due to fees).
# -------------------------------------------------------------------------
def pass3_payouts_vs_shine
return [] if @shine.empty?
gc_credits = ShineParser.gocardless_credits(@shine)
return [] if gc_credits.empty?
if @gc_payouts.any?
pass3_with_payouts_csv(gc_credits)
else
pass3_from_payments_csv(gc_credits)
end
end
def pass3_with_payouts_csv(gc_credits)
used_shine_ids = []
# Only consider payouts whose arrival_date falls within the reconciliation window.
payouts_in_range = @gc_payouts.select do |p|
p.arrival_date >= @from && p.arrival_date <= @to
end
payouts_in_range.map do |payout|
# 1. Strong match: payout reference found in Shine Libellé
shine_tx = gc_credits.find do |tx|
!used_shine_ids.include?(tx.id) &&
tx.label.include?(payout.reference)
end
# 2. Fallback: exact net amount + date within tolerance
if shine_tx.nil?
shine_tx = gc_credits.find do |tx|
!used_shine_ids.include?(tx.id) &&
tx.credit_cents == payout.amount_cents &&
(tx.date - payout.arrival_date).abs <= PAYOUT_DATE_TOLERANCE
end
end
if shine_tx
used_shine_ids << shine_tx.id
flag = PAYOUT_VERIFIED
else
flag = PAYOUT_MISSING
end
PayoutMatch.new(
flag: flag,
payout_id: payout.id,
payout_date: payout.arrival_date,
expected_amount_cents: payout.amount_cents,
shine_transaction: shine_tx,
actual_amount_cents: shine_tx&.credit_cents || 0,
gc_payment_ids: [],
fee_cents: payout.fee_cents,
gross_amount_cents: payout.total_payment_cents
)
end
end
def pass3_from_payments_csv(gc_credits)
used_shine_ids = []
by_payout = @gc_payments
.select { |p| p.status == "paid_out" && p.payout_id && !p.payout_id.empty? && p.payout_date }
.group_by(&:payout_id)
by_payout.filter_map do |payout_id, payments|
payout_date = payments.map(&:payout_date).compact.max
gross_cents = payments.sum(&:amount_cents)
# Without the payouts CSV we don't know the exact net amount, so match
# by date only and accept any credit from GOCARDLESS SAS that day.
shine_tx = gc_credits.find do |tx|
!used_shine_ids.include?(tx.id) &&
(tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE
end
if shine_tx
used_shine_ids << shine_tx.id
flag = shine_tx.credit_cents == gross_cents ? PAYOUT_VERIFIED : PAYOUT_AMOUNT_MISMATCH
actual = shine_tx.credit_cents
fee_cents = gross_cents - actual
else
flag = PAYOUT_MISSING
actual = 0
fee_cents = nil
end
PayoutMatch.new(
flag: flag,
payout_id: payout_id,
payout_date: payout_date,
expected_amount_cents: gross_cents,
shine_transaction: shine_tx,
actual_amount_cents: actual,
gc_payment_ids: payments.map(&:id),
fee_cents: fee_cents,
gross_amount_cents: gross_cents
)
end
end
# -------------------------------------------------------------------------
# Invoice lookup
# -------------------------------------------------------------------------
def find_invoice(payment)
# 1. Strong match: GC description == Dolibarr invoice ref
# Applies when the user puts the invoice ref in the GC payment description.
invoice = @invoices.find do |inv|
!payment.description.empty? &&
inv.ref.casecmp(payment.description) == 0
end
return [invoice, :strong] if invoice
# 2. Soft match: same amount + customer name + date within tolerance.
# The user creates invoices after GC payment succeeds, so the invoice date
# should be close to the GC charge_date. Since multiple customers pay the
# same amount, customer name matching is required to avoid false positives.
invoice = @invoices.find do |inv|
inv.amount_cents == payment.amount_cents &&
inv.date &&
(inv.date - payment.charge_date).abs <= DATE_TOLERANCE &&
names_match?(inv.customer_name, payment.customer_name)
end
return [invoice, :soft] if invoice
[nil, nil]
end
# Normalise a name for comparison: remove accents, lowercase, collapse spaces.
def normalize_name(name)
name.to_s
.unicode_normalize(:nfd)
.gsub(/\p{Mn}/, "") # strip combining diacritical marks
.downcase
.gsub(/[^a-z0-9 ]/, "")
.split
.sort
.join(" ")
end
def names_match?(dolibarr_name, gc_name)
return false if dolibarr_name.to_s.strip.empty? || gc_name.to_s.strip.empty?
# Both names normalised and compared as sorted word sets so
# "DUPONT Jean" matches "Jean DUPONT" and accent variants.
normalize_name(dolibarr_name) == normalize_name(gc_name)
end
def determine_flag(payment, invoice)
if GocardlessParser::COLLECTED_STATUSES.include?(payment.status)
if invoice.nil?
GC_NO_INVOICE
elsif invoice.status == DolibarrFetcher::STATUS_PAID
MATCHED
else
GC_PAID_DOLIBARR_OPEN
end
elsif GocardlessParser::FAILED_STATUSES.include?(payment.status)
GC_FAILED
else
GC_CANCELLED
end
end
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
require "csv"
require "date"
module Reconciliation
class GocardlessPayoutsParser
Payout = Struct.new(
:id,
:amount_cents, # Net amount transferred to bank (after all fees)
:total_payment_cents, # Gross sum of underlying payments (before fees)
:fee_cents, # Total fee deducted (total_payment - amount)
:transaction_fee_cents, # Per-transaction fee component
:surcharge_fee_cents, # Surcharge fee component
:tax_cents, # Tax on fees (informational, already in total fee)
:reference, # e.g. "CYANET-DKV4KN8FTM2" — appears in Shine Libellé
:status,
:arrival_date,
keyword_init: true
)
def self.parse(csv_path)
rows = CSV.read(csv_path, headers: true, encoding: "UTF-8")
payouts = rows.filter_map do |row|
next if row["status"] != "paid"
arrival_date = safe_parse_date(row["arrival_date"])
next unless arrival_date
amount_cents = to_cents(row["amount"])
total_payment_cents = to_cents(row["total_payment_amount"])
Payout.new(
id: row["id"].to_s.strip,
amount_cents: amount_cents,
total_payment_cents: total_payment_cents,
fee_cents: total_payment_cents - amount_cents,
transaction_fee_cents: to_cents(row["transaction_fee_debit"]),
surcharge_fee_cents: to_cents(row["surcharge_fee_debit"]),
tax_cents: to_cents(row["tax_debit"]),
reference: row["reference"].to_s.strip,
status: row["status"].to_s.strip,
arrival_date: arrival_date
)
end
$stderr.puts "[GocardlessPayoutsParser] Loaded #{payouts.size} paid payouts from #{csv_path}"
payouts
end
private_class_method def self.to_cents(str)
return 0 if str.nil? || str.strip.empty?
(str.strip.to_f * 100).round
end
private_class_method def self.safe_parse_date(str)
return nil if str.nil? || str.strip.empty?
Date.parse(str.strip)
rescue Date::Error, ArgumentError
nil
end
end
end

View File

@@ -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