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:
28
.env.example
28
.env.example
@@ -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
614
README.md
@@ -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.
|
||||||
|
|||||||
@@ -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]
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
64
lib/reconciliation/gocardless_payouts_parser.rb
Normal file
64
lib/reconciliation/gocardless_payouts_parser.rb
Normal 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
|
||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user