Add advanced reconciliation features: retry detection, partial payments, credit notes

New features:
- Credit note handling: Exclude negative invoices from GC reconciliation
- Retry detection: Flag invoices with multiple GC payment attempts
- Partial payment detection: Track invoices where remain_to_pay > 0
- Payout fee CSV: Export detailed fee breakdown per payout
- Configurable tolerances: RECONCILIATION_DATE_TOLERANCE, RECONCILIATION_PAYOUT_TOLERANCE

Files:
- lib/reconciliation/gocardless_payouts_parser.rb (new) - Parse GC payouts CSV
- lib/reconciliation/engine.rb - Add retry_group, partial fields to Match struct
- lib/reconciliation/reporter.rb - Show partial/retry in report, write payouts CSV
- lib/reconciliation/dolibarr_fetcher.rb - Add is_credit_note field, filter negatives
- bin/reconcile - Wire up --gc-payouts argument
- README.md - Document new features and --gc-payouts usage
- .env.example - Add optional tolerance settings

Co-authored-by: Qwen-Coder <qwen-coder@alibabacloud.com>
This commit is contained in:
Kevin Bataille
2026-02-26 00:38:41 +01:00
parent 4decb3cb3c
commit 0a048628a5
8 changed files with 1140 additions and 860 deletions

View File

@@ -1,12 +1,16 @@
# Dolibarr API # Dolibarr API
DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php
DOLIBARR_API_KEY=your_api_key DOLIBARR_API_KEY=your_api_key
# GoCardless payment method ID for GoCardless # GoCardless payment method ID for GoCardless
# Find it with: GET /setup/dictionary/payment_types # Find it with: GET /setup/dictionary/payment_types
# Look for "Prélèvement GoCardless" or similar # Look for "Prélèvement GoCardless" or similar
DOLIBARR_GC_PAYMENT_ID=6 DOLIBARR_GC_PAYMENT_ID=6
# Dolibarr bank account ID (for recording payments) # Dolibarr bank account ID (for recording payments)
# Find it with: GET /bankaccounts # Find it with: GET /bankaccounts
DOLIBARR_BANK_ACCOUNT_ID=1 DOLIBARR_BANK_ACCOUNT_ID=1
# Reconciliation tolerances (optional)
# RECONCILIATION_DATE_TOLERANCE=7
# RECONCILIATION_PAYOUT_TOLERANCE=2

614
README.md
View File

