Files
dolibarr_shine_reconciliation/lib/reconciliation/engine.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

244 lines
8.9 KiB
Ruby

# 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