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>
This commit is contained in:
64
lib/reconciliation/shine_parser.rb
Normal file
64
lib/reconciliation/shine_parser.rb
Normal file
@@ -0,0 +1,64 @@
|
||||
# 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
|
||||
Reference in New Issue
Block a user