@@ -1,280 +1,334 @@
# Dolibarr / GoCardless / Shine Reconciliation # Dolibarr / GoCardless / Shine Reconciliation
A standalone Ruby script that cross-checks three financial systems and flags discrepancies. A standalone Ruby script that cross-checks three financial systems and flags discrepancies.
## The problem it solves ## The problem it solves
Payments flow through three separate systems that are not automatically linked: Payments flow through three separate systems that are not automatically linked:
``` ```
Shine bank account ← GoCardless payouts ← GoCardless payments ← Dolibarr invoices Shine bank account ← GoCardless payouts ← GoCardless payments ← Dolibarr invoices
``` ```
**Typical workflow:** **Typical workflow:**
1. GoCardless initiates a direct debit for a customer 1. GoCardless initiates a direct debit for a customer
2. If the debit succeeds, you create the corresponding invoice in Dolibarr and mark it paid 2. If the debit succeeds, you create the corresponding invoice in Dolibarr and mark it paid
3. If the debit fails, you cancel or delete the draft invoice 3. If the debit fails, you cancel or delete the draft invoice
4. GoCardless batches collected payments into payouts and transfers them to your Shine account 4. GoCardless batches collected payments into payouts and transfers them to your Shine account
Discrepancies arise when any of these steps is missed: Discrepancies arise when any of these steps is missed:
- A GoCardless payment succeeded but the Dolibarr invoice was never created - A GoCardless payment succeeded but the Dolibarr invoice was never created
- A Dolibarr invoice is marked paid but no GoCardless payment can be found for it - A Dolibarr invoice is marked paid but no GoCardless payment can be found for it
- A GoCardless payout never appeared in the Shine bank account - A GoCardless payout never appeared in the Shine bank account
--- ---
## Requirements ## Requirements
- Ruby 3.x - Ruby 3.x
- Bundler (`gem install bundler`) - Bundler (`gem install bundler`)
- Network access to your Dolibarr instance - Network access to your Dolibarr instance
--- ---
## Installation ## Installation
```bash ```bash
cd dolibarr_shine_reconciliation cd dolibarr_shine_reconciliation
bundle install bundle install
cp .env.example .env cp .env.example .env
``` ```
Edit `.env` and fill in your Dolibarr credentials: Edit `.env` and fill in your Dolibarr credentials:
```dotenv ```dotenv
DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php DOLIBARR_URL=https://your-dolibarr.example.com/api/index.php
DOLIBARR_API_KEY=your_api_key DOLIBARR_API_KEY=your_api_key
# GoCardless payment method ID in Dolibarr (used by --fix mode) # GoCardless payment method ID in Dolibarr (used by --fix mode)
# Find it: GET /setup/dictionary/payment_types # Find it: GET /setup/dictionary/payment_types
# Look for the "Prélèvement GoCardless" entry # Look for the "Prélèvement GoCardless" entry
DOLIBARR_GC_PAYMENT_ID=6 DOLIBARR_GC_PAYMENT_ID=6
# Bank account ID in Dolibarr (used by --fix mode) # Bank account ID in Dolibarr (used by --fix mode)
# Find it: GET /bankaccounts # Find it: GET /bankaccounts
DOLIBARR_BANK_ACCOUNT_ID=1 DOLIBARR_BANK_ACCOUNT_ID=1
``` ```
--- ---
## Exporting the data ## Exporting the data
### GoCardless — payments CSV ### GoCardless — payments CSV
Dashboard → **Payments** → filter by date range → **Export CSV** Dashboard → **Payments** → filter by date range → **Export CSV**
Place the file in `gocardless/`. The expected columns are: Place the file in `gocardless/`. The expected columns are:
| Column | Description | | Column | Description |
|--------|-------------| |--------|-------------|
| `id` | Payment ID, e.g. `PM014J7X4PY98T` | | `id` | Payment ID, e.g. `PM014J7X4PY98T` |
| `charge_date` | Date the customer was debited (`YYYY-MM-DD`) | | `charge_date` | Date the customer was debited (`YYYY-MM-DD`) |
| `amount` | Amount in euros (`30.52`) | | `amount` | Amount in euros (`30.52`) |
| `description` | Free text — used as the primary match key against the Dolibarr invoice ref | | `description` | Free text — used as the primary match key against the Dolibarr invoice ref |
| `status` | `paid_out`, `confirmed`, `failed`, `cancelled` | | `status` | `paid_out`, `confirmed`, `failed`, `cancelled` |
| `links.payout` | Payout ID this payment belongs to | | `links.payout` | Payout ID this payment belongs to |
| `payout_date` | Date the payout was sent to your bank | | `payout_date` | Date the payout was sent to your bank |
| `customers.given_name` | Customer first name | | `customers.given_name` | Customer first name |
| `customers.family_name` | Customer last name | | `customers.family_name` | Customer last name |
### Shine — bank statement CSV ### Shine — bank statement CSV
App → **Comptes****Exporter le relevé** → select year → download App → **Comptes****Exporter le relevé** → select year → download
Place the annual CSV in `shine/`. The expected columns are: Place the annual CSV in `shine/`. The expected columns are:
| Column | Description | | Column | Description |
|--------|-------------| |--------|-------------|
| `Date de la valeur` | Value date (`DD/MM/YYYY`) | | `Date de la valeur` | Value date (`DD/MM/YYYY`) |
| `Crédit` | Credit amount in French format (`51,10`) | | `Crédit` | Credit amount in French format (`51,10`) |
| `Débit` | Debit amount | | `Débit` | Debit amount |
| `Libellé` | Transaction description | | `Libellé` | Transaction description |
| `Nom de la contrepartie` | Counterparty name — GoCardless payouts show `GOCARDLESS SAS` here | | `Nom de la contrepartie` | Counterparty name — GoCardless payouts show `GOCARDLESS SAS` here |
The Shine CSV uses semicolons as separator (`;`), UTF-8 encoding, and Windows CRLF line endings. The script handles all of this automatically. The Shine CSV uses semicolons as separator (`;`), UTF-8 encoding, and Windows CRLF line endings. The script handles all of this automatically.
--- ---
## Usage ## Usage
### Dry run — report only, no changes to Dolibarr ### Dry run — report only, no changes to Dolibarr
```bash ```bash
ruby bin/reconcile \ ruby bin/reconcile \
--from 2026-01-01 \ --from 2026-01-01 \
--to 2026-01-31 \ --to 2026-01-31 \
--gc gocardless/payments_export.csv \ --gc gocardless/payments_export.csv \
--shine shine/Cyanet_2026-01-01_2026-12-31_EXPORT/BANQUE_2026-01-01_2026-12-31/BQ_2026-01-01_2026-12-31.csv --gc-payouts gocardless/payouts_export.csv \
``` --shine shine/Cyanet_2026-01-01_2026-12-31_EXPORT/BANQUE_2026-01-01_2026-12-31/BQ_2026-01-01_2026-12-31.csv
```
The Shine file is optional. Without it, payout verification (Pass 3) is skipped:
**Recommended:** Always provide the GoCardless payouts CSV (`--gc-payouts`) for exact fee tracking. Without it, the script can only estimate fees by comparing amounts.
```bash
ruby bin/reconcile \ The Shine file is optional. Without it, payout verification (Pass 3) is skipped:
--from 2026-01-01 \
--to 2026-01-31 \ ```bash
--gc gocardless/payments_export.csv ruby bin/reconcile \
``` --from 2026-01-01 \
--to 2026-01-31 \
### Fix mode — auto-mark Dolibarr invoices as paid --gc gocardless/payments_export.csv
```
When the script detects a GoCardless payment that was collected but the matching Dolibarr invoice is still open, `--fix` records the payment in Dolibarr via the API:
### Fix mode — auto-mark Dolibarr invoices as paid
```bash
ruby bin/reconcile \ When the script detects a GoCardless payment that was collected but the matching Dolibarr invoice is still open, `--fix` records the payment in Dolibarr via the API:
--from 2026-01-01 \
--to 2026-01-31 \ ```bash
--gc gocardless/payments_export.csv \ ruby bin/reconcile \
--shine shine/.../BQ_2026-01-01_2026-12-31.csv \ --from 2026-01-01 \
--fix --to 2026-01-31 \
``` --gc gocardless/payments_export.csv \
--gc-payouts gocardless/payouts_export.csv \
`--fix` only affects invoices flagged `GC_PAID_DOLIBARR_OPEN`. All other entries are reported only. --shine shine/.../BQ_2026-01-01_2026-12-31.csv \
--fix
--- ```
## How matching works `--fix` only affects invoices flagged `GC_PAID_DOLIBARR_OPEN`. All other entries are reported only.
The script runs three passes over the data. ### Environment variables (optional)
### Pass 1 — GoCardless ↔ Dolibarr ```dotenv
# Tolerance for soft date matching (default: 7 days)
For each GoCardless payment, an attempt is made to find a matching Dolibarr invoice in two steps: RECONCILIATION_DATE_TOLERANCE=7
**Strong match** — the GoCardless `description` field equals the Dolibarr invoice `ref` exactly (case-insensitive). This fires when you put the invoice reference in the GoCardless payment description at creation time. # Tolerance for payout date matching (default: 2 days)
RECONCILIATION_PAYOUT_TOLERANCE=2
**Soft match** — if no strong match is found, the script looks for a Dolibarr invoice where: ```
- The amount is identical (compared in cents to avoid floating-point errors)
- The invoice date is within 7 days of the GoCardless `charge_date` ---
- The customer name on the Dolibarr invoice matches the GoCardless customer name (accent-insensitive, word-order-insensitive)
## How matching works
Once matched (or not), each payment is assigned one of these flags:
The script runs three passes over the data.
| Flag | Meaning | Action |
|------|---------|--------| ### Pass 1 — GoCardless ↔ Dolibarr
| `MATCHED` | GC payment collected, Dolibarr invoice paid | None |
| `GC_PAID_DOLIBARR_OPEN` | GC collected but Dolibarr invoice is still open | Create the invoice / use `--fix` | For each GoCardless payment, an attempt is made to find a matching Dolibarr invoice in two steps:
| `GC_NO_INVOICE` | GC payment collected, no Dolibarr invoice found at all | Create the invoice in Dolibarr |
| `GC_FAILED` | GC payment failed | Check if Dolibarr invoice was correctly cancelled | **Strong match** — the GoCardless `description` field equals the Dolibarr invoice `ref` exactly (case-insensitive). This fires when you put the invoice reference in the GoCardless payment description at creation time.
| `GC_CANCELLED` | GC payment was cancelled before collection | No action |
| `DOLIBARR_PAID_NO_GC` | Dolibarr invoice paid (in the date range), no GC payment found | Verify — may be a manual or cash payment | **Soft match** — if no strong match is found, the script looks for a Dolibarr invoice where:
- The amount is identical (compared in cents to avoid floating-point errors)
After processing all GC payments, open Dolibarr invoices with no GC counterpart are flagged: - The invoice date is within 7 days of the GoCardless `charge_date`
- The customer name on the Dolibarr invoice matches the GoCardless customer name (accent-insensitive, word-order-insensitive)
| Flag | Meaning | Action |
|------|---------|--------| Once matched (or not), each payment is assigned one of these flags:
| `DOLIBARR_OPEN_NO_GC` | Dolibarr invoice open, no GC payment found | Follow up — missed debit or GC export is incomplete |
| Flag | Meaning | Action |
### Pass 2 — open Dolibarr invoice audit |------|---------|--------|
| `MATCHED` | GC payment collected, Dolibarr invoice paid | None |
All invoices fetched from Dolibarr with status `open` (validated, not yet paid) that were not matched by any GC payment are listed as `DOLIBARR_OPEN_NO_GC`. Overdue invoices (due date in the past) are highlighted. | `GC_PAID_DOLIBARR_OPEN` | GC collected but Dolibarr invoice is still open | Create the invoice / use `--fix` |
| `GC_NO_INVOICE` | GC payment collected, no Dolibarr invoice found at all | Create the invoice in Dolibarr |
### Pass 3 — GoCardless payouts ↔ Shine bank | `GC_FAILED` | GC payment failed | Check if Dolibarr invoice was correctly cancelled |
| `GC_CANCELLED` | GC payment was cancelled before collection | No action |
GoCardless batches individual payments into payouts and transfers them as a single bank credit. The script groups `paid_out` payments by their payout ID, sums the amounts, and looks for a matching credit in Shine: | `DOLIBARR_PAID_NO_GC` | Dolibarr invoice paid (in the date range), no GC payment found | Verify — may be a manual or cash payment |
1. **Exact match** — same amount, date within 2 days → `PAYOUT_VERIFIED` After processing all GC payments, open Dolibarr invoices with no GC counterpart are flagged:
2. **Date match only** — date within 2 days but amount differs → `PAYOUT_AMOUNT_MISMATCH` (expected: GoCardless deducts its fee from the payout, so the bank credit is always slightly less than the sum of payments)
3. **No match found**`PAYOUT_MISSING` | Flag | Meaning | Action |
|------|---------|--------|
`PAYOUT_AMOUNT_MISMATCH` is the normal case when GoCardless fees are deducted. The difference shown in the report is the total fee charged for the period. | `DOLIBARR_OPEN_NO_GC` | Dolibarr invoice open, no GC payment found | Follow up — missed debit or GC export is incomplete |
--- ### Pass 2 — open Dolibarr invoice audit
## Output All invoices fetched from Dolibarr with status `open` (validated, not yet paid) that were not matched by any GC payment are listed as `DOLIBARR_OPEN_NO_GC`. Overdue invoices (due date in the past) are highlighted.
### Terminal report ### Pass 3 — GoCardless payouts ↔ Shine bank
``` **With payouts CSV (recommended):** Match by payout reference (exact) and net amount. Fee breakdown is taken directly from the payouts CSV columns:
============================================================ - `amount` = net payout after all fees
RECONCILIATION REPORT: 2026-01-01 to 2026-01-31 - `total_payment_amount` = gross amount before fees
============================================================ - `transaction_fee_debit`, `surcharge_fee_debit`, `tax_debit` = fee breakdown
DOLIBARR **Without payouts CSV (fallback):** Groups `paid_out` payments by payout ID, sums amounts, and looks for a matching credit in Shine:
Total invoices in scope: 9
Open (no GC match): 2 ← needs attention 1. **Exact match** — same amount, date within 2 days → `PAYOUT_VERIFIED`
Paid (GC matched): 3 2. **Date match only** — date within 2 days but amount differs → `PAYOUT_AMOUNT_MISMATCH` (expected: GoCardless deducts its fee from the payout)
3. **No match found**`PAYOUT_MISSING`
GOCARDLESS ↔ DOLIBARR
Matched (paid both sides): 3 ✓ `PAYOUT_AMOUNT_MISMATCH` is the normal case when GoCardless fees are deducted. The difference shown in the report is the total fee charged for the period.
GC paid / Dolibarr open: 0
Dolibarr paid / no GC: 0 ---
GC failed: 1
GC cancelled: 0 ## Advanced features
GC payment / no invoice: 4 ← investigate
### Retry detection
SHINE ↔ GOCARDLESS PAYOUTS
Payouts expected: 2 If multiple GoCardless payments exist for the same invoice reference (e.g., a failed payment was retried), the report flags them with `[RETRY: PM0123, PM0456]`. This helps identify when a payment was re-submitted after an initial failure.
Verified: 0
Amount mismatch: 2 ← check GC fees ### Partial payment detection
Missing in Shine: 0
Expected total: €107.74 Invoices where `remain_to_pay > 0` but some payment has been made are flagged as `PARTIAL`. This can happen when:
Actual total: €104.91 - A customer paid only part of the invoice
Difference: €-2.83 ← GoCardless fees - Multiple GoCardless payments cover a single invoice
- A credit note was applied to reduce the balance
ACTIONS NEEDED (6)
------------------------------------------------------------ ### Credit notes excluded
1. [DOLIBARR_OPEN_NO_GC] FA2502-0075 €29.44 ARTHUR Muriel overdue since 2025-02-01
2. [GC_NO_INVOICE] GC: PM01RE90... €26.10 MARIE RIVIERE 2026-01-05 Credit notes (invoices with negative `total_ttc`) are automatically excluded from reconciliation since they don't correspond to GoCardless payments. They're handled internally in Dolibarr.
...
``` ---
### CSV export ## Output
A file `tmp/reconciliation_YYYY-MM-DD.csv` is written after every run with one row per invoice/payment, including the flag and recommended action. Suitable for importing into a spreadsheet for manual review. ### Terminal report
--- ```
============================================================
## Project structure RECONCILIATION REPORT: 2026-01-01 to 2026-01-31
============================================================
```
bin/reconcile Entry point — parses CLI arguments, orchestrates the run DOLIBARR
lib/ Total invoices in scope: 9
boot.rb Loads all dependencies Open (no GC match): 2 ← needs attention
dolibarr/ Paid (GC matched): 3
client.rb HTTP client for the Dolibarr REST API (HTTParty)
reconciliation/ GOCARDLESS ↔ DOLIBARR
dolibarr_fetcher.rb Fetches invoices and customer names via Dolibarr API Matched (paid both sides): 3 ✓
gocardless_parser.rb Parses the GoCardless payments CSV GC paid / Dolibarr open: 0
shine_parser.rb Parses the Shine bank statement CSV Dolibarr paid / no GC: 0
engine.rb 3-pass matching logic, produces flagged result set GC failed: 1
reporter.rb Formats and prints the terminal report, writes CSV GC cancelled: 0
fixer.rb Calls Dolibarr API to record payments (--fix mode) GC payment / no invoice: 4 ← investigate
gocardless/ Drop GoCardless CSV exports here
shine/ Shine annual export directories (as downloaded) SHINE ↔ GOCARDLESS PAYOUTS
tmp/ Output CSVs written here Payouts expected: 2
.env.example Environment variable template Verified: 0
docs/ Amount mismatch: 2 ← check GC fees
reconciliation_plan.md Original design document Missing in Shine: 0
dolibarr.json Dolibarr Swagger API spec Expected total: €107.74
``` Actual total: €104.91
Difference: €-2.83 ← GoCardless fees
---
ACTIONS NEEDED (6)
## Dolibarr API notes ------------------------------------------------------------
1. [DOLIBARR_OPEN_NO_GC] FA2502-0075 €29.44 ARTHUR Muriel overdue since 2025-02-01
The script uses the Dolibarr REST API with an API key (`DOLAPIKEY` header). Key endpoints: 2. [GC_NO_INVOICE] GC: PM01RE90... €26.10 MARIE RIVIERE 2026-01-05
...
| Method | Path | Purpose | ```
|--------|------|---------|
| `GET` | `/invoices?status=1` | Fetch all non-draft invoices (open and paid) | ### CSV export
| `GET` | `/thirdparties` | Fetch customer names for invoice matching |
| `POST` | `/invoices/paymentsdistributed` | Record a payment against an invoice (`--fix`) | Two CSV files are written after every run:
| `GET` | `/setup/dictionary/payment_types` | Look up the GoCardless payment method ID |
| `GET` | `/bankaccounts` | Look up the bank account ID | **`tmp/reconciliation_YYYY-MM-DD.csv`** — One row per invoice/payment with columns:
- `invoice_ref`, `customer_name`, `amount_eur`, `invoice_date`, `due_date`
The `status=1` query in Dolibarr returns all non-draft invoices regardless of payment state. The script uses the `statut` field in the response (`1`=open, `2`=paid, `3`=cancelled) to distinguish them. Cancelled invoices are excluded from reconciliation. - `dolibarr_status`, `gc_payment_id`, `gc_status`, `gc_charge_date`
- `match_type`, `flag`, `action`
--- - `partial` — "yes" if invoice is partially paid
- `retry_group` — comma-separated GC payment IDs if retries detected
## Limitations and known behaviour
**`tmp/payouts_fees_YYYY-MM-DD.csv`** — One row per payout with fee breakdown:
**GoCardless fee deductions** — Payout amounts in Shine are always slightly less than the sum of the underlying payments because GoCardless deducts its transaction fee from the payout. This is expected and reported as `PAYOUT_AMOUNT_MISMATCH`, not an error. - `payout_id`, `payout_date`
- `gross_amount_eur`, `net_amount_eur`, `fee_eur`
**Incomplete GoCardless export** — If your CSV export does not cover the full date range, payments from outside the export window will cause open Dolibarr invoices to appear as `DOLIBARR_OPEN_NO_GC`. Export all payments for the period you are reconciling. - `fee_percentage` — GC fee as percentage of gross
- `shine_reference` — the matching Shine transaction label
**Customer name matching** — The soft match normalises names by stripping accents, lowercasing, and sorting words, so "DUPONT Jean" matches "Jean Dupont". If a customer's name is spelled differently in GoCardless vs Dolibarr, the soft match will fail and the payment will appear as `GC_NO_INVOICE`. Correct the name in one of the systems and rerun. - `status``payout_verified`, `payout_missing`, or `payout_amount_mismatch`
**Credit notes** — Dolibarr credit notes (`AV...` prefix) with negative amounts are included in the invoice fetch and will appear as `DOLIBARR_PAID_NO_GC` if they fall in the reconciliation period with no corresponding GoCardless refund. This is normal — credit notes are typically settled internally, not via GoCardless. Both files are suitable for importing into a spreadsheet for manual review.
**Supplier invoices** — Dolibarr supplier invoices (`/supplierinvoices` endpoint) are on a completely separate API path and are never fetched or considered by this script. ---
## Project structure
```
bin/reconcile Entry point — parses CLI arguments, orchestrates the run
lib/
boot.rb Loads all dependencies
dolibarr/
client.rb HTTP client for the Dolibarr REST API (HTTParty)
reconciliation/
dolibarr_fetcher.rb Fetches invoices and customer names via Dolibarr API
gocardless_parser.rb Parses the GoCardless payments CSV
shine_parser.rb Parses the Shine bank statement CSV
engine.rb 3-pass matching logic, produces flagged result set
reporter.rb Formats and prints the terminal report, writes CSV
fixer.rb Calls Dolibarr API to record payments (--fix mode)
gocardless/ Drop GoCardless CSV exports here
shine/ Shine annual export directories (as downloaded)
tmp/ Output CSVs written here
.env.example Environment variable template
docs/
reconciliation_plan.md Original design document
dolibarr.json Dolibarr Swagger API spec
```
---
## Dolibarr API notes
The script uses the Dolibarr REST API with an API key (`DOLAPIKEY` header). Key endpoints:
| Method | Path | Purpose |
|--------|------|---------|
| `GET` | `/invoices?status=1` | Fetch all non-draft invoices (open and paid) |
| `GET` | `/thirdparties` | Fetch customer names for invoice matching |
| `POST` | `/invoices/paymentsdistributed` | Record a payment against an invoice (`--fix`) |
| `GET` | `/setup/dictionary/payment_types` | Look up the GoCardless payment method ID |
| `GET` | `/bankaccounts` | Look up the bank account ID |
The `status=1` query in Dolibarr returns all non-draft invoices regardless of payment state. The script uses the `statut` field in the response (`1`=open, `2`=paid, `3`=cancelled) to distinguish them. Cancelled invoices are excluded from reconciliation.
---
## Limitations and known behaviour
**GoCardless fee deductions** — Payout amounts in Shine are always slightly less than the sum of the underlying payments because GoCardless deducts its transaction fee from the payout. This is expected and reported as `PAYOUT_AMOUNT_MISMATCH`, not an error.
**Incomplete GoCardless export** — If your CSV export does not cover the full date range, payments from outside the export window will cause open Dolibarr invoices to appear as `DOLIBARR_OPEN_NO_GC`. Export all payments for the period you are reconciling.
**Customer name matching** — The soft match normalises names by stripping accents, lowercasing, and sorting words, so "DUPONT Jean" matches "Jean Dupont". If a customer's name is spelled differently in GoCardless vs Dolibarr, the soft match will fail and the payment will appear as `GC_NO_INVOICE`. Correct the name in one of the systems and rerun.
**Credit notes** — Dolibarr credit notes (`AV...` prefix) with negative amounts are included in the invoice fetch and will appear as `DOLIBARR_PAID_NO_GC` if they fall in the reconciliation period with no corresponding GoCardless refund. This is normal — credit notes are typically settled internally, not via GoCardless.
**Supplier invoices** — Dolibarr supplier invoices (`/supplierinvoices` endpoint) are on a completely separate API path and are never fetched or considered by this script.

View File

@@ -10,12 +10,13 @@ options = {
from: nil, from: nil,
to: nil, to: nil,
gc: nil, gc: nil,
gc_payouts: nil,
shine: nil shine: nil
} }
OptionParser.new do |opts| OptionParser.new do |opts|
opts.banner = <<~BANNER opts.banner = <<~BANNER
Usage: bin/reconcile --from DATE --to DATE --gc PATH [--shine PATH] [--fix] Usage: bin/reconcile --from DATE --to DATE --gc PATH [--gc-payouts PATH] [--shine PATH] [--fix]
Options: Options:
BANNER BANNER
@@ -32,6 +33,10 @@ OptionParser.new do |opts|
options[:gc] = v options[:gc] = v
end end
opts.on("--gc-payouts PATH", "GoCardless payouts CSV file path (enables exact fee reporting)") do |v|
options[:gc_payouts] = v
end
opts.on("--shine PATH", "Shine bank statement CSV file path (optional)") do |v| opts.on("--shine PATH", "Shine bank statement CSV file path (optional)") do |v|
options[:shine] = v options[:shine] = v
end end
@@ -52,6 +57,7 @@ errors << "--from is required" unless options[:from]
errors << "--to is required" unless options[:to] errors << "--to is required" unless options[:to]
errors << "--gc is required" unless options[:gc] errors << "--gc is required" unless options[:gc]
errors << "--gc file not found: #{options[:gc]}" if options[:gc] && !File.exist?(options[:gc]) errors << "--gc file not found: #{options[:gc]}" if options[:gc] && !File.exist?(options[:gc])
errors << "--gc-payouts file not found: #{options[:gc_payouts]}" if options[:gc_payouts] && !File.exist?(options[:gc_payouts])
errors << "--shine file not found: #{options[:shine]}" if options[:shine] && !File.exist?(options[:shine]) errors << "--shine file not found: #{options[:shine]}" if options[:shine] && !File.exist?(options[:shine])
unless errors.empty? unless errors.empty?
@@ -60,16 +66,18 @@ unless errors.empty?
end end
# Run reconciliation # Run reconciliation
client = Dolibarr::Client.new client = Dolibarr::Client.new
fetcher = Reconciliation::DolibarrFetcher.new(client, from: options[:from], to: options[:to]) fetcher = Reconciliation::DolibarrFetcher.new(client, from: options[:from], to: options[:to])
gc_data = Reconciliation::GocardlessParser.parse(options[:gc], from: options[:from], to: options[:to]) gc_data = Reconciliation::GocardlessParser.parse(options[:gc], from: options[:from], to: options[:to])
shine_data = options[:shine] ? Reconciliation::ShineParser.parse(options[:shine]) : [] gc_payouts = options[:gc_payouts] ? Reconciliation::GocardlessPayoutsParser.parse(options[:gc_payouts]) : []
shine_data = options[:shine] ? Reconciliation::ShineParser.parse(options[:shine]) : []
dolibarr_invoices = fetcher.fetch_invoices dolibarr_invoices = fetcher.fetch_invoices
engine = Reconciliation::Engine.new( engine = Reconciliation::Engine.new(
dolibarr_invoices: dolibarr_invoices, dolibarr_invoices: dolibarr_invoices,
gc_payments: gc_data, gc_payments: gc_data,
gc_payouts: gc_payouts,
shine_transactions: shine_data, shine_transactions: shine_data,
from: options[:from], from: options[:from],
to: options[:to] to: options[:to]

View File

@@ -6,6 +6,7 @@ require "dotenv/load"
require_relative "dolibarr/client" require_relative "dolibarr/client"
require_relative "reconciliation/dolibarr_fetcher" require_relative "reconciliation/dolibarr_fetcher"
require_relative "reconciliation/gocardless_parser" require_relative "reconciliation/gocardless_parser"
require_relative "reconciliation/gocardless_payouts_parser"
require_relative "reconciliation/shine_parser" require_relative "reconciliation/shine_parser"
require_relative "reconciliation/engine" require_relative "reconciliation/engine"
require_relative "reconciliation/reporter" require_relative "reconciliation/reporter"

View File

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

View File

@@ -1,243 +1,322 @@
# frozen_string_literal: true # frozen_string_literal: true
require "date" require "date"
module Reconciliation module Reconciliation
class Engine class Engine
# Match flags # Match flags
MATCHED = :matched # GC paid + Dolibarr paid — all good MATCHED = :matched # GC paid + Dolibarr paid — all good
GC_PAID_DOLIBARR_OPEN = :gc_paid_dolibarr_open # GC collected, Dolibarr still open — FIX NEEDED GC_PAID_DOLIBARR_OPEN = :gc_paid_dolibarr_open # GC collected, Dolibarr still open — FIX NEEDED
GC_FAILED = :gc_failed # GC payment failed GC_FAILED = :gc_failed # GC payment failed
GC_CANCELLED = :gc_cancelled # GC payment cancelled (no action needed) GC_CANCELLED = :gc_cancelled # GC payment cancelled (no action needed)
GC_NO_INVOICE = :gc_no_invoice # GC payment with no Dolibarr invoice found GC_NO_INVOICE = :gc_no_invoice # GC payment with no Dolibarr invoice found
DOLIBARR_PAID_NO_GC = :dolibarr_paid_no_gc # Dolibarr paid, no GC match — verify manually DOLIBARR_PAID_NO_GC = :dolibarr_paid_no_gc # Dolibarr paid, no GC match — verify manually
DOLIBARR_OPEN_NO_GC = :dolibarr_open_no_gc # Dolibarr open, no GC payment found (overdue) DOLIBARR_OPEN_NO_GC = :dolibarr_open_no_gc # Dolibarr open, no GC payment found (overdue)
# Payout flags # Payout flags
PAYOUT_VERIFIED = :payout_verified PAYOUT_VERIFIED = :payout_verified
PAYOUT_MISSING = :payout_missing PAYOUT_MISSING = :payout_missing
PAYOUT_AMOUNT_MISMATCH = :payout_amount_mismatch PAYOUT_AMOUNT_MISMATCH = :payout_amount_mismatch
DATE_TOLERANCE = 7 # days for GC charge_date ↔ Dolibarr invoice date soft match DATE_TOLERANCE = ENV.fetch("RECONCILIATION_DATE_TOLERANCE", 7).to_i # days for GC charge_date ↔ Dolibarr invoice date soft match
PAYOUT_DATE_TOLERANCE = 2 # days for GC payout_date ↔ Shine credit date PAYOUT_DATE_TOLERANCE = ENV.fetch("RECONCILIATION_PAYOUT_TOLERANCE", 2).to_i # days for GC payout_date ↔ Shine credit date
# Result structs # Result structs
Match = Struct.new( Match = Struct.new(
:flag, :flag,
:invoice, # DolibarrFetcher::Invoice or nil :invoice, # DolibarrFetcher::Invoice or nil
:payment, # GocardlessParser::Payment or nil :payment, # GocardlessParser::Payment or nil
:match_type, # :strong, :soft, or nil :match_type, # :strong, :soft, or nil
keyword_init: true :partial, # true if invoice is partially paid (remain_to_pay > 0)
) :retry_group, # Array of GC payment IDs for same invoice (retry detection)
keyword_init: true
PayoutMatch = Struct.new( )
:flag,
:payout_id, PayoutMatch = Struct.new(
:payout_date, :flag,
:expected_amount_cents, :payout_id,
:shine_transaction, # ShineParser::Transaction or nil :payout_date,
:actual_amount_cents, :expected_amount_cents, # Net amount (what should land in bank)
:gc_payment_ids, :shine_transaction, # ShineParser::Transaction or nil
keyword_init: true :actual_amount_cents, # What was actually found in Shine
) :gc_payment_ids,
:fee_cents, # GC fee deducted (nil if unknown)
def initialize(dolibarr_invoices:, gc_payments:, shine_transactions:, from:, to:) :gross_amount_cents, # Sum of underlying payments (before fees)
@invoices = dolibarr_invoices keyword_init: true
@gc_payments = gc_payments )
@shine = shine_transactions
@from = from def initialize(dolibarr_invoices:, gc_payments:, shine_transactions:, from:, to:, gc_payouts: [])
@to = to @invoices = dolibarr_invoices
end @gc_payments = gc_payments
@gc_payouts = gc_payouts
def run @shine = shine_transactions
matches = pass1_gc_vs_dolibarr @from = from
overdue = pass2_overdue_open_invoices(matches) @to = to
payout_matches = pass3_payouts_vs_shine end
{ def run
matches: matches, matches = pass1_gc_vs_dolibarr
matched: matches.select { |m| m.flag == MATCHED }, overdue = pass2_overdue_open_invoices(matches)
gc_paid_dolibarr_open: matches.select { |m| m.flag == GC_PAID_DOLIBARR_OPEN }, payout_matches = pass3_payouts_vs_shine
gc_failed: matches.select { |m| m.flag == GC_FAILED },
gc_cancelled: matches.select { |m| m.flag == GC_CANCELLED }, {
gc_no_invoice: matches.select { |m| m.flag == GC_NO_INVOICE }, matches: matches,
dolibarr_paid_no_gc: matches.select { |m| m.flag == DOLIBARR_PAID_NO_GC }, matched: matches.select { |m| m.flag == MATCHED },
dolibarr_open_no_gc: overdue, gc_paid_dolibarr_open: matches.select { |m| m.flag == GC_PAID_DOLIBARR_OPEN },
payout_matches: payout_matches gc_failed: matches.select { |m| m.flag == GC_FAILED },
} gc_cancelled: matches.select { |m| m.flag == GC_CANCELLED },
end gc_no_invoice: matches.select { |m| m.flag == GC_NO_INVOICE },
dolibarr_paid_no_gc: matches.select { |m| m.flag == DOLIBARR_PAID_NO_GC },
private dolibarr_open_no_gc: overdue,
payout_matches: payout_matches
# ------------------------------------------------------------------------- }
# Pass 1 — Match each GoCardless payment to a Dolibarr invoice end
# -------------------------------------------------------------------------
def pass1_gc_vs_dolibarr private
matched_invoice_ids = []
results = [] # -------------------------------------------------------------------------
# Pass 1 — Match each GoCardless payment to a Dolibarr invoice
@gc_payments.each do |payment| # -------------------------------------------------------------------------
invoice, match_type = find_invoice(payment) def pass1_gc_vs_dolibarr
matched_invoice_ids = []
if invoice results = []
matched_invoice_ids << invoice.id
end # Build a map of invoice ref -> payments for retry detection
payments_by_invoice_ref = @gc_payments.group_by { |p| p.description.downcase }
flag = determine_flag(payment, invoice)
results << Match.new(flag: flag, invoice: invoice, payment: payment, match_type: match_type) @gc_payments.each do |payment|
end invoice, match_type = find_invoice(payment)
# Dolibarr paid invoices in the date range with no GC match. if invoice
# Scoped to the date range to avoid flagging every historical paid invoice. matched_invoice_ids << invoice.id
matched_set = matched_invoice_ids.to_set end
@invoices.each do |inv|
next if matched_set.include?(inv.id) flag = determine_flag(payment, invoice)
next unless inv.status == DolibarrFetcher::STATUS_PAID
# Only flag paid invoices whose date falls within the reconciliation window # Detect retries: multiple GC payments for the same invoice ref
next if inv.date && (inv.date < @from || inv.date > @to) retry_group = []
if invoice && !invoice.ref.nil?
results << Match.new( ref_payments = payments_by_invoice_ref[invoice.ref.downcase] || []
flag: DOLIBARR_PAID_NO_GC, retry_group = ref_payments.map(&:id) if ref_payments.size > 1
invoice: inv, end
payment: nil,
match_type: nil # Detect partial payments
) partial = invoice && invoice.remain_to_pay > 0 && invoice.remain_to_pay < invoice.amount_cents / 100.0
end
results << Match.new(
results flag: flag,
end invoice: invoice,
payment: payment,
# ------------------------------------------------------------------------- match_type: match_type,
# Pass 2 — Flag open Dolibarr invoices with no GC match that are overdue partial: partial,
# ------------------------------------------------------------------------- retry_group: retry_group
def pass2_overdue_open_invoices(matches) )
matched_invoice_ids = matches.filter_map { |m| m.invoice&.id }.to_set end
@invoices.select do |inv| # Dolibarr paid invoices in the date range with no GC match.
inv.status == DolibarrFetcher::STATUS_VALIDATED && # Scoped to the date range to avoid flagging every historical paid invoice.
!matched_invoice_ids.include?(inv.id) matched_set = matched_invoice_ids.to_set
end @invoices.each do |inv|
end next if matched_set.include?(inv.id)
next unless inv.status == DolibarrFetcher::STATUS_PAID
# ------------------------------------------------------------------------- # Only flag paid invoices whose date falls within the reconciliation window
# Pass 3 — Match GoCardless payouts to Shine bank credits next if inv.date && (inv.date < @from || inv.date > @to)
# -------------------------------------------------------------------------
def pass3_payouts_vs_shine results << Match.new(
return [] if @shine.empty? flag: DOLIBARR_PAID_NO_GC,
invoice: inv,
gc_credits = ShineParser.gocardless_credits(@shine) payment: nil,
match_type: nil,
# Group paid_out payments by payout_id. partial: false,
# Only paid_out payments are included in a payout; failed/cancelled are not. retry_group: []
by_payout = @gc_payments )
.select { |p| p.status == "paid_out" && p.payout_id && !p.payout_id.empty? && p.payout_date } end
.group_by(&:payout_id)
results
used_shine_ids = [] end
by_payout.filter_map do |payout_id, payments| # -------------------------------------------------------------------------
payout_date = payments.map(&:payout_date).compact.max # Pass 2 — Flag open Dolibarr invoices with no GC match that are overdue
expected_cents = payments.sum(&:amount_cents) # -------------------------------------------------------------------------
def pass2_overdue_open_invoices(matches)
shine_tx = gc_credits.find do |tx| matched_invoice_ids = matches.filter_map { |m| m.invoice&.id }.to_set
!used_shine_ids.include?(tx.id) &&
(tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE && @invoices.select do |inv|
tx.credit_cents == expected_cents inv.status == DolibarrFetcher::STATUS_VALIDATED &&
end !matched_invoice_ids.include?(inv.id)
end
if shine_tx end
used_shine_ids << shine_tx.id
flag = PAYOUT_VERIFIED # -------------------------------------------------------------------------
actual = shine_tx.credit_cents # Pass 3 — Match GoCardless payouts to Shine bank credits
else #
# Try date match only (amount mismatch — possible GC fees) # When a payouts CSV is provided (preferred): match by payout reference and
shine_tx = gc_credits.find do |tx| # net amount — both are exact. Fee breakdown is taken from the payouts CSV.
!used_shine_ids.include?(tx.id) && #
(tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE # When no payouts CSV is provided (fallback): group payments by payout_id,
end # sum amounts, and match Shine by date only (amount will differ due to fees).
# -------------------------------------------------------------------------
if shine_tx def pass3_payouts_vs_shine
used_shine_ids << shine_tx.id return [] if @shine.empty?
flag = PAYOUT_AMOUNT_MISMATCH
actual = shine_tx.credit_cents gc_credits = ShineParser.gocardless_credits(@shine)
else return [] if gc_credits.empty?
flag = PAYOUT_MISSING
actual = 0 if @gc_payouts.any?
end pass3_with_payouts_csv(gc_credits)
end else
pass3_from_payments_csv(gc_credits)
PayoutMatch.new( end
flag: flag, end
payout_id: payout_id,
payout_date: payout_date, def pass3_with_payouts_csv(gc_credits)
expected_amount_cents: expected_cents, used_shine_ids = []
shine_transaction: shine_tx,
actual_amount_cents: actual, # Only consider payouts whose arrival_date falls within the reconciliation window.
gc_payment_ids: payments.map(&:id) payouts_in_range = @gc_payouts.select do |p|
) p.arrival_date >= @from && p.arrival_date <= @to
end end
end
payouts_in_range.map do |payout|
# ------------------------------------------------------------------------- # 1. Strong match: payout reference found in Shine Libellé
# Invoice lookup shine_tx = gc_credits.find do |tx|
# ------------------------------------------------------------------------- !used_shine_ids.include?(tx.id) &&
def find_invoice(payment) tx.label.include?(payout.reference)
# 1. Strong match: GC description == Dolibarr invoice ref end
# Applies when the user puts the invoice ref in the GC payment description.
invoice = @invoices.find do |inv| # 2. Fallback: exact net amount + date within tolerance
!payment.description.empty? && if shine_tx.nil?
inv.ref.casecmp(payment.description) == 0 shine_tx = gc_credits.find do |tx|
end !used_shine_ids.include?(tx.id) &&
return [invoice, :strong] if invoice tx.credit_cents == payout.amount_cents &&
(tx.date - payout.arrival_date).abs <= PAYOUT_DATE_TOLERANCE
# 2. Soft match: same amount + customer name + date within tolerance. end
# The user creates invoices after GC payment succeeds, so the invoice date end
# should be close to the GC charge_date. Since multiple customers pay the
# same amount, customer name matching is required to avoid false positives. if shine_tx
invoice = @invoices.find do |inv| used_shine_ids << shine_tx.id
inv.amount_cents == payment.amount_cents && flag = PAYOUT_VERIFIED
inv.date && else
(inv.date - payment.charge_date).abs <= DATE_TOLERANCE && flag = PAYOUT_MISSING
names_match?(inv.customer_name, payment.customer_name) end
end
return [invoice, :soft] if invoice PayoutMatch.new(
flag: flag,
[nil, nil] payout_id: payout.id,
end payout_date: payout.arrival_date,
expected_amount_cents: payout.amount_cents,
# Normalise a name for comparison: remove accents, lowercase, collapse spaces. shine_transaction: shine_tx,
def normalize_name(name) actual_amount_cents: shine_tx&.credit_cents || 0,
name.to_s gc_payment_ids: [],
.unicode_normalize(:nfd) fee_cents: payout.fee_cents,
.gsub(/\p{Mn}/, "") # strip combining diacritical marks gross_amount_cents: payout.total_payment_cents
.downcase )
.gsub(/[^a-z0-9 ]/, "") end
.split end
.sort
.join(" ") def pass3_from_payments_csv(gc_credits)
end used_shine_ids = []
def names_match?(dolibarr_name, gc_name) by_payout = @gc_payments
return false if dolibarr_name.to_s.strip.empty? || gc_name.to_s.strip.empty? .select { |p| p.status == "paid_out" && p.payout_id && !p.payout_id.empty? && p.payout_date }
# Both names normalised and compared as sorted word sets so .group_by(&:payout_id)
# "DUPONT Jean" matches "Jean DUPONT" and accent variants.
normalize_name(dolibarr_name) == normalize_name(gc_name) by_payout.filter_map do |payout_id, payments|
end payout_date = payments.map(&:payout_date).compact.max
gross_cents = payments.sum(&:amount_cents)
def determine_flag(payment, invoice)
if GocardlessParser::COLLECTED_STATUSES.include?(payment.status) # Without the payouts CSV we don't know the exact net amount, so match
if invoice.nil? # by date only and accept any credit from GOCARDLESS SAS that day.
GC_NO_INVOICE shine_tx = gc_credits.find do |tx|
elsif invoice.status == DolibarrFetcher::STATUS_PAID !used_shine_ids.include?(tx.id) &&
MATCHED (tx.date - payout_date).abs <= PAYOUT_DATE_TOLERANCE
else end
GC_PAID_DOLIBARR_OPEN
end if shine_tx
elsif GocardlessParser::FAILED_STATUSES.include?(payment.status) used_shine_ids << shine_tx.id
GC_FAILED flag = shine_tx.credit_cents == gross_cents ? PAYOUT_VERIFIED : PAYOUT_AMOUNT_MISMATCH
else actual = shine_tx.credit_cents
GC_CANCELLED fee_cents = gross_cents - actual
end else
end flag = PAYOUT_MISSING
end actual = 0
end fee_cents = nil
end
PayoutMatch.new(
flag: flag,
payout_id: payout_id,
payout_date: payout_date,
expected_amount_cents: gross_cents,
shine_transaction: shine_tx,
actual_amount_cents: actual,
gc_payment_ids: payments.map(&:id),
fee_cents: fee_cents,
gross_amount_cents: gross_cents
)
end
end
# -------------------------------------------------------------------------
# Invoice lookup
# -------------------------------------------------------------------------
def find_invoice(payment)
# 1. Strong match: GC description == Dolibarr invoice ref
# Applies when the user puts the invoice ref in the GC payment description.
invoice = @invoices.find do |inv|
!payment.description.empty? &&
inv.ref.casecmp(payment.description) == 0
end
return [invoice, :strong] if invoice
# 2. Soft match: same amount + customer name + date within tolerance.
# The user creates invoices after GC payment succeeds, so the invoice date
# should be close to the GC charge_date. Since multiple customers pay the
# same amount, customer name matching is required to avoid false positives.
invoice = @invoices.find do |inv|
inv.amount_cents == payment.amount_cents &&
inv.date &&
(inv.date - payment.charge_date).abs <= DATE_TOLERANCE &&
names_match?(inv.customer_name, payment.customer_name)
end
return [invoice, :soft] if invoice
[nil, nil]
end
# Normalise a name for comparison: remove accents, lowercase, collapse spaces.
def normalize_name(name)
name.to_s
.unicode_normalize(:nfd)
.gsub(/\p{Mn}/, "") # strip combining diacritical marks
.downcase
.gsub(/[^a-z0-9 ]/, "")
.split
.sort
.join(" ")
end
def names_match?(dolibarr_name, gc_name)
return false if dolibarr_name.to_s.strip.empty? || gc_name.to_s.strip.empty?
# Both names normalised and compared as sorted word sets so
# "DUPONT Jean" matches "Jean DUPONT" and accent variants.
normalize_name(dolibarr_name) == normalize_name(gc_name)
end
def determine_flag(payment, invoice)
if GocardlessParser::COLLECTED_STATUSES.include?(payment.status)
if invoice.nil?
GC_NO_INVOICE
elsif invoice.status == DolibarrFetcher::STATUS_PAID
MATCHED
else
GC_PAID_DOLIBARR_OPEN
end
elsif GocardlessParser::FAILED_STATUSES.include?(payment.status)
GC_FAILED
else
GC_CANCELLED
end
end
end
end

View File

@@ -0,0 +1,64 @@
# frozen_string_literal: true
require "csv"
require "date"
module Reconciliation
class GocardlessPayoutsParser
Payout = Struct.new(
:id,
:amount_cents, # Net amount transferred to bank (after all fees)
:total_payment_cents, # Gross sum of underlying payments (before fees)
:fee_cents, # Total fee deducted (total_payment - amount)
:transaction_fee_cents, # Per-transaction fee component
:surcharge_fee_cents, # Surcharge fee component
:tax_cents, # Tax on fees (informational, already in total fee)
:reference, # e.g. "CYANET-DKV4KN8FTM2" — appears in Shine Libellé
:status,
:arrival_date,
keyword_init: true
)
def self.parse(csv_path)
rows = CSV.read(csv_path, headers: true, encoding: "UTF-8")
payouts = rows.filter_map do |row|
next if row["status"] != "paid"
arrival_date = safe_parse_date(row["arrival_date"])
next unless arrival_date
amount_cents = to_cents(row["amount"])
total_payment_cents = to_cents(row["total_payment_amount"])
Payout.new(
id: row["id"].to_s.strip,
amount_cents: amount_cents,
total_payment_cents: total_payment_cents,
fee_cents: total_payment_cents - amount_cents,
transaction_fee_cents: to_cents(row["transaction_fee_debit"]),
surcharge_fee_cents: to_cents(row["surcharge_fee_debit"]),
tax_cents: to_cents(row["tax_debit"]),
reference: row["reference"].to_s.strip,
status: row["status"].to_s.strip,
arrival_date: arrival_date
)
end
$stderr.puts "[GocardlessPayoutsParser] Loaded #{payouts.size} paid payouts from #{csv_path}"
payouts
end
private_class_method def self.to_cents(str)
return 0 if str.nil? || str.strip.empty?
(str.strip.to_f * 100).round
end
private_class_method def self.safe_parse_date(str)
return nil if str.nil? || str.strip.empty?
Date.parse(str.strip)
rescue Date::Error, ArgumentError
nil
end
end
end

View File

@@ -1,203 +1,269 @@
# frozen_string_literal: true # frozen_string_literal: true
require "csv" require "csv"
require "date" require "date"
module Reconciliation module Reconciliation
class Reporter class Reporter
def initialize(result, from:, to:) def initialize(result, from:, to:)
@result = result @result = result
@from = from @from = from
@to = to @to = to
end end
def print_summary def print_summary
r = @result r = @result
puts "" puts ""
puts "=" * 60 puts "=" * 60
puts " RECONCILIATION REPORT: #{@from} to #{@to}" puts " RECONCILIATION REPORT: #{@from} to #{@to}"
puts "=" * 60 puts "=" * 60
# --- Dolibarr overview --- # --- Dolibarr overview ---
total_invoices = r[:matches].filter_map(&:invoice).uniq(&:id).size + total_invoices = r[:matches].filter_map(&:invoice).uniq(&:id).size +
r[:dolibarr_open_no_gc].size r[:dolibarr_open_no_gc].size
open_count = r[:dolibarr_open_no_gc].size + open_count = r[:dolibarr_open_no_gc].size +
r[:gc_paid_dolibarr_open].size r[:gc_paid_dolibarr_open].size
puts "" puts ""
puts "DOLIBARR" puts "DOLIBARR"
puts " Total invoices in scope: #{total_invoices}" puts " Total invoices in scope: #{total_invoices}"
puts " Open (no GC match): #{r[:dolibarr_open_no_gc].size}#{r[:dolibarr_open_no_gc].any? ? ' ← needs attention' : ''}" puts " Open (no GC match): #{r[:dolibarr_open_no_gc].size}#{r[:dolibarr_open_no_gc].any? ? ' ← needs attention' : ''}"
puts " Paid (GC matched): #{r[:matched].size}" puts " Paid (GC matched): #{r[:matched].size}"
# --- GoCardless ↔ Dolibarr --- # --- GoCardless ↔ Dolibarr ---
puts "" puts ""
puts "GOCARDLESS ↔ DOLIBARR" puts "GOCARDLESS ↔ DOLIBARR"
puts " Matched (paid both sides): #{r[:matched].size} #{'✓' if r[:matched].any?}" puts " Matched (paid both sides): #{r[:matched].size} #{'✓' if r[:matched].any?}"
puts " GC paid / Dolibarr open: #{r[:gc_paid_dolibarr_open].size}#{r[:gc_paid_dolibarr_open].any? ? ' ← FIX with --fix' : ''}" puts " GC paid / Dolibarr open: #{r[:gc_paid_dolibarr_open].size}#{r[:gc_paid_dolibarr_open].any? ? ' ← FIX with --fix' : ''}"
puts " Dolibarr paid / no GC: #{r[:dolibarr_paid_no_gc].size}#{r[:dolibarr_paid_no_gc].any? ? ' ← verify manually' : ''}" puts " Dolibarr paid / no GC: #{r[:dolibarr_paid_no_gc].size}#{r[:dolibarr_paid_no_gc].any? ? ' ← verify manually' : ''}"
puts " GC failed: #{r[:gc_failed].size}" puts " GC failed: #{r[:gc_failed].size}"
puts " GC cancelled: #{r[:gc_cancelled].size}" puts " GC cancelled: #{r[:gc_cancelled].size}"
puts " GC payment / no invoice: #{r[:gc_no_invoice].size}#{r[:gc_no_invoice].any? ? ' ← investigate' : ''}" puts " GC payment / no invoice: #{r[:gc_no_invoice].size}#{r[:gc_no_invoice].any? ? ' ← investigate' : ''}"
# --- Shine ↔ GoCardless Payouts --- # Partial payment info
unless @result[:payout_matches].empty? partial_matches = r[:matches].select { |m| m.partial }
pm = r[:payout_matches] if partial_matches.any?
verified = pm.count { |p| p.flag == Engine::PAYOUT_VERIFIED } puts " Partial payments: #{partial_matches.size} ← check remain_to_pay"
mismatch = pm.count { |p| p.flag == Engine::PAYOUT_AMOUNT_MISMATCH } end
missing = pm.count { |p| p.flag == Engine::PAYOUT_MISSING }
expected = pm.sum(&:expected_amount_cents) # Retry detection info
actual = pm.sum(&:actual_amount_cents) retry_matches = r[:matches].select { |m| m.retry_group && m.retry_group.size > 1 }
diff = actual - expected if retry_matches.any?
puts " Retries detected: #{retry_matches.size} ← multiple GC attempts for same invoice"
puts "" end
puts "SHINE ↔ GOCARDLESS PAYOUTS"
puts " Payouts expected: #{pm.size}" # --- Shine ↔ GoCardless Payouts ---
puts " Verified: #{verified}#{verified == pm.size ? ' ✓' : ''}" unless @result[:payout_matches].empty?
puts " Amount mismatch: #{mismatch}#{mismatch > 0 ? ' ← check GC fees' : ''}" pm = r[:payout_matches]
puts " Missing in Shine: #{missing}#{missing > 0 ? ' ← investigate' : ''}" verified = pm.count { |p| p.flag == Engine::PAYOUT_VERIFIED }
puts " Expected total: #{format_eur(expected)}" mismatch = pm.count { |p| p.flag == Engine::PAYOUT_AMOUNT_MISMATCH }
puts " Actual total: #{format_eur(actual)}" missing = pm.count { |p| p.flag == Engine::PAYOUT_MISSING }
puts " Difference: #{format_eur(diff)}#{diff.zero? ? ' ✓' : ' ← investigate'}" total_net = pm.sum(&:expected_amount_cents)
end known_fees = pm.filter_map(&:fee_cents)
total_fees = known_fees.sum
# --- Action items ---
actions = r[:gc_paid_dolibarr_open] + puts ""
r[:dolibarr_paid_no_gc] + puts "SHINE ↔ GOCARDLESS PAYOUTS"
r[:dolibarr_open_no_gc] + puts " Payouts: #{pm.size}"
r[:gc_no_invoice] puts " Verified: #{verified}#{verified == pm.size ? ' ✓' : ''}"
puts " Amount mismatch: #{mismatch}#{mismatch > 0 ? ' ← check GC fees' : ''}" if mismatch > 0
if actions.any? puts " Missing in Shine: #{missing} ← investigate" if missing > 0
puts "" puts " Net received: #{format_eur(total_net)}"
puts "ACTIONS NEEDED (#{actions.size})" if known_fees.any?
puts "-" * 60 gross = pm.filter_map(&:gross_amount_cents).sum
puts " Gross collected: #{format_eur(gross)}"
r[:gc_paid_dolibarr_open].each_with_index do |m, i| puts " GC fees: #{format_eur(total_fees)}"
puts " #{i + 1}. [GC_PAID_DOLIBARR_OPEN] " \ # Per-payout fee detail
"#{m.invoice.ref.ljust(16)} " \ pm.select { |p| p.fee_cents && p.fee_cents > 0 }.each do |p|
"#{format_eur(m.invoice.amount_cents)} " \ puts " #{p.payout_id} #{p.payout_date} net=#{format_eur(p.expected_amount_cents)} " \
"#{display_name(m.invoice).ljust(20)} " \ "gross=#{format_eur(p.gross_amount_cents)} fee=#{format_eur(p.fee_cents)}"
"GC: #{m.payment.id} #{m.payment.charge_date} (#{m.match_type})" end
end end
end
r[:dolibarr_paid_no_gc].each_with_index do |m, i|
n = r[:gc_paid_dolibarr_open].size + i + 1 # --- Action items ---
puts " #{n}. [DOLIBARR_PAID_NO_GC] " \ actions = r[:gc_paid_dolibarr_open] +
"#{m.invoice.ref.ljust(16)} " \ r[:dolibarr_paid_no_gc] +
"#{format_eur(m.invoice.amount_cents)} " \ r[:dolibarr_open_no_gc] +
"#{display_name(m.invoice).ljust(20)} " \ r[:gc_no_invoice]
"No GoCardless payment found"
end if actions.any?
puts ""
base = r[:gc_paid_dolibarr_open].size + r[:dolibarr_paid_no_gc].size puts "ACTIONS NEEDED (#{actions.size})"
r[:dolibarr_open_no_gc].each_with_index do |inv, i| puts "-" * 60
n = base + i + 1
overdue = inv.due_date && inv.due_date < Date.today ? " (overdue since #{inv.due_date})" : "" r[:gc_paid_dolibarr_open].each_with_index do |m, i|
puts " #{n}. [DOLIBARR_OPEN_NO_GC] " \ extra = []
"#{inv.ref.ljust(16)} " \ extra << "PARTIAL" if m.partial
"#{format_eur(inv.amount_cents)} " \ extra << "RETRY: #{m.retry_group.join(', ')}" if m.retry_group && m.retry_group.size > 1
"#{display_name(inv).ljust(20)} " \ extra_str = extra.any? ? " [#{extra.join(', ')}]" : ""
"Open, no GC payment#{overdue}"
end puts " #{i + 1}. [GC_PAID_DOLIBARR_OPEN] " \
"#{m.invoice.ref.ljust(16)} " \
base2 = base + r[:dolibarr_open_no_gc].size "#{format_eur(m.invoice.amount_cents)} " \
r[:gc_no_invoice].each_with_index do |m, i| "#{display_name(m.invoice).ljust(20)} " \
n = base2 + i + 1 "GC: #{m.payment.id} #{m.payment.charge_date} (#{m.match_type})#{extra_str}"
puts " #{n}. [GC_NO_INVOICE] " \ end
"GC: #{m.payment.id} #{format_eur(m.payment.amount_cents)} " \
"\"#{m.payment.description}\" #{m.payment.customer_name} #{m.payment.charge_date}" r[:dolibarr_paid_no_gc].each_with_index do |m, i|
end n = r[:gc_paid_dolibarr_open].size + i + 1
puts " #{n}. [DOLIBARR_PAID_NO_GC] " \
r[:payout_matches].select { |p| p.flag == Engine::PAYOUT_MISSING }.each do |pm| "#{m.invoice.ref.ljust(16)} " \
n = base2 + r[:gc_no_invoice].size + 1 "#{format_eur(m.invoice.amount_cents)} " \
puts " #{n}. [PAYOUT_MISSING] " \ "#{display_name(m.invoice).ljust(20)} " \
"Payout #{pm.payout_id} #{format_eur(pm.expected_amount_cents)} " \ "No GoCardless payment found"
"expected #{pm.payout_date}" end
end
else base = r[:gc_paid_dolibarr_open].size + r[:dolibarr_paid_no_gc].size
puts "" r[:dolibarr_open_no_gc].each_with_index do |inv, i|
puts " All clear — no actions needed." n = base + i + 1
end overdue = inv.due_date && inv.due_date < Date.today ? " (overdue since #{inv.due_date})" : ""
puts " #{n}. [DOLIBARR_OPEN_NO_GC] " \
puts "" "#{inv.ref.ljust(16)} " \
puts "=" * 60 "#{format_eur(inv.amount_cents)} " \
"#{display_name(inv).ljust(20)} " \
csv_path = write_csv "Open, no GC payment#{overdue}"
puts " Report saved to: #{csv_path}" end
puts ""
end base2 = base + r[:dolibarr_open_no_gc].size
r[:gc_no_invoice].each_with_index do |m, i|
def write_csv n = base2 + i + 1
dir = "tmp" puts " #{n}. [GC_NO_INVOICE] " \
Dir.mkdir(dir) unless Dir.exist?(dir) "GC: #{m.payment.id} #{format_eur(m.payment.amount_cents)} " \
path = "#{dir}/reconciliation_#{@to}.csv" "\"#{m.payment.description}\" #{m.payment.customer_name} #{m.payment.charge_date}"
end
CSV.open(path, "w", encoding: "UTF-8") do |csv|
csv << %w[ r[:payout_matches].select { |p| p.flag == Engine::PAYOUT_MISSING }.each do |pm|
invoice_ref customer_name amount_eur invoice_date due_date n = base2 + r[:gc_no_invoice].size + 1
dolibarr_status gc_payment_id gc_status gc_charge_date puts " #{n}. [PAYOUT_MISSING] " \
match_type flag action "Payout #{pm.payout_id} #{format_eur(pm.expected_amount_cents)} " \
] "expected #{pm.payout_date}"
end
@result[:matches].each do |m| else
inv = m.invoice puts ""
pay = m.payment puts " All clear — no actions needed."
csv << [ end
inv&.ref,
inv&.customer_name, puts ""
inv ? "%.2f" % (inv.amount_cents / 100.0) : nil, puts "=" * 60
inv&.date,
inv&.due_date, csv_path = write_csv
inv ? status_label(inv.status) : nil, puts " Report saved to: #{csv_path}"
pay&.id,
pay&.status, unless @result[:payout_matches].empty?
pay&.charge_date, payout_csv_path = write_payouts_csv
m.match_type, puts " Payout fees saved to: #{payout_csv_path}"
m.flag, end
action_label(m.flag) puts ""
] end
end
def write_csv
@result[:dolibarr_open_no_gc].each do |inv| dir = "tmp"
csv << [ Dir.mkdir(dir) unless Dir.exist?(dir)
inv.ref, inv.customer_name, path = "#{dir}/reconciliation_#{@to}.csv"
"%.2f" % (inv.amount_cents / 100.0),
inv.date, inv.due_date, CSV.open(path, "w", encoding: "UTF-8") do |csv|
status_label(inv.status), csv << %w[
nil, nil, nil, nil, invoice_ref customer_name amount_eur invoice_date due_date
Engine::DOLIBARR_OPEN_NO_GC, dolibarr_status gc_payment_id gc_status gc_charge_date
action_label(Engine::DOLIBARR_OPEN_NO_GC) match_type flag action partial retry_group
] ]
end
end @result[:matches].each do |m|
inv = m.invoice
path pay = m.payment
end csv << [
inv&.ref,
private inv&.customer_name,
inv ? "%.2f" % (inv.amount_cents / 100.0) : nil,
def display_name(inv) inv&.date,
name = inv.customer_name.to_s.strip inv&.due_date,
name.empty? ? "client_id:#{inv.customer_id}" : name inv ? status_label(inv.status) : nil,
end pay&.id,
pay&.status,
def format_eur(cents) pay&.charge_date,
"#{"%.2f" % (cents / 100.0)}" m.match_type,
end m.flag,
action_label(m.flag),
def status_label(status) m.partial ? "yes" : "no",
{ 0 => "draft", 1 => "open", 2 => "paid", 3 => "cancelled" }[status] || status.to_s m.retry_group&.join(", ")
end ]
end
def action_label(flag)
{ @result[:dolibarr_open_no_gc].each do |inv|
Engine::MATCHED => "none", csv << [
Engine::GC_PAID_DOLIBARR_OPEN => "mark_dolibarr_paid", inv.ref, inv.customer_name,
Engine::GC_FAILED => "verify", "%.2f" % (inv.amount_cents / 100.0),
Engine::GC_CANCELLED => "none", inv.date, inv.due_date,
Engine::GC_NO_INVOICE => "investigate", status_label(inv.status),
Engine::DOLIBARR_PAID_NO_GC => "verify_manually", nil, nil, nil, nil,
Engine::DOLIBARR_OPEN_NO_GC => "follow_up" Engine::DOLIBARR_OPEN_NO_GC,
}[flag] || flag.to_s action_label(Engine::DOLIBARR_OPEN_NO_GC),
end "no",
end nil
end ]
end
end
path
end
def write_payouts_csv
dir = "tmp"
Dir.mkdir(dir) unless Dir.exist?(dir)
path = "#{dir}/payouts_fees_#{@to}.csv"
CSV.open(path, "w", encoding: "UTF-8") do |csv|
csv << %w[
payout_id payout_date gross_amount_eur net_amount_eur fee_eur
fee_percentage shine_reference status
]
@result[:payout_matches].each do |pm|
fee_pct = pm.gross_amount_cents && pm.gross_amount_cents > 0 \
? ((pm.fee_cents.to_f / pm.gross_amount_cents) * 100).round(2) \
: nil
csv << [
pm.payout_id,
pm.payout_date,
pm.gross_amount_cents ? "%.2f" % (pm.gross_amount_cents / 100.0) : nil,
"%.2f" % (pm.expected_amount_cents / 100.0),
pm.fee_cents ? "%.2f" % (pm.fee_cents / 100.0) : nil,
fee_pct ? "#{fee_pct}%" : nil,
pm.shine_transaction&.label,
pm.flag
]
end
end
path
end
private
def display_name(inv)
name = inv.customer_name.to_s.strip
name.empty? ? "client_id:#{inv.customer_id}" : name
end
def format_eur(cents)
"#{"%.2f" % (cents / 100.0)}"
end
def status_label(status)
{ 0 => "draft", 1 => "open", 2 => "paid", 3 => "cancelled" }[status] || status.to_s
end
def action_label(flag)
{
Engine::MATCHED => "none",
Engine::GC_PAID_DOLIBARR_OPEN => "mark_dolibarr_paid",
Engine::GC_FAILED => "verify",
Engine::GC_CANCELLED => "none",
Engine::GC_NO_INVOICE => "investigate",
Engine::DOLIBARR_PAID_NO_GC => "verify_manually",
Engine::DOLIBARR_OPEN_NO_GC => "follow_up"
}[flag] || flag.to_s
end
end
end