# 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 :is_credit_note, # true if this is a credit note (negative amount) 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 } .reject { |inv| inv.is_credit_note } # Credit notes (negative amounts) excluded from GC reconciliation 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 total_ttc = raw["total_ttc"].to_f Invoice.new( id: raw["id"].to_i, ref: raw["ref"].to_s.strip, status: raw["statut"].to_i, total_ttc: total_ttc, amount_cents: (total_ttc * 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, is_credit_note: total_ttc < 0 ) 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