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>
65 lines
2.2 KiB
Ruby
65 lines
2.2 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
require "date"
|
|
|
|
module Reconciliation
|
|
class GocardlessParser
|
|
Payment = Struct.new(
|
|
:id, # e.g. "PM014J7X4PY98T"
|
|
:charge_date, # Date
|
|
:amount_cents, # Integer (euros * 100)
|
|
:description, # Free text — often the Dolibarr invoice ref
|
|
:status, # "paid_out", "failed", "cancelled", etc.
|
|
:payout_id, # e.g. "PO0010R1ARR0QZ"
|
|
:payout_date, # Date or nil
|
|
:customer_name, # "Jean DUPONT"
|
|
:customer_email,
|
|
keyword_init: true
|
|
)
|
|
|
|
# Statuses that represent a successful collection from the customer.
|
|
COLLECTED_STATUSES = %w[paid_out confirmed].freeze
|
|
FAILED_STATUSES = %w[failed].freeze
|
|
|
|
def self.parse(csv_path, from: nil, to: nil)
|
|
rows = CSV.read(csv_path, headers: true, encoding: "UTF-8")
|
|
|
|
payments = rows.filter_map do |row|
|
|
charge_date = safe_parse_date(row["charge_date"])
|
|
next if charge_date.nil?
|
|
next if from && charge_date < from
|
|
next if to && charge_date > to
|
|
|
|
payout_date = safe_parse_date(row["payout_date"])
|
|
|
|
Payment.new(
|
|
id: row["id"].to_s.strip,
|
|
charge_date: charge_date,
|
|
amount_cents: (row["amount"].to_f * 100).round,
|
|
description: row["description"].to_s.strip,
|
|
status: row["status"].to_s.strip,
|
|
payout_id: row["links.payout"].to_s.strip.then { |v| v.empty? ? nil : v },
|
|
payout_date: payout_date,
|
|
customer_name: build_name(row["customers.given_name"], row["customers.family_name"]),
|
|
customer_email: row["customers.email"].to_s.strip
|
|
)
|
|
end
|
|
|
|
$stderr.puts "[GocardlessParser] Loaded #{payments.size} payments from #{csv_path}"
|
|
payments
|
|
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
|
|
|
|
private_class_method def self.build_name(given, family)
|
|
[given.to_s.strip, family.to_s.strip].reject(&:empty?).join(" ")
|
|
end
|
|
end
|
|
end
|