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.0 KiB
Ruby
65 lines
2.0 KiB
Ruby
# frozen_string_literal: true
|
|
|
|
require "csv"
|
|
require "date"
|
|
|
|
module Reconciliation
|
|
class ShineParser
|
|
Transaction = Struct.new(
|
|
:id,
|
|
:date,
|
|
:credit_cents, # Integer, > 0 for incoming money
|
|
:debit_cents, # Integer, > 0 for outgoing money
|
|
:label, # Libellé
|
|
:counterparty, # Nom de la contrepartie
|
|
keyword_init: true
|
|
)
|
|
|
|
def self.parse(csv_path)
|
|
content = File.read(csv_path, encoding: "UTF-8")
|
|
# Normalise Windows CRLF
|
|
content = content.gsub("\r\n", "\n").gsub("\r", "\n")
|
|
|
|
rows = CSV.parse(content, headers: true, col_sep: ";")
|
|
|
|
transactions = rows.filter_map do |row|
|
|
date = safe_parse_date(row["Date de la valeur"])
|
|
next unless date
|
|
|
|
Transaction.new(
|
|
id: row["Transaction ID"].to_s.strip,
|
|
date: date,
|
|
credit_cents: parse_french_amount(row["Crédit"]),
|
|
debit_cents: parse_french_amount(row["Débit"]),
|
|
label: row["Libellé"].to_s.strip,
|
|
counterparty: row["Nom de la contrepartie"].to_s.strip
|
|
)
|
|
end
|
|
|
|
$stderr.puts "[ShineParser] Loaded #{transactions.size} transactions from #{csv_path}"
|
|
transactions
|
|
end
|
|
|
|
# Returns all GoCardless credit transactions (incoming payouts from GC)
|
|
def self.gocardless_credits(transactions)
|
|
transactions.select do |t|
|
|
t.credit_cents > 0 &&
|
|
t.counterparty.upcase.include?("GOCARDLESS")
|
|
end
|
|
end
|
|
|
|
private_class_method def self.safe_parse_date(str)
|
|
return nil if str.nil? || str.strip.empty?
|
|
Date.strptime(str.strip, "%d/%m/%Y")
|
|
rescue Date::Error, ArgumentError
|
|
nil
|
|
end
|
|
|
|
# French decimal format: "51,10" → 5110 (cents)
|
|
private_class_method def self.parse_french_amount(str)
|
|
return 0 if str.nil? || str.strip.empty?
|
|
(str.strip.gsub(/\s/, "").gsub(",", ".").to_f * 100).round
|
|
end
|
|
end
|
|
end
|