# 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