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:
115
lib/reconciliation/dolibarr_fetcher.rb
Normal file
115
lib/reconciliation/dolibarr_fetcher.rb
Normal file
@@ -0,0 +1,115 @@
|
||||
# frozen_string_literal: true
|
||||
|
||||
require "date"
|
||||
|
||||
module Reconciliation
|
||||
class DolibarrFetcher
|
||||
# Dolibarr invoice statuses
|
||||
STATUS_DRAFT = 0
|
||||
STATUS_VALIDATED = 1
|
||||
STATUS_PAID = 2
|
||||
STATUS_CANCELLED = 3
|
||||
|
||||
Invoice = Struct.new(
|
||||
:id, # Dolibarr internal ID
|
||||
:ref, # Invoice reference, e.g. "FA2407-0003"
|
||||
:status, # 0=draft, 1=validated/open, 2=paid, 3=cancelled
|
||||
:total_ttc, # Total amount TTC (euros, float)
|
||||
:amount_cents, # total_ttc * 100, rounded (integer)
|
||||
:paye, # 0=unpaid, 1=paid
|
||||
:sumpayed, # Amount already paid (euros)
|
||||
:remain_to_pay, # Remaining balance (euros)
|
||||
:date, # Invoice date (Date)
|
||||
:due_date, # Due date (Date)
|
||||
:customer_id, # socid
|
||||
:customer_name, # nom of third party if present
|
||||
keyword_init: true
|
||||
)
|
||||
|
||||
def initialize(client, from:, to:)
|
||||
@client = client
|
||||
@from = from
|
||||
@to = to
|
||||
end
|
||||
|
||||
# Fetches all validated invoices and resolves customer names.
|
||||
# Note: querying status=1 returns all non-draft invoices in Dolibarr (open, paid, cancelled).
|
||||
# Cancelled invoices are filtered out here.
|
||||
def fetch_invoices
|
||||
$stderr.puts "[DolibarrFetcher] Fetching thirdparty names..."
|
||||
name_by_id = fetch_thirdparty_names
|
||||
|
||||
$stderr.puts "[DolibarrFetcher] Fetching invoices..."
|
||||
raw_invoices = fetch_all(status: STATUS_VALIDATED)
|
||||
|
||||
invoices = raw_invoices
|
||||
.map { |raw| parse_invoice(raw, name_by_id) }
|
||||
.reject { |inv| inv.status == STATUS_CANCELLED }
|
||||
|
||||
open_count = invoices.count { |inv| inv.status == STATUS_VALIDATED }
|
||||
paid_count = invoices.count { |inv| inv.status == STATUS_PAID }
|
||||
$stderr.puts "[DolibarrFetcher] Total: #{invoices.size} invoices (#{open_count} open, #{paid_count} paid)"
|
||||
invoices
|
||||
rescue Dolibarr::Client::Error => e
|
||||
$stderr.puts "[DolibarrFetcher] Error: #{e.message}"
|
||||
raise
|
||||
end
|
||||
|
||||
private
|
||||
|
||||
def fetch_all(status:, sqlfilters: nil)
|
||||
invoices = []
|
||||
page = 0
|
||||
loop do
|
||||
params = { status: status, limit: 100, page: page }
|
||||
params[:sqlfilters] = sqlfilters if sqlfilters
|
||||
batch = @client.get("/invoices", params)
|
||||
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
|
||||
|
||||
invoices.concat(batch)
|
||||
break if batch.size < 100
|
||||
|
||||
page += 1
|
||||
end
|
||||
invoices
|
||||
end
|
||||
|
||||
def parse_invoice(raw, name_by_id = {})
|
||||
customer_id = raw["socid"].to_i
|
||||
Invoice.new(
|
||||
id: raw["id"].to_i,
|
||||
ref: raw["ref"].to_s.strip,
|
||||
status: raw["statut"].to_i,
|
||||
total_ttc: raw["total_ttc"].to_f,
|
||||
amount_cents: (raw["total_ttc"].to_f * 100).round,
|
||||
paye: raw["paye"].to_i,
|
||||
sumpayed: raw["sumpayed"].to_f,
|
||||
remain_to_pay: raw["remaintopay"].to_f,
|
||||
date: parse_unix_date(raw["datef"] || raw["date"]),
|
||||
due_date: parse_unix_date(raw["date_lim_reglement"]),
|
||||
customer_id: customer_id,
|
||||
customer_name: name_by_id[customer_id] || raw.dig("thirdparty", "name").to_s.strip
|
||||
)
|
||||
end
|
||||
|
||||
def fetch_thirdparty_names
|
||||
result = {}
|
||||
page = 0
|
||||
loop do
|
||||
batch = @client.get("/thirdparties", limit: 100, page: page)
|
||||
break if batch.nil? || !batch.is_a?(Array) || batch.empty?
|
||||
batch.each { |tp| result[tp["id"].to_i] = tp["name"].to_s.strip }
|
||||
break if batch.size < 100
|
||||
page += 1
|
||||
end
|
||||
result
|
||||
end
|
||||
|
||||
def parse_unix_date(value)
|
||||
return nil if value.nil? || value.to_s.strip.empty? || value.to_i.zero?
|
||||
Time.at(value.to_i).utc.to_date
|
||||
rescue
|
||||
nil
|
||||
end
|
||||
end
|
||||
end
|
||||
Reference in New Issue
Block a user