Compare commits

..

58 Commits

Author SHA1 Message Date
223dae227e Merge pull request 'feat(backup): skip-if-unchanged + scheduled CronJob in the chart' (#32) from claude/dolibarr-backup-cronjob into main 2026-06-30 15:53:57 +02:00
a3f0586c77 feat(backup): skip-if-unchanged + scheduled CronJob in the chart
Builds on the dedicated backup (erp#31).

Skip-if-unchanged: each half (DB / documents) carries a content fingerprint at
erp/<env>/.fp-{db,docs} and is dumped+uploaded only if it differs from the last
run — a quiet ERP day re-uploads nothing. Fingerprint = durable BUSINESS content
only: DB = count+max(tms) over tms tables EXCEPT volatile churn (llx_const,
llx_user, session/cron); docs EXCLUDE */temp/* (Dolibarr stats cache) — from both
the fingerprint and the tar. Proven live: 1st run uploads both, immediate 2nd run
skips both (uploaded=0).

Automation: the in-container logic moves to chart/files/backup-job.sh (single
source of truth, read by the orchestrator AND the chart). New
chart/templates/backup-cronjob.yaml renders a daily CronJob + ConfigMap +
VaultStaticSecret, gated by backup.enabled (default false). Helm-verified: off by
default (0 CronJobs), on renders correctly, env-aware (PREFIX erp/prod vs
erp/sandbox), script embedded.

Activation (documented): store GCS HMAC creds at kvv2/<backup.vaultS3Path>
(default erp/backup), grant the erp `auth` Vault role read on it (tools change),
set backup.enabled=true. Until then the orchestrator runs on demand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 15:53:13 +02:00
e69717c2d9 Merge pull request 'feat(ops): dedicated Dolibarr backup (DB + documents → offsite GCS, 10y retention)' (#31) from claude/dolibarr-backup-strategy into main 2026-06-30 15:33:29 +02:00
8ec8fde67e feat(ops): dedicated Dolibarr backup (DB + documents → offsite GCS, 10y retention)
The accounting data + issued documents are legally retained 10 years and warrant a
backup dedicated to Dolibarr. An audit found the generic Longhorn external backup
NEVER covered the erp volume (its Longhorn volume sits in the orphaned `default`
recurring-job group; the only job has groups=[] → serves nothing; lastBackupAt=never).
So /var/www/documents (invoice PDFs, supplier pieces, contracts, ECM) had zero
offsite copy — only in-cluster replicas.

ops/backup/dolibarr-backup.sh (orchestrator) + ops/backup/backup-job.sh (in-container
logic, env-driven, single source of truth):
- pg_dump -Fc of the DB + tar of the documents PVC (RWX, read-only mount) ->
  s3://arcodange-backup/erp/<env>/{db,docs}/<ts>, then tiered prune (daily 30d /
  monthly 12m / yearly 10y).
- prod is READ-only (dump+tar read; writes go only to the backup bucket); the DB is
  read with the env's own dynamic creds; the GCS HMAC secret is copied transiently
  (base64, deleted on exit) and never printed; the whole script ships base64.
- fixes the aws-cli v2.23+ default-checksum incompatibility with GCS/S3-compat
  (SignatureDoesNotMatch) via AWS_*_CHECKSUM_*=when_required.

Proven live: sandbox end-to-end (dump+tar+upload+prune, verified in GCS, cleaned up)
and retention logic unit-tested (1100 daily -> 46 kept). The FIRST real prod backup
was taken (erp/prod/db 1.2 MB + erp/prod/docs 12.5 MB) — closing the gap now.

Automation (recurring CronJob in the chart + a dedicated erp Vault policy for its
own S3 creds) is the documented next step; the orchestrator works today on demand.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 15:32:36 +02:00
d27b5bfd45 Merge pull request 'feat(skills,cli): dolibarr-sandbox-checkpoint — manage the sandbox iso-prod checkpoint' (#30) from claude/sandbox-checkpoint-cli into main 2026-06-30 07:20:21 +02:00
275a59b478 feat(skills,cli): dolibarr-sandbox-checkpoint — manage the sandbox iso-prod checkpoint
A skill + CLI group to drive the ADR-0003 sandbox lifecycle, instead of the manual
kubectl/deno/.env dance:

  arcodange sandbox checkpoint status       # liveness + is the write agent armed?
  arcodange sandbox checkpoint refresh --yes # re-seed iso-prod (DESTRUCTIVE, gated)
  arcodange sandbox checkpoint provision     # re-create ai_agent_sandbox (Playwright) + relink
  arcodange sandbox checkpoint relink-env    # rewrite write skill .env from the key + verify

- refresh wraps ops/sandbox/sandbox-lifecycle.sh; requires --yes (it wipes the agent
  too, since iso-prod overwrites llx_user). --db-only skips the documents sync.
- provision runs test/provisionSandbox.ts (you do the admin login — PROD creds,
  iso-prod) then auto-relinks; relink-env writes .env mode 600 and verifies via
  GET /users/info.
- scripts resolve the repo root from ARCO_ROOT (set by bin/arcodange) or their own
  path, so they work via the CLI or standalone.

Tested: status reports armed/not-armed correctly; refresh refuses without --yes
(exit 3); relink-env errors with no key (exit 1); help/usage wired.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 07:19:59 +02:00
d31e995acd Merge pull request 'fix(ops): sandbox refresh-from-prod actually restores (pg_restore -U + self-heal pause)' (#29) from claude/sandbox-lifecycle-restore-fix into main 2026-06-30 07:00:03 +02:00
434be7488d fix(ops): sandbox refresh-from-prod actually restores now (pg_restore -U + self-heal pause)
refresh-from-prod was structurally broken and silently no-op'd the restore:

1. pg_restore lacked -U, so the postgres image connected as its OS user `root`
   and auth-failed. The failure was swallowed by `|| echo "ignorable warnings"`,
   so the script reported success while the DROP OWNED had already emptied the DB.
   E2's original seed was a manual process, so this path had never really run.
   Fix: pass `-h $PGHOST -U $SB_PGUSER`; don't trust pg_restore's exit code (it
   returns non-zero on the harmless "schema public already exists" notice) — verify
   by counting restored llx_* tables and FAIL the Job if < 250.

2. erp-sandbox is ArgoCD-managed with self-heal ON, which reverts the
   `kubectl scale --replicas=0` within seconds — so the seed ran with Dolibarr
   still connected. Fix: pause self-heal for the duration, re-arm it after; app
   restore + self-heal restoration + secret cleanup are guarded by an EXIT trap so
   an interrupt can't strand the sandbox at replicas=0 / self-heal off.

Validated end-to-end on the live sandbox: 295 llx tables, company=Arcodange,
owner=erp_sandbox_role, self-heal re-armed, pod 1/1. README documents the self-heal
pause and the iso-prod consequence (ai_agent_sandbox is wiped → re-provision).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 06:59:39 +02:00
0688e3d7fd Merge pull request 'feat(bank-match): exact tx-id match pass (consumes payment transaction_id)' (#28) from claude/bank-match-exact-txid into main 2026-06-30 00:39:44 +02:00
4b17e5f22c feat(bank-match): exact tx-id match pass (consumes payment transaction_id)
The consumer side of erp#26/#27: now that a règlement stores its originating bank
transaction id (transaction_id -> llx_bank.num_chq), bank-match uses it.

New PASS 0 (exact), highest priority, before wire-ref and amt+date:
- carry each feed movement's own id (Qonto transaction id; Wise activity + transfer
  resource id) as feed_ids, and each Dolibarr payment's num.
- match when a payment's num equals a feed id. Tagged [tx-id].
- DATE-WINDOW-INDEPENDENT — the id is proof, so it pairs movements whose bank
  settlement and Dolibarr saisie are weeks apart (which amt+date would miss).
Pass 0 runs before the ref index is built, so its matches are excluded from the
later passes (no double-match).

Fixture-proven: a payment dated 15d off the bank movement (outside the ±7d window)
matches via [tx-id] when num carries the Qonto id, and correctly does NOT match
when num is empty.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 00:39:21 +02:00
01e3eaac5e Merge pull request 'feat(payment): first-class transaction_id when recording a règlement' (#27) from claude/dolibarr-payment-txn-integrate into main 2026-06-30 00:34:32 +02:00
b945c8de47 feat(payment): first-class transaction_id when recording a règlement
Make the originating bank transaction id a first-class input on payment-record.sh
so every règlement is tied to the real bank movement at write time.

- `transaction_id` is the canonical field (the Qonto/Wise feed tx id); `num` stays
  as a back-compat alias. It's stored on the payment's bank line (llx_bank.num_chq),
  the reconciliation key.
- Recording WITHOUT a transaction_id prints a stderr warning (still posts, but won't
  auto-reconcile) — nudges the agent to always carry it.
- Output normalises to {id, bank_transaction_id, transaction_id}.
- Promote: manifests' payment ops carry transaction_id; promote-plan shows it
  (tx=… or tx=MISSING).

Proven live: customer + supplier record with transaction_id; the `num` alias maps
to the same field; the no-tx warning fires; promote plan/apply carry it through.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 00:34:10 +02:00
d81bff0ed3 Merge pull request 'feat(payment): return the bank transaction id on règlements (reconciliation link)' (#26) from claude/dolibarr-payment-txn into main 2026-06-30 00:17:59 +02:00
e748efd8f0 feat(payment): return the bank transaction id on règlements (reconciliation link)
A payment only returned its paiement id, which isn't what bank reconciliation
keys on. payment-record.sh now emits {id, bank_transaction_id, num}:

- bank_transaction_id = the Dolibarr bank line (llx_bank.fk_bank_line) the payment
  created, resolved via GET /{invoices|supplierinvoices}/{id}/payments (correlated
  by num, else the most recent line). Works for customer and supplier.
- num stores the originating bank tx id (Qonto/Wise) and lands on that bank line's
  num_chq — so arcodange-bank-reco can match a règlement to a statement line by id
  instead of fuzzy amount/date. Both ends captured at write time.

Proven live: customer {id:13,bank_transaction_id:35,num:QONTO-TX-1234},
supplier {id:16,bank_transaction_id:36,num:WISE-TX-5678}; llx_bank rows 35/36
carry the refs in num_chq. promote-apply still extracts .id unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 00:17:41 +02:00
463e10a417 Merge pull request 'feat(skills,cli): supplier avoirs + banque lire (bank-account discovery)' (#25) from claude/dolibarr-last-bits into main 2026-06-30 00:08:07 +02:00
64d2cb4237 feat(skills,cli): supplier avoirs + banque lire (bank-account discovery)
The two V9 follow-ups, both proven live on the sandbox.

- creditnote-create.sh: `kind:"supplier"` makes an avoir fournisseur on
  /supplierinvoices (type=2 + fk_facture_source, carries ref_supplier); default
  customer path unchanged. Proven: customer AVC002 (-240) + supplier AVF2026001
  (-144, ref_supplier carried, linked to source, validated).
- bank-accounts.sh + `arcodange sandbox accounts`: list bank accounts (id/label/
  bank) so a payment can pick its account_id. Needs `banque lire` (rights 111),
  now added to the provisioner's WRITE_IDS so fresh runs include it; the existing
  ai_agent_sandbox user was granted it live. GET /bankaccounts now returns the 3
  accounts (QONTO, WISE EURO, Compte Courant Asso).
- SKILL.md: supplier-avoir example + accounts helper + updated banque-lire note.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-30 00:07:48 +02:00
09a2cbab0a Merge pull request 'feat(promote): resolve pre-existing entities by business key (#entity:field=value)' (#24) from claude/dolibarr-promote-lookup into main 2026-06-29 23:58:41 +02:00
04985fe15c feat(promote): resolve pre-existing entities by business key (#entity:field=value)
Closes the last promote gap: a manifest can now reference records it does NOT
create. A value like "#thirdparty:name=KissMetrics" (or :code=CL0007) is looked
up on the TARGET at apply time and resolved to that target's id — so the same
manifest is portable (sandbox id on --target sandbox, prod id on --target prod).

promote-apply.sh: resolve() gains a "#" branch + a lookup() helper that queries
the target via the GET wrapper with sqlfilters. Supports thirdparty
(name/code/supplier_code) and invoice/supplierinvoice (ref/ref_supplier). A
lookup matching nothing OR more than one record ABORTS the run — it never
guesses, so it cannot write to the wrong entity.

Proven live: "#thirdparty:name=ACME Conseil" resolved to the existing client and
invoiced it; a not-found code and an ambiguous (2-match) name both aborted with
exit 1. Combined with @refs, arbitrary self-contained-or-referential change-sets
now replay cleanly.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 23:57:49 +02:00
7949ab34f8 Merge pull request 'feat(skills,cli): promote-to-prod replay (ADR-0003 capstone) + supplier payment fix' (#23) from claude/dolibarr-promote into main 2026-06-29 23:49:22 +02:00
00d86b47a3 feat(skills,cli): promote-to-prod replay (ADR-0003 capstone) + supplier payment fix
The human-gated path that carries a reviewed sandbox change to prod.

- promote-plan.sh: render a manifest (JSON array of write ops with symbolic @refs
  instead of ids — portable sandbox->prod) as a human-readable change-set.
- promote-apply.sh <manifest> --target sandbox|prod: replay it, resolving each
  @ref to the id actually created during the run (dependent ops wire up). sandbox
  rehearses via dol-write.sh; prod via dol-prod-write.sh.
- dol-prod-write.sh: the ONLY prod-write path. Prod key read from the ENVIRONMENT
  only (DOLIBARR_PROD_WRITE_KEY, never a stored .env); every write refused unless
  ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD.
- create scripts take a DOL_WRITE override so promote-apply reuses them per target.
- bin/arcodange: `promote {plan|apply}` group + example manifest.
- payment-record.sh: fixed supplier payments (payment_mode_id + closepaidinvoices).

Proven live: plan renders; apply --target sandbox replays a 3-op chain with refs
resolved (@tp1->id, invoice socid=@tp1, payment invoice=@inv1); --target prod
without the confirm flag is REFUSED before sending. Supplier payment now works
end-to-end via the script.

Limitation (documented): manifests reference entities they create (@ref);
pre-existing prod entities need business-key resolution (follow-up).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 23:48:47 +02:00
e4c67c0108 Merge pull request 'feat(skills,cli): sandbox avoir (credit note) + arcodange sandbox CLI group' (#22) from claude/dolibarr-sandbox-write-cli into main 2026-06-29 21:05:25 +02:00
79286650d7 feat(skills,cli): sandbox avoir (credit note) + arcodange sandbox CLI group
- dolibarr-sandbox-write/scripts/creditnote-create.sh: create a customer avoir
  (credit note) — a customer invoice type=2 referencing source_invoice
  (fk_facture_source); amounts negative, validates to an AVC… ref. Proven live.
- bin/arcodange: new `sandbox` command group wiring the write scripts —
  `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}` (JSON on
  stdin). Header + usage updated to note the CLI now does host-guarded sandbox
  writes (still read-only on prod).
- SKILL.md: avoir workflow + CLI notes.

Verified end-to-end through the CLI: thirdparty -> invoice (FAC…) -> avoir
(AVC…, total_ttc -240, fk_facture_source set); host-guard intact via the CLI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 21:04:49 +02:00
52f4d02722 Merge pull request 'feat(skills): dolibarr-sandbox-write — host-guarded write skill (V9)' (#21) from claude/dolibarr-sandbox-write into main 2026-06-29 20:50:11 +02:00
d2e8b3a3a4 feat(skills): dolibarr-sandbox-write — host-guarded write skill (V9)
The write-capable companion to the read-only dolibarr* skills, scoped to the
erp-sandbox. Lets an AI agent rehearse bookkeeping writes against a copy of prod
(ADR-0003) before a human promotes the reviewed change to prod.

- scripts/dol-write.sh: write wrapper that REFUSES any host that is not
  erp-sandbox.arcodange.lab (the structural prod-safety guarantee) using the
  ai_agent_sandbox key from a gitignored .env.
- scripts/thirdparty-create.sh: create client/supplier fiches; codes auto-assign
  via the elephant mask (code="-1").
- scripts/invoice-create.sh: customer (/invoices) or supplier (/supplierinvoices)
  invoices with product/service lines + ref_supplier, optional validate.
- scripts/payment-record.sh: record a règlement (VIR/CB/CHQ/LIQ); customer pays
  full + marks paid, supplier needs an amount.
- SKILL.md (safety model + workflows + the human-gated promote flow), .env.example,
  example input.

Proven end-to-end live against the sandbox: client -> invoice (service+product
lines, HT 1100 / TTC 1320) -> validate -> payment (paid); supplier -> supplier
invoice (ref_supplier carried) -> validate. Host guard verified to refuse a prod
URL before sending.

Avoirs (credit notes) and bin/arcodange CLI wiring are planned follow-ups.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 20:49:31 +02:00
c269751422 Merge pull request 'fix(test): grant societe client voir (262) so /thirdparties list works' (#20) from claude/sandbox-provision-run into main 2026-06-29 20:17:20 +02:00
7619a4f358 fix(test): grant societe client voir (262) so /thirdparties list works
Validating ai_agent_sandbox's key against the sandbox API, /thirdparties
returned 404 (the voir_tous ACL trap) while /invoices, /products,
/supplierinvoices returned 200. The missing right is `societe client voir`
(id 262, "see all thirdparties") — prod's ai_agent has it. Added it to
WRITE_IDS so the list endpoint works; other modules' lists are fine with plain
`lire`.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 20:16:59 +02:00
37865c55c5 Merge pull request 'fix(test): persist API key + anchor rights selector + idempotent createUser' (#19) from claude/sandbox-provision-run into main 2026-06-29 14:31:24 +02:00
18c5d0ebda fix(test): persist generated API key + anchor rights selector + idempotent createUser
First real run against the sandbox revealed three issues in userSetup.ts:

1. generateApiKey generated the key client-side and read it into the file but
   never submitted the edit form, so Dolibarr never persisted api_key (DB stayed
   NULL → the key could not authenticate). Now it clicks Save after generating.

2. assignRights matched `rights=<id>` as an href substring, so a short id like
   12 (facture creer) also matched rights=121 / rights=1232 and .first() clicked
   the wrong link — facture creer was never granted. Anchored with a trailing
   "&" (rights=<id>&) for an exact match.

3. createUser was not idempotent: a re-run hit the existing login and failed to
   parse a new id. Added findUserId (look up by login via the user list) and
   return the existing id instead of creating a duplicate.

Verified the symptoms live: ai_agent_sandbox (rowid 4) had api_key NULL and was
missing only facture/creer among the 11 intended rights.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 14:30:36 +02:00
650594bb70 Merge pull request 'fix(sandbox): Dolibarr dev mode (DOLI_PROD=0) + correct install.lock path' (#18) from claude/sandbox-dev-mode into main 2026-06-29 14:11:18 +02:00
416cd807a3 fix(sandbox): run Dolibarr in dev mode (DOLI_PROD=0) + correct install.lock path
After seeding erp-sandbox from prod, the home dashboard rendered a generic
"technical error" banner per box: prod mode ($dolibarr_main_prod=1, the image
default via DOLI_PROD) escalates the seed's minor non-fatal warnings into that
banner. Setting DOLI_PROD=0 for non-prod environments makes Dolibarr render
real errors inline (correct for a rehearsal env) and clears the banners.

config.yaml adds `DOLI_PROD: "0"` only when env != prod, so the prod configmap
is byte-identical (prod keeps the image default DOLI_PROD=1) — verified via
helm template diff. ArgoCD rolls only the sandbox pod.

Also corrects the test/README install.lock path: Dolibarr checks the DATA root
(/var/www/documents, a PVC — persists across restarts), not /var/www/html. And
notes that a prod-seeded sandbox still needs install.lock created (the seed +
documents/mycompany sync don't include it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 14:09:59 +02:00
2154bf319e Merge pull request 'feat(test): split env config — .env (prod) vs .env.sandbox (sandbox)' (#17) from claude/test-env-sandbox-split into main 2026-06-29 11:26:39 +02:00
e4a7f99333 feat(test): split env config — .env (prod) vs .env.sandbox (sandbox)
provisionSandbox.ts now loads its own .env.sandbox (via @std/dotenv loadSync)
instead of the shared .env, so prod (main.ts → .env) and sandbox
(provisionSandbox.ts → .env.sandbox) configs don't collide. .gitignore widened
to .env* (keeping .env.example tracked). .env.example rewritten to document the
two-file convention + the per-env kubectl secret sources, including the caveat
that a prod-seeded sandbox uses PROD's admin password.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 11:26:05 +02:00
04281f0ab7 Merge pull request 'docs(test): preserve the install.lock step in test/README' (#16) from claude/test-readme-install-lock into main 2026-06-29 07:54:22 +02:00
c010099dae docs(test): preserve the install.lock step in test/README
The pre-existing (untracked) test/README documented creating Dolibarr's
install.lock after a fresh install — a non-obvious operational step missing from
the rewritten README. Preserve it (generalized to the per-env namespace/label,
with a note that a prod-seeded instance doesn't need it).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 07:54:01 +02:00
4cc5ca39ce Merge pull request 'feat(ops): erp-sandbox iso-prod seed + documents sync tooling (ADR-0003 E2)' (#15) from claude/e2-sandbox-lifecycle into main 2026-06-29 07:43:44 +02:00
fbc0cc6962 Merge pull request 'feat(test): provision erp-sandbox via Playwright (REST API + write-scoped ai_agent_sandbox user)' (#14) from claude/poc-provision-sandbox into main 2026-06-29 07:43:32 +02:00
7264f00ed4 feat(ops): erp-sandbox iso-prod seed + documents sync tooling (ADR-0003 E2)
Productionizes the sandbox state-lifecycle mechanisms validated live against
erp-sandbox. `ops/sandbox/sandbox-lifecycle.sh`:
  - refresh-from-prod: read-only pg_dump of prod erp (default_transaction_read_only)
    -> DROP OWNED BY erp_sandbox_role CASCADE -> pg_restore into erp-sandbox, using
    the sandbox's own membership creds (no DROP/CREATE DATABASE, no CREATEDB, no
    superuser). Dumps the full public schema (so app helper functions + triggers
    come over) and filters the provisioner-owned pgbouncer user_lookup function
    from the restore TOC. Scales the pod to 0 for exclusive access; copies prod
    creds into a transient secret that is deleted on exit.
  - sync-documents: tar-pipe the documents/mycompany tree (company logo + uploads)
    prod -> sandbox, since uploaded files live on the PVC, not the DB.

Prod integrity is structural: prod is read-only during dump; the restore can only
write erp-sandbox (erp_sandbox_role owns only the sandbox DB and cannot drop prod
erp/erp_role); the platform's only prod-capable superuser stays behind the
human-gated postgres.yaml CI and is never used here.

README documents the integrity guarantee, the encryption + PVC fidelity caveats,
the BDD reset loop, and the hardening backlog (dedicated read-only dump role,
golden-cache PVC).

Refs ADR-0003 (factory#19). Chart owner-role fix = erp#13.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 07:42:00 +02:00
523f0cf001 feat(test): provision erp-sandbox via Playwright (REST API + write-scoped ai_agent_sandbox user)
Extend the Deno + Playwright UI-automation POC to provision the erp-sandbox
Dolibarr for the AI agent:

- moduleSetup.ts: add enableApiModule(ctx) — toggles the REST API / Web services
  module on /admin/modules.php (kanban). Resilient: tries the fr_FR card label
  "API/Web services REST (serveur)" first, falls back to a /API.*REST|REST.*API/i
  title match if the exact label is absent.
- userSetup.ts (new): createUser (returns the new numeric id), assignRights
  (clicks each addrights link on /user/perms.php, idempotent), generateApiKey
  (triggers Dolibarr's generate control on the user card and reads the value back).
- provisionSandbox.ts (new entrypoint, main.ts untouched): login → enable API →
  create ai_agent_sandbox (non-admin) → grant write rights → generate API key,
  then write the key to test/.ai_agent_sandbox.key (gitignored) instead of
  printing it.
- .gitignore (new), .env.example + README.md: sandbox vars, the
  deno run --allow-all provisionSandbox.ts command, and kubectl one-liners to
  pull DOLI_ADMIN_PASSWORD (secretkv) / DOLI_DB_PASSWORD (vso-db-credentials)
  from the erp-sandbox namespace.

Why UI not SQL: API keys are encrypted with the instance's DOLI_INSTANCE_UNIQUE_ID,
so the key must be generated by the sandbox itself, not INSERTed raw.

deno check passes for provisionSandbox.ts and scripts/admin/userSetup.ts.
NOT run end-to-end: the sandbox Dolibarr is not installed yet (empty DB / fresh
install wizard), so the selectors are best-effort Dolibarr 22 conventions and
must be confirmed on the first real run.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-29 07:34:21 +02:00
cb17332314 Merge pull request 'fix(chart): template Postgres owner role in update_ownership.sql for multi-env' (#13) from claude/fix-chart-owner-role into main 2026-06-28 22:30:54 +02:00
c0d5f2e144 fix(chart): template Postgres owner role in update_ownership.sql for multi-env
The Dolibarr before-start step `chart/scripts/update_ownership.sql` (embedded
into a ConfigMap by `chart/templates/scripts-config.yaml`) hardcoded the
Postgres owner role `erp_role`. It reassigns ownership of all public-schema
objects to that role after install. For any non-prod environment the owner
role differs — by the multi-env elision rule (ADR-0002/0003) it is snake-case
`<app>_role` for prod and `<app>_<env>_role` for non-prod, so the sandbox owner
role is `erp_sandbox_role`. With the literal `erp_role`, installing Dolibarr in
`erp-sandbox` would reassign sandbox tables to prod's `erp_role`, which (a)
breaks the sandbox runtime (its dynamic DB creds are a member of
`erp_sandbox_role`, not `erp_role`) and (b) breaks the ADR-0003 reset
(`DROP OWNED BY erp_sandbox_role`).

Fix: make the owner role env-aware via a new chart value `db.ownerRole`.
- values.yaml: default `ownerRole: erp_role` (prod).
- values-sandbox.yaml: override `ownerRole: erp_sandbox_role`.
- update_ownership.sql: all `'erp_role'` literals → `'{{ .Values.db.ownerRole }}'`.
- scripts-config.yaml: render that one SQL file through `tpl` so the value is
  substituted (the other script has no template vars and stays on `.Files.Get`).
  The SQL's `$$`, `%I`, `format(...)`, `RAISE NOTICE` are not Go-template syntax,
  so `tpl` only substitutes the added `{{ .Values.db.ownerRole }}`.

Verified: the prod ConfigMap render (values.yaml only) is byte-identical to
origin/main (empty diff, still `erp_role`); the sandbox render
(-f values.yaml -f values-sandbox.yaml) now contains `erp_sandbox_role` and no
bare `erp_role`; `helm lint` passes (no worse than origin/main).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 22:29:18 +02:00
354b40549d Merge pull request 'feat(multi-env): Phase D3 — erp iac creates erp-sandbox Vault auth + creds + KV' (#12) from claude/phaseD-erp-sandbox-iac into main 2026-06-28 17:30:57 +02:00
e2fa327361 feat(multi-env): Phase D3 — erp iac creates erp-sandbox Vault auth + creds + KV
ADR-0002 Phase D, erp-repo layer. iac/main.tf iterates `envs = ["prod",
"sandbox"]` so the app_roles module + the admin-bootstrap resources are
materialised per environment:
  - module.app_roles["sandbox"] → auth/kubernetes/role/erp-sandbox +
    postgres/creds/erp-sandbox (dynamic role GRANTs erp_sandbox_role — the
    snake-case owner role created in factory#17 — and REVOKEs on DATABASE
    erp-sandbox; token_policies ["default","erp-sandbox"] = the policy from
    tools#3).
  - random_password.admin_initial_password["sandbox"], random_uuid.dolibarr_id
    ["sandbox"], and vault_kv_secret_v2.dolibarr_admin_setup["sandbox"]
    (kvv2/erp-sandbox/config) → the sandbox Dolibarr's own admin password +
    encryption id.

State migration via `moved` blocks: the pre-existing single-env resources are
re-keyed into the for_each map under "prod" so introducing for_each does NOT
destroy+recreate them. Critical for random_uuid.dolibarr_id (prevent_destroy =
true — prod encryption id + paid-module binding): a wrong/absent moved would
HARD-FAIL the apply rather than lose it. The module's internal moved
(role -> role[0]) chains with the module re-key. Verified the exact compound
(old-module state → new module count+moved + for_each, one apply) with two
standalone tofu plans: both show "X moved", sandbox created, 0 destroyed.

env=prod renders byte-identical to the single-env baseline (module elision
rule), so the prod erp Vault auth role, dynamic creds, admin secret + KV are
unchanged.

D3 of Phase D. D1 = factory#17 (DB+role, merged). D2 = tools#3 (Vault
policies, merged). D4 (ArgoCD Application) is next.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-06-28 17:26:46 +02:00
b4bdbe75df Merge pull request 'chart: Phase C of multi-env evolution — template literals, add sandbox overlay' (#11) from claude/chart-multi-env-prep into main 2026-05-31 23:27:05 +02:00
ec4df4719f chart: template hardcoded single-env literals; add values-sandbox.yaml overlay
Phase C of the multi-env evolution discussed in the runbook design thread
(see PR description). Pure refactor — the prod helm template render is
verified byte-identical (10857 bytes both before and after, diff exit 0).

What was hardcoded, now templated:
- chart/templates/vaultauth.yaml          role: erp                       → role: {{ .Values.vault.k8sRole }}
- chart/templates/vaultdynamicsecret.yaml path: creds/erp                 → path: {{ .Values.vault.dynamicPath }}
- chart/templates/vaultsecret.yaml        path: erp/config                → path: {{ .Values.vault.staticPath }}
- chart/templates/config.yaml             DOLI_DB_NAME: erp               → DOLI_DB_NAME: {{ .Values.db.name }}
                                          DOLI_URL_ROOT: https://erp..lab → DOLI_URL_ROOT: 'https://{{ .Values.host }}'

values.yaml gains a documented multi-env coordinate block with prod defaults
(env, instance, host, db.name, vault.k8sRole, vault.dynamicPath, vault.staticPath).
The elision rule (env=prod → no suffix, env=non-prod → "<app>-<env>" suffix)
guarantees the prod render is unchanged.

chart/values-sandbox.yaml is added as the ready-to-use overlay for Phase D.
It is NOT wired into any helm install / ArgoCD app today — the platform side
(factory/postgres/iac tfvars, tools/hashicorp-vault/iac module signature) is
not yet evolved. The file documents the convention so the Phase D commit can
just `helm install -f values.yaml -f values-sandbox.yaml`.

Also fixes .gitea/workflows/vault.yaml CI typo: the vault_step JWT role was
gitea_cicd_webapp (copy-paste from the template repo) instead of
gitea_cicd_erp. Real bug — the erp CI would have failed JWT auth against
Vault. Fix unrelated to multi-env but bundled here because it's small and
touches the same file family.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 23:26:20 +02:00
444886b91a Merge pull request 'arcodange-email-ingest V8.1: filter calendar invites + newsletter senders' (#10) from claude/arcodange-email-ingest-v81 into main 2026-05-31 15:18:58 +02:00
1d38f25c23 arcodange-email-ingest V8.1: filter calendar invites + newsletter senders
email-list.sh gains two hard-exclusion filters (applied before the
candidate test, regardless of attachments):

- EXCLUDE_PATTERN matches subjects starting with Invitation: / Updated
  invitation: / Canceled event: / Accepted: / Declined: / Tentative: /
  Maybe: (after stripping Re:/Fwd:/Tr: prefixes). Filters Google Calendar
  events that always carry an .ics attachment.
- EXCLUDE_SENDER matches updates.<domain>, noreply@*calendar, news@,
  newsletter@. Filters newsletter blast traffic.

Effect on --all-folders --candidates-only baseline: 27 noisy → 12
actionable (calendar invites + the staying-ahead.ai newsletter blast
removed). Real supplier docs intact: Darnis F1042 in /Notification, 3 Free
Mobile factures in /Inbox/abonnements, Mistral + Anthropic in /Inbox/books.

The originally-planned --mark-ingested feature is deferred to V8.2:
flag-set requires the Zoho OAuth scope ZohoMail.messages.UPDATE which our
read-only refresh_token doesn't have. Documented in SKILL.md: once the
user opts in to the wider scope, --mark-ingested becomes a one-line flag
on email-inspect.sh and is_candidate() learns to skip flag_info messages.

Captured the new --all-folders baseline at examples/email-list-all-folders.txt.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 15:18:31 +02:00
794aa18d2a Merge pull request 'add arcodange-email-ingest — Zoho Mail → Dolibarr supplier-invoice drafts' (#9) from claude/arcodange-email-ingest into main 2026-05-31 14:56:58 +02:00
c2d8479f5e add arcodange-email-ingest — Zoho Mail → Dolibarr supplier-invoice drafts
V8 — first inbound-side skill. Closes the loop from "bill arrives by email"
to "ready to enter in Dolibarr UI". Read-only at every layer.

What ships:
- arcodange-email-ingest/scripts/zoho-curl.sh   OAuth wrapper with token cache
                                                (50 min TTL, mode 600) — avoids
                                                hitting Zoho OAuth rate limit on
                                                every invocation.
- arcodange-email-ingest/scripts/email-list.sh   List candidates in /Inbox/books
                                                (where the books@ alias auto-
                                                routes mail). --candidates-only
                                                filter on supplier patterns or
                                                attachments. --all-folders to
                                                scan everything.
- arcodange-email-ingest/scripts/email-inspect.sh   Pull message + attachments,
                                                pdftotext on each PDF, heuristic
                                                extract (supplier, ref, dates,
                                                totals, VAT rate), emit Dolibarr
                                                supplier-invoice draft JSON.

Architecture choice — Zoho API (not IMAP):
- books@arcodange.fr is an alias of gabrielradureau@arcodange.fr → one OAuth
  refresh_token covers everything.
- Gmail folded in via forwarding (arcodange@gmail.com → books@) — no Google
  API setup, no app-passwords, no second OAuth flow.
- Token-based auth, no SCA rabbit hole.

V8.0 baseline (in /Inbox/books):
- 3 candidates: Mistral AI facture, Anthropic Stripe receipt (Fwd Gmail),
  INPI payment receipt (Fwd Gmail).
- Heuristic extraction is best-effort: works on amounts/refs for some
  templates, misses others (Mistral PDF format, Stripe receipt layout).
- --save-pdf <DIR> lets the operator grab the PDFs for manual entry when
  the heuristic falls short.

Rate-limit pitfall documented: Zoho OAuth refresh has an aggressive throttle
("too many requests continuously"). The cache file at $TMPDIR/zoho-access-$USER
(mode 600, 50 min TTL) prevents this; on 401 the wrapper auto-refreshes once
and retries.

V8.1+ ideas in SKILL.md out-of-scope:
- mark ingested emails (IMAP flag or Zoho label)
- body text extraction (inline-HTML invoices)
- per-template parsers or LLM-based extraction
- IMAP fallback for non-Zoho mailboxes

CLI: bin/arcodange email {list|inspect|curl} integrated.
Base updates: dolibarr/SKILL.md cross-link, dolibarr/README.md env schema
extended with ZOHO_CLIENT_ID/SECRET/REFRESH_TOKEN/DC.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:56:15 +02:00
a1042a483b Merge pull request 'arcodange-bank-reco V7: avoir netting + fk_account context + wire-ref matching' (#8) from claude/arcodange-bank-reco-v7 into main 2026-05-31 14:20:37 +02:00
246c7fc5a9 arcodange-bank-reco V7: avoir netting + fk_account context + wire-ref matching
Three improvements that reduce the V6.1 exit-1 signal from 10 to 1 on
the current Arcodange baseline. Every bucket now has a single, clear
purpose; the only entry counted as a failure is a genuine action item.

A. fk_account context on dolibarr-only
   - Fetches /bankaccounts and tags each dolibarr-only with the account
     ref + label (e.g. "CCA1 (G.RADUREAU Compte Courant Asso)").
   - Splits dolibarr-only into "on API-tracked accounts" (QON*/WIS* — real
     gaps) vs "not in API scope" (CCA1 / personal — expected gaps).
   - Personal-account entries no longer count toward the failure verdict.

B. Avoir-cycle netting
   - Pairs AVC entries of -X on socid S with FAC entries of +X on the
     same socid within ±5d.
   - Both surface in a dedicated AVOIR-NETTED bucket and are excluded from
     dolibarr-only, since the bank only sees the net of the cycle.
   - Resolves the V6.1 noise where AVC001-CL0001001 + FAC001-CL00001
     appeared as fake gaps for a 510€ cancel-and-reissue dance.

C. Wire-reference strong matching (--enrich flag, opt-in)
   - When --enrich is passed, bank-match.sh fetches /v1/transfers/{id}
     per Wise TRANSFER and reads the wire `reference` field.
   - References containing a FAC\d+(CL\d+)? pattern strong-match against
     the corresponding Dolibarr customer invoice (annotated [wire-ref]
     vs the loose [amt+date] kind).
   - Verified on FAC002 5100€: KM's wire memo "FOR INVOICE FAC002CL0001002"
     gives an unambiguous match independent of date drift.

Baseline (Jan-May 2026, --enrich on):
  6 matched · 1 internal · 2 avoir-netted · 7 bank-known · 1 bank-UNKNOWN
  0 dol-only-API · 7 dol-only-personal
  → exit-1 count = 1 (just the +2147€ KM Wise 2026-05-29 to record).

The CLI (bin/arcodange) gains --enrich on the match subcommand. The
SKILL.md has a new "V7 bucket structure" section explaining the seven
buckets and a before/after table showing the signal/noise improvement.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:20:06 +02:00
0f5b6bcbad Merge pull request 'arcodange-bank-reco: known-patterns catalog + annotated bank-only buckets' (#7) from claude/arcodange-bank-reco-patterns into main 2026-05-31 14:07:39 +02:00
4b6a5f7529 arcodange-bank-reco: add known-patterns.json catalog + bank-match annotation
V6.1 follow-up to the bank-reco V6 ship. Splits the BANK-ONLY bucket into
"known patterns" (intentional gaps, documented and classified) vs
"unknown" (real action items).

What the catalog covers today:
- FOUREZ Quentin → capital_deposit (apport en capital 1000 € initial,
  notaire FOUREZ centralisateur du dépôt). Maps to Dolibarr account 1013.
- URSSAF → social_charges (account 645100)
- MISTRAL.AI, CLAUDE.AI → ai_subscription (account 6262)
- Wise *Plan, qonto_fee → bank_fee (account 627)
- BALANCE_DEPOSIT / FEATURE_CHARGE on Wise → internal_topup (self-funding
  pair, often nets to zero)

Effect on the V6 baseline (Jan-May 2026):
- Before catalog: 8 BANK-ONLY mixed entries (noise + signal)
- After catalog:  7 known + 1 UNKNOWN (just the +2147 € KM Wise payment
  2026-05-29 that genuinely needs a Dolibarr entry)

The catalog is JSON (not YAML — stdlib only, no dependency). Schema
documented in SKILL.md. Pattern matches case-insensitive regex against
both bank label AND operation type. Optional filters: bank, side,
amount_min, amount_max.

Exit code now reflects only the UNKNOWN bank-only and dolibarr-only
counts — the verdict is no longer noisy because of intentional gaps.

Edit known-patterns.json as new recurring patterns emerge.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 14:06:46 +02:00
f398003eae Merge pull request 'add arcodange-bank-reco — Qonto + Wise reconciliation against Dolibarr' (#6) from claude/arcodange-bank-reco into main 2026-05-31 13:58:06 +02:00
bd90266372 add arcodange-bank-reco — Qonto + Wise reconciliation against Dolibarr
V6 — the first cross-system skill (under arcodange-* not dolibarr-*).
Closes the loop between what Dolibarr says (ERP-internal) and what the
bank actually saw.

What ships:
- arcodange-bank-reco/scripts/bank-curl.sh        unified read-only wrapper for Qonto + Wise
- arcodange-bank-reco/scripts/bank-probe.sh       auth + discovery (org slug, profile id, balances)
- arcodange-bank-reco/scripts/qonto-transactions  Qonto txn lister with pagination + filters
- arcodange-bank-reco/scripts/wise-transactions   Wise activity lister with --enrich for wire refs
- arcodange-bank-reco/scripts/bank-match.sh       3-bucket reconciliation (matched/bank-only/dol-only)
                                                  with internal Wise↔Qonto consolidation detection
- arcodange-bank-reco/scripts/bank-balance.sh     live balances + Dolibarr cumulative-by-fk_account

The headline bank-curl.sh is SCA-aware (Wise RSA dance) even though we
don't end up using it: the EU statement endpoint is region-blocked
("Funding transfers and retrieving balance statements via API are not
supported except for accounts based in the US, Canada, Australia, New
Zealand, Singapore, and Malaysia" per Wise docs). The wrapper supports
SCA so when/if Wise opens it, we're ready.

The pivot that unblocked Wise incoming: /v1/profiles/{pid}/activities
(documented at https://docs.wise.com/api-reference/activity/activitylist.md)
returns ALL movements in a unified HTML-tagged feed, no SCA required.
Parsing strips the HTML and recovers structured amount/sign/currency.

CLI integration:
- bin/arcodange bank {probe,qonto-transactions,wise-transactions,match,balance,curl}
- dolibarr/SKILL.md catalogue + Pointers updated
- dolibarr/README.md env schema extended with QONTO_*, WISE_*

Live baseline findings to raise with the cohort review (captured in
examples/bank-match-2026-01-to-05.txt):
- Wise 2026-05-29 +2147 EUR Kissmetrics NOT YET in Dolibarr
- Qonto bank-only: MISTRAL.AI 172.68, CLAUDE.AI 180, URSSAF 493, FOUREZ +1000
- 6 movements matched cleanly across Jan-May 2026
- Wise→Qonto 5000 EUR consolidation on 2026-03-13 auto-detected as internal
- Live balance: Qonto 4191.54 + Wise 5308.25 = 9499.79 EUR

V7 candidates noted in SKILL.md out-of-scope: reference-based matching
via the Wise --enrich wire refs (FOR INVOICE FAC***), multi-row Dolibarr
sub-payment aggregation, smarter avoir cycle handling.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-31 13:57:21 +02:00
4252a88681 Merge pull request 'add bin/arcodange CLI and dolibarr-tva-summary skill' (#5) from claude/arcodange-cli-tva-summary into main 2026-05-29 11:30:55 +02:00
1c0ba8ea75 add bin/arcodange CLI and dolibarr-tva-summary skill
Two changes that go together: now operators can run every read-only
workflow without going through Claude. The skills (SKILL.md files)
remain the source of behaviour documentation and Claude triggers;
bin/arcodange is the human-facing entry point.

bin/arcodange:
- Bash dispatcher at the project root. Subcommands per domain:
  tva {collect, collect-detail, deductible, deductible-detail, summary},
  invoice {list, audit}, thirdparty {audit, audit-all},
  payments {state, timeline, by-month},
  templates {list, inspect},
  snapshot, whoami, ping, curl, help.
- Locates the project root via `git rev-parse` so it works from any
  CWD (including from a worktree).
- Per-subcommand `help` text. Unknown commands exit 2 with a hint.
- Reuses the existing per-skill scripts under .claude/skills/<name>/
  scripts/ via `exec` (zero behaviour drift, full credit to the
  existing tested code).

dolibarr-tva-summary:
- Composes dolibarr-tva-reconciliation (TVA collectée customer-side)
  and dolibarr-tva-deductible (TVA déductible supplier-side) into a
  single CA3-ready monthly summary with per-month net verdict
  (TVA à reverser / crédit de TVA / équilibre) and a cumulative line.
- Live baseline: Arcodange en crédit de TVA de 223.22 € cumulé
  (0 € collectée 259-1° CGI vs 223.22 € déductible).
- Exposed as `arcodange tva summary [--year|--since|--until]`.

Each existing skill's SKILL.md gets a one-line "CLI shortcut" near
the top so the human path is discoverable from any skill page.
The project root README.md gets a CLI section as the primary
operator entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:30:18 +02:00
8ba10e6f65 Merge pull request 'add 2 dolibarr-* skills: thirdparty-completeness, tva-deductible' (#4) from claude/dolibarr-thirdparty-tva-deductible into main 2026-05-29 06:15:04 +02:00
585b7beb03 add dolibarr-thirdparty-completeness and dolibarr-tva-deductible
V4 bundle — two more sibling skills, both read-only, both depending
on the dolibarr base skill.

dolibarr-thirdparty-completeness:
- audit-thirdparty.sh <socid>: country-aware completeness audit for
  any thirdparty (FR: SIREN + SIRET + tva_intra; EU non-FR: tva_intra;
  extra-EU: national tax id). Generalizes the V1 KM-hardcoded script.
- audit-all-thirdparties.sh: loops over /thirdparties and surfaces a
  compact table of gaps. --clients-only / --suppliers-only flags.
- Live baseline finds 5/10 thirdparties with mandatory gaps:
  KissMetrics (US tax id), Wise Europe SA (BE tva_intra), Medialex
  (FR SIRET + tva_intra), Qonto (SIRET), Infogreffe (SIRET).

dolibarr-tva-deductible:
- deductible-by-month.sh: TVA déductible aggregated per period × rate.
- deductible-line-detail.sh: per supplier-invoice line with country-
  based CA3 bucket assignment (ligne 20 for 20 % FR, ligne 19 for
  reduced rates, ligne 17+24 for intra-UE autoliquidation).
- Live baseline: 223.22 € total TVA déductible across 13 lines.
  Wise Europe SA correctly identified as intra-UE autoliquidation;
  La Poste correctly identified as FR exempt (timbres).
- Mirrors dolibarr-tva-reconciliation on the supplier side. Together
  they give the two numbers a CA3 monthly declaration needs.

Also extends dolibarr/SKILL.md endpoint catalogue with /supplierinvoices
(noting the 403 on the /lines sub-endpoint — inline lines on the detail
endpoint make this a non-issue). dolibarr/README.md gains two new
permission checkboxes for Factures fournisseurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 06:14:28 +02:00
89 changed files with 11107 additions and 41 deletions

View File

@@ -0,0 +1,246 @@
---
name: arcodange-bank-reco
description: Bank-side reconciliation for Arcodange — cross-check Dolibarr customer + supplier payments against the actual movements on Qonto (FR business account, full API access) and Wise (BUSINESS EUR balance, activity-list API). Five workflows — (1) probe / discover auth + IDs; (2) list Qonto transactions for a period; (3) list Wise activities including incoming KissMetrics payments (via /v1/profiles/{pid}/activities — bypasses the EU statement endpoint restriction); (4) match bank movements against Dolibarr payments in three buckets (matched, bank-only, dolibarr-only) with auto-detection of Wise↔Qonto internal consolidations; (5) live balances per account with Dolibarr cross-check per fk_account. Surfaces concrete findings — incoming KM payments not yet entered in Dolibarr, expenses on the bank without supplier invoices recorded, date drift between bank settlement and Dolibarr saisie, and personal-account fk_account=3 movements that are invisible via API. Use when the user asks "réconcilier la banque", "qu'est-ce que la banque a vu que Dolibarr n'a pas", "match bank vs ERP", "audit comptable Arcodange", "did KM actually pay X", "cohort review bank evidence". Depends on `dolibarr` for the ERP side. SKIP for write operations (this is read-only; entries go through Dolibarr UI), for non-Arcodange bank accounts, and for Wise BUSINESS balance-statement endpoints (not available to EU personal tokens — we use the activity list instead).
requires:
bins: ["curl", "jq", "python3", "openssl"]
auth: true
---
# arcodange-bank-reco — close the loop between Dolibarr and the bank
The V1-V5 skills tell you what Dolibarr *thinks* happened. This one tells you what the **bank actually saw**, and matches the two sides. The three buckets it produces (matched / bank-only / dolibarr-only) are the foundation for any clean accounting audit and any cohort-review evidence pack.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
**CLI shortcuts:** `bin/arcodange bank probe | qonto-transactions | wise-transactions | match | balance | curl`
## Wise SCA & the EU restriction — the path we settled on
Wise has TWO ways to list movements:
1. **`/v1/profiles/{pid}/balance-statements/{balanceId}/statement.json`** — the obvious "statement" endpoint. **Returns 403 for EU personal tokens** (FR included). Wise's own docs say it: "Funding transfers and retrieving balance statements via API are not supported except for accounts based in the US, Canada, Australia, New Zealand, Singapore, and Malaysia." Even with full SCA setup (RSA keypair + uploaded public key), the BUSINESS profile statements stay 403. Don't go down that rabbit hole — we did, and Wise just keeps responding `x-2fa-approval-result: REJECTED` regardless.
2. **`/v1/profiles/{pid}/activities`** — the activity list. **Works for EU personal tokens with no SCA.** Returns incoming + outgoing in a unified HTML-tagged feed. This is what the skill uses. The cost is that amounts come back as `"<positive>+ 5,100.00 EUR</positive>"` strings instead of structured numerics, so we parse HTML out — easy.
We tried the SCA path first, didn't work, then found this. Documented so the next operator doesn't repeat the dance.
## Qonto — no surprises
Qonto's API works as documented:
- `Authorization: <login>:<secret_key>` header.
- `/v2/organization` lists bank accounts + balances.
- `/v2/transactions?bank_account_id=<id>&settled_at_from=<iso>&settled_at_to=<iso>` lists transactions with pagination via `current_page`.
Wise↔Qonto integration in the Qonto UI does NOT expose Wise data via Qonto's API. We confirmed: no `/v2/external_accounts`, no `/v2/aggregated_accounts` endpoint surfaces Wise transactions. The two banks remain separately queried, then merged in the matching layer.
## Prerequisites
1. Base skill set up ([dolibarr/README.md](../dolibarr/README.md)).
2. `.env` extended with:
```
QONTO_LOGIN=arcodange-XXXXX
QONTO_SECRET_KEY=<secret>
QONTO_ORG_SLUG=arcodange-XXXXX
WISE_API_TOKEN=<token>
WISE_PROFILE_ID=<numeric id of the BUSINESS profile>
```
3. `chmod 600 ~/.config/arcodange-erp/.env`; propagate to the two in-repo hard copies.
To generate the tokens:
- **Qonto**: https://app.qonto.com/ → Settings → Integrations → API → Generate a new key. Copy login + secret (shown once).
- **Wise**: https://wise.com/your-account/integrations-and-tools/api-tokens → Add new token → name it `arcodange-bank-reco-readonly` → scope read-only.
The `WISE_SCA_KEY_PATH` variable exists in the `.env` schema for completeness but is **NOT REQUIRED** today — the activity-list endpoint we use doesn't need SCA. Keep the keypair generated under `~/.config/arcodange-erp/wise-sca-*.pem` so we can revisit if Wise ever opens the statement endpoint to EU tokens.
## Workflows
### 1. Probe / discovery
```bash
bin/arcodange bank probe
```
Confirms auth on both banks and prints discovered IDs (Qonto org slug, Wise profile id, balance ids). Run once after any token rotation.
### 2. Qonto transactions
```bash
bin/arcodange bank qonto-transactions # last 90 days
bin/arcodange bank qonto-transactions --month 2026-03
bin/arcodange bank qonto-transactions --since 2026-01-01 --until 2026-05-31
bin/arcodange bank qonto-transactions --side credit # filter incoming only
```
Captured at [examples/qonto-transactions.txt](examples/qonto-transactions.txt). The full Jan-May 2026 view shows 9 transactions netting to +4191.54 € — exactly the current Qonto balance.
### 3. Wise activities
```bash
bin/arcodange bank wise-transactions # last 365 days
bin/arcodange bank wise-transactions --month 2026-03
bin/arcodange bank wise-transactions --type TRANSFER # filter
bin/arcodange bank wise-transactions --since 2026-01-01 --enrich # add wire references
```
Captured at [examples/wise-transactions.txt](examples/wise-transactions.txt). With `--enrich`, each TRANSFER is annotated with its wire reference (e.g. `FROM KISSMETRICS HOLDINGS INC FOR INVOICE FAC002CL0001002/ VENDOR:DEV`), which makes manual cross-checking trivial.
Net = +5308.25 € over the whole period, matches the live balance.
### 4. Bank ↔ Dolibarr match (the headline)
```bash
bin/arcodange bank match --month 2026-03 # one month
bin/arcodange bank match --since 2026-01-01 --until 2026-05-31
bin/arcodange bank match --month 2026-03 --window-days 14 # looser date tolerance
bin/arcodange bank match --include-fees # include cashback / charges in matching
```
Exit 0 if every bank movement and every Dolibarr payment in the window pair up cleanly; exit 1 otherwise (with the unmatched entries surfaced).
Captured at [examples/bank-match-2026-01-to-05.txt](examples/bank-match-2026-01-to-05.txt) — the V1 baseline.
**Output buckets:**
- `MATCHED` — bank ↔ Dolibarr, with the date delta (`Δ+6d`) so you can see drift between bank settlement and Dolibarr saisie.
- `INTERNAL` — Wise↔Qonto consolidations auto-detected by equal-amount opposite-sign on close dates. Excluded from matching against Dolibarr (they're transfers between Arcodange's own accounts, not external operations).
- `BANK-ONLY` — bank movements with no Dolibarr counterpart. Each one is either (a) a missing supplier invoice or unrecorded incoming payment, or (b) a Wise platform fee / cashback that doesn't translate to a Dolibarr entry.
- `DOLIBARR-ONLY` — Dolibarr payments without a bank movement. Usually fk_account=3 (the CCA1 personal account, not API-visible) or the cancel-and-reissue avoir cycle (bank sees the net, Dolibarr sees the three-way breakdown).
**Known findings from the V1 baseline** (to raise during cohort review):
- **Wise 2026-05-29 +2147 € from Kissmetrics NOT in Dolibarr** — M4 invoice probably emitted but the payment hasn't been entered. Action: enter the payment in Dolibarr.
- **+1000 € FOUREZ Quentin on Qonto 2026-01-21** — unknown income, ask Gabriel.
- **MISTRAL.AI -172.68 €, CLAUDE.AI -180 €, URSSAF -493 € on Qonto** — bank-only expenses, missing supplier invoices.
- **fk_account=3 supplier payments** (~430 € cumul) — paid from G.RADUREAU CCA personal account, not visible via Qonto/Wise APIs. This is normal; documented gap.
### 5. Live balances
```bash
bin/arcodange bank balance
```
Prints live balances per bank + the Dolibarr-side cumulative-payments-per-fk_account for cross-reference. Captured at [examples/bank-balance.txt](examples/bank-balance.txt).
Current state (V1 baseline):
- Qonto **Compte principal** : 4 191,54 € live
- Wise **STANDARD EUR** : 5 308,25 € live
- **Total bank-side** : 9 499,79 €
## Known-patterns catalog ([known-patterns.json](known-patterns.json))
Bank movements that have no Dolibarr counterpart fall into two groups:
1. **Intentional gaps** — operational expenses or one-off events the operator knows about (URSSAF mensuel, AI subs, capital deposit, Wise plan fees). These keep recurring but their accounting treatment is well-understood.
2. **Real action items** — incoming payments not yet entered, expenses missing a supplier invoice, anomalies.
Without a catalog, both look identical in the BANK-ONLY bucket — noise drowns the signal. The catalog is an operator-curated list of patterns; `bank-match.sh` reads it and splits BANK-ONLY into two sub-buckets:
- **BANK-ONLY — known patterns** : annotated with `[classification]` + a one-line note (which Dolibarr account to use, etc.). Don't action; just verify.
- **BANK-ONLY — unknown** : the real signal. Each entry is either a missing supplier invoice, an unrecorded payment, or a new pattern to add to the catalog.
**Schema** (JSON, see [known-patterns.json](known-patterns.json) for current entries):
```json
{
"patterns": [
{
"pattern": "regex (case-insensitive, matched against bank label + operation type)",
"classification": "capital_deposit | social_charges | ai_subscription | bank_fee | internal_topup | personal_apport | needs_classification",
"bank": "qonto | wise (optional, default both)",
"side": "credit | debit (optional, default both)",
"amount_min": 0.0, "amount_max": 99999.0, // optional numeric bounds
"note": "human-readable context — what this is, which Dolibarr account, recurring schedule, etc."
}
]
}
```
**Editing workflow:**
1. Run `bin/arcodange bank match` → look at BANK-ONLY unknown.
2. For each recurring entry that's "expected", add a pattern to `known-patterns.json`.
3. Re-run match → the entry should now appear in the known sub-bucket.
4. For one-off action items (e.g. "+2147 € KM May 29 not in Dolibarr"), don't add a pattern — enter it in Dolibarr instead.
**V6.1 catalog** ships with these patterns for the current Arcodange baseline:
- `FOUREZ Quentin` → capital_deposit (initial 1000 € apport via notaire, 2026-01-21)
- `URSSAF` → social_charges
- `MISTRAL.AI` / `CLAUDE.AI` → ai_subscription
- `Wise *Plan` → bank_fee (Wise account plan billed via Qonto card)
- `qonto_fee` → bank_fee
- `BALANCE_DEPOSIT|For your account plan` → internal_topup (the Wise +50/-50 self-funding pair)
After applying the catalog to the V6 baseline, the **only remaining BANK-UNKNOWN** is the **+2147 € KissMetrics payment on 2026-05-29** that hasn't been entered in Dolibarr — the actual signal.
## V7 bucket structure
V7 adds three improvements that reshape the output buckets:
| Bucket | Meaning | Counts toward exit-1? |
|---|---|---|
| **MATCHED** | Bank ↔ Dolibarr paired. Match kind: `[tx-id]` (exact — the payment's `transaction_id` equals the feed tx id; date-independent), `[wire-ref]` (strong, via `--enrich`), or `[amt+date]` (loose). | No |
| **INTERNAL** | Wise↔Qonto consolidations (5000€ moved between Arcodange's own accounts). | No |
| **AVOIR-NETTED** | Dolibarr AVC + FAC cancellation cycles paired and excluded (the bank only saw the net). | No |
| **BANK-ONLY — known patterns** | Bank movement with a `known-patterns.json` annotation. Intentional gap. | No |
| **BANK-ONLY — unknown** | Bank movement with no Dolibarr counterpart AND no catalog pattern. **Real action item**. | Yes |
| **DOLIBARR-ONLY — on API-tracked accounts** (QON*/WIS*) | Dolibarr payment that the bank should have shown. **Real gap**. | Yes |
| **DOLIBARR-ONLY — not in API scope** (CCA1 perso etc.) | Expected gap — we have no API on those accounts. | No |
Exit code 0 iff the two "real gap" buckets are empty.
### Matching priority — exact tx-id first
Matching runs in three passes, highest confidence first:
1. **`[tx-id]` (exact)** — a Dolibarr payment whose stored `num` (`llx_bank.num_chq`,
set from the règlement's `transaction_id`, see `dolibarr-sandbox-write`) equals the
feed transaction's own id. **Date-window-independent** — the id is proof, so it
matches even when bank settlement and Dolibarr saisie are weeks apart. Record
payments with their `transaction_id` and reconciliation becomes deterministic.
2. **`[wire-ref]` (strong)** — via `--enrich`, below.
3. **`[amt+date]` (loose)** — the fallback heuristic.
### `--enrich` — wire-reference strong matching
`bank-match.sh --enrich` fetches `/v1/transfers/{id}` for each Wise TRANSFER and reads the `reference` field (the wire memo from the sender, e.g. `FROM KISSMETRICS HOLDINGS INC FOR INVOICE FAC002CL0001002/ VENDOR:DEV`). When the reference contains a `FAC\d+(CL\d+)?` pattern matching a Dolibarr customer invoice, that pairing takes precedence over the loose date+amount match. Only the strong-matched ones get `[wire-ref]`; the rest fall through to `[amt+date]`. Cost: 1 extra HTTP call per Wise transfer.
### Avoir cycle netting
When Arcodange cancels and reissues an invoice (FAC001 → AVC001 + FAC001-NEW), the bank sees one net credit but Dolibarr stores 3 payment entries. V7 pairs AVC entries of -X with FAC entries of +X for the same socid within ±5d, surfaces them in **AVOIR-NETTED**, and excludes them from `dolibarr-only`. Removes the V6.1 noise where AVC001 + FAC001-CL00001 appeared as fake gaps.
### fk_account context
`bank-match.sh` now fetches `/bankaccounts` and tags `dolibarr-only` entries with their account ref + label. Splits into API-tracked (QON*/WIS* — real gaps) vs not-in-scope (everything else — expected). The 7 CCA1 personal-account entries that used to look like failures are now correctly classified as expected gaps.
### Effect on the baseline
| | V6 | V6.1 | V7 |
|---|---|---|---|
| MATCHED | 6 (all amt+date) | 6 | 6 (1 wire-ref strong + 5 amt+date when --enrich) |
| BANK-ONLY total | 8 mixed | 7 known + 1 UNKNOWN | 7 known + 1 UNKNOWN |
| AVOIR-NETTED | — | — | 2 (silently absorbed) |
| DOL-only TRUE GAP | 9 (noisy) | 9 (noisy) | **0** |
| DOL-only EXPECTED | — | — | 7 (CCA1 personal) |
| Exit-1 signal count | 17 (noise) | 10 (less noise) | **1** (just the +2147€ KM) |
## Matching heuristic — what's in v1 and what's V7
Today's match logic:
- **Amount equality** within 0.01 € (absolute value).
- **Date proximity** within `--window-days` (default 7 — covers most settlement drift).
- **Direction-aware** (bank credit ↔ Dolibarr customer payment; bank debit ↔ Dolibarr supplier payment).
- **Smallest date delta wins** when multiple candidates qualify.
- **Internal consolidation detection** by equal-amount opposite-sign cross-bank within ±3d.
V7 improvements to consider:
- **Reference-based matching** using the `--enrich` wire reference: search the Wise reference text for `FAC\d+` patterns and match by ref string. Stronger than date+amount when wire ref is informative.
- **Multi-row aggregation** for Dolibarr sub-payments: if invoice FAFXXX has two sub-payments on different dates summing to one bank movement, aggregate Dolibarr-side before matching.
- **Avoir cycle handling**: the V1 AVC001/FAC001-CL00001/FAC001-CL0001001 dance produces 3 Dolibarr-only rows for 1 bank credit. A smarter matcher would net the AVOIR before matching.
These are deliberately deferred — V1's accuracy is "good enough to surface the real issues" and the simple heuristic is easy to reason about.
## Out of scope
- **Writes**: the API tokens are read-only; payment recording happens in Dolibarr UI.
- **Wise BUSINESS balance statements via API** — region-blocked for EU personal tokens (documented above).
- **Wise transactions routed through Qonto** — Qonto's API doesn't expose them; the integration is UI-only.
- **fk_account=3 (CCA1 personal account)** — not API-accessible. Movements there must be reconciled manually against personal bank statements.
- **Bank statement export to CSV** — possible V8 fallback if any of the APIs go away; out of scope today.
- **Currency conversion** — everything is EUR. Multi-currency would need adapting the amount parser.

View File

@@ -0,0 +1,24 @@
==========================================================================================
BANK-SIDE balances (live)
==========================================================================================
bank ref currency balance details
--------------------------------------------------------------------------------------
Qonto Compte principal EUR 4191.54 iban=...25639215 id=019b93fe-3cb8-
Wise STANDARD EUR 5308.25 id=147831931
Qonto total: 4191.54 EUR
Wise total : 5308.25 EUR
==========================================================================================
DOLIBARR-SIDE cumulative payments per fk_account
==========================================================================================
fk_account ref label cust paid in sup paid out net
--------------------------------------------------------------------------------------
1 QON1 QONTO 0.00 968.00 -968.00
2 WIS2 WISE EURO 8160.00 0.00 8160.00
3 CCA1 G.RADUREAU Compte Courant Asso 0.00 429.75 -429.75
# Note: Dolibarr-side numbers are CUMULATIVE since the account started in Dolibarr,
# not the current bank balance. Mismatch with the bank-side is expected when
# the account predates Dolibarr or has movements not recorded in Dolibarr
# (e.g. URSSAF, AI subscriptions — see bank-match.sh BANK-ONLY bucket).

View File

@@ -0,0 +1,50 @@
# Bank reconciliation: 2026-01-01 → 2026-05-31 (window ±7d, fees: off, enrich: on)
=== MATCHED (6 bank ↔ Dolibarr) ===
Qonto 2026-01-27 - 50.00 card Wise *Plan ↔[amt+date] supplier FAF2026001 (2026-01-26, Δ-1d)
Wise 2026-02-05 + 510.00 TRANSFER Kissmetrics Holdings Inc ↔[amt+date] customer FAC001-CL0001001 (2026-02-05, Δ+0d)
Wise 2026-03-06 + 5100.00 TRANSFER Kissmetrics Holdings Inc ↔[wire-ref] customer FAC002-CL0001002 (2026-03-12, Δ+6d)
Qonto 2026-03-13 - 612.00 transfer DARNIS OPERATIONS ↔[amt+date] supplier FAF2026008 (2026-03-13, Δ+0d)
Wise 2026-04-20 + 2550.00 TRANSFER Kissmetrics Holdings Inc ↔[amt+date] customer FAC003-CL0001003 (2026-04-20, Δ+0d)
Qonto 2026-05-10 - 306.00 transfer DARNIS OPERATIONS ↔[amt+date] supplier FAF2026009 (2026-05-10, Δ+0d)
=== INTERNAL (Wise↔Qonto consolidations, 1) ===
Wise 2026-03-13 - 5000.00 TRANSFER ARCODANGE ↔ Qonto 2026-03-13 +5000.00
=== AVOIR-NETTED (2 Dolibarr entries pairing AVC↔FAC cancellation cycles) ===
customer 2026-02-05 -510.00 AVC001-CL0001001 ↔ netted against FAC001-CL00001
customer 2026-02-05 510.00 FAC001-CL00001 ↔ netted against AVC001-CL0001001
=== BANK-ONLY — known patterns (7, intentional gaps documented in known-patterns.json) ===
Qonto 2026-01-16 + 5.22 qonto_fee Qonto [bank_fee]
└─ Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627.
Qonto 2026-01-21 + 1000.00 income FOUREZ Quentin [capital_deposit]
└─ Apport en capital social initial 1000 €. Maître FOUREZ Quentin, notaire centralisateur du dépôt. Date typique : 2026-01-21. Dolibarr: account 1013.
Wise 2026-01-26 - 50.00 FEATURE_CHARGE For your account plan [internal_topup]
└─ Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour).
Wise 2026-01-26 + 50.00 BALANCE_DEPOSIT To EUR [internal_topup]
└─ Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour).
Qonto 2026-04-03 - 172.68 card MISTRAL.AI [ai_subscription]
└─ Mistral AI API subscription. Récurrent mensuel. Dolibarr: account 6262 + supplier 'Mistral AI'.
Qonto 2026-04-13 - 180.00 card CLAUDE.AI SUBSCRIPTION [ai_subscription]
└─ Claude AI subscription (Anthropic). Récurrent mensuel. Dolibarr: account 6262 + supplier 'Anthropic'.
Qonto 2026-05-22 - 493.00 direct_debit URSSAF D ILE DE FRANCE [social_charges]
└─ Cotisations sociales URSSAF (régime mensuel/trimestriel). Dolibarr: account 645100 (charges de sécurité sociale).
=== BANK-ONLY — unknown (1, NEEDS attention: missing supplier invoice / unrecorded payment / new pattern) ===
Wise 2026-05-29 + 2147.00 TRANSFER Kissmetrics Holdings Inc
=== DOLIBARR-ONLY — on API-tracked accounts (0, REAL GAP: bank should have shown this) ===
=== DOLIBARR-ONLY — on accounts NOT in API scope (7, expected gap: CCA1 perso etc.) ===
supplier 2026-01-04 1.99 FAF2026003 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-06 202.80 FAF2026005 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-09 55.93 FAF2026002 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-09 148.80 FAF2026004 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-12 8.43 FAF2026006 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-15 1.30 FAF2026002 (CCA1 (G.RADUREAU Compte Courant Asso))
supplier 2026-01-17 3.20 FAF2026007 (CCA1 (G.RADUREAU Compte Courant Asso))
----------------------------------------------------------------------------------------------------
# 6 matched, 1 internal, 2 avoir-netted, 7 bank-known, 1 bank-UNKNOWN, 0 dol-only-API, 7 dol-only-personal
# patterns loaded from /Users/gabrielradureau/Work/Arcodange/erp/.claude/worktrees/happy-wilson-ee5645/.claude/skills/arcodange-bank-reco/scripts/../known-patterns.json: 7 pattern(s)

View File

@@ -0,0 +1,37 @@
================================================================================
bank-probe — auth + discovery
================================================================================
--- QONTO ---
base : https://thirdparty.qonto.com
auth shape : Authorization: <login>:<secret>
env vars : QONTO_LOGIN=<set> QONTO_SECRET_KEY=<set> QONTO_ORG_SLUG=arcodange-1246
[OK] auth succeeded
slug : arcodange-1246
legal_name : ARCODANGE
legal_country : FR
1 bank account(s):
id=019b93fe-3cb8-7e28-9404-ee637f7aff27 name=Compte principal ...iban=25639215 status=active balance=4191.54
--- WISE ---
base : https://api.wise.com
auth shape : Authorization: Bearer <token>
env vars : WISE_API_TOKEN=<set> WISE_PROFILE_ID=82958299
[OK] auth succeeded
2 profile(s):
id=82958299 type=BUSINESS name=ARCODANGE ← .env WISE_PROFILE_ID
id=82958415 type=PERSONAL name=Gabriel Jonathan Marc Radureau
--- WISE balances (for the BUSINESS profile) ---
1 balance(s):
id=147831931 type=STANDARD currency=EUR balance=5308.25 EUR name=None
--- summary ---
.env should ultimately contain:
QONTO_LOGIN=<set>
QONTO_SECRET_KEY=<set>
QONTO_ORG_SLUG=arcodange-1246
WISE_API_TOKEN=<set>
WISE_PROFILE_ID=<the BUSINESS id from above>

View File

@@ -0,0 +1,15 @@
# Qonto transactions on account 019b93fe... (2026-01-01 → 2026-05-31)
date side amount cur op label
----------------------------------------------------------------------------------------------------
2026-01-16 credit 5.22 EUR qonto_fee Qonto
2026-01-21 credit 1000.00 EUR income FOUREZ Quentin
2026-01-27 debit 50.00 EUR card Wise *Plan
2026-03-13 credit 5000.00 EUR income ARCODANGE
2026-03-13 debit 612.00 EUR transfer DARNIS OPERATIONS
2026-04-03 debit 172.68 EUR card MISTRAL.AI
2026-04-13 debit 180.00 EUR card CLAUDE.AI SUBSCRIPTION
2026-05-10 debit 306.00 EUR transfer DARNIS OPERATIONS
2026-05-22 debit 493.00 EUR direct_debit URSSAF D ILE DE FRANCE
----------------------------------------------------------------------------------------------------
# 9 txn(s) — credit total: 6005.22, debit total: 1813.68, net: +4191.54

View File

@@ -0,0 +1,16 @@
# Wise activities for profile (window 2026-01-01 → 2026-05-31)
date type status sign amount cur title
-------------------------------------------------------------------------------------------
2026-01-26 BALANCE_DEPOSIT COMPLETED + 50.00 EUR To EUR
2026-01-26 FEATURE_CHARGE COMPLETED - 50.00 EUR For your account plan
2026-02-05 TRANSFER COMPLETED + 510.00 EUR Kissmetrics Holdings Inc
2026-03-05 BALANCE_CASHBACK COMPLETED + 0.19 EUR Cashback
2026-03-06 TRANSFER COMPLETED + 5100.00 EUR Kissmetrics Holdings Inc
2026-03-13 TRANSFER COMPLETED - 5000.00 EUR ARCODANGE
2026-04-06 BALANCE_CASHBACK COMPLETED + 0.35 EUR Cashback
2026-04-20 TRANSFER COMPLETED + 2550.00 EUR Kissmetrics Holdings Inc
2026-05-07 BALANCE_CASHBACK COMPLETED + 0.71 EUR Cashback
2026-05-29 TRANSFER COMPLETED + 2147.00 EUR Kissmetrics Holdings Inc
-------------------------------------------------------------------------------------------
# 10 activity(ies) — credit: +10358.25, debit: -5050.00, net: +5308.25

View File

@@ -0,0 +1,60 @@
{
"_schema": "v1",
"_description": "Operator-curated catalogue of known recurring/intentional bank movements. Used by bank-match.sh to annotate the BANK-ONLY bucket so the operator can immediately tell 'needs Dolibarr entry' from 'documented intentional gap'. Edit this file as new recurring patterns emerge.",
"_match_rules": "Pattern matched case-insensitively as a regex against the bank label. Optional filters: bank (qonto|wise), side (credit|debit), amount_min, amount_max, type (Wise activity type). All present filters must match.",
"_classifications": {
"capital_deposit": "Apport en capital social. Dolibarr account 1013 (capital souscrit appelé versé).",
"social_charges": "URSSAF, retraite complémentaire, etc. Dolibarr account 645x.",
"ai_subscription": "Claude / Mistral / OpenAI / similar. Dolibarr account 6262 (frais télécom / abonnements logiciels).",
"bank_fee": "Plan bancaire, frais d'opération, refunds. Dolibarr account 627 (services bancaires).",
"internal_topup": "Solde Wise/Qonto rechargé pour couvrir un frais immédiat. Often nets out.",
"personal_apport": "Apport en compte courant d'associé (Gabriel finançant Arcodange depuis son perso). Dolibarr account 4551.",
"needs_classification": "Pattern catched but no Dolibarr account assignment defined yet; surface for review."
},
"patterns": [
{
"pattern": "FOUREZ.*Quentin",
"classification": "capital_deposit",
"bank": "qonto",
"side": "credit",
"note": "Apport en capital social initial 1000 €. Maître FOUREZ Quentin, notaire centralisateur du dépôt. Date typique : 2026-01-21. Dolibarr: account 1013."
},
{
"pattern": "URSSAF",
"classification": "social_charges",
"bank": "qonto",
"side": "debit",
"note": "Cotisations sociales URSSAF (régime mensuel/trimestriel). Dolibarr: account 645100 (charges de sécurité sociale)."
},
{
"pattern": "MISTRAL\\.AI",
"classification": "ai_subscription",
"side": "debit",
"note": "Mistral AI API subscription. Récurrent mensuel. Dolibarr: account 6262 + supplier 'Mistral AI'."
},
{
"pattern": "CLAUDE\\.AI",
"classification": "ai_subscription",
"side": "debit",
"note": "Claude AI subscription (Anthropic). Récurrent mensuel. Dolibarr: account 6262 + supplier 'Anthropic'."
},
{
"pattern": "Wise.*Plan",
"classification": "bank_fee",
"side": "debit",
"note": "Wise account plan billed via card. Wise's internal fee for keeping the BUSINESS profile active."
},
{
"pattern": "qonto_fee",
"classification": "bank_fee",
"bank": "qonto",
"note": "Qonto fees ou refunds. Petites valeurs. Dolibarr: account 627."
},
{
"pattern": "BALANCE_DEPOSIT|For your account plan",
"classification": "internal_topup",
"bank": "wise",
"note": "Solde Wise rechargé pour couvrir un frais immédiat (souvent net zéro avec le FEATURE_CHARGE du même jour)."
}
]
}

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Live balances per bank account, plus a Dolibarr cross-check by fk_account.
#
# Usage:
# bank-balance.sh
#
# Outputs each bank account (Qonto + Wise) with:
# - current live balance (from each bank's API)
# - sum of all Dolibarr payments touching it (per fk_account)
#
# The sum-of-payments isn't the bank balance — it's just the cumulative
# operations Dolibarr has recorded on that account. Useful as a sanity check
# (e.g. "did I record everything?"). The bank-side current balance is
# authoritative.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
: "${WISE_PROFILE_ID:?bank-balance.sh: WISE_PROFILE_ID not set}"
WORK="$(mktemp -d -t bankbal.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# Bank side
"${BANK_CURL}" qonto /v2/organization > "${WORK}/qonto.json"
"${BANK_CURL}" wise "/v4/profiles/${WISE_PROFILE_ID}/balances?types=STANDARD" > "${WORK}/wise.json"
# Dolibarr side: bank accounts + payments per invoice
"${DOL_CURL}" /bankaccounts > "${WORK}/dol_acct.json"
"${DOL_CURL}" '/invoices?limit=500' > "${WORK}/dol_inv.json"
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json"
mkdir -p "${WORK}/dol_pay" "${WORK}/dol_supay"
for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_inv.json"); do
"${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/dol_pay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_pay/${id}.json"
done
for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_sup.json"); do
"${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json"
done
python3 - "${WORK}" <<'PY'
import json, os, sys, collections
work = sys.argv[1]
# Bank-side balances
q = json.load(open(os.path.join(work, "qonto.json")))
qonto_accs = (q.get("organization") or {}).get("bank_accounts") or []
wise_bals = json.load(open(os.path.join(work, "wise.json")))
print("=" * 90)
print(" BANK-SIDE balances (live)")
print("=" * 90)
print(f" {'bank':<8} {'ref':<20} {'currency':<3} {'balance':>12} details")
print(" " + "-" * 86)
qonto_total = 0.0
for a in qonto_accs:
bal = float(a.get("balance") or 0); qonto_total += bal
iban = (a.get("iban") or "")[-8:]
print(f" {'Qonto':<8} {a.get('name','-')[:20]:<20} {a.get('balance_cents_currency','EUR'):<3} {bal:>12.2f} iban=...{iban} id={a.get('id','-')[:14]}")
for b in wise_bals:
amt = (b.get("amount") or {})
print(f" {'Wise':<8} {b.get('type','-'):<20} {amt.get('currency','-'):<3} {float(amt.get('value') or 0):>12.2f} id={b.get('id','-')}")
print()
print(f" Qonto total: {qonto_total:.2f} EUR")
print(f" Wise total : {sum(float((b.get('amount') or {}).get('value') or 0) for b in wise_bals):.2f} EUR")
print()
# Dolibarr-side: sum of all payments per fk_account
dol_accts = {str(a["id"]): a for a in json.load(open(os.path.join(work, "dol_acct.json")))}
sums = collections.defaultdict(lambda: {"customer": 0.0, "supplier": 0.0})
inv = {str(r["id"]): r for r in json.load(open(os.path.join(work, "dol_inv.json")))}
for fn in os.listdir(os.path.join(work, "dol_pay")):
iid = fn[:-5]; i = inv.get(iid)
if not i: continue
fka = str(i.get("fk_account") or "?")
for p in json.load(open(os.path.join(work, "dol_pay", fn))):
sums[fka]["customer"] += float(p.get("amount") or 0)
sup = {str(r["id"]): r for r in json.load(open(os.path.join(work, "dol_sup.json")))}
for fn in os.listdir(os.path.join(work, "dol_supay")):
iid = fn[:-5]; s = sup.get(iid)
if not s: continue
fka = str(s.get("fk_account") or "?")
for p in json.load(open(os.path.join(work, "dol_supay", fn))):
sums[fka]["supplier"] += float(p.get("amount") or 0)
print("=" * 90)
print(" DOLIBARR-SIDE cumulative payments per fk_account")
print("=" * 90)
print(f" {'fk_account':<10} {'ref':<10} {'label':<30} {'cust paid in':>12} {'sup paid out':>12} {'net':>10}")
print(" " + "-" * 86)
for fka in sorted(sums, key=lambda k: int(k) if k.isdigit() else 99):
s = sums[fka]
net = s["customer"] - s["supplier"]
a = dol_accts.get(fka, {})
ref = a.get("ref","-")[:10]; label = (a.get("label") or "-")[:30]
print(f" {fka:<10} {ref:<10} {label:<30} {s['customer']:>12.2f} {s['supplier']:>12.2f} {net:>10.2f}")
print()
print("# Note: Dolibarr-side numbers are CUMULATIVE since the account started in Dolibarr,")
print("# not the current bank balance. Mismatch with the bank-side is expected when")
print("# the account predates Dolibarr or has movements not recorded in Dolibarr")
print("# (e.g. URSSAF, AI subscriptions — see bank-match.sh BANK-ONLY bucket).")
PY

View File

@@ -0,0 +1,107 @@
#!/usr/bin/env bash
# Read-only curl wrapper for the two banks Arcodange uses: Qonto + Wise.
#
# Usage:
# bank-curl.sh qonto <path> # e.g. bank-curl.sh qonto /v2/organization
# bank-curl.sh wise <path> # e.g. bank-curl.sh wise /v2/profiles
# bank-curl.sh -i <bank> <path> # include curl's -i (response headers)
#
# Reads credentials from ../../dolibarr/.env (the shared canonical file
# used by every dolibarr-* and arcodange-* skill). Required vars:
# QONTO_LOGIN, QONTO_SECRET_KEY (for qonto)
# WISE_API_TOKEN (for wise)
#
# Exits non-zero on HTTP >=400 and writes the body to stdout + a short
# "bank-curl.sh: HTTP <code>" message to stderr — same shape as dol-curl.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/../../dolibarr/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "bank-curl.sh: missing ${ENV_FILE}" >&2
echo " See dolibarr/README.md for the .env schema; arcodange-bank-reco extends it" >&2
echo " with QONTO_LOGIN, QONTO_SECRET_KEY, WISE_API_TOKEN, WISE_PROFILE_ID." >&2
exit 2
fi
set -a; source "${ENV_FILE}"; set +a
PASSTHRU=()
while [[ $# -gt 2 ]]; do
PASSTHRU+=("$1"); shift
done
if [[ $# -lt 2 ]]; then
echo "bank-curl.sh: usage: bank-curl.sh [curl-opts] <qonto|wise> <api-path>" >&2
exit 2
fi
BANK="$1"; API_PATH="$2"
case "${BANK}" in
qonto)
: "${QONTO_LOGIN:?bank-curl.sh: QONTO_LOGIN not set in .env}"
: "${QONTO_SECRET_KEY:?bank-curl.sh: QONTO_SECRET_KEY not set in .env}"
BASE="https://thirdparty.qonto.com"
AUTH_HEADER="Authorization: ${QONTO_LOGIN}:${QONTO_SECRET_KEY}"
;;
wise)
: "${WISE_API_TOKEN:?bank-curl.sh: WISE_API_TOKEN not set in .env}"
BASE="https://api.wise.com"
AUTH_HEADER="Authorization: Bearer ${WISE_API_TOKEN}"
;;
*)
echo "bank-curl.sh: unknown bank '${BANK}' (use qonto or wise)" >&2
exit 2
;;
esac
BODY_FILE="$(mktemp -t bankcurl.XXXXXX)"
HEADERS_FILE="$(mktemp -t bankcurlhdr.XXXXXX)"
trap 'rm -f "${BODY_FILE}" "${HEADERS_FILE}"' EXIT
do_call() {
local extra_header_1="${1:-}" extra_header_2="${2:-}"
local extra_args=()
[[ -n "${extra_header_1}" ]] && extra_args+=("-H" "${extra_header_1}")
[[ -n "${extra_header_2}" ]] && extra_args+=("-H" "${extra_header_2}")
curl -sS \
-H "${AUTH_HEADER}" \
-H "Accept: application/json" \
--max-time 30 \
-o "${BODY_FILE}" \
-D "${HEADERS_FILE}" \
-w "%{http_code}" \
${PASSTHRU[@]+"${PASSTHRU[@]}"} \
${extra_args[@]+"${extra_args[@]}"} \
"${BASE}${API_PATH}"
}
HTTP_CODE=$(do_call)
# Wise SCA flow: a 403 with x-2fa-approval-result: REJECTED + x-2fa-approval: <token>
# header means the endpoint is sensitive and the call must be signed with our
# registered RSA private key. We sign the one-time token and retry.
if [[ "${BANK}" == "wise" && "${HTTP_CODE}" == "403" ]]; then
ONE_TIME=$(awk 'tolower($1) == "x-2fa-approval:" { gsub(/[\r\n]/, "", $2); print $2; exit }' "${HEADERS_FILE}")
if [[ -n "${ONE_TIME}" ]]; then
KEY_PATH="${WISE_SCA_KEY_PATH:-}"
# Expand ~ if used
KEY_PATH="${KEY_PATH/#\~/$HOME}"
if [[ -z "${KEY_PATH}" || ! -f "${KEY_PATH}" ]]; then
echo "bank-curl.sh: Wise endpoint requires SCA but WISE_SCA_KEY_PATH is missing." >&2
echo " Generate a keypair, upload the public key to Wise, set WISE_SCA_KEY_PATH in .env." >&2
echo " See arcodange-bank-reco/SKILL.md for the setup steps." >&2
cat "${BODY_FILE}"
exit 1
fi
SIGNATURE=$(printf '%s' "${ONE_TIME}" | openssl dgst -sha256 -sign "${KEY_PATH}" | base64 | tr -d '\n')
HTTP_CODE=$(do_call "x-2fa-approval: ${ONE_TIME}" "X-Signature: ${SIGNATURE}")
fi
fi
cat "${BODY_FILE}"
if [[ "${HTTP_CODE}" -ge 400 ]]; then
echo "bank-curl.sh: HTTP ${HTTP_CODE} on ${BANK}${API_PATH}" >&2
exit 1
fi

View File

@@ -0,0 +1,386 @@
#!/usr/bin/env bash
# Match bank movements (Qonto + Wise) against Dolibarr payments.
#
# Usage:
# bank-match.sh [--month YYYY-MM | --since YYYY-MM-DD --until YYYY-MM-DD]
# [--window-days N] # date tolerance, default 7
# [--include-fees] # include Wise cashback / charges (default off)
#
# Output: three buckets
# - MATCHED bank movement ↔ Dolibarr payment
# - BANK-ONLY bank movement with no Dolibarr counterpart (potential
# missing supplier invoice or unrecorded incoming payment)
# - DOLIBARR-ONLY Dolibarr payment with no bank movement (timing or error)
#
# Internal Wise↔Qonto consolidations (e.g. 5000 € moved Wise→Qonto same day)
# are auto-detected and excluded from matching against Dolibarr.
#
# Exit 0 if everything in the window matches cleanly, 1 if there's any bank-only
# or dolibarr-only entry.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""; UNTIL=""; MONTH=""; WINDOW=7; INCLUDE_FEES=0; ENRICH=0
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--month) MONTH="$2"; shift 2 ;;
--window-days) WINDOW="$2"; shift 2 ;;
--include-fees) INCLUDE_FEES=1; shift ;;
--enrich) ENRICH=1; shift ;;
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "bank-match.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
if [[ -n "${MONTH}" ]]; then
SINCE="${MONTH}-01"
UNTIL="$(python3 -c "import calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")"
fi
[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=365)).strftime('%Y-%m-%d'))")"
[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")"
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
: "${WISE_PROFILE_ID:?bank-match.sh: WISE_PROFILE_ID not set}"
WORK="$(mktemp -d -t bankmatch.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# --- 1. Pull Qonto transactions ---
TMP_ORG=$(mktemp -t qontoorg.XXXXXX.json)
"${BANK_CURL}" qonto /v2/organization > "${TMP_ORG}"
QONTO_ACCT=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
accs = (d.get('organization') or {}).get('bank_accounts') or []
print([a for a in accs if a.get('status')=='active'][0]['id'])" "${TMP_ORG}")
rm -f "${TMP_ORG}"
QURL="/v2/transactions?bank_account_id=${QONTO_ACCT}&settled_at_from=${SINCE}T00:00:00Z&settled_at_to=${UNTIL}T23:59:59Z&per_page=100"
"${BANK_CURL}" qonto "${QURL}" > "${WORK}/qonto.json"
# --- 2. Pull Wise activities ---
"${BANK_CURL}" wise "/v1/profiles/${WISE_PROFILE_ID}/activities?size=100&since=${SINCE}T00:00:00.000Z&until=${UNTIL}T23:59:59.999Z" > "${WORK}/wise.json"
# --- 3. Pull Dolibarr customer + supplier invoices, payments, and bank accounts ---
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/dol_inv.json"
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/dol_sup.json"
"${DOL_CURL}" '/bankaccounts' > "${WORK}/dol_acct.json"
mkdir -p "${WORK}/dol_pay" "${WORK}/dol_supay"
for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_inv.json"); do
"${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/dol_pay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_pay/${id}.json"
done
for id in $(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/dol_sup.json"); do
"${DOL_CURL}" "/supplierinvoices/${id}/payments" > "${WORK}/dol_supay/${id}.json" 2>/dev/null || echo "[]" > "${WORK}/dol_supay/${id}.json"
done
# --- 3b. Optional: enrich Wise TRANSFER activities with wire references ---
if [[ "${ENRICH}" == "1" ]]; then
mkdir -p "${WORK}/wise_refs"
for tid in $(python3 -c "
import json, sys
acts = json.load(open(sys.argv[1])).get('activities') or []
for a in acts:
r = a.get('resource') or {}
if r.get('type')=='TRANSFER' and r.get('id'): print(r['id'])
" "${WORK}/wise.json"); do
"${BANK_CURL}" wise "/v1/transfers/${tid}" > "${WORK}/wise_refs/${tid}.json" 2>/dev/null || true
done
fi
# --- 4. Match in python ---
PATTERNS_FILE="${SCRIPT_DIR}/../known-patterns.json"
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${WINDOW}" "${INCLUDE_FEES}" "${PATTERNS_FILE}" "${ENRICH}" <<'PY'
import json, sys, os, re, datetime, collections
work, since, until, window_days, include_fees, patterns_file, enrich = sys.argv[1:8]
window = int(window_days); include_fees = include_fees == "1"; enrich = enrich == "1"
since_d = datetime.date.fromisoformat(since); until_d = datetime.date.fromisoformat(until)
def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip()
# 4a. Normalize Qonto
qonto_movs = []
for t in (json.load(open(os.path.join(work,"qonto.json"))).get("transactions") or []):
dt = datetime.date.fromisoformat((t.get("settled_at") or "")[:10])
if dt < since_d or dt > until_d: continue
amt = float(t.get("amount") or 0)
sign = "+" if t.get("side") == "credit" else "-"
label = t.get("label") or t.get("operation_type") or "-"
# feed_ids: the Qonto transaction's own id — exact-match key against a Dolibarr
# payment whose stored num (num_chq) is that id (set via payment transaction_id).
feed_ids = [str(t["id"])] if t.get("id") else []
qonto_movs.append({"bank":"Qonto", "date":dt, "sign":sign, "amount":amt, "label":label[:40], "op":t.get("operation_type",""), "feed_ids":feed_ids, "matched_dol":None, "matched_internal":False})
# 4b. Normalize Wise
wise_movs = []
for a in (json.load(open(os.path.join(work,"wise.json"))).get("activities") or []):
dt = datetime.date.fromisoformat((a.get("createdOn") or "")[:10])
if dt < since_d or dt > until_d: continue
typ = a.get("type","-")
if not include_fees and typ in ("BALANCE_CASHBACK", "BALANCE_INTEREST"):
continue
pa = strip(a.get("primaryAmount") or "")
sign = "+" if pa.startswith("+") else "-"
m = re.search(r'([\d,.]+)\s*([A-Z]{3})', pa)
amt = float(m.group(1).replace(",", "")) if m else 0.0
title = strip(a.get("title") or "")[:40]
res = a.get("resource") or {}
resource_id = str(res.get("id")) if res.get("type") == "TRANSFER" else None
# feed_ids: the Wise activity id and (for transfers) the resource id — either
# may have been stored as the payment's transaction_id; match against both.
feed_ids = []
if a.get("id"): feed_ids.append(str(a["id"]))
if resource_id: feed_ids.append(resource_id)
wise_movs.append({"bank":"Wise", "date":dt, "sign":sign, "amount":amt, "label":title, "op":typ, "feed_ids":feed_ids, "matched_dol":None, "matched_internal":False, "wise_resource_id":resource_id, "wire_ref":""})
# 4b'. If --enrich, load per-transfer wire references and attach to Wise movs
if enrich:
ref_dir = os.path.join(work, "wise_refs")
if os.path.isdir(ref_dir):
for m in wise_movs:
if not m["wise_resource_id"]: continue
p = os.path.join(ref_dir, f"{m['wise_resource_id']}.json")
if not os.path.isfile(p): continue
try:
t = json.load(open(p))
m["wire_ref"] = (t.get("reference") or "")
except Exception: pass
bank_movs = qonto_movs + wise_movs
# 4c. Detect internal Wise<->Qonto consolidations: same date, equal amount, opposite signs, one Wise + one Qonto
for w in [m for m in bank_movs if m["bank"]=="Wise" and m["sign"]=="-"]:
for q in [m for m in bank_movs if m["bank"]=="Qonto" and m["sign"]=="+" and not m["matched_internal"]]:
if abs(w["amount"] - q["amount"]) < 0.01 and abs((w["date"] - q["date"]).days) <= 3:
w["matched_internal"] = q; q["matched_internal"] = w
break
# 4d. Normalize Dolibarr payments — carry socid too for avoir netting
dol_pays = []
inv_by_id = {str(r["id"]): r for r in json.load(open(os.path.join(work,"dol_inv.json")))}
for fn in os.listdir(os.path.join(work,"dol_pay")):
iid = fn[:-5]; inv = inv_by_id.get(iid)
if not inv: continue
for p in json.load(open(os.path.join(work,"dol_pay",fn))):
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
if d < since_d or d > until_d: continue
amt = float(p.get("amount") or 0)
dol_pays.append({"side":"customer", "ref":inv["ref"], "date":d, "amount":amt,
"fk_account":inv.get("fk_account"), "socid":inv.get("socid"),
"num": (p.get("num") or ""),
"matched_bank":None, "netted_against":None})
sup_by_id = {str(r["id"]): r for r in json.load(open(os.path.join(work,"dol_sup.json")))}
for fn in os.listdir(os.path.join(work,"dol_supay")):
iid = fn[:-5]; sup = sup_by_id.get(iid)
if not sup: continue
for p in json.load(open(os.path.join(work,"dol_supay",fn))):
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
if d < since_d or d > until_d: continue
amt = float(p.get("amount") or 0)
dol_pays.append({"side":"supplier", "ref":sup["ref"], "date":d, "amount":amt,
"fk_account":sup.get("fk_account"), "socid":sup.get("socid"),
"num": (p.get("num") or ""),
"matched_bank":None, "netted_against":None})
# 4d.1. AVOIR cycle netting: an AVC (credit note) for -X on socid S cancels out
# a FAC for +X on the same socid, within a small date window. Bank sees the NET
# of the cycle (typically +X for the reissued FAC with the new ref scheme).
# Pair an AVC with a FAC of opposite sign + equal abs(amount) + same socid +
# within ±5d. Mark both as "netted" so they're excluded from matching and
# excluded from the dolibarr-only failure count.
avcs = [p for p in dol_pays if p["side"]=="customer" and p["ref"].startswith("AVC") and p["amount"] < 0]
for avc in avcs:
candidates = [p for p in dol_pays
if p is not avc
and p["side"]=="customer"
and p["socid"] == avc["socid"]
and abs(p["amount"] + avc["amount"]) < 0.01 # opposite signs equal magnitude
and abs((p["date"] - avc["date"]).days) <= 5
and p["netted_against"] is None
and p["matched_bank"] is None]
if candidates:
# Prefer the OLDEST (the original cancelled FAC), not the reissue.
# Heuristic: refs with shorter / older numbering scheme. If multiple,
# pick smallest date delta.
candidates.sort(key=lambda p: (abs((p["date"] - avc["date"]).days), p["ref"]))
partner = candidates[0]
avc["netted_against"] = partner["ref"]
partner["netted_against"] = avc["ref"]
# 4e. Match — three-pass, highest confidence first:
# PASS 0 (exact) : a Dolibarr payment whose stored num (num_chq = the
# transaction_id recorded with the règlement) equals the feed
# transaction's own id. Date-window-independent — the id is proof.
# PASS 1 (strong) : Wise transfers with an --enrich'd wire reference containing
# a "FAC***" pattern match the Dolibarr invoice with that ref.
# PASS 2 (loose) : remaining bank movements use the date+amount heuristic.
# Netted Dolibarr entries (avoir cycle) are excluded from all passes.
# Pass 0: exact match on the feed transaction id stored as the payment's num
# (num_chq = the transaction_id recorded with the règlement). Date-independent.
for m in [x for x in bank_movs if not x["matched_internal"] and not x["matched_dol"] and x.get("feed_ids")]:
fids = set(m["feed_ids"])
for p in dol_pays:
if (p["matched_bank"] is None and p["netted_against"] is None
and p.get("num") and str(p["num"]) in fids):
m["matched_dol"] = p; m["match_kind"] = "tx-id"
p["matched_bank"] = m
break
# Build customer ref -> dol payment index (only un-netted, un-matched entries)
ref_index = collections.defaultdict(list)
for p in dol_pays:
if p["matched_bank"] is None and p["netted_against"] is None:
# Strip trailing dash/suffix variants — FAC002CL0001002 vs FAC002-CL0001002 are equivalent
normalized = re.sub(r'[^A-Z0-9]', '', p["ref"].upper())
ref_index[normalized].append(p)
# Pass 1: strong match on wire references
for m in [x for x in bank_movs if not x["matched_internal"] and x.get("wire_ref")]:
refs_in_wire = re.findall(r'FAC\d+(?:CL\d+)?', (m["wire_ref"] or "").upper().replace("-",""))
for r in refs_in_wire:
if r in ref_index and ref_index[r]:
p = ref_index[r].pop(0)
m["matched_dol"] = p; m["match_kind"] = "wire-ref"
p["matched_bank"] = m
break
# Pass 2: loose date+amount match for remaining bank movements
for m in [x for x in bank_movs if not x["matched_internal"] and not x["matched_dol"]]:
candidates = [p for p in dol_pays
if p["matched_bank"] is None and p["netted_against"] is None
and abs(p["amount"] - m["amount"]) < 0.01
and abs((p["date"] - m["date"]).days) <= window]
if candidates:
candidates.sort(key=lambda p: abs((p["date"] - m["date"]).days))
p = candidates[0]
m["matched_dol"] = p; m["match_kind"] = "amt+date"
p["matched_bank"] = m
# 4f. Annotate non-matched movements with known-patterns catalog
patterns = []
if os.path.isfile(patterns_file):
try: patterns = json.load(open(patterns_file)).get("patterns", [])
except Exception as e: print(f" /!\\ failed to load {patterns_file}: {e}", file=sys.stderr)
def match_pattern(mov):
# Match against both the bank label AND the operation type — different
# banks surface useful info in different fields (Qonto puts the operation
# type in `op`, e.g. "qonto_fee"; Wise puts the activity type in `op`,
# e.g. "BALANCE_DEPOSIT", and the human title in `label`).
haystack = (mov.get("label") or "") + " | " + (mov.get("op") or "")
for pat in patterns:
if pat.get("bank") and pat["bank"] != mov["bank"].lower(): continue
if pat.get("side") and pat["side"] != ("credit" if mov["sign"]=="+" else "debit"): continue
amin = pat.get("amount_min"); amax = pat.get("amount_max")
if amin is not None and mov["amount"] < amin: continue
if amax is not None and mov["amount"] > amax: continue
if re.search(pat["pattern"], haystack, re.IGNORECASE):
return pat
return None
for m in bank_movs:
if m["matched_dol"] or m["matched_internal"]: continue
m["known"] = match_pattern(m)
# --- 5. Render ---
# Load Dolibarr bank accounts (for fk_account context on dolibarr-only)
dol_accts = {}
try:
for a in json.load(open(os.path.join(work, "dol_acct.json"))):
dol_accts[str(a["id"])] = {"ref": a.get("ref","-"), "label": a.get("label","-"), "country": a.get("country_code","")}
except Exception: pass
# Heuristic: which Dolibarr accounts are NOT covered by Qonto/Wise API today?
# Convention: CCA = Compte Courant d'Associé (personal). Anything not QON*/WIS*
# is treated as "API-invisible" and tagged as such.
def account_kind(fk_account):
if not fk_account: return ("unknown", "fk_account=None")
a = dol_accts.get(str(fk_account))
if not a: return ("unknown", f"fk_account={fk_account} (not in /bankaccounts)")
ref = (a["ref"] or "").upper()
if ref.startswith(("QON", "WIS")):
return ("api_tracked", f"{a['ref']} ({a['label']})")
return ("personal_or_other", f"{a['ref']} ({a['label']})")
def fmt_bank(m):
return f" {m['bank']:<5} {m['date']} {m['sign']:<2}{m['amount']:>9.2f} {m['op'][:18]:<18} {m['label']}"
print(f"# Bank reconciliation: {since} → {until} (window ±{window}d, fees: {'on' if include_fees else 'off'}, enrich: {'on' if enrich else 'off'})")
print()
matched = [m for m in bank_movs if m["matched_dol"]]
internal = [m for m in bank_movs if m["matched_internal"] and m["sign"]=="-"]
bank_only = [m for m in bank_movs if not m["matched_dol"] and not m["matched_internal"]]
netted_dol_pairs = [p for p in dol_pays if p["netted_against"]]
dol_only = [p for p in dol_pays if p["matched_bank"] is None and p["netted_against"] is None]
print(f"=== MATCHED ({len(matched)} bank ↔ Dolibarr) ===")
for m in sorted(matched, key=lambda m: m["date"]):
p = m["matched_dol"]
delta = (p["date"] - m["date"]).days
kind = m.get("match_kind", "?")
print(fmt_bank(m) + f" ↔[{kind}] {p['side']:<8} {p['ref']:<24} ({p['date']}, Δ{delta:+d}d)")
print()
print(f"=== INTERNAL (Wise↔Qonto consolidations, {len(internal)}) ===")
for m in sorted(internal, key=lambda m: m["date"]):
other = m["matched_internal"]
print(fmt_bank(m) + f" ↔ {other['bank']} {other['date']} {other['sign']}{other['amount']:.2f}")
print()
# Avoir cycles netted out (informational; bank correctly sees only the net)
if netted_dol_pairs:
print(f"=== AVOIR-NETTED ({len(netted_dol_pairs)} Dolibarr entries pairing AVC↔FAC cancellation cycles) ===")
for p in sorted(netted_dol_pairs, key=lambda p: (p["date"], p["ref"])):
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ↔ netted against {p['netted_against']}")
print()
bank_known = [m for m in bank_only if m.get("known")]
bank_unknown = [m for m in bank_only if not m.get("known")]
print(f"=== BANK-ONLY — known patterns ({len(bank_known)}, intentional gaps documented in known-patterns.json) ===")
for m in sorted(bank_known, key=lambda m: m["date"]):
k = m["known"]
cls = k.get("classification","?")
print(fmt_bank(m) + f" [{cls}]")
print(f" └─ {k.get('note','')}")
print()
print(f"=== BANK-ONLY — unknown ({len(bank_unknown)}, NEEDS attention: missing supplier invoice / unrecorded payment / new pattern) ===")
for m in sorted(bank_unknown, key=lambda m: m["date"]):
print(fmt_bank(m))
print()
# Split dolibarr-only by whether the fk_account is API-tracked (real gap)
# or personal_or_other (expected gap — we have no API on those accounts)
dol_only_api = [p for p in dol_only if account_kind(p["fk_account"])[0] == "api_tracked"]
dol_only_personal = [p for p in dol_only if account_kind(p["fk_account"])[0] != "api_tracked"]
print(f"=== DOLIBARR-ONLY — on API-tracked accounts ({len(dol_only_api)}, REAL GAP: bank should have shown this) ===")
for p in sorted(dol_only_api, key=lambda p: p["date"]):
_, ctx = account_kind(p["fk_account"])
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ({ctx})")
print()
print(f"=== DOLIBARR-ONLY — on accounts NOT in API scope ({len(dol_only_personal)}, expected gap: CCA1 perso etc.) ===")
for p in sorted(dol_only_personal, key=lambda p: p["date"]):
_, ctx = account_kind(p["fk_account"])
print(f" {p['side']:<8} {p['date']} {p['amount']:>9.2f} {p['ref']:<24} ({ctx})")
print()
# Verdict: only UNKNOWN bank-only AND dol-only-on-API-tracked count as failures.
# Avoir-netted pairs and personal-account dolibarr entries are intentional/expected.
fails = len(bank_unknown) + len(dol_only_api)
print("-" * 100)
print(f"# {len(matched)} matched, {len(internal)} internal, {len(netted_dol_pairs)} avoir-netted, {len(bank_known)} bank-known, {len(bank_unknown)} bank-UNKNOWN, {len(dol_only_api)} dol-only-API, {len(dol_only_personal)} dol-only-personal")
print(f"# patterns loaded from {patterns_file}: {len(patterns)} pattern(s)")
sys.exit(0 if fails == 0 else 1)
PY

View File

@@ -0,0 +1,140 @@
#!/usr/bin/env bash
# Auth + discovery probe for Qonto + Wise. Run this once after dropping
# fresh tokens into .env. It confirms auth works and prints the IDs you
# need (Qonto bank account ids, Wise business profile id + balance ids).
#
# Usage:
# bank-probe.sh
#
# Token values are NEVER printed. Only metadata + slugs / ids are.
# Output is safe to commit as an examples/ baseline.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
# A redactor that masks anything that looks like a token / secret / key
# in case the API ever echoes credentials back to us. Defense in depth.
redact() {
python3 - <<'PY'
import sys, re
s = sys.stdin.read()
# Mask long alphanumeric runs (typical tokens) and any explicit "token"/"secret"/"key" values
s = re.sub(r'([A-Fa-f0-9]{32,})', '<REDACTED-HEX>', s)
s = re.sub(r'([A-Za-z0-9_-]{40,})', '<REDACTED-TOKEN>', s)
s = re.sub(r'("(?:token|secret|key|password)"\s*:\s*")[^"]*(")',
r'\1<REDACTED>\2', s, flags=re.IGNORECASE)
print(s, end='')
PY
}
echo "================================================================================"
echo " bank-probe — auth + discovery"
echo "================================================================================"
echo
# ---------------------------- Qonto ----------------------------
echo "--- QONTO ---"
echo " base : https://thirdparty.qonto.com"
echo " auth shape : Authorization: <login>:<secret>"
echo " env vars : QONTO_LOGIN=${QONTO_LOGIN:+<set>} QONTO_SECRET_KEY=${QONTO_SECRET_KEY:+<set>} QONTO_ORG_SLUG=${QONTO_ORG_SLUG:-<unset>}"
echo
if QONTO_ORG_RAW="$("${BANK_CURL}" qonto /v2/organization 2>&1)"; then
python3 - <<PY
import json
d = json.loads(${QONTO_ORG_RAW@Q}) if False else json.loads("""${QONTO_ORG_RAW}""")
PY
fi 2>/dev/null || true
# Robust version: write to tmp file, parse from there (avoids quoting hell)
TMP_QONTO=$(mktemp -t qonto.XXXXXX.json)
trap 'rm -f "${TMP_QONTO}"' EXIT
if "${BANK_CURL}" qonto /v2/organization > "${TMP_QONTO}"; then
python3 - "${TMP_QONTO}" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
org = d.get("organization") or {}
print(f" [OK] auth succeeded")
print(f" slug : {org.get('slug')}")
print(f" legal_name : {org.get('legal_name')}")
print(f" legal_country : {org.get('legal_country')}")
accs = org.get("bank_accounts") or []
print(f" {len(accs)} bank account(s):")
for a in accs:
iban = a.get("iban") or ""
iban_tail = iban[-8:] if iban else "-"
print(f" id={a.get('id')} name={a.get('name','-')} ...iban={iban_tail} status={a.get('status','-')} balance={a.get('balance','-')} {a.get('balance_cents_currency','')}")
PY
else
echo " [XX] Qonto auth FAILED — check QONTO_LOGIN / QONTO_SECRET_KEY in .env"
fi
echo
# ---------------------------- Wise ----------------------------
echo "--- WISE ---"
echo " base : https://api.wise.com"
echo " auth shape : Authorization: Bearer <token>"
echo " env vars : WISE_API_TOKEN=${WISE_API_TOKEN:+<set>} WISE_PROFILE_ID=${WISE_PROFILE_ID:-<unset>}"
echo
TMP_WISE_PROFILES=$(mktemp -t wise.XXXXXX.json)
trap 'rm -f "${TMP_QONTO}" "${TMP_WISE_PROFILES}"' EXIT
if "${BANK_CURL}" wise /v2/profiles > "${TMP_WISE_PROFILES}"; then
python3 - "${TMP_WISE_PROFILES}" "${WISE_PROFILE_ID:-}" <<'PY'
import json, sys
profiles = json.load(open(sys.argv[1]))
env_pid = sys.argv[2]
print(f" [OK] auth succeeded")
print(f" {len(profiles)} profile(s):")
business_pid = None
for p in profiles:
name = p.get("fullName") or p.get("businessName") or (p.get("details") or {}).get("name", "-")
marker = ""
if str(p.get("id")) == env_pid:
marker = " ← .env WISE_PROFILE_ID"
if p.get("type") == "BUSINESS" and not business_pid:
business_pid = p.get("id")
print(f" id={p.get('id')} type={p.get('type','-'):<10} name={name}{marker}")
if env_pid and business_pid and str(business_pid) != env_pid:
print(f" [!!] WISE_PROFILE_ID in .env ({env_pid}) is NOT the BUSINESS profile ({business_pid}).")
print(f" Use the BUSINESS one for Arcodange.")
elif not env_pid and business_pid:
print(f" [→] Set WISE_PROFILE_ID={business_pid} in .env (BUSINESS profile).")
PY
else
echo " [XX] Wise auth FAILED — check WISE_API_TOKEN in .env"
fi
echo
# Wise balances + balance ids (needed for /balance-statements queries)
if [[ -n "${WISE_PROFILE_ID:-}" ]]; then
echo "--- WISE balances (for the BUSINESS profile) ---"
TMP_WISE_BAL=$(mktemp -t wisebal.XXXXXX.json)
trap 'rm -f "${TMP_QONTO}" "${TMP_WISE_PROFILES}" "${TMP_WISE_BAL}"' EXIT
if "${BANK_CURL}" wise "/v4/profiles/${WISE_PROFILE_ID}/balances?types=STANDARD" > "${TMP_WISE_BAL}"; then
python3 - "${TMP_WISE_BAL}" <<'PY'
import json, sys
bal = json.load(open(sys.argv[1]))
print(f" {len(bal)} balance(s):")
for b in bal:
amt = (b.get('amount') or {}).get('value', '?')
cur = (b.get('amount') or {}).get('currency', '?')
print(f" id={b.get('id')} type={b.get('type','-'):<8} currency={cur:<3} balance={amt} {cur} name={b.get('name','-')}")
PY
else
echo " [XX] /balances fetch failed — token may not have balances scope, or profile id is wrong."
fi
fi
echo
echo "--- summary ---"
echo " .env should ultimately contain:"
echo " QONTO_LOGIN=<set>"
echo " QONTO_SECRET_KEY=<set>"
echo " QONTO_ORG_SLUG=$(grep -E '^QONTO_ORG_SLUG' "${SCRIPT_DIR}/../../dolibarr/.env" | cut -d= -f2 || echo '<from probe>')"
echo " WISE_API_TOKEN=<set>"
echo " WISE_PROFILE_ID=<the BUSINESS id from above>"

View File

@@ -0,0 +1,116 @@
#!/usr/bin/env bash
# List Qonto transactions for a period, on one bank account.
#
# Usage:
# qonto-transactions.sh [--since YYYY-MM-DD] [--until YYYY-MM-DD]
# [--account <id>] # default: first active account
# [--month YYYY-MM]
# [--side credit|debit] # filter
# [--json] # raw JSON, no table
#
# Output: a compact table — date | side | amount | currency | op type | label.
# Pagination is followed automatically.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
SINCE=""; UNTIL=""; MONTH=""; ACCOUNT=""; SIDE=""; FMT="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--month) MONTH="$2"; shift 2 ;;
--account) ACCOUNT="$2"; shift 2 ;;
--side) SIDE="$2"; shift 2 ;;
--json) FMT="json"; shift ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "qonto-transactions.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
# Default window: --month overrides --since/--until; otherwise last 90 days.
if [[ -n "${MONTH}" ]]; then
SINCE="${MONTH}-01"
# Last day of month — works for any month using python
UNTIL="$(python3 -c "import datetime,calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")"
fi
[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=90)).strftime('%Y-%m-%d'))")"
[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")"
# Discover default account if not specified
if [[ -z "${ACCOUNT}" ]]; then
TMP_ORG=$(mktemp -t qontoorg.XXXXXX.json)
trap 'rm -f "${TMP_ORG}"' EXIT
"${BANK_CURL}" qonto /v2/organization > "${TMP_ORG}"
ACCOUNT=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
accs = (d.get('organization') or {}).get('bank_accounts') or []
active = [a for a in accs if a.get('status') == 'active']
if not active: sys.exit('no active account')
print(active[0]['id'])
" "${TMP_ORG}")
fi
# Paginate
TMP_ALL=$(mktemp -t qontoall.XXXXXX.json)
trap 'rm -f "${TMP_ORG:-/dev/null}" "${TMP_ALL}"' EXIT
echo '[]' > "${TMP_ALL}"
page=1
while :; do
TMP_PAGE=$(mktemp -t qontop.XXXXXX.json)
Q="bank_account_id=${ACCOUNT}&settled_at_from=${SINCE}T00:00:00Z&settled_at_to=${UNTIL}T23:59:59Z&per_page=100&current_page=${page}"
"${BANK_CURL}" qonto "/v2/transactions?${Q}" > "${TMP_PAGE}"
# Merge transactions into the all-array
python3 - "${TMP_ALL}" "${TMP_PAGE}" <<'PY'
import json, sys
all_arr = json.load(open(sys.argv[1]))
page = json.load(open(sys.argv[2]))
all_arr.extend(page.get("transactions") or [])
json.dump(all_arr, open(sys.argv[1], "w"))
PY
NEXT=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
m = d.get('meta') or {}
print(m.get('next_page') or '')
" "${TMP_PAGE}")
rm -f "${TMP_PAGE}"
[[ -z "${NEXT}" || "${NEXT}" == "None" ]] && break
page=$((page+1))
done
if [[ "${FMT}" == "json" ]]; then
cat "${TMP_ALL}"
exit 0
fi
python3 - "${TMP_ALL}" "${SIDE}" "${SINCE}" "${UNTIL}" "${ACCOUNT}" <<'PY'
import json, sys
txs = json.load(open(sys.argv[1]))
side_filter, since, until, account = sys.argv[2:6]
if side_filter:
txs = [t for t in txs if t.get("side") == side_filter]
txs.sort(key=lambda t: t.get("settled_at") or "")
print(f"# Qonto transactions on account {account[:8]}... ({since} → {until})")
print()
print(f"{'date':<10} {'side':<6} {'amount':>10} {'cur':<3} {'op':<14} {'label':<40}")
print("-" * 100)
total_credit = total_debit = 0.0
for t in txs:
dt = (t.get("settled_at") or "")[:10]
amt = float(t.get("amount") or 0)
side = t.get("side") or "-"
op = (t.get("operation_type") or "-")[:14]
label = (t.get("label") or "-")[:40]
cur = t.get("currency") or "-"
print(f"{dt:<10} {side:<6} {amt:>10.2f} {cur:<3} {op:<14} {label:<40}")
if side == "credit": total_credit += amt
if side == "debit": total_debit += amt
print("-" * 100)
print(f"# {len(txs)} txn(s) — credit total: {total_credit:.2f}, debit total: {total_debit:.2f}, net: {total_credit - total_debit:+.2f}")
PY

View File

@@ -0,0 +1,154 @@
#!/usr/bin/env bash
# List Wise activities (incoming + outgoing) for a period.
#
# Usage:
# wise-transactions.sh [--since YYYY-MM-DD] [--until YYYY-MM-DD]
# [--month YYYY-MM]
# [--type TRANSFER|CARD_ORDER|BALANCE_CASHBACK|FEATURE_CHARGE|BALANCE_DEPOSIT|...]
# [--status COMPLETED|CANCELLED|IN_PROGRESS|UPCOMING|REQUIRES_ATTENTION]
# [--enrich] # also fetch /v1/transfers/{id} for the wire reference
# [--json] # raw list, no table
#
# Backed by GET /v1/profiles/{WISE_PROFILE_ID}/activities — does NOT require
# SCA and DOES expose incoming transfers (unlike /balance-statements which is
# region-restricted for EU personal tokens, see SKILL.md).
#
# Pagination is followed automatically via the cursor.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
BANK_CURL="${SCRIPT_DIR}/bank-curl.sh"
SINCE=""; UNTIL=""; MONTH=""; TYPE=""; STATUS=""; ENRICH=0; FMT="table"
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--month) MONTH="$2"; shift 2 ;;
--type) TYPE="$2"; shift 2 ;;
--status) STATUS="$2"; shift 2 ;;
--enrich) ENRICH=1; shift ;;
--json) FMT="json"; shift ;;
-h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "wise-transactions.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
: "${WISE_PROFILE_ID:?wise-transactions.sh: WISE_PROFILE_ID not set in .env}"
# Date handling — Wise wants full ISO 8601 with millis + Z
if [[ -n "${MONTH}" ]]; then
SINCE="${MONTH}-01"
UNTIL="$(python3 -c "import calendar; y,m=map(int,'${MONTH}'.split('-')); print(f'{y:04d}-{m:02d}-{calendar.monthrange(y,m)[1]:02d}')")"
fi
[[ -z "${SINCE}" ]] && SINCE="$(python3 -c "import datetime; print((datetime.date.today()-datetime.timedelta(days=365)).strftime('%Y-%m-%d'))")"
[[ -z "${UNTIL}" ]] && UNTIL="$(python3 -c "import datetime; print(datetime.date.today().strftime('%Y-%m-%d'))")"
SINCE_ISO="${SINCE}T00:00:00.000Z"
UNTIL_ISO="${UNTIL}T23:59:59.999Z"
WORK="$(mktemp -d -t wiseact.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# Paginate
echo '[]' > "${WORK}/all.json"
CURSOR=""
PAGE=1
while :; do
Q="size=100&since=${SINCE_ISO}&until=${UNTIL_ISO}"
[[ -n "${TYPE}" ]] && Q="${Q}&monetaryResourceType=${TYPE}"
[[ -n "${STATUS}" ]] && Q="${Q}&status=${STATUS}"
[[ -n "${CURSOR}" ]] && Q="${Q}&nextCursor=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))" "${CURSOR}")"
TMP_PAGE=$(mktemp -t wisepg.XXXXXX.json)
"${BANK_CURL}" wise "/v1/profiles/${WISE_PROFILE_ID}/activities?${Q}" > "${TMP_PAGE}"
python3 - "${WORK}/all.json" "${TMP_PAGE}" <<'PY'
import json, sys
all_arr = json.load(open(sys.argv[1]))
page = json.load(open(sys.argv[2]))
all_arr.extend(page.get("activities") or [])
json.dump(all_arr, open(sys.argv[1], "w"))
PY
CURSOR=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print(d.get('cursor') or '')" "${TMP_PAGE}")
rm -f "${TMP_PAGE}"
if [[ -z "${CURSOR}" || "${CURSOR}" == "None" ]]; then
break
fi
PAGE=$((PAGE+1))
done
# Optional enrichment: for each TRANSFER, fetch /v1/transfers/{id} and merge `reference`
if [[ "${ENRICH}" == "1" ]]; then
python3 - "${WORK}/all.json" <<'PY' > "${WORK}/ids.txt"
import json, sys
acts = json.load(open(sys.argv[1]))
for a in acts:
r = a.get("resource") or {}
if r.get("type") == "TRANSFER" and r.get("id"):
print(r["id"])
PY
> "${WORK}/refs.json"
echo '{}' > "${WORK}/refs.json"
while read -r tid; do
[[ -z "${tid}" ]] && continue
TMP_T=$(mktemp -t wiset.XXXXXX.json)
if "${BANK_CURL}" wise "/v1/transfers/${tid}" > "${TMP_T}" 2>/dev/null; then
python3 - "${WORK}/refs.json" "${TMP_T}" "${tid}" <<'PY'
import json, sys
refs = json.load(open(sys.argv[1]))
t = json.load(open(sys.argv[2]))
refs[sys.argv[3]] = t.get("reference") or ""
json.dump(refs, open(sys.argv[1], "w"))
PY
fi
rm -f "${TMP_T}"
done < "${WORK}/ids.txt"
fi
if [[ "${FMT}" == "json" ]]; then
cat "${WORK}/all.json"
exit 0
fi
python3 - "${WORK}/all.json" "${WORK}/refs.json" "${ENRICH}" "${SINCE}" "${UNTIL}" <<'PY'
import json, sys, re, os
acts_path, refs_path, enrich, since, until = sys.argv[1:6]
acts = json.load(open(acts_path))
refs = json.load(open(refs_path)) if os.path.exists(refs_path) else {}
def strip(s): return re.sub(r'<[^>]+>', '', s or '').strip()
def parse_amount(s):
# "<positive>+ 5,100.00 EUR</positive>" -> (+, 5100.00, EUR)
s = strip(s)
sign = "+" if s.startswith("+") else "-"
m = re.search(r'([\d,.]+)\s*([A-Z]{3})', s)
if not m: return sign, 0.0, "?"
return sign, float(m.group(1).replace(",", "")), m.group(2)
# Sort by date (ascending)
acts.sort(key=lambda a: a.get("createdOn") or "")
print(f"# Wise activities for profile (window {since} → {until})")
print()
header = f"{'date':<10} {'type':<18} {'status':<10} {'sign':<4} {'amount':>10} {'cur':<3} {'title':<28}"
if int(enrich): header += " reference"
print(header)
print("-" * (len(header) + 30 if int(enrich) else len(header)))
total_credit = total_debit = 0.0
for a in acts:
dt = (a.get("createdOn") or "")[:10]
sign, amt, cur = parse_amount(a.get("primaryAmount") or "")
typ = a.get("type", "-")
st = a.get("status", "-")
title = strip(a.get("title") or "")[:28]
line = f"{dt:<10} {typ:<18} {st:<10} {sign:<4} {amt:>10.2f} {cur:<3} {title:<28}"
if int(enrich):
r = a.get("resource") or {}
ref = refs.get(str(r.get("id", "")), "") if r.get("type") == "TRANSFER" else ""
line += f" {ref}"
print(line)
if sign == "+": total_credit += amt
if sign == "-": total_debit += amt
print("-" * (len(header) + 30 if int(enrich) else len(header)))
print(f"# {len(acts)} activity(ies) — credit: +{total_credit:.2f}, debit: -{total_debit:.2f}, net: {total_credit - total_debit:+.2f}")
PY

View File

@@ -0,0 +1,113 @@
---
name: arcodange-email-ingest
description: Scrape supplier-invoice emails from the Arcodange Zoho mailbox (`gabrielradureau@arcodange.fr` + its `books@arcodange.fr` alias + forwarded Gmail) via the Zoho Mail OAuth API, list candidates matching supplier patterns, download PDF attachments, run pdftotext + heuristic extract, and emit Dolibarr-ready supplier-invoice draft JSON for the operator to paste into the Dolibarr UI. Two workflows — (1) list candidates in a folder (default `/Inbox/books` where the alias auto-routes mail); (2) inspect one message by id, download + parse PDFs, propose draft entries. Surfaces concrete data: supplier name guess (first PDF line), invoice ref, invoice date, total HT/TVA/TTC, VAT rate. Read-only at every layer (Zoho scopes are READ-only; no write to Dolibarr). Use when the user asks "list pending supplier invoices in mail", "ingest invoices from email", "draft Dolibarr entry from this email", "audit cohort supplier docs from mail". Depends on `dolibarr` for the shared `.env`. SKIP for write-side Dolibarr operations (V9 candidate), for non-Zoho mailboxes (use IMAP fallback in a future skill if needed), and for attachments that aren't PDFs (only PDF text extraction is wired today).
requires:
bins: ["curl", "jq", "python3", "pdftotext"]
auth: true
---
# arcodange-email-ingest — supplier-invoice emails → Dolibarr draft
Close the inbound side of the accounting loop: bills land in `books@arcodange.fr`, this skill turns them into Dolibarr-ready draft entries for the operator to validate + create.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill (shared `.env`).
**CLI shortcuts:** `bin/arcodange email list | inspect | curl`
## Architecture choice — Zoho API, not IMAP
We chose the Zoho Mail OAuth API over IMAP because:
- **Richer metadata** — folder paths, attachment IDs, search operators, threads.
- **One account covers everything** — `books@arcodange.fr` is an alias of `gabrielradureau@arcodange.fr`. One refresh_token + the `/accounts` endpoint exposes both, plus all the other aliases (`contact@`, `bonjour@`, etc.).
- **Gmail folded in via forwarding** — `arcodange@gmail.com` forwards incoming to `books@` (configured in Gmail UI). No Google API setup, no app-passwords, no second OAuth flow.
- **Token-only auth** — no app-password fragility, no SCA dance (unlike Wise).
The single canonical inbox path: **`/Inbox/books`** — Zoho's auto-filter routes incoming mail to the `books@` alias into this sub-folder. Scan it first; widen with `--all-folders` only if needed.
## Prerequisites
1. Base skill set up ([dolibarr/README.md](../dolibarr/README.md)).
2. Zoho OAuth Self-Client created and a refresh_token generated. The `.env` extension:
```
ZOHO_CLIENT_ID=<from api-console.zoho.com self-client>
ZOHO_CLIENT_SECRET=<same>
ZOHO_REFRESH_TOKEN=<exchanged from one-time code>
ZOHO_DC=eu # eu | com | in | au
```
Setup walkthrough is in the V8 prep section of the cohort review notes.
3. Gmail forwarding to `books@arcodange.fr` enabled (Gmail Settings → Forwarding and POP/IMAP).
4. `pdftotext` (`brew install poppler` on macOS).
## Workflows
### 1. List candidates
```bash
bin/arcodange email list # default: /Inbox/books, last 30 msgs, no filter
bin/arcodange email list --candidates-only # filter to subjects/attachments matching supplier patterns
bin/arcodange email list --folder /Inbox/contact --limit 50
bin/arcodange email list --all-folders --candidates-only # scan everything (slower, more API calls)
```
Captured at [examples/email-list.txt](examples/email-list.txt). The candidate filter matches subjects against `facture|invoice|receipt|reçu|payment|paiement|abonnement|subscription|order|commande|bill` OR any message with an attachment.
**Hard exclusions** (V8.1) — applied before the candidate test, regardless of attachments:
- Subjects starting with `Invitation:` / `Updated invitation:` / `Canceled event:` / `Accepted:` / `Declined:` / `Tentative:` / `Maybe:` (after stripping `Re:` / `Fwd:` / `Tr:` prefixes) → filters calendar events that always carry an `.ics` attachment.
- Senders matching newsletter/marketing patterns (`updates.<domain>`, `noreply@*calendar*`, `news@`, `newsletter@`, etc.).
The `[*]` column marks candidates, `[Y]` marks emails with attachments. Compared to V8.0, V8.1 cuts the `--all-folders --candidates-only` baseline from ~27 noisy entries down to ~12 actionable ones.
### 2. Inspect one email + draft Dolibarr entry
```bash
bin/arcodange email inspect 1775141901205014300
bin/arcodange email inspect 1775141901205014300 --folder /Inbox/books # default
bin/arcodange email inspect 1775141901205014300 --save-pdf ~/Documents/factures-2026-Q2/
bin/arcodange email inspect 1775141901205014300 --json # machine-readable
```
The script:
1. Fetches the email metadata (subject / from / date) via `/messages/view`.
2. Lists attachments via `/messages/{mid}/attachmentinfo`.
3. Downloads each attachment via `/messages/{mid}/attachments/{aid}`.
4. For each `.pdf`, runs `pdftotext -layout`, applies regex heuristics to extract:
- Supplier name guess (first non-empty PDF line — often the supplier letterhead).
- Invoice reference (`facture/invoice n° XXX`).
- Invoice date.
- Total HT / TVA / TTC + VAT rate %.
5. Emits a draft JSON record per attachment — paste into the Dolibarr UI manually.
Heuristics are intentionally conservative (regex-based, no LLM dependency). For PDF templates where the heuristic fails, the raw `pdftotext` output is on disk in the work dir; rerun with `--save-pdf` to grab the PDF for manual entry.
Captured at [examples/email-inspect.txt](examples/email-inspect.txt) for the V8 baseline (Mistral AI receipt).
## What it doesn't do (V8.0 scope)
- **Does not write to Dolibarr.** The supplier invoice is still created manually in the Dolibarr UI from the draft JSON. V9 candidate: automate via `/supplierinvoices` POST.
- **Does not mark emails as ingested.** Each run re-emits the same candidates. Implementing this requires extending the OAuth scope: the current refresh_token only has READ scopes (`ZohoMail.messages.READ` etc.). The flag-set endpoint (`PUT /api/accounts/{aid}/updatemessage`) requires `ZohoMail.messages.UPDATE`, which would force the user to regenerate the refresh_token. **V8.2 candidate** — once the user opts in to the wider scope, `--mark-ingested` becomes a one-line flag on `email-inspect.sh` and `is_candidate()` in `email-list.sh` learns to skip messages with `flagid == flag_info`.
- **No body extraction yet.** We only parse PDF attachments. Inline-HTML invoices (rare — most suppliers send PDFs) would need body fetch via `/content`.
- **Heuristic extraction is best-effort.** Different supplier PDF templates yield different field-extraction reliability. The draft JSON is a starting point, not ground truth.
## Token cache
`zoho-curl.sh` caches the OAuth access_token in `$TMPDIR/zoho-access-$USER` (mode 600, TTL 50 min). Avoids hitting Zoho's OAuth refresh rate-limit on every invocation. On 401, the wrapper auto-refreshes once and retries.
## API endpoints used (Zoho Mail)
| Endpoint | Purpose |
|---|---|
| `POST /oauth/v2/token` (accounts.zoho.{dc}) | Refresh access_token from refresh_token |
| `GET /accounts` | Discover accountId + aliases on the account |
| `GET /accounts/{aid}/folders` | List folders (with paths like `/Inbox/books`) |
| `GET /accounts/{aid}/messages/view?folderId=&limit=&start=` | List messages in a folder |
| `GET /accounts/{aid}/folders/{fid}/messages/{mid}/attachmentinfo` | List attachments metadata |
| `GET /accounts/{aid}/folders/{fid}/messages/{mid}/attachments/{aid}` | Download attachment bytes |
## Out of scope
- **Writing to Dolibarr** (V9 candidate — would lift the read-only constraint on the API key, or use a separate write-scoped key).
- **Marking ingested emails** (V8.1 trivial follow-up).
- **Non-PDF attachments** (heuristics are PDF-specific).
- **Body-text extraction** (would need `/content` endpoint, deferred).
- **IMAP fallback** for non-Zoho mailboxes (deferred — Gmail forwarding to books@ covers the only known external mailbox today).
- **LLM-based extraction** (deferred — regex covers the current set of supplier templates well enough).

View File

@@ -0,0 +1,31 @@
================================================================================
Email 1775141901205014300
================================================================================
subject : Votre facture nº MSTRL-API-814045-001 de Mistral AI SAS
from : no-reply@mistral.ai
date : 2026-04-02
attached : True
-- Attachment 1: invoice-MSTRL-API-814045-001.pdf (74377 bytes, 1771 chars extracted) --
pdf_top_line = 'Facture'
invoice_ref = 'API-814045-001'
invoice_date_raw = None
total_ht = None
total_tva = None
total_ttc = None
vat_rate_pct = '20.0'
Suggested Dolibarr supplier-invoice draft entries:
[
{
"supplier_hint": "Facture",
"invoice_ref": "API-814045-001",
"invoice_date": null,
"total_ht": null,
"total_tva": null,
"total_ttc": null,
"vat_rate_pct": "20.0",
"source_email": "1775141901205014300",
"source_attachment": "invoice-MSTRL-API-814045-001.pdf"
}
]

View File

@@ -0,0 +1,16 @@
date cand att messageId folder from subject
----------------------------------------------------------------------------------------------------------------------------------
2026-05-20 [*] [Y] 1779312401677014300 /clients/KissMetrics rsirvent@digitalocean.com Re: VM not running despite status=active, after volume
2026-05-20 [*] [Y] 1779298419301014300 /clients/KissMetrics tdziuba@kissmetrics.io Re: VM not running despite status=active, after volume
2026-05-20 [*] [Y] 1779285954272004400 /clients/KissMetrics tdziuba@kissmetrics.io Re: VM not running despite status=active, after volume
2026-05-05 [*] [ ] 1777970798248014300 /Inbox/abonnements freemobile@free-mobile.fr Votre facture mobile Free est disponible
2026-04-21 [*] [Y] 1776785469477004300 /Notification noreply@hiway.fr Darnis Operations - Facture F1042
2026-04-12 [*] [Y] 1776017238960014300 /Inbox/books arcodange@gmail.com Fwd: Your receipt from Anthropic Ireland, Limited #2109
2026-04-04 [*] [ ] 1775264759983014300 /Inbox/abonnements freemobile@free-mobile.fr Votre facture mobile Free est disponible
2026-04-02 [*] [Y] 1775141901205014300 /Inbox/books no-reply@mistral.ai Votre facture nº MSTRL-API-814045-001 de Mistral AI SAS
2026-03-05 [*] [ ] 1772689535069004400 /Inbox/helloworld freemobile@free-mobile.fr Votre facture mobile Free est disponible
2026-02-08 [*] [Y] 1770582421208004400 /Inbox/bureaux ne-pas-repondre@portailpro.gouv.fr Valider votre espace personnel sur Portailpro.gouv
2026-01-09 [*] [ ] 1767989744791004400 /Inbox/books gabrielradureau@gmail.com Fwd: INPI - Votre paiement pour la commande Réf. 181876
2026-01-06 [*] [Y] 1767710535894005600 /Inbox gabrielradureau@gmail.com Statuts
----------------------------------------------------------------------------------------------------------------------------------
# 12 message(s) (candidates only)

View File

@@ -0,0 +1,7 @@
date cand att messageId folder from subject
----------------------------------------------------------------------------------------------------------------------------------
2026-04-12 [*] [Y] 1776017238960014300 /Inbox/books arcodange@gmail.com Fwd: Your receipt from Anthropic Ireland, Limited #2109
2026-04-02 [*] [Y] 1775141901205014300 /Inbox/books no-reply@mistral.ai Votre facture nº MSTRL-API-814045-001 de Mistral AI SAS
2026-01-09 [*] [ ] 1767989744791004400 /Inbox/books gabrielradureau@gmail.com Fwd: INPI - Votre paiement pour la commande Réf. 181876
----------------------------------------------------------------------------------------------------------------------------------
# 3 message(s) (candidates only)

View File

@@ -0,0 +1,256 @@
#!/usr/bin/env bash
# Inspect one email by id and propose a Dolibarr supplier-invoice draft.
#
# Usage:
# email-inspect.sh <messageId> [--folder PATH] # default folder: /Inbox/books
# [--save-pdf DIR] # save PDF attachments under DIR/
# [--json] # emit a single JSON object on stdout
#
# Pipeline (read-only):
# 1. Find the message (in the given folder, default /Inbox/books).
# 2. List attachments via /attachmentinfo.
# 3. For each PDF attachment: download, run pdftotext, extract supplier-side
# heuristics (name, totals, dates, ref).
# 4. Emit a draft "Dolibarr-ready" record per attachment so the operator can
# hand-create the supplier invoice in the Dolibarr UI.
#
# This skill DOES NOT write to Dolibarr. Auto-creation of supplier invoices is
# V9 candidate.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ZOHO_CURL="${SCRIPT_DIR}/zoho-curl.sh"
if [[ $# -lt 1 ]]; then
echo "email-inspect.sh: missing <messageId>" >&2
echo " Hint: bin/arcodange email list to see candidate ids." >&2
exit 2
fi
MID="$1"; shift || true
FOLDER="/Inbox/books"; SAVE_PDF_DIR=""; FMT="text"
while [[ $# -gt 0 ]]; do
case "$1" in
--folder) FOLDER="$2"; shift 2 ;;
--save-pdf) SAVE_PDF_DIR="$2"; shift 2 ;;
--json) FMT="json"; shift ;;
-h|--help) sed -n '2,18p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "email-inspect.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
command -v pdftotext >/dev/null || { echo "email-inspect.sh: pdftotext not found (brew install poppler)" >&2; exit 2; }
WORK="$(mktemp -d -t emailinspect.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# 1. accountId + folderId
"${ZOHO_CURL}" /accounts > "${WORK}/accounts.json"
AID=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print((d.get('data') or [{}])[0].get('accountId',''))" "${WORK}/accounts.json")
"${ZOHO_CURL}" "/accounts/${AID}/folders" > "${WORK}/folders.json"
FID=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
target = sys.argv[2]
for f in (d.get('data') or []):
if f.get('path') == target:
print(f.get('folderId')); break" "${WORK}/folders.json" "${FOLDER}")
[[ -z "${FID}" ]] && { echo "email-inspect.sh: folder '${FOLDER}' not found" >&2; exit 2; }
# 2. Find the message in the folder listing (to grab metadata: subject, from, date)
"${ZOHO_CURL}" "/accounts/${AID}/messages/view?folderId=${FID}&limit=100&sortorder=false&start=1" > "${WORK}/folder_msgs.json"
python3 - "${WORK}/folder_msgs.json" "${MID}" > "${WORK}/meta.json" <<'PY'
import json, sys
d = json.load(open(sys.argv[1]))
mid = sys.argv[2]
for m in (d.get("data") or []):
if str(m.get("messageId")) == mid:
json.dump(m, sys.stdout); sys.exit(0)
sys.exit(f"messageId {mid} not found in this folder")
PY
# 3. Attachment metadata
"${ZOHO_CURL}" "/accounts/${AID}/folders/${FID}/messages/${MID}/attachmentinfo" > "${WORK}/attachinfo.json"
# 4. Download each attachment — needs raw bytes (Accept: */*), not the JSON
# wrapper's default. We bypass zoho-curl.sh for the attachment download but
# reuse the cached access_token it wrote.
set -a; source "${SCRIPT_DIR}/../../dolibarr/.env"; set +a
: "${ZOHO_DC:=eu}"
TOKEN_CACHE="${TMPDIR:-/tmp}/zoho-access-$(whoami)"
if [[ ! -s "${TOKEN_CACHE}" ]]; then
echo "email-inspect.sh: missing access token cache — run any zoho-curl call first to populate it" >&2
exit 2
fi
ACCESS_TOKEN=$(cat "${TOKEN_CACHE}")
MAIL_BASE="https://mail.zoho.${ZOHO_DC}/api"
mkdir -p "${WORK}/atts" "${WORK}/text"
ATT_IDS=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
data = d.get('data') or {}
for a in (data.get('attachments') or []):
print(f\"{a.get('attachmentId')}|{a.get('attachmentName','-')}\")" "${WORK}/attachinfo.json")
while IFS='|' read -r aid aname; do
[[ -z "${aid}" ]] && continue
outpath="${WORK}/atts/${aname}"
curl -sS \
-H "Authorization: Zoho-oauthtoken ${ACCESS_TOKEN}" \
-H "Accept: */*" \
--max-time 60 \
-o "${outpath}" \
"${MAIL_BASE}/accounts/${AID}/folders/${FID}/messages/${MID}/attachments/${aid}" || true
# If pdf, extract text (bash 3.2 compatible — no ${var,,})
aname_lc=$(echo "${aname}" | tr '[:upper:]' '[:lower:]')
if [[ "${aname_lc}" == *.pdf ]]; then
pdftotext -layout "${outpath}" "${WORK}/text/${aname%.pdf}.txt" 2>/dev/null || true
fi
done <<< "${ATT_IDS}"
# Optional save
if [[ -n "${SAVE_PDF_DIR}" ]]; then
mkdir -p "${SAVE_PDF_DIR}"
cp "${WORK}/atts/"*.pdf "${SAVE_PDF_DIR}/" 2>/dev/null || true
fi
# 5. Heuristic extract + render
python3 - "${WORK}" "${FMT}" <<'PY'
import json, sys, os, re, datetime, glob
work, fmt = sys.argv[1:3]
meta = json.load(open(os.path.join(work,"meta.json")))
ts = int(meta.get("sentDateInGMT") or meta.get("receivedTime") or 0) // 1000
mail_date = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else None
mail_from = (meta.get("fromAddress") or meta.get("sender") or "-").replace("&lt;","<").replace("&gt;",">").replace("<","").replace(">","")
mail_subject = meta.get("subject") or "-"
# Heuristics on PDF text
def extract(text):
out = {}
# First non-empty line is often the supplier name (or the address block first line)
lines = [l.strip() for l in text.splitlines() if l.strip()]
out["pdf_top_line"] = lines[0] if lines else None
# Total TTC / HT / TVA — try multiple French/English patterns
def first_match(*patterns):
for p in patterns:
for line in lines:
m = re.search(p, line, re.IGNORECASE)
if m: return m.group(1).replace(",", ".").replace(" ", "")
return None
def parse_amount(s):
if not s: return None
clean = s.replace(",", ".").replace(" ", "")
try:
v = float(clean)
# Money amounts < 1M EUR; filters out VAT-number false positives (FR12345678901)
return v if 0 <= v < 1_000_000 else None
except: return None
def first_amount(*patterns):
for p in patterns:
for line in lines:
m = re.search(p, line, re.IGNORECASE)
if m:
v = parse_amount(m.group(1))
if v is not None: return f"{v:.2f}"
return None
out["total_ht"] = first_amount(r'(?:total\s*ht|montant\s*ht|net\s*amount|subtotal)[^\d-]*([\d \.,]+)')
# TVA: require currency suffix to avoid matching VAT-number digits
out["total_tva"] = first_amount(r'(?:tva|vat)[^\d-]*([\d \.,]+)\s*(?:€|eur)\b')
out["total_ttc"] = first_amount(r'(?:total\s*ttc|amount\s*due|total\s*due|grand\s*total|montant\s*total|amount\s*paid)[^\d-]*([\d \.,]+)')
# Invoice ref — must contain a digit (filters "umber", "Invoice", etc.)
m = re.search(r'(?:facture|invoice|receipt|reçu)\s*(?:n[°o]?|number|#|:)\s*([A-Za-z0-9][\w\d/-]{2,})', text, re.IGNORECASE)
if m and any(c.isdigit() for c in m.group(1)):
out["invoice_ref"] = m.group(1)
else:
# Fallback: any reasonable ref-shaped token after "Invoice" / "Facture" header
m = re.search(r'\b([A-Z]{2,}[-/]?\d[\w\d/-]{2,})\b', text)
out["invoice_ref"] = m.group(1) if m else None
# Invoice date — try ISO, French DD/MM/YYYY, English MM/DD/YYYY, French long form
out["invoice_date_raw"] = None
for p in (
r'\b(\d{4}-\d{2}-\d{2})\b',
r'(?:date|émise\s*le|invoice\s*date|date\s*de\s*facturation)[:\s]*(\d{1,2}[\s/.-]\d{1,2}[\s/.-]\d{2,4})',
r'(?:date|émise\s*le|invoice\s*date)[:\s]*(\d{1,2}\s+\w{3,9}\.?\s+\d{4})',
):
m = re.search(p, text, re.IGNORECASE)
if m: out["invoice_date_raw"] = m.group(1).strip(); break
# VAT rate (e.g. "20%") — restrict to 0-25% so "100%" / page footers don't match.
vrate = None
for line in lines:
m = re.search(r'\b(\d{1,2}([.,]\d+)?)\s*%', line)
if m:
v = float(m.group(1).replace(",", "."))
if 0 <= v <= 25:
vrate = m.group(1).replace(",", "."); break
out["vat_rate_pct"] = vrate
return out
pdfs = []
for pdf in sorted(glob.glob(os.path.join(work,"atts","*.pdf")) +
glob.glob(os.path.join(work,"atts","*.PDF"))):
name = os.path.basename(pdf)
txt_path = os.path.join(work,"text", os.path.splitext(name)[0] + ".txt")
text = open(txt_path).read() if os.path.isfile(txt_path) else ""
h = extract(text)
h["attachment_name"] = name
h["pdf_size_bytes"] = os.path.getsize(pdf)
h["pdf_text_len"] = len(text)
pdfs.append(h)
result = {
"email": {
"messageId": meta.get("messageId"),
"subject": mail_subject,
"from": mail_from,
"date": mail_date,
"hasAttachment": str(meta.get("hasAttachment","")) == "1",
},
"attachments": pdfs,
"dolibarr_draft_suggestions": [
{
"supplier_hint": p.get("pdf_top_line"),
"invoice_ref": p.get("invoice_ref"),
"invoice_date": p.get("invoice_date_raw"),
"total_ht": p.get("total_ht"),
"total_tva": p.get("total_tva"),
"total_ttc": p.get("total_ttc"),
"vat_rate_pct": p.get("vat_rate_pct"),
"source_email": meta.get("messageId"),
"source_attachment": p.get("attachment_name"),
} for p in pdfs
]
}
if fmt == "json":
print(json.dumps(result, indent=2, ensure_ascii=False))
sys.exit(0)
print("=" * 80)
print(f" Email {meta.get('messageId')}")
print("=" * 80)
print(f" subject : {mail_subject}")
print(f" from : {mail_from}")
print(f" date : {mail_date}")
print(f" attached : {result['email']['hasAttachment']}")
print()
if not pdfs:
print(" (no PDF attachments — try inspecting body or other types)")
for i, p in enumerate(pdfs, 1):
print(f" -- Attachment {i}: {p['attachment_name']} ({p['pdf_size_bytes']} bytes, {p['pdf_text_len']} chars extracted) --")
for k in ("pdf_top_line","invoice_ref","invoice_date_raw","total_ht","total_tva","total_ttc","vat_rate_pct"):
v = p.get(k)
print(f" {k:<16} = {v!r}")
print()
print(" Suggested Dolibarr supplier-invoice draft entries:")
print(json.dumps(result["dolibarr_draft_suggestions"], indent=4, ensure_ascii=False))
PY

View File

@@ -0,0 +1,141 @@
#!/usr/bin/env bash
# List candidate supplier-invoice emails from the books@ Zoho mailbox.
#
# Usage:
# email-list.sh [--folder PATH] # default: /Inbox/books (the books@ alias-filtered folder)
# [--limit N] # default: 30
# [--candidates-only] # filter by subject pattern OR attachment
# [--all-folders] # scan every folder (slow, lots of API calls)
#
# Output: table with mid, date, from, subject, hasAttachment.
# A "candidate" is a message whose subject matches a supplier-like pattern
# (facture/invoice/receipt/reçu/payment/paiement/abonnement/order/commande)
# OR which has an attachment.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ZOHO_CURL="${SCRIPT_DIR}/zoho-curl.sh"
FOLDER="/Inbox/books"
LIMIT=30
CANDIDATES_ONLY=0
ALL_FOLDERS=0
while [[ $# -gt 0 ]]; do
case "$1" in
--folder) FOLDER="$2"; shift 2 ;;
--limit) LIMIT="$2"; shift 2 ;;
--candidates-only) CANDIDATES_ONLY=1; shift ;;
--all-folders) ALL_FOLDERS=1; shift ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "email-list.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t emailist.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# 1. Discover accountId
"${ZOHO_CURL}" /accounts > "${WORK}/accounts.json"
AID=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print((d.get('data') or [{}])[0].get('accountId',''))" "${WORK}/accounts.json")
[[ -z "${AID}" ]] && { echo "email-list.sh: no accountId in /accounts response" >&2; exit 1; }
# 2. Resolve folder path → folderId
"${ZOHO_CURL}" "/accounts/${AID}/folders" > "${WORK}/folders.json"
# Build list of (folderId, path) tuples to scan
if [[ "${ALL_FOLDERS}" == "1" ]]; then
FOLDER_IDS=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
for f in (d.get('data') or []):
fid = f.get('folderId'); path = f.get('path') or f.get('folderName','-')
# Skip noisy system folders
if path in ('/Drafts','/Templates','/Snoozed','/Sent','/Spam','/Trash','/Outbox'): continue
print(f\"{fid}|{path}\")" "${WORK}/folders.json")
else
FOLDER_IDS=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
target = sys.argv[2]
for f in (d.get('data') or []):
if f.get('path') == target:
print(f\"{f.get('folderId')}|{f.get('path')}\")
break" "${WORK}/folders.json" "${FOLDER}")
if [[ -z "${FOLDER_IDS}" ]]; then
echo "email-list.sh: folder '${FOLDER}' not found. Available:" >&2
python3 -c "import json,sys; [print(f' {f.get(\"path\",\"-\")}') for f in json.load(open(sys.argv[1])).get('data',[])]" "${WORK}/folders.json" >&2
exit 2
fi
fi
# 3. Fetch messages per folder
mkdir -p "${WORK}/msgs"
COUNT=0
while IFS='|' read -r fid fpath; do
[[ -z "${fid}" ]] && continue
COUNT=$((COUNT+1))
out="${WORK}/msgs/$(printf '%03d' "${COUNT}").json"
"${ZOHO_CURL}" "/accounts/${AID}/messages/view?folderId=${fid}&limit=${LIMIT}&sortorder=false&start=1" > "${out}" 2>/dev/null || echo '{"data":[]}' > "${out}"
echo "${fpath}" > "${out}.path"
done <<< "${FOLDER_IDS}"
# 4. Render
python3 - "${WORK}/msgs" "${CANDIDATES_ONLY}" <<'PY'
import json, sys, os, re, datetime, glob
msgs_dir, candidates_only_str = sys.argv[1:3]
candidates_only = candidates_only_str == "1"
CANDIDATE_PATTERN = re.compile(
r'facture|invoice|receipt|re[cç]u|payment|paiement|abonnement|subscription|order|commande|invoice|bill',
re.IGNORECASE,
)
# Subjects that look like calendar invites / event updates / generic notifications
# get filtered out of --candidates-only — they always have a .ics attachment so
# the "has-attachment" heuristic alone catches them as false positives.
EXCLUDE_PATTERN = re.compile(
r'^(?:re:\s*|fwd:\s*|tr:\s*)*' # strip Re:/Fwd:/Tr: prefixes
r'(?:invitation|updated\s+invitation|canceled\s+event|accepted|declined|tentative|maybe)\s*:',
re.IGNORECASE,
)
# Senders that are pure noise — newsletter/marketing patterns.
EXCLUDE_SENDER = re.compile(
r'(updates\.|noreply@.*calendar|@calendar\.|news@|newsletter@|@updates\.)',
re.IGNORECASE,
)
def is_candidate(m):
subj = m.get("subject","") or ""
sender = m.get("fromAddress","") or m.get("sender","") or ""
# Hard exclusions take precedence over inclusions
if EXCLUDE_PATTERN.match(subj.strip()): return False
if EXCLUDE_SENDER.search(sender): return False
if str(m.get("hasAttachment","")) == "1": return True
if CANDIDATE_PATTERN.search(subj): return True
return False
rows = []
for f in sorted(glob.glob(os.path.join(msgs_dir, "*.json"))):
fpath = open(f + ".path").read().strip()
try: data = json.load(open(f)).get("data") or []
except: continue
for m in data:
if candidates_only and not is_candidate(m): continue
ts = int(m.get("sentDateInGMT") or m.get("receivedTime") or 0) // 1000
dt = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "-"
frm = (m.get("fromAddress") or m.get("sender") or "-").replace("&lt;","<").replace("&gt;",">").replace("<","").replace(">","")[:36]
subj = (m.get("subject") or "-")[:55]
has = "Y" if str(m.get("hasAttachment","")) == "1" else " "
cand = "*" if is_candidate(m) else " "
rows.append((dt, fpath, cand, has, m.get("messageId","-"), frm, subj))
rows.sort(key=lambda r: r[0], reverse=True)
print(f"{'date':<10} {'cand':<4} {'att':<3} {'messageId':<22} {'folder':<22} {'from':<36} subject")
print("-" * 130)
for dt, fpath, cand, has, mid, frm, subj in rows:
print(f"{dt:<10} [{cand}] [{has}] {mid:<22} {fpath[:22]:<22} {frm:<36} {subj}")
print("-" * 130)
print(f"# {len(rows)} message(s)" + (" (candidates only)" if candidates_only else ""))
PY

View File

@@ -0,0 +1,126 @@
#!/usr/bin/env bash
# Read-only curl wrapper for the Zoho Mail API.
#
# Usage:
# zoho-curl.sh <path> # e.g. zoho-curl.sh /accounts
# zoho-curl.sh -i <path> # include curl's -i (response headers)
# zoho-curl.sh -o file.json <path> # write body to file
#
# Reads credentials from ../../dolibarr/.env (the shared canonical file).
# Required vars:
# ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_DC
#
# Token strategy: each invocation refreshes a short-lived access_token from
# the refresh_token (Zoho access_tokens live 1h; the cost of refreshing on
# every call is ~150 ms and avoids state on disk). On 401 from the mail API
# we re-refresh once and retry (covers refresh-token rotation cases).
#
# Exits non-zero on HTTP >= 400 and writes body to stdout + a short message
# to stderr — same shape as dol-curl.sh / bank-curl.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/../../dolibarr/.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "zoho-curl.sh: missing ${ENV_FILE}" >&2
echo " Required vars: ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_DC." >&2
echo " See arcodange-email-ingest/SKILL.md for the OAuth setup." >&2
exit 2
fi
set -a; source "${ENV_FILE}"; set +a
: "${ZOHO_CLIENT_ID:?zoho-curl.sh: ZOHO_CLIENT_ID not set in .env}"
: "${ZOHO_CLIENT_SECRET:?zoho-curl.sh: ZOHO_CLIENT_SECRET not set in .env}"
: "${ZOHO_REFRESH_TOKEN:?zoho-curl.sh: ZOHO_REFRESH_TOKEN not set in .env}"
: "${ZOHO_DC:=eu}"
ACCOUNTS_BASE="https://accounts.zoho.${ZOHO_DC}"
MAIL_BASE="https://mail.zoho.${ZOHO_DC}/api"
# Parse pass-through curl args (everything before the last positional)
PASSTHRU=()
while [[ $# -gt 1 ]]; do
PASSTHRU+=("$1"); shift
done
if [[ $# -lt 1 ]]; then
echo "zoho-curl.sh: missing API path. Example: zoho-curl.sh /accounts" >&2
exit 2
fi
API_PATH="$1"
# Cache access_token in tmpfs to avoid hitting OAuth rate limits on every
# zoho-curl invocation. Zoho access_tokens live 1h; we refresh after 50 min.
CACHE_FILE="${TMPDIR:-/tmp}/zoho-access-$(whoami)"
CACHE_TTL_SECONDS=$((50 * 60))
get_access_token() {
if [[ -f "${CACHE_FILE}" ]]; then
local age
age=$(( $(date +%s) - $(stat -f %m "${CACHE_FILE}" 2>/dev/null || stat -c %Y "${CACHE_FILE}") ))
if [[ ${age} -lt ${CACHE_TTL_SECONDS} ]]; then
cat "${CACHE_FILE}"
return 0
fi
fi
local token
if ! token=$(curl -sS -X POST "${ACCOUNTS_BASE}/oauth/v2/token" \
--max-time 15 \
-d "grant_type=refresh_token" \
-d "client_id=${ZOHO_CLIENT_ID}" \
-d "client_secret=${ZOHO_CLIENT_SECRET}" \
-d "refresh_token=${ZOHO_REFRESH_TOKEN}" \
| python3 -c "
import json, sys
try: d = json.load(sys.stdin)
except: sys.exit('failed to parse OAuth response')
if 'access_token' not in d:
sys.exit(f'OAuth refresh failed: {d}')
print(d['access_token'])"); then
return 1
fi
if [[ -z "${token}" ]]; then
return 1
fi
# Store cache (mode 600) only on success
printf '%s' "${token}" > "${CACHE_FILE}"
chmod 600 "${CACHE_FILE}"
printf '%s' "${token}"
}
do_call() {
local token="$1"
local body_file="$2"
local headers_file="$3"
curl -sS \
-H "Authorization: Zoho-oauthtoken ${token}" \
-H "Accept: application/json" \
--max-time 30 \
-o "${body_file}" \
-D "${headers_file}" \
-w "%{http_code}" \
${PASSTHRU[@]+"${PASSTHRU[@]}"} \
"${MAIL_BASE}${API_PATH}"
}
ACCESS_TOKEN=$(get_access_token)
[[ -z "${ACCESS_TOKEN}" ]] && { echo "zoho-curl.sh: empty access_token" >&2; exit 1; }
BODY_FILE="$(mktemp -t zohocurl.XXXXXX)"
HEADERS_FILE="$(mktemp -t zohohdr.XXXXXX)"
trap 'rm -f "${BODY_FILE}" "${HEADERS_FILE}"' EXIT
HTTP_CODE=$(do_call "${ACCESS_TOKEN}" "${BODY_FILE}" "${HEADERS_FILE}")
# Retry once on 401 with a fresh token (handles edge cases of refresh-token rotation)
if [[ "${HTTP_CODE}" == "401" ]]; then
ACCESS_TOKEN=$(get_access_token)
HTTP_CODE=$(do_call "${ACCESS_TOKEN}" "${BODY_FILE}" "${HEADERS_FILE}")
fi
cat "${BODY_FILE}"
if [[ "${HTTP_CODE}" -ge 400 ]]; then
echo "zoho-curl.sh: HTTP ${HTTP_CODE} on ${API_PATH}" >&2
exit 1
fi

View File

@@ -8,6 +8,8 @@ requires:
# dolibarr-data-snapshot — point-in-time JSON dump of Dolibarr read state
**CLI shortcut:** `bin/arcodange snapshot [--out FILE | --print-only]`
One script: [`snapshot.sh`](scripts/snapshot.sh). Pulls every read-only endpoint the `dolibarr-*` family uses and bundles into a single JSON file with a content hash. Read-only, no side effects.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.

View File

@@ -8,6 +8,8 @@ requires:
# dolibarr-invoice-audit — KissMetrics billing audit
**CLI shortcuts:** `bin/arcodange invoice list` · `bin/arcodange invoice audit <id>`
This skill answers the questions the Arcodange × KissMetrics cohort review keeps asking:
- Has the M-N invoice been emitted? Is it paid?

View File

@@ -8,6 +8,8 @@ requires:
# dolibarr-payments-state — KissMetrics cash-receipt tracking
**CLI shortcuts:** `bin/arcodange payments state` · `bin/arcodange payments timeline` · `bin/arcodange payments by-month`
This skill answers "**who owes Arcodange what, and when did they pay?**" against the live Dolibarr. It's the natural follow-up to [dolibarr-invoice-audit](../dolibarr-invoice-audit/SKILL.md): that one validates that a given invoice was emitted correctly; this one validates the money landed.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill for the connection, the `.env`, and the `voir_tous` permission flags. The existing **Factures → Voir toutes** flag from V1 is sufficient — payments are nested under invoices (`/invoices/{id}/payments`), and there's no `/payments` list-all endpoint (501 Not Implemented).

View File

@@ -8,6 +8,8 @@ requires:
# dolibarr-recurring-templates — modèles de factures récurrentes
**CLI shortcuts:** `bin/arcodange templates list` · `bin/arcodange templates inspect <id>`
Recurring invoice templates are the Dolibarr objects that drive automated monthly (or arbitrary-frequency) billing. This skill answers: **does the template actually fire on schedule? when's the next one? and what does it generate?**
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.

View File

@@ -0,0 +1,71 @@
---
name: dolibarr-sandbox-checkpoint
description: Manage the erp-sandbox iso-prod checkpoint — status, reset (refresh-from-prod), re-provision the write agent, relink the write skill .env. Use after rehearsing writes when you want a clean prod-shaped sandbox again.
---
# dolibarr-sandbox-checkpoint
Lifecycle management for the **erp-sandbox** iso-prod checkpoint (ADR-0003). The
sandbox exists so an agent can rehearse Dolibarr writes on prod-shaped data; this
skill resets it back to a clean iso-prod baseline and re-arms the write path.
All commands are exposed via the CLI:
```sh
arcodange sandbox checkpoint status
arcodange sandbox checkpoint refresh --yes
arcodange sandbox checkpoint provision
arcodange sandbox checkpoint relink-env
```
## The reset cycle
```
refresh --yes provision (auto) relink-env
───────────────► ──────────────────────► ─────────────────────────►
wipe + re-seed re-create the write rewrite the write skill
iso-prod from agent (Playwright; .env from the new key +
prod (~2-3 min) you log in) + key verify it authenticates
```
1. **`status`** — HTTP liveness + whether the write agent (`ai_agent_sandbox`) is
*armed* (its key authenticates `GET /users/info`). Read-only, no cluster access.
2. **`refresh --yes`** — re-seed the sandbox iso-prod from prod, wrapping
`ops/sandbox/sandbox-lifecycle.sh` (read-only `pg_dump` of prod → `DROP OWNED`
`pg_restore`, then documents/logo sync). **Destructive**: requires `--yes`, and
it wipes the write agent too (iso-prod overwrites `llx_user` with prod's, which
has no `ai_agent_sandbox`). `--db-only` skips the documents sync. Needs `kubectl`
on the lab cluster.
3. **`provision`** — re-create the write agent by running the Playwright POC
(`test/provisionSandbox.ts`). It opens a browser; **you complete the admin
login** — with the **PROD** admin credentials, since the sandbox is iso-prod
(they come from `test/.env.sandbox`). The POC re-grants the agent's rights
(including `banque lire`) and writes the key to `test/.ai_agent_sandbox.key`,
then this command auto-runs `relink-env`. Needs `deno`.
4. **`relink-env`** — (re)write `dolibarr-sandbox-write/.env` from
`test/.ai_agent_sandbox.key` (mode 600) and verify it authenticates. Run it
standalone any time the key changed.
## Why a refresh wipes the agent (and the key)
A full refresh is **iso-prod**: it replaces the whole `public` schema (incl.
`llx_user` and `llx_const`) with prod's. So `ai_agent_sandbox` — created *after* the
seed, absent from prod — disappears, and `DOLI_INSTANCE_UNIQUE_ID` reverts to prod's,
which invalidates the instance-encrypted API key. That's why re-provisioning (not
just re-linking) is required after every refresh. This is by design (ADR-0003): the
sandbox's prod-write isolation is structural, and the agent is cheap to recreate.
## Gotchas
- **Run from an up-to-date checkout.** The `.env` is written next to the
`dolibarr-sandbox-write` skill in *this* checkout — invoke `arcodange` from a
worktree synced to `origin/main` (the trunk may lag), or the skill/`.env` won't be
where your writes look for them.
- **PROD admin creds for `provision`.** If the Playwright login fails, fix
`DOLI_ADMIN_PASSWORD` in `test/.env.sandbox` to prod's admin password.
- **`refresh` needs `kubectl`** (lab cluster context); **`provision` needs `deno`**.
- The lifecycle script pauses ArgoCD self-heal for the re-seed and restores it via
an EXIT trap — an interrupted refresh won't strand the sandbox scaled to 0.
See also: `dolibarr-sandbox-write/SKILL.md` (the writes this arms), `ops/sandbox/`
(the lifecycle script + README), factory `vibe/ADR/0003-sandbox-state-lifecycle.md`.

View File

@@ -0,0 +1,23 @@
#!/usr/bin/env bash
# Re-provision the ai_agent_sandbox write user after a refresh, then relink the
# write skill .env. Runs the Playwright POC (test/provisionSandbox.ts): it opens a
# browser — YOU complete the admin login.
#
# IMPORTANT: the sandbox is iso-prod, so log in with the PROD admin credentials.
# Those come from test/.env.sandbox (DOLI_ADMIN_LOGIN / DOLI_ADMIN_PASSWORD) — make
# sure they are prod's. The POC re-grants the agent's rights (incl. banque lire) and
# writes the new key to test/.ai_agent_sandbox.key.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ARCO_ROOT:-$(cd "${SCRIPT_DIR}/../../../.." && pwd)}"
POC="${ROOT}/test/provisionSandbox.ts"
command -v deno >/dev/null || { echo "checkpoint-provision: deno not found (https://deno.land)" >&2; exit 1; }
[[ -f "${POC}" ]] || { echo "checkpoint-provision: missing ${POC}" >&2; exit 1; }
[[ -f "${ROOT}/test/.env.sandbox" ]] || echo "checkpoint-provision: WARN no test/.env.sandbox (admin creds) — login may fail" >&2
echo ">>> launching provisionSandbox.ts — complete the admin login in the browser (use PROD admin creds)"
( cd "${ROOT}/test" && deno run --allow-all provisionSandbox.ts )
echo ">>> provisioning finished; relinking the write skill .env"
exec "${SCRIPT_DIR}/checkpoint-relink-env.sh"

View File

@@ -0,0 +1,40 @@
#!/usr/bin/env bash
# Re-seed erp-sandbox to a clean iso-prod checkpoint (ADR-0003). Wraps
# ops/sandbox/sandbox-lifecycle.sh.
#
# DESTRUCTIVE: wipes ALL sandbox data — including the ai_agent_sandbox write user
# and its API key (iso-prod overwrites llx_user with prod's). After it completes
# you MUST re-provision: arcodange sandbox checkpoint provision
#
# checkpoint-refresh.sh --yes # db re-seed + documents (logo) sync
# checkpoint-refresh.sh --yes --db-only # db re-seed only (skip documents)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ARCO_ROOT:-$(cd "${SCRIPT_DIR}/../../../.." && pwd)}"
LIFECYCLE="${ROOT}/ops/sandbox/sandbox-lifecycle.sh"
[[ -f "${LIFECYCLE}" ]] || { echo "checkpoint-refresh: missing ${LIFECYCLE}" >&2; exit 1; }
MODE="refresh"; YES=0
while [[ $# -gt 0 ]]; do
case "$1" in
--yes) YES=1; shift ;;
--db-only) MODE="refresh-from-prod"; shift ;;
*) echo "checkpoint-refresh: unknown arg '$1'" >&2; exit 2 ;;
esac
done
if [[ "${YES}" != "1" ]]; then
cat >&2 <<EOF
checkpoint-refresh: this WIPES all erp-sandbox data and re-seeds iso-prod from prod.
- the ai_agent_sandbox write user + its key are wiped → you re-provision after
- prod is read ONLY (structural guarantee, ADR-0003); only the sandbox is written
Re-run with --yes to proceed. Then: arcodange sandbox checkpoint provision
EOF
exit 3
fi
echo ">>> re-seeding erp-sandbox (${MODE}) — ~2-3 min (scale-down, pg_dump prod, restore, scale-up)"
bash "${LIFECYCLE}" "${MODE}"
echo
echo ">>> iso-prod checkpoint restored. The write agent was wiped — bring it back with:"
echo " arcodange sandbox checkpoint provision"

View File

@@ -0,0 +1,30 @@
#!/usr/bin/env bash
# (Re)write the dolibarr-sandbox-write skill .env from the provisioned key file,
# then verify it authenticates. Run this after 'provision' (or any time the key
# changed). The key is instance-encrypted per Dolibarr instance, so a refresh
# invalidates it — re-provision first if this fails to authenticate.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ARCO_ROOT:-$(cd "${SCRIPT_DIR}/../../../.." && pwd)}"
SB_URL="${DOLIBARR_SANDBOX_URL:-https://erp-sandbox.arcodange.lab}"
KEY="${ROOT}/test/.ai_agent_sandbox.key"
ENV="${ROOT}/.claude/skills/dolibarr-sandbox-write/.env"
DOLW="${ROOT}/.claude/skills/dolibarr-sandbox-write/scripts/dol-write.sh"
[[ -s "${KEY}" ]] || { echo "checkpoint-relink-env: no key at ${KEY}" >&2
echo " run 'arcodange sandbox checkpoint provision' first." >&2; exit 1; }
umask 077
{ echo "DOLIBARR_SANDBOX_URL=${SB_URL}"
printf 'DOLIBARR_SANDBOX_API_KEY=%s\n' "$(tr -d '\r\n' < "${KEY}")"; } > "${ENV}"
chmod 600 "${ENV}"
echo "✓ wrote ${ENV} (mode 600)"
printf 'verify: '
"${DOLW}" GET /users/info | python3 -c "import json,sys
d = json.load(sys.stdin)
if isinstance(d, dict) and d.get('login'):
print('OK — armed as %s (id %s)' % (d['login'], d.get('id')))
else:
msg = d.get('error', {}).get('message', '?') if isinstance(d, dict) else str(d)
print('FAILED — %s' % msg); sys.exit(1)"

View File

@@ -0,0 +1,32 @@
#!/usr/bin/env bash
# Report the erp-sandbox checkpoint state: HTTP liveness + whether the write agent
# (ai_agent_sandbox) is armed (its key authenticates). Read-only, no cluster access.
set -uo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ROOT="${ARCO_ROOT:-$(cd "${SCRIPT_DIR}/../../../.." && pwd)}"
SB_URL="${DOLIBARR_SANDBOX_URL:-https://erp-sandbox.arcodange.lab}"
WRITE_ENV="${ROOT}/.claude/skills/dolibarr-sandbox-write/.env"
DOLW="${ROOT}/.claude/skills/dolibarr-sandbox-write/scripts/dol-write.sh"
KEY="${ROOT}/test/.ai_agent_sandbox.key"
echo "erp-sandbox checkpoint status"
code=$(curl -s -o /dev/null -w '%{http_code}' --max-time 10 "${SB_URL}/" 2>/dev/null || echo "---")
echo " HTTP : ${code} (${SB_URL})"
echo " agent key file : $([ -s "${KEY}" ] && echo "present" || echo "absent (provision needed)")"
if [[ -f "${WRITE_ENV}" ]]; then
echo " write .env : present"
printf ' write agent : '
"${DOLW}" GET /users/info 2>/dev/null | python3 -c "import json,sys
try:
d = json.load(sys.stdin)
except Exception:
print('NOT armed — no/invalid response'); sys.exit(0)
if isinstance(d, dict) and d.get('login'):
print('ARMED — login=%s id=%s' % (d['login'], d.get('id')))
else:
msg = d.get('error', {}).get('message', '?') if isinstance(d, dict) else 'unexpected'
print('NOT armed — %s' % msg[:80])"
else
echo " write .env : ABSENT"
echo " write agent : not linked — run 'arcodange sandbox checkpoint relink-env' after provisioning"
fi

View File

@@ -0,0 +1,10 @@
# Copy to .env (mode 600 — gitignored). Never commit a real key.
DOLIBARR_SANDBOX_URL=https://erp-sandbox.arcodange.lab
DOLIBARR_SANDBOX_API_KEY=
# Populate the key from the Playwright provisioner output (repo test/):
# printf 'DOLIBARR_SANDBOX_API_KEY=%s\n' "$(cat ../../../test/.ai_agent_sandbox.key)" >> .env
#
# The host guard only allows the sandbox FQDN. Override the pattern ONLY if the
# sandbox host changes — never widen it to a prod host.
# DOLIBARR_SANDBOX_HOST_RE=^https://erp-sandbox\.arcodange\.lab(/|$)

View File

@@ -0,0 +1,190 @@
---
name: dolibarr-sandbox-write
description: >-
WRITE operations against the Arcodange Dolibarr SANDBOX (erp-sandbox.arcodange.lab)
— the rehearsal environment where an AI agent records thirdparties, invoices and
payments before any change is promoted to prod. Create client/supplier fiches
(auto-coded), customer + supplier invoices with product/service lines and the
supplier's own reference, validate them, and record règlements (payments). Every
write goes through dol-write.sh, which REFUSES any host that is not the sandbox —
the structural guarantee (ADR-0003) that this skill can never mutate production.
Use when the user asks to "create a thirdparty / supplier / client fiche", "saisir
une facture", "record an invoice with lines", "enregistrer un règlement / paiement",
or to rehearse a write before promoting it to prod. SKIP for production writes
(prod stays read-only via the `dolibarr` skill's `ai_agent` key; promotion is a
separate, human-gated replay), and for credit notes/avoirs (a planned follow-up).
Depends on the write-scoped `ai_agent_sandbox` Dolibarr user + its API key.
requires:
bins: [bash, curl, python3]
auth: ".env with DOLIBARR_SANDBOX_URL + DOLIBARR_SANDBOX_API_KEY (mode 600, gitignored)"
---
# dolibarr-sandbox-write
Write-capable companion to the read-only `dolibarr*` skills, scoped to the
**sandbox**. It exists so an AI agent can *rehearse* bookkeeping writes against a
faithful copy of prod (see ADR-0003 + the `ops/sandbox/` seed tooling), then a
human promotes the reviewed change to prod.
## The safety model (read this first)
- **Host guard.** `scripts/dol-write.sh` reads `DOLIBARR_SANDBOX_URL` from `.env`
and refuses to send any request unless it matches `erp-sandbox.arcodange.lab`.
Point it at `erp.arcodange.lab` (prod) and it exits non-zero *before* the
request. This is the structural reason the skill cannot write prod.
- **Credential scope.** The key is `ai_agent_sandbox`'s — valid only on the
sandbox host, with create+read rights on thirdparties / invoices / supplier
invoices / products / contacts (+ `societe client voir`). Prod's `ai_agent`
key is read-only and lives in a different skill's `.env`.
- **Resettable.** Anything written here is wiped by `ops/sandbox/sandbox-lifecycle.sh
refresh-from-prod`, so mistakes cost a reset, not data.
- **Promotion to prod is gated, not automatic.** Rehearse here → review the
change-set (`promote-plan.sh`) → replay it on prod (`promote-apply.sh --target
prod`). The prod write key is supplied via the **environment at apply time**
(`DOLIBARR_PROD_WRITE_KEY`), never stored in any `.env`, and `dol-prod-write.sh`
refuses every prod write unless `ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD`.
See "Promote to prod" below.
## Setup
Create `.env` (mode 600, gitignored) next to `scripts/`:
```sh
cd .claude/skills/dolibarr-sandbox-write
umask 077
{ echo "DOLIBARR_SANDBOX_URL=https://erp-sandbox.arcodange.lab"
printf 'DOLIBARR_SANDBOX_API_KEY=%s\n' "$(cat /path/to/.ai_agent_sandbox.key)"; } > .env
```
The key is produced by the Playwright provisioner in the repo's `test/`
(`provisionSandbox.ts` → `.ai_agent_sandbox.key`). Verify: `scripts/dol-write.sh
GET /status` should return HTTP 200 with `"environment":"non-production"`.
## Workflows
All three read a JSON object on **stdin** (or a file path as `$1`) and emit ids.
### 1 · Thirdparty (fiche client/fournisseur) — `scripts/thirdparty-create.sh`
```sh
echo '{"name":"KissMetrics","role":"client","tva_intra":"US.."}' | scripts/thirdparty-create.sh
echo '{"name":"OVH","role":"supplier","siret":"..."}' | scripts/thirdparty-create.sh
```
`role`: `client` | `supplier` | `both`. Codes auto-assign from the mask
(`CL{0000}` / `FO{0000}`) via the `-1` sentinel; pass `client_code`/`supplier_code`
to override. Optional: `country_id` (default 1=FR), `siret`, `tva_intra`,
`address`, `zip`, `town`, `email`, `phone`, `idprof1`. Emits the new id.
### 2 · Invoice (facture) — `scripts/invoice-create.sh`
```sh
echo '{"socid":42,"kind":"customer","validate":true,
"lines":[{"desc":"Conseil","qty":2,"price_ht":500,"tva":20,"type":"service"},
{"desc":"Licence","qty":1,"price_ht":100,"tva":20,"type":"product"}]}' \
| scripts/invoice-create.sh
# supplier invoice carrying the supplier's own reference:
echo '{"socid":7,"kind":"supplier","ref_supplier":"INV-2026-042","validate":true,
"lines":[{"desc":"Hosting","qty":1,"price_ht":80,"tva":20,"type":"service"}]}' \
| scripts/invoice-create.sh
```
`kind`: `customer` (`/invoices`) | `supplier` (`/supplierinvoices`). Lines carry
`desc, qty, price_ht, tva, type` (product|service) and optional `product_id`
(`fk_product`) to link a catalogue product. Totals + TVA are computed by Dolibarr.
`validate:true` turns the draft (`PROV…`) into a final numbered invoice; omit it
to leave a draft. Emits `{id, ref, ref_supplier, total_ht, total_ttc, statut}`.
### 3 · Payment (règlement) — `scripts/payment-record.sh`
```sh
echo '{"invoice_id":19,"mode":"VIR","account_id":1,"transaction_id":"QONTO-TX-1234"}' | scripts/payment-record.sh
echo '{"invoice_id":13,"kind":"supplier","mode":"VIR","account_id":1,"amount":96,"transaction_id":"WISE-TX-5678"}' \
| scripts/payment-record.sh
```
The invoice must be **validated** first. `mode`: `VIR|CB|CHQ|LIQ`. Customer
payments settle the full remaining amount and mark the invoice paid; **supplier**
payments require an explicit `amount`. `account_id` is the bank account id — list
them with `scripts/bank-accounts.sh` (`ai_agent_sandbox` now holds `banque lire`).
**Always pass `transaction_id`** — the originating bank transaction id (the
Qonto/Wise tx id from the feed). It is the first-class way to tie a règlement to
the real bank movement: stored on the payment's bank line (`llx_bank.num_chq`), so
reconciliation matches **by id** rather than by fuzzy amount/date. (`num` is a
back-compat alias for the same field.) Recording without it prints a warning — the
payment still posts, but it won't auto-reconcile.
Emits **`{id, bank_transaction_id, transaction_id}`**. `bank_transaction_id` is the
Dolibarr bank line (`llx_bank.fk_bank_line`) the payment created — the id the
reconciliation (`arcodange-bank-reco`) keys on. Both ends are captured at write time.
### 4 · Credit note (avoir) — `scripts/creditnote-create.sh`
```sh
# customer avoir
echo '{"socid":42,"source_invoice":19,"validate":true,
"lines":[{"desc":"Avoir partiel conseil","qty":1,"price_ht":100,"tva":20,"type":"service"}]}' \
| scripts/creditnote-create.sh
# supplier avoir (avoir fournisseur)
echo '{"socid":12,"kind":"supplier","source_invoice":17,"ref_supplier":"AV-2026-77","validate":true,
"lines":[{"desc":"Avoir hosting","qty":1,"price_ht":120,"tva":20,"type":"service"}]}' \
| scripts/creditnote-create.sh
```
An invoice of `type=2` referencing `source_invoice` (`fk_facture_source`); amounts
come out negative. `kind:"supplier"` targets `/supplierinvoices` (carry
`ref_supplier`); default `customer` targets `/invoices`. `validate:true` numbers it
(`AVC…` for customer, `AVF…` for supplier). Emits `{id, ref, ref_supplier, total_ht,
total_ttc, fk_facture_source, statut}`.
## Promote to prod (rehearse → review → replay)
The ADR-0003 capstone: take a change rehearsed in the sandbox and apply the **same
operations** to prod, with a human in the loop. The unit is a **manifest** — a JSON
array of write ops using **symbolic refs** (`@name`) instead of ids, so it is
portable from sandbox to prod (an invoice references `@tp1`, the thirdparty created
earlier in the run). See `examples/promote-manifest.json`.
```sh
scripts/promote-plan.sh change.json # 1. human-readable review
scripts/promote-apply.sh change.json --target sandbox # 2. rehearse the replay (safe)
# 3. promote — writes PROD; the key is env-only and the confirm flag is mandatory:
DOLIBARR_PROD_WRITE_KEY="<prod write key>" \
ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \
scripts/promote-apply.sh change.json --target prod
```
`promote-apply` resolves each `@ref` to the id actually created during the run, so
dependent ops wire up on the target. `--target sandbox` writes via `dol-write.sh`;
`--target prod` writes via `dol-prod-write.sh`, which reads `DOLIBARR_PROD_WRITE_KEY`
**from the environment only** (never a stored `.env`) and refuses any write unless
`ARCO_PROMOTE_CONFIRM` is set exactly. Pair it with `dolibarr-data-snapshot` (prod
before/after) to confirm only the intended records changed.
A manifest value can reference another entity two ways, both resolved against the
**target** so the same file is portable sandbox↔prod:
- **`@ref`** — an entity **created earlier in this manifest** (resolves to the id
just created on the target). For new records.
- **`#entity:field=value`** — a **pre-existing** entity, looked up on the target by
business key, e.g. `#thirdparty:name=KissMetrics` or `#thirdparty:code=CL0007`.
Supports `thirdparty` (name/code/supplier_code) and `invoice`/`supplierinvoice`
(ref/ref_supplier). A lookup matching **nothing or more than one** record **aborts
the run** — it never guesses, so it can't write to the wrong entity.
So a real change like "invoice the existing client KissMetrics and record its
payment" is `{"socid":"#thirdparty:name=KissMetrics", ...}` — it resolves to the
sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`.
## Gotchas
- **Validate before paying.** A draft (`statut=0`, ref `PROV…`) cannot be paid.
- **Codes.** The thirdparty code module is `mod_codeclient_elephant` (auto). The
REST create needs `code_client`/`code_fournisseur = "-1"` to trigger it — the
script does this; without it the API errors `ErrorCustomerCodeRequired`.
- **Dates** are sent as Unix epochs; pass `date:"YYYY-MM-DD"` or omit for today.
- **`banque lire`** (rights id 111) is granted → `scripts/bank-accounts.sh` lists
accounts (id/label/bank) so a payment can pick its `account_id`. It's in the
provisioner's `WRITE_IDS`, so a fresh `provisionSandbox.ts` run includes it.
- **Avoirs (credit notes)** → `creditnote-create.sh` (customer invoice `type=2`
referencing `source_invoice`; amounts negative, ref `AVC…`). Supplier avoirs
are a follow-up.
- **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}`
(JSON on stdin) — `arcodange sandbox help` for the list.

View File

@@ -0,0 +1,9 @@
{
"socid": 42,
"kind": "customer",
"validate": true,
"lines": [
{ "desc": "Conseil (jours)", "qty": 2, "price_ht": 500, "tva": 20, "type": "service" },
{ "desc": "Licence annuelle", "qty": 1, "price_ht": 100, "tva": 20, "type": "product" }
]
}

View File

@@ -0,0 +1,13 @@
[
{ "op": "thirdparty", "ref": "tp1",
"input": { "name": "ACME Conseil", "role": "client", "tva_intra": "FR..." } },
{ "op": "invoice", "ref": "inv1",
"input": { "socid": "@tp1", "kind": "customer", "validate": true,
"lines": [ { "desc": "Prestation conseil", "qty": 2, "price_ht": 500, "tva": 20, "type": "service" },
{ "desc": "Licence annuelle", "qty": 1, "price_ht": 100, "tva": 20, "type": "product" } ] } },
{ "op": "payment",
"input": { "invoice_id": "@inv1", "mode": "VIR", "account_id": 1,
"transaction_id": "WISE-TX-2026-042", "comment": "Acompte" } }
]

View File

@@ -0,0 +1,14 @@
#!/usr/bin/env bash
# List the sandbox bank accounts (id + label) so a payment can pick its account_id.
# Read-only; needs the ai_agent_sandbox user to hold `banque lire` (rights id 111).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
"${W}" GET "/bankaccounts" | python3 -c "import json,sys
d = json.load(sys.stdin); rows = d if isinstance(d, list) else []
if not rows:
print('(no bank accounts defined)'); sys.exit(0)
print('%-4s %-34s %-10s %s' % ('id', 'label', 'bank', 'currency'))
for a in rows:
print('%-4s %-34s %-10s %s' % (a.get('id'), (a.get('label') or '')[:34],
a.get('bank') or '', a.get('currency_code') or ''))"

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# Create a credit note (avoir) in the SANDBOX — an invoice of type 2 that
# references the original invoice. Amounts come out negative. Customer avoir =
# /invoices; supplier avoir (avoir fournisseur) = /supplierinvoices.
#
# Input: a JSON object on stdin (or a file path in $1):
# socid (required) the third party (must match the source invoice's)
# source_invoice (required) the original invoice id being credited
# kind "customer" (default) | "supplier"
# ref_supplier (supplier only) the supplier's avoir reference (recommended —
# some supplier setups require it to validate)
# date "YYYY-MM-DD" (default today)
# validate true|false (default false = leave draft)
# lines: [ { desc, qty, price_ht, tva, type: "product"|"service", product_id? } ]
# the lines being credited (positive amounts; Dolibarr nets them as a credit)
#
# Emits {id, ref, ref_supplier, total_ht, total_ttc, fk_facture_source, statut}.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi
PYF="$(mktemp -t dolpy.XXXXXX)"; trap 'rm -f "${PYF}"' EXIT
cat > "${PYF}" <<'PY'
import json, sys, datetime
d = json.loads(sys.stdin.read())
for req in ("socid", "source_invoice"):
if not d.get(req):
sys.exit("creditnote-create.sh: '%s' is required" % req)
is_supplier = str(d.get("kind", "customer")).lower() in ("supplier", "fournisseur")
ds = d.get("date")
epoch = int((datetime.datetime.strptime(ds, "%Y-%m-%d") if ds
else datetime.datetime.now()).timestamp())
lines = []
for ln in d.get("lines", []):
is_product = ln.get("type", "service").lower() in ("product", "produit")
L = {
"desc": ln.get("desc", ""),
"subprice": str(ln.get("price_ht", ln.get("subprice", 0))),
"qty": str(ln.get("qty", 1)),
"tva_tx": str(ln.get("tva", ln.get("tva_tx", 20))),
"product_type": "0" if is_product else "1",
}
if ln.get("product_id"):
L["fk_product"] = str(ln["product_id"])
lines.append(L)
body = {"socid": d["socid"], "type": 2, "fk_facture_source": d["source_invoice"],
"date": epoch, "lines": lines}
if is_supplier and d.get("ref_supplier"):
body["ref_supplier"] = d["ref_supplier"]
print(json.dumps(body))
print("1" if d.get("validate") else "0")
print("/supplierinvoices" if is_supplier else "/invoices")
PY
MAPPED="$(printf '%s' "${INPUT}" | python3 "${PYF}")"
BODY="$(sed -n 1p <<<"${MAPPED}")"
VALIDATE="$(sed -n 2p <<<"${MAPPED}")"
ENDPOINT="$(sed -n 3p <<<"${MAPPED}")"
ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
echo "creditnote-create.sh: create did not return an id: ${ID}" >&2
exit 1
fi
if [[ "${VALIDATE}" == "1" ]]; then
"${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
fi
"${W}" GET "${ENDPOINT}/${ID}" | python3 -c "import json,sys
d=json.load(sys.stdin)
print(json.dumps({k:d.get(k) for k in ('id','ref','ref_supplier','total_ht','total_ttc','fk_facture_source','statut')}))"

View File

@@ -0,0 +1,39 @@
#!/usr/bin/env bash
# GATED prod-write wrapper — the ONLY path in this skill that writes PRODUCTION.
#
# Used solely by promote-apply.sh --target prod, at promotion time. Two deliberate
# frictions make accidental or autonomous prod writes impossible (ADR-0003):
# 1. The prod write key is read from the ENVIRONMENT (DOLIBARR_PROD_WRITE_KEY),
# never from a stored .env — a human must supply it per invocation.
# 2. Any write method (POST/PUT/DELETE/PATCH) is REFUSED unless ARCO_PROMOTE_CONFIRM
# equals exactly "I-UNDERSTAND-THIS-WRITES-PROD".
# GET is allowed (id read-back) but still needs the key.
#
# DOLIBARR_PROD_WRITE_KEY=… ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD \
# dol-prod-write.sh POST /thirdparties '{...}'
set -euo pipefail
PROD_URL="${DOLIBARR_PROD_URL:-https://erp.arcodange.lab}"
: "${DOLIBARR_PROD_WRITE_KEY:?dol-prod-write.sh: DOLIBARR_PROD_WRITE_KEY must be supplied in the environment (never stored)}"
if [[ $# -lt 2 ]]; then echo "usage: dol-prod-write.sh <METHOD> <path> [body]" >&2; exit 2; fi
METHOD="$1"; API_PATH="$2"; BODY="${3-}"
case "${METHOD}" in
GET) ;;
POST|PUT|DELETE|PATCH)
if [[ "${ARCO_PROMOTE_CONFIRM:-}" != "I-UNDERSTAND-THIS-WRITES-PROD" ]]; then
echo "dol-prod-write.sh: REFUSING ${METHOD} ${API_PATH} on PRODUCTION (${PROD_URL})." >&2
echo " Set ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD to authorize prod writes." >&2
exit 3
fi ;;
*) echo "dol-prod-write.sh: unsupported method '${METHOD}'" >&2; exit 2 ;;
esac
CURL_ARGS=( -sS -X "${METHOD}" -H "DOLAPIKEY: ${DOLIBARR_PROD_WRITE_KEY}" -H "Accept: application/json" --max-time 30 )
[[ -n "${BODY}" ]] && CURL_ARGS+=( -H "Content-Type: application/json" --data "${BODY}" )
BODY_FILE="$(mktemp -t dolprod.XXXXXX)"; trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE=$(curl "${CURL_ARGS[@]}" -o "${BODY_FILE}" -w "%{http_code}" "${PROD_URL}/api/index.php${API_PATH}")
cat "${BODY_FILE}"
if [[ "${HTTP_CODE}" -ge 400 ]]; then
echo "" >&2; echo "dol-prod-write.sh: HTTP ${HTTP_CODE} on ${METHOD} ${API_PATH}" >&2; exit 1
fi

View File

@@ -0,0 +1,82 @@
#!/usr/bin/env bash
# Host-guarded WRITE wrapper for the Arcodange Dolibarr SANDBOX API.
#
# THE GUARANTEE (ADR-0003): this wrapper REFUSES to run against anything that is
# not the erp-sandbox host. It is the structural reason an AI agent driving this
# skill can never mutate production — prod is a different host, and the guard
# below rejects it before any request is sent.
#
# Usage:
# dol-write.sh <METHOD> <path> [json-body|@file]
# dol-write.sh POST /thirdparties '{"name":"Acme","client":"1"}'
# dol-write.sh POST /invoices @invoice.json
# dol-write.sh PUT /thirdparties/42 '{"phone":"+33..."}'
# dol-write.sh GET /thirdparties?limit=5 # reads are allowed too
#
# Reads DOLIBARR_SANDBOX_URL + DOLIBARR_SANDBOX_API_KEY from the sibling .env
# (.claude/skills/dolibarr-sandbox-write/.env), mode 600, gitignored.
# Prints the response body to stdout; exits non-zero on HTTP >= 400.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
ENV_FILE="${SCRIPT_DIR}/../.env"
if [[ ! -f "${ENV_FILE}" ]]; then
echo "dol-write.sh: missing ${ENV_FILE}" >&2
echo " Create it with DOLIBARR_SANDBOX_URL + DOLIBARR_SANDBOX_API_KEY. See README.md." >&2
exit 2
fi
# shellcheck disable=SC1090
set -a; source "${ENV_FILE}"; set +a
: "${DOLIBARR_SANDBOX_URL:?dol-write.sh: DOLIBARR_SANDBOX_URL not set in .env}"
: "${DOLIBARR_SANDBOX_API_KEY:?dol-write.sh: DOLIBARR_SANDBOX_API_KEY not set in .env}"
# ---------------------------------------------------------------------------
# HOST GUARD — the structural safety invariant. Only the sandbox host passes.
# Override the allowed pattern only via DOLIBARR_SANDBOX_HOST_RE in .env if the
# sandbox FQDN ever changes; never widen it to include a prod host.
# ---------------------------------------------------------------------------
ALLOWED_RE="${DOLIBARR_SANDBOX_HOST_RE:-^https://erp-sandbox\.arcodange\.lab(/|$)}"
if [[ ! "${DOLIBARR_SANDBOX_URL}" =~ ${ALLOWED_RE} ]]; then
echo "dol-write.sh: REFUSING to write — DOLIBARR_SANDBOX_URL='${DOLIBARR_SANDBOX_URL}'" >&2
echo " is not the erp-sandbox host (allowed: ${ALLOWED_RE})." >&2
echo " This skill only writes the sandbox. Promotion to prod is a separate, human-gated step." >&2
exit 3
fi
if [[ $# -lt 2 ]]; then
echo "dol-write.sh: usage: dol-write.sh <METHOD> <path> [json-body|@file]" >&2
exit 2
fi
METHOD="$1"; API_PATH="$2"; BODY="${3-}"
case "${METHOD}" in
GET|POST|PUT|DELETE|PATCH) ;;
*) echo "dol-write.sh: unsupported method '${METHOD}'" >&2; exit 2 ;;
esac
CURL_ARGS=(
-sS -X "${METHOD}"
-H "DOLAPIKEY: ${DOLIBARR_SANDBOX_API_KEY}"
-H "Accept: application/json"
--max-time 30
)
if [[ -n "${BODY}" ]]; then
# curl --data supports '@file' to read a JSON body from a file.
CURL_ARGS+=( -H "Content-Type: application/json" --data "${BODY}" )
fi
BODY_FILE="$(mktemp -t dolwrite.XXXXXX)"
trap 'rm -f "${BODY_FILE}"' EXIT
HTTP_CODE=$(curl "${CURL_ARGS[@]}" \
-o "${BODY_FILE}" -w "%{http_code}" \
"${DOLIBARR_SANDBOX_URL}/api/index.php${API_PATH}")
cat "${BODY_FILE}"
if [[ "${HTTP_CODE}" -ge 400 ]]; then
echo "" >&2
echo "dol-write.sh: HTTP ${HTTP_CODE} on ${METHOD} ${API_PATH}" >&2
exit 1
fi

View File

@@ -0,0 +1,68 @@
#!/usr/bin/env bash
# Create a customer or supplier invoice (facture) with product/service lines in
# the SANDBOX, optionally validating it.
#
# Input: a JSON object on stdin (or a file path in $1):
# socid (required) thirdparty id
# kind "customer" | "supplier" (default "customer")
# date "YYYY-MM-DD" (default today)
# ref_supplier supplier's own invoice ref (supplier invoices)
# validate true|false (default false = leave draft)
# lines: [ { desc, qty, price_ht, tva, type: "product"|"service", product_id? } ]
#
# Emits {id, ref, ref_supplier, total_ht, total_ttc, statut} on stdout.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi
PYF="$(mktemp -t dolpy.XXXXXX)"; trap 'rm -f "${PYF}"' EXIT
cat > "${PYF}" <<'PY'
import json, sys, datetime
d = json.loads(sys.stdin.read())
if not d.get("socid"):
sys.exit("invoice-create.sh: 'socid' is required")
supplier = d.get("kind", "customer").lower() in ("supplier", "fournisseur")
endpoint = "/supplierinvoices" if supplier else "/invoices"
ds = d.get("date")
epoch = int((datetime.datetime.strptime(ds, "%Y-%m-%d") if ds
else datetime.datetime.now()).timestamp())
lines = []
for ln in d.get("lines", []):
is_product = ln.get("type", "service").lower() in ("product", "produit")
L = {
"desc": ln.get("desc", ""),
"subprice": str(ln.get("price_ht", ln.get("subprice", 0))),
"qty": str(ln.get("qty", 1)),
"tva_tx": str(ln.get("tva", ln.get("tva_tx", 20))),
"product_type": "0" if is_product else "1",
}
if ln.get("product_id"):
L["fk_product"] = str(ln["product_id"])
lines.append(L)
body = {"socid": d["socid"], "date": epoch, "type": 0, "lines": lines}
if supplier and d.get("ref_supplier"):
body["ref_supplier"] = d["ref_supplier"]
print(endpoint)
print(json.dumps(body))
print("1" if d.get("validate") else "0")
PY
MAPPED="$(printf '%s' "${INPUT}" | python3 "${PYF}")"
ENDPOINT="$(sed -n 1p <<<"${MAPPED}")"
BODY="$(sed -n 2p <<<"${MAPPED}")"
VALIDATE="$(sed -n 3p <<<"${MAPPED}")"
ID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
if [[ ! "${ID}" =~ ^[0-9]+$ ]]; then
echo "invoice-create.sh: create did not return an id: ${ID}" >&2
exit 1
fi
if [[ "${VALIDATE}" == "1" ]]; then
"${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
fi
"${W}" GET "${ENDPOINT}/${ID}" | python3 -c "import json,sys
d=json.load(sys.stdin)
print(json.dumps({k:d.get(k) for k in ('id','ref','ref_supplier','total_ht','total_ttc','statut')}))"

View File

@@ -0,0 +1,103 @@
#!/usr/bin/env bash
# Record a payment (règlement) on a validated invoice in the SANDBOX.
#
# Input: a JSON object on stdin (or a file path in $1):
# invoice_id (required) the invoice to pay
# kind "customer" | "supplier" (default "customer")
# mode "VIR" | "CB" | "CHQ" | "LIQ" (default "VIR")
# account_id (required) the bank account id receiving/paying
# date "YYYY-MM-DD" (default today)
# amount (REQUIRED for supplier; customer pays the full remaining)
# transaction_id (recommended) the originating bank transaction id (the Qonto/Wise
# tx id from the feed). Stored on the payment's bank line
# (llx_bank.num_chq) so the règlement reconciles to the feed by id.
# `num` is a back-compat alias for the same field.
# comment (optional)
#
# The invoice must be VALIDATED first (invoice-create.sh ... "validate":true).
# Emits {id, bank_transaction_id, transaction_id} on stdout. `bank_transaction_id`
# is the Dolibarr bank line (llx_bank.fk_bank_line) the payment created — the id
# bank reconciliation (arcodange-bank-reco) keys on to link this règlement to a
# statement line. Recording without a transaction_id warns (it won't auto-reconcile).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi
PYF="$(mktemp -t dolpy.XXXXXX)"; PYF2="$(mktemp -t dolpy2.XXXXXX)"
trap 'rm -f "${PYF}" "${PYF2}"' EXIT
cat > "${PYF}" <<'PY'
import json, sys, datetime
d = json.loads(sys.stdin.read())
if not d.get("invoice_id"):
sys.exit("payment-record.sh: 'invoice_id' is required")
if not d.get("account_id"):
sys.exit("payment-record.sh: 'account_id' is required")
# Stable Dolibarr c_paiement ids (sandbox seeded from prod / standard defaults).
MODE = {"VIR": 2, "CB": 6, "CHQ": 7, "LIQ": 4}
mode = MODE.get(str(d.get("mode", "VIR")).upper())
if mode is None:
sys.exit("payment-record.sh: unknown mode (use VIR|CB|CHQ|LIQ)")
supplier = d.get("kind", "customer").lower() in ("supplier", "fournisseur")
ds = d.get("date")
epoch = int((datetime.datetime.strptime(ds, "%Y-%m-%d") if ds
else datetime.datetime.now()).timestamp())
inv = d["invoice_id"]
# transaction_id is the first-class bank-feed tx id; num is the back-compat alias.
tx = str(d.get("transaction_id") or d.get("num") or "")
if not tx:
sys.stderr.write("payment-record.sh: WARNING — no transaction_id given; this "
"règlement won't auto-reconcile to the bank feed\n")
if supplier:
if d.get("amount") is None:
sys.exit("payment-record.sh: supplier payments require an 'amount'")
endpoint = "/supplierinvoices/%s/payments" % inv
body = {"datepaye": epoch, "payment_mode_id": mode, "closepaidinvoices": "yes",
"accountid": d["account_id"],
"amount": str(d["amount"]), "num_payment": tx,
"comment": d.get("comment", "")}
else:
endpoint = "/invoices/%s/payments" % inv
body = {"datepaye": epoch, "paymentid": mode, "closepaidinvoices": "yes",
"accountid": d["account_id"], "num_payment": tx,
"comment": d.get("comment", "")}
print(endpoint)
print(json.dumps(body))
print(tx)
PY
# Correlate the created payment back to its bank transaction line. The payments
# list carries fk_bank_line but not the paiement rowid, so match on the provided
# transaction_id (the external bank ref), else fall back to the most recent line.
cat > "${PYF2}" <<'PY'
import json, sys, os
rows = json.load(sys.stdin); rows = rows if isinstance(rows, list) else []
tx = os.environ.get("TX", ""); pid = int(os.environ["PAYID"])
pick = None
if tx:
cand = [r for r in rows if str(r.get("num", "")) == tx]
if cand:
pick = cand[-1]
if pick is None and rows:
pick = max(rows, key=lambda r: r.get("date", ""))
btx = (pick or {}).get("fk_bank_line")
print(json.dumps({"id": pid,
"bank_transaction_id": int(btx) if btx and str(btx).isdigit() else btx,
"transaction_id": (pick or {}).get("num", "")}))
PY
MAPPED="$(printf '%s' "${INPUT}" | python3 "${PYF}")"
ENDPOINT="$(sed -n 1p <<<"${MAPPED}")"
BODY="$(sed -n 2p <<<"${MAPPED}")"
TX="$(sed -n 3p <<<"${MAPPED}")"
PAYID="$("${W}" POST "${ENDPOINT}" "${BODY}")"
if [[ ! "${PAYID}" =~ ^[0-9]+$ ]]; then
echo "payment-record.sh: payment POST did not return an id: ${PAYID}" >&2
exit 1
fi
# Same path serves the GET list; resolve fk_bank_line and emit the enriched record.
"${W}" GET "${ENDPOINT}" | PAYID="${PAYID}" TX="${TX}" python3 "${PYF2}"

View File

@@ -0,0 +1,111 @@
#!/usr/bin/env bash
# Replay a promote manifest against a target — sandbox (rehearsal) or prod (real).
#
# Resolves the manifest's symbolic @refs to the ids actually created during this
# run, so dependent ops (an invoice -> its just-created thirdparty) wire up on the
# target. Sandbox uses dol-write.sh; prod uses the gated dol-prod-write.sh.
#
# promote-apply.sh <manifest.json> [--target sandbox|prod]
#
# --target prod requires, in the environment (never stored):
# DOLIBARR_PROD_WRITE_KEY=<prod write key>
# ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
MANIFEST="${1:?usage: promote-apply.sh <manifest.json> [--target sandbox|prod]}"; shift || true
TARGET="sandbox"
while [[ $# -gt 0 ]]; do
case "$1" in
--target) TARGET="${2:?}"; shift 2 ;;
*) echo "promote-apply.sh: unknown arg '$1'" >&2; exit 2 ;;
esac
done
case "${TARGET}" in
sandbox) export DOL_WRITE="${SCRIPT_DIR}/dol-write.sh" ;;
prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;;
*) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;;
esac
echo ">>> promote-apply target=${TARGET} (writes via $(basename "${DOL_WRITE}"))" >&2
python3 - "$MANIFEST" "$SCRIPT_DIR" <<'PY'
import json, sys, subprocess, os
manifest_path, script_dir = sys.argv[1], sys.argv[2]
ops = json.load(open(manifest_path))
OP_SCRIPT = {"thirdparty": "thirdparty-create.sh", "invoice": "invoice-create.sh",
"creditnote": "creditnote-create.sh", "payment": "payment-record.sh"}
refmap = {}
import urllib.parse
DOL_WRITE = os.environ.get("DOL_WRITE") # GET wrapper for the chosen target
# Business-key lookup of a PRE-EXISTING entity on the target, so a manifest can
# reference records it does not create (e.g. an invoice for an existing client).
# Resolves against the *target*, so #thirdparty:name=X becomes the sandbox id on
# --target sandbox and the prod id on --target prod (portability for real changes).
ENTITY_LOOKUP = {
"thirdparty": ("/thirdparties", {"name": "t.nom", "code": "t.code_client",
"supplier_code": "t.code_fournisseur"}),
"invoice": ("/invoices", {"ref": "t.ref"}),
"supplierinvoice": ("/supplierinvoices", {"ref": "t.ref", "ref_supplier": "t.ref_supplier"}),
}
def lookup(spec):
try:
ent, rest = spec.split(":", 1); field, value = rest.split("=", 1)
except ValueError:
sys.exit("promote-apply: bad lookup '#%s' (use #entity:field=value)" % spec)
if ent not in ENTITY_LOOKUP:
sys.exit("promote-apply: unknown lookup entity '%s'" % ent)
endpoint, cols = ENTITY_LOOKUP[ent]
if field not in cols:
sys.exit("promote-apply: cannot look %s up by '%s' (try %s)" % (ent, field, "/".join(cols)))
flt = "(%s:=:'%s')" % (cols[field], value.replace("'", "''"))
path = "%s?limit=2&sqlfilters=%s" % (endpoint, urllib.parse.quote(flt))
r = subprocess.run([DOL_WRITE, "GET", path], capture_output=True, text=True, env=os.environ)
if r.returncode != 0:
sys.stderr.write(r.stdout + r.stderr + "\n")
sys.exit("promote-apply: lookup '#%s' query failed" % spec)
try:
rows = json.loads(r.stdout)
except Exception:
rows = []
rows = rows if isinstance(rows, list) else []
if len(rows) == 0:
sys.exit("promote-apply: lookup '#%s' matched nothing on the target" % spec)
if len(rows) > 1:
sys.exit("promote-apply: lookup '#%s' is ambiguous (%d matches) — use a unique key" % (spec, len(rows)))
return int(rows[0]["id"])
def resolve(v):
if isinstance(v, str) and v.startswith("@"):
k = v[1:]
if k not in refmap:
sys.exit("promote-apply: unresolved ref @%s (is it created earlier in the manifest?)" % k)
return refmap[k]
if isinstance(v, str) and v.startswith("#"):
return lookup(v[1:])
if isinstance(v, dict):
return {kk: resolve(vv) for kk, vv in v.items()}
if isinstance(v, list):
return [resolve(x) for x in v]
return v
for i, op in enumerate(ops, 1):
t = op["op"]; script = OP_SCRIPT.get(t)
if not script:
sys.exit("promote-apply: unknown op '%s'" % t)
inp = resolve(op.get("input", {}))
r = subprocess.run([os.path.join(script_dir, script)], input=json.dumps(inp),
capture_output=True, text=True, env=os.environ)
if r.returncode != 0:
sys.stderr.write(r.stdout + r.stderr + "\n")
sys.exit("promote-apply: op %d (%s) FAILED" % (i, t))
out = r.stdout.strip()
try:
rid = json.loads(out).get("id")
except Exception:
rid = out if out.isdigit() else None
ref = op.get("ref")
if ref and rid is not None:
refmap[ref] = int(rid) if str(rid).isdigit() else rid
print(" [%d/%d] %-11s %-8s -> id=%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid))
print("OK — promote complete. ref -> id: %s" % json.dumps(refmap))
PY

View File

@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# Render a promote manifest as a human-readable change-set for review.
#
# A manifest is a JSON array of operations using SYMBOLIC refs (@name) instead of
# ids, so the SAME manifest replays on sandbox or prod:
# [ {"op":"thirdparty","ref":"tp1","input":{"name":"OVH","role":"supplier"}},
# {"op":"invoice","ref":"inv1","input":{"socid":"@tp1","kind":"supplier",
# "ref_supplier":"X","validate":true,"lines":[{"desc":"Hosting","qty":1,"price_ht":80,"tva":20,"type":"service"}]}},
# {"op":"payment","input":{"invoice_id":"@inv1","mode":"VIR","account_id":1}} ]
set -euo pipefail
MANIFEST="${1:?usage: promote-plan.sh <manifest.json>}"
python3 - "$MANIFEST" <<'PY'
import json, sys
ops = json.load(open(sys.argv[1]))
print("Promote plan — %d operation(s) (symbolic refs resolve at apply time):\n" % len(ops))
for i, op in enumerate(ops, 1):
t = op["op"]; inp = op.get("input", {}); ref = op.get("ref")
print(" %d. %s%s" % (i, t, (" => @%s" % ref) if ref else ""))
if t == "thirdparty":
print(" name=%r role=%s%s" % (inp.get("name"), inp.get("role", "client"),
(" tva=%s" % inp["tva_intra"]) if inp.get("tva_intra") else ""))
elif t == "invoice":
print(" socid=%s kind=%s%s validate=%s" % (inp.get("socid"), inp.get("kind", "customer"),
(" ref_supplier=%s" % inp["ref_supplier"]) if inp.get("ref_supplier") else "", bool(inp.get("validate"))))
for ln in inp.get("lines", []):
print(" - %r qty=%s pu_ht=%s tva=%s%% [%s]" % (ln.get("desc", ""), ln.get("qty", 1),
ln.get("price_ht", ln.get("subprice")), ln.get("tva", ln.get("tva_tx", 20)), ln.get("type", "service")))
elif t == "creditnote":
print(" socid=%s source_invoice=%s validate=%s" % (inp.get("socid"), inp.get("source_invoice"), bool(inp.get("validate"))))
for ln in inp.get("lines", []):
print(" - %r qty=%s pu_ht=%s tva=%s%%" % (ln.get("desc", ""), ln.get("qty", 1),
ln.get("price_ht", ln.get("subprice")), ln.get("tva", ln.get("tva_tx", 20))))
elif t == "payment":
txid = inp.get("transaction_id") or inp.get("num")
print(" invoice=%s mode=%s account=%s %s%s" % (inp.get("invoice_id"), inp.get("mode", "VIR"),
inp.get("account_id"), ("amount=%s" % inp["amount"]) if inp.get("amount") else "(full)",
(" tx=%s" % txid) if txid else " tx=MISSING"))
print("\nNext:")
print(" promote-apply.sh <manifest> --target sandbox # rehearse the replay (safe)")
print(" promote-apply.sh <manifest> --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY")
print(" # + ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD")
PY

View File

@@ -0,0 +1,50 @@
#!/usr/bin/env bash
# Create a client and/or supplier thirdparty (fiche tiers) in the SANDBOX.
#
# Input: a JSON object on stdin (or a file path in $1). Fields:
# name (required)
# role "client" | "supplier" | "both" (default "client")
# country_id numeric, default 1 (France)
# client_code / supplier_code default "-1" = auto-generate via the code mask
# siret, tva_intra, address, zip, town, email, phone, idprof1 (optional)
#
# Emits the new thirdparty id on stdout. All writes go through dol-write.sh,
# which refuses any host that is not the sandbox.
#
# Examples:
# echo '{"name":"KissMetrics","role":"client","tva_intra":"US.."}' | thirdparty-create.sh
# thirdparty-create.sh fournisseur.json
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}"
SRC="${1:-}"
if [[ -n "${SRC}" && "${SRC}" != "-" ]]; then INPUT="$(cat "${SRC}")"; else INPUT="$(cat)"; fi
PYF="$(mktemp -t dolpy.XXXXXX)"; trap 'rm -f "${PYF}"' EXIT
cat > "${PYF}" <<'PY'
import json, sys
d = json.loads(sys.stdin.read())
if not d.get("name"):
sys.exit("thirdparty-create.sh: 'name' is required")
role = d.get("role", "client").lower()
is_client = role in ("client", "both")
is_supp = role in ("supplier", "fournisseur", "both")
body = {
"name": d["name"],
"client": "1" if is_client else "0",
"fournisseur": "1" if is_supp else "0",
"country_id": str(d.get("country_id", 1)),
# "-1" => Dolibarr auto-assigns the next code from the mask
# (COMPANY_ELEPHANT_MASK_CUSTOMER / _SUPPLIER); "0" when that role is off.
"code_client": (d.get("client_code", "-1") if is_client else "0"),
"code_fournisseur": (d.get("supplier_code", "-1") if is_supp else "0"),
}
for k in ("siret", "tva_intra", "address", "zip", "town", "email", "phone", "idprof1"):
if d.get(k):
body[k] = d[k]
print(json.dumps(body))
PY
BODY="$(printf '%s' "${INPUT}" | python3 "${PYF}")"
"${W}" POST /thirdparties "${BODY}"

View File

@@ -0,0 +1,104 @@
---
name: dolibarr-thirdparty-completeness
description: Audit the completeness of any Arcodange Dolibarr thirdparty (client or supplier) against country-specific French / EU / extra-EU rules. Two workflows — (1) audit one thirdparty by socid (generalized from the V1 KissMetrics-only audit), with country-aware checklist : FR requires SIREN + SIRET + tva_intra (if VAT-registered), EU non-FR requires tva_intra for B2B autoliquidation, extra-EU requires a national tax id in idprof1; (2) audit ALL visible thirdparties in one shot, compact table showing each one's mandatory gaps and exit code reflecting whether the dataset is fully compliant. Use when the user asks "audit fiche client", "audit fiche fournisseur", "complétude des tiers", "qui n'a pas son SIRET / TVA intra", "qualité des données tiers Dolibarr". Supersedes the V1 dolibarr-invoice-audit/scripts/audit-km-thirdparty.sh (KM-hardcoded) by handling any socid and any country. Depends on the `dolibarr` skill. SKIP for write/correction tasks (use the Dolibarr UI), for invoice-side mandatory mentions (handled by `dolibarr-invoice-audit`), and for TVA computation (handled by `dolibarr-tva-reconciliation` / `dolibarr-tva-deductible`).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-thirdparty-completeness — generalized thirdparty audit
**CLI shortcuts:** `bin/arcodange thirdparty audit <socid>` · `bin/arcodange thirdparty audit-all`
Replaces the V1 KM-only `dolibarr-invoice-audit/scripts/audit-km-thirdparty.sh` with a generalized, country-aware audit that scales as Arcodange adds more clients and suppliers.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## The country-aware checklist
| Country | Mandatory | Optional but useful |
|---|---|---|
| **FR** | name, address, zip, town, country_code, **SIREN** (idprof1), **SIRET** (idprof2), **tva_intra** (if supplier or VAT-registered) | APE (idprof3), email, phone, url, IBAN |
| **EU non-FR** | name, address, zip, town, country_code, **tva_intra** (required for B2B autoliquidation under article 196 directive TVA) | national registration id (idprof1), email, phone, url |
| **Extra-EU** | name, address, zip, town, country_code, **national tax id** (idprof1 — EIN for US, equivalent elsewhere) | idprof2, email, phone, url |
The rules mirror the field a French accountant would expect to file on monthly TVA declarations and on the customer file as audit evidence.
## Workflow 1 — Audit one thirdparty
```bash
./scripts/audit-thirdparty.sh 1 # KissMetrics (US, client)
./scripts/audit-thirdparty.sh 2 # Wise Europe SA (BE, supplier)
./scripts/audit-thirdparty.sh 5 # Medialex (FR, supplier)
echo "exit: $?" # 0 = complete, 1 = mandatory gap
```
Live output for the FR-supplier case (captured at [examples/audit-thirdparty-5.txt](examples/audit-thirdparty-5.txt)):
```
================================================================================
Thirdparty 5 — Medialex [supplier, country=FR]
================================================================================
[OK] name = 'Medialex'
[OK] address = '10 rue du Breil'
[OK] zip = '35063'
[OK] town = 'Rennes'
[OK] country_code = 'FR'
[OK] idprof1 (SIREN) = '353403074'
[XX] idprof2 (SIRET) = ''
[OK] idprof3 (APE) = '7312Z' (optional)
[XX] tva_intra (VAT) = ''
...
8 pass, 2 mandatory fail(s), 2 optional unset
```
Status codes:
- `[OK]` — value present.
- `[XX]` — mandatory and missing.
- `[--]` — optional and unset.
## Workflow 2 — Audit all visible thirdparties
```bash
./scripts/audit-all-thirdparties.sh # everyone
./scripts/audit-all-thirdparties.sh --clients-only
./scripts/audit-all-thirdparties.sh --suppliers-only
echo "exit: $?" # 0 if dataset is fully compliant
```
Live output (captured at [examples/audit-all-thirdparties.txt](examples/audit-all-thirdparties.txt)) — **5 / 10 thirdparties have mandatory gaps**:
```
id name cnty role missing
--------------------------------------------------------------------------------------------------------------
1 KissMetrics US client tax_id
2 Wise Europe SA BE supplier tva_intra
3 Greffe du tribunal de commerce Évry FR supplier (complete)
4 YOLAW FR supplier (complete)
5 Medialex FR supplier SIRET, tva_intra
6 Qonto FR supplier SIRET
7 OVH FR supplier (complete)
8 La Poste FR supplier (complete)
9 Infogreffe FR supplier SIRET
10 Darnis Operations FR supplier (complete)
--------------------------------------------------------------------------------------------------------------
# 10 thirdparties audited, 5 with mandatory gaps
```
The 5 gaps to surface to the cohort review / accountant:
- **KissMetrics**: US tax id (EIN) — already known from V1, still pending.
- **Wise Europe SA**: BE tva_intra missing (mandatory for intra-UE B2B autoliquidation; currently the FAF2026001 supplier invoice is recorded with TVA=0 but no tva_intra, which the accountant will flag).
- **Medialex**: SIRET + tva_intra. SIREN is present so the SIRET should be derivable (SIRET = SIREN + 5-digit NIC). The tva_intra is FR + 2 chars + SIREN.
- **Qonto, Infogreffe**: SIRET missing on otherwise-complete records.
Fixing these is a Dolibarr UI task — this skill is read-only.
## Relationship with V1
The V1 `dolibarr-invoice-audit/scripts/audit-km-thirdparty.sh` is kept for backward compatibility but is now a special case of this skill. Recommendation: invoke `./scripts/audit-thirdparty.sh 1` from this skill instead of the V1 script for any new automation.
## Out of scope
- **Writes / corrections.** The API key is read-only. Fix via the Dolibarr UI.
- **VAT-number validation** against the EU VIES service. Future enhancement: `tva_intra` could be looked up against `https://ec.europa.eu/taxation_customs/vies/services/checkVatService` to validate the number. Out of scope here (external API).
- **idprof normalization across forks** of Dolibarr. The idprof1..6 slots can be reordered or relabeled by Dolibarr admins. This skill assumes the stock French Dolibarr mapping (idprof1=SIREN, idprof2=SIRET, idprof3=APE).

View File

@@ -0,0 +1,14 @@
id name cnty role missing
--------------------------------------------------------------------------------------------------------------
1 KissMetrics US client tax_id
2 Wise Europe SA BE supplier tva_intra
3 Greffe du tribunal de commerce Évry FR supplier (complete)
4 YOLAW FR supplier (complete)
5 Medialex FR supplier SIRET, tva_intra
6 Qonto FR supplier SIRET
7 OVH FR supplier (complete)
8 La Poste FR supplier (complete)
9 Infogreffe FR supplier SIRET
10 Darnis Operations FR supplier (complete)
--------------------------------------------------------------------------------------------------------------
# 10 thirdparties audited, 5 with mandatory gaps

View File

@@ -0,0 +1,16 @@
================================================================================
Thirdparty 1 — KissMetrics [client, country=US]
================================================================================
[OK] name = 'KissMetrics'
[OK] address = '2850 34th Street North, 307'
[OK] zip = '33713'
[OK] town = 'St. Petersburg'
[OK] country_code = 'US'
[XX] idprof1 (tax id / EIN) = ''
[--] idprof2 = '' (optional)
[OK] email = 'evan@kissmetrics.io' (optional)
[--] phone = None (optional)
[--] url = None (optional)
[--] iban = (not set) (optional)
6 pass, 1 mandatory fail(s), 3 optional unset

View File

@@ -0,0 +1,16 @@
================================================================================
Thirdparty 2 — Wise Europe SA [supplier, country=BE]
================================================================================
[OK] name = 'Wise Europe SA'
[OK] address = 'Rue du Trône 100, 3rd floor'
[OK] zip = '1050'
[OK] town = 'Brussels'
[OK] country_code = 'BE'
[XX] tva_intra (VAT EU) = ''
[--] idprof1 (national reg) = '' (optional)
[--] email = None (optional)
[--] phone = None (optional)
[OK] url = 'wise.com' (optional)
[--] iban = (not set) (optional)
6 pass, 1 mandatory fail(s), 3 optional unset

View File

@@ -0,0 +1,18 @@
================================================================================
Thirdparty 5 — Medialex [supplier, country=FR]
================================================================================
[OK] name = 'Medialex'
[OK] address = '10 rue du Breil'
[OK] zip = '35063'
[OK] town = 'Rennes'
[OK] country_code = 'FR'
[OK] idprof1 (SIREN) = '353403074'
[XX] idprof2 (SIRET) = ''
[OK] idprof3 (APE) = '7312Z' (optional)
[XX] tva_intra (VAT) = ''
[--] email = None (optional)
[OK] phone = '0299264200' (optional)
[--] url = None (optional)
[--] iban = (not set) (optional)
8 pass, 2 mandatory fail(s), 2 optional unset

View File

@@ -0,0 +1,92 @@
#!/usr/bin/env bash
# Audit every visible Arcodange thirdparty for completeness.
#
# Usage:
# audit-all-thirdparties.sh [--clients-only] [--suppliers-only]
#
# Iterates /thirdparties, runs the per-id audit logic inline (one HTTP call
# per thirdparty), prints a compact table: id, name, country, role,
# mandatory-fail count, optional-unset count, top missing fields. Exits 0
# only if every visible thirdparty has zero mandatory failures.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
FILTER="all"
while [[ $# -gt 0 ]]; do
case "$1" in
--clients-only) FILTER="client"; shift ;;
--suppliers-only) FILTER="supplier"; shift ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "audit-all-thirdparties.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t dolaud.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/thirdparties?limit=500' > "${WORK}/list.json"
IDS=$(python3 -c "
import json,sys
print(' '.join(str(t['id']) for t in json.load(open(sys.argv[1])) if t.get('id')))
" "${WORK}/list.json")
mkdir -p "${WORK}/tp"
for id in ${IDS}; do "${DOL_CURL}" "/thirdparties/${id}" > "${WORK}/tp/${id}.json"; done
python3 - "${WORK}" "${FILTER}" <<'PY'
import json, sys, os
work, filt = sys.argv[1], sys.argv[2]
EU = set("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
def audit(d):
cnty = d.get("country_code") or ""
is_client = str(d.get("client") or "0") in ("1","2","3")
is_supplier = str(d.get("fournisseur") or "0") == "1"
# Build the same rules as audit-thirdparty.sh (keep in sync)
mandatory_missing = []
for label, val, mandatory in [
("name", d.get("name"), True),
("address", d.get("address"), True),
("zip", d.get("zip"), True),
("town", d.get("town"), True),
("country", d.get("country_code"), True),
]:
if mandatory and not (val not in (None, "", "0")): mandatory_missing.append(label)
if cnty == "FR":
for label, val in (("SIREN", d.get("idprof1")), ("SIRET", d.get("idprof2"))):
if not val: mandatory_missing.append(label)
if is_supplier and not d.get("tva_intra"):
mandatory_missing.append("tva_intra")
elif cnty in EU and cnty:
if not d.get("tva_intra"): mandatory_missing.append("tva_intra")
elif cnty:
if not d.get("idprof1"): mandatory_missing.append("tax_id")
role_bits = []
if is_client: role_bits.append("client")
if is_supplier: role_bits.append("supplier")
role = "/".join(role_bits) or "-"
return cnty, role, mandatory_missing
rows = []
for fn in sorted(os.listdir(os.path.join(work, "tp")), key=lambda f: int(f[:-len(".json")])):
try: d = json.load(open(os.path.join(work, "tp", fn)))
except json.JSONDecodeError: continue
cnty, role, missing = audit(d)
if filt == "client" and "client" not in role: continue
if filt == "supplier" and "supplier" not in role: continue
rows.append((d.get("id"), d.get("name") or d.get("ref"), cnty, role, missing))
print(f"{'id':>3} {'name':<35} {'cnty':<4} {'role':<16} {'missing'}")
print("-" * 110)
fails = 0
for iid, name, cnty, role, missing in rows:
miss = ", ".join(missing) if missing else "(complete)"
print(f"{iid:>3} {(name or '-')[:35]:<35} {cnty:<4} {role:<16} {miss}")
if missing: fails += 1
print("-" * 110)
print(f"# {len(rows)} thirdparties audited, {fails} with mandatory gaps")
sys.exit(0 if fails == 0 else 1)
PY

View File

@@ -0,0 +1,113 @@
#!/usr/bin/env bash
# Country-aware completeness audit for one Arcodange thirdparty.
#
# Usage:
# audit-thirdparty.sh <socid>
#
# Replaces the V1 dolibarr-invoice-audit/scripts/audit-km-thirdparty.sh
# (which hardcoded socid=1) with a generalized version that:
# - Detects whether the thirdparty is a client / supplier / both
# - Applies country-specific completeness rules
# FR → SIREN (idprof1) + SIRET (idprof2) + tva_intra (if VAT-registered)
# EU non-FR → tva_intra required for B2B autoliquidation
# Extra-EU → EIN-equivalent in idprof1 (or note that there's no enforceable rule)
# - Checks email / phone / url / IBAN where applicable
#
# Exits 0 if every applicable field is populated, 1 otherwise.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
if [[ $# -lt 1 ]]; then
echo "audit-thirdparty.sh: missing socid. Usage: audit-thirdparty.sh <socid>" >&2
exit 2
fi
SOCID="$1"
TMP_JSON="$(mktemp -t doltp.XXXXXX.json)"
trap 'rm -f "${TMP_JSON}"' EXIT
"${DOL_CURL}" "/thirdparties/${SOCID}" > "${TMP_JSON}"
python3 - "${TMP_JSON}" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
if not d.get("id"):
print(f"audit-thirdparty.sh: no thirdparty at id={sys.argv[1]}", file=sys.stderr)
sys.exit(2)
EU = set("AT BE BG HR CY CZ DK EE FI FR DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
socid = d.get("id")
name = d.get("name") or d.get("ref")
cnty = d.get("country_code") or ""
is_client = str(d.get("client") or "0") in ("1", "2", "3") # 1=client, 2=prospect, 3=both
is_supplier = str(d.get("fournisseur") or "0") == "1"
role_bits = []
if is_client: role_bits.append("client")
if is_supplier: role_bits.append("supplier")
role = "/".join(role_bits) or "neither"
# What completeness rules apply depends on country + role
def rules_for(cnty, is_client, is_supplier):
rules = []
# Universal
rules.append(("name", d.get("name"), True))
rules.append(("address", d.get("address"), True))
rules.append(("zip", d.get("zip"), True))
rules.append(("town", d.get("town"), True))
rules.append(("country_code", d.get("country_code"), True))
# Country-specific identifiers
if cnty == "FR":
rules.append(("idprof1 (SIREN)", d.get("idprof1"), True))
rules.append(("idprof2 (SIRET)", d.get("idprof2"), True))
rules.append(("idprof3 (APE)", d.get("idprof3"), False)) # nice-to-have
rules.append(("tva_intra (VAT)", d.get("tva_intra"), is_supplier)) # mandatory if supplier
elif cnty in EU and cnty:
rules.append(("tva_intra (VAT EU)", d.get("tva_intra"), True)) # autoliquidation requires it
rules.append(("idprof1 (national reg)", d.get("idprof1"), False))
elif cnty:
# Non-EU
rules.append(("idprof1 (tax id / EIN)", d.get("idprof1"), True))
rules.append(("idprof2", d.get("idprof2"), False))
# Contact
rules.append(("email", d.get("email"), False))
rules.append(("phone", d.get("phone"), False))
rules.append(("url", d.get("url"), False))
return rules
rules = rules_for(cnty, is_client, is_supplier)
print("=" * 80)
print(f" Thirdparty {socid} — {name} [{role}, country={cnty or '?'}]")
print("=" * 80)
mandatory_fails = 0
optional_fails = 0
for label, value, mandatory in rules:
ok = value not in (None, "", "0")
flag = "OK" if ok else ("XX" if mandatory else "--")
suffix = "" if mandatory else " (optional)"
print(f" [{flag}] {label:<22} = {value!r}{suffix}")
if not ok:
if mandatory: mandatory_fails += 1
else: optional_fails += 1
# Surface what bank account info we have, if any
account_iban = d.get("iban") or ""
if account_iban:
print(f" [OK] iban = {account_iban!r}")
elif is_client or is_supplier:
print(f" [--] iban = (not set) (optional)")
ao = d.get("array_options") or {}
if ao:
print(f" array_options (extrafields): {ao}")
print()
print(f" {len(rules) - mandatory_fails - optional_fails} pass, {mandatory_fails} mandatory fail(s), {optional_fails} optional unset")
sys.exit(0 if mandatory_fails == 0 else 1)
PY

View File

@@ -0,0 +1,115 @@
---
name: dolibarr-tva-deductible
description: Prepare the TVA déductible side of the French monthly declaration (CA3 lignes 19 / 20 / 17 / 24) from Arcodange supplier invoices (`/supplierinvoices`). Two workflows — (1) per-period basis × rate aggregation (HT and TVA déductible grouped by year-month and tva_tx) ready to transcribe onto CA3 lignes 20 (standard 20 %), 19 (taux réduits), 17+24 (autoliquidation intra-UE); (2) per-line audit trail with supplier country classification (FR domestic / EU intra-UE / extra-EU import). Mirrors `dolibarr-tva-reconciliation` on the supplier side — TVA collectée vs TVA déductible = TVA nette à reverser. Today on Arcodange the TVA déductible totals ~223 €/all-time across 12 supplier-side 20 % lines plus 1 autoliquidation intra-UE entry (Wise BE) and 1 FR exempt line (timbres La Poste). Use when the user asks "préparer TVA déductible", "achats du mois", "TVA à récupérer", "réconciliation TVA fournisseur", "combien de TVA nette à reverser". Depends on the `dolibarr` skill. SKIP for TVA collectée (handled by `dolibarr-tva-reconciliation`), for the customer-side audit (different skills), and for writes (the declaration goes through impots.gouv.fr).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-tva-deductible — supplier-side TVA monthly preparation
**CLI shortcuts:** `bin/arcodange tva deductible` · `bin/arcodange tva deductible-detail` (or the combined `bin/arcodange tva summary`)
The mirror of [dolibarr-tva-reconciliation](../dolibarr-tva-reconciliation/SKILL.md) on the supplier side. Together they give you the two numbers a French CA3 needs:
- **TVA collectée** (`dolibarr-tva-reconciliation`) — what Arcodange invoiced and is obligated to remit.
- **TVA déductible** (this skill) — what Arcodange paid to suppliers and can deduct.
The net to remit is the difference. **Read-only**: the declaration itself goes through impots.gouv.fr.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## CA3 mapping (supplier side)
| Bucket | Condition | CA3 line(s) |
|---|---|---|
| **20 % standard** | `tva_tx == 20` | ligne 20 |
| **10 % intermediate** | `tva_tx == 10` | ligne 19 |
| **5.5 % reduced** | `tva_tx == 5.5` | ligne 19 |
| **2.1 % special** | `tva_tx == 2.1` | ligne 19 |
| **Intra-UE autoliquidation** | `tva_tx == 0` AND supplier in EU (excl. FR) | ligne 17 (TVA auto-collectée) + ligne 24 (TVA déductible) |
| **FR exempt / HT-only** | `tva_tx == 0` AND supplier country == FR | (e.g. timbres La Poste — no TVA line) |
| **Extra-EU import** | `tva_tx == 0` AND supplier outside EU | ligne 7 (import TVA — usually via customs declaration) |
## API gotchas to know
- **`/supplierinvoices/{id}/lines` returns HTTP 403** for `ai_agent`. We don't need it — lines are also included inline on `/supplierinvoices/{id}`. (Reference: [../dolibarr/examples/acl_403_supplier_lines.json](../dolibarr/examples/acl_403_supplier_lines.json).)
- **`/supplierinvoices` list works fine** — no `voir_tous` gap on supplier invoices the way V1 hit on customer ones.
## Workflow 1 — Aggregate per month × rate (the CA3 basis)
```bash
./scripts/deductible-by-month.sh # all-time
./scripts/deductible-by-month.sh --year 2026
./scripts/deductible-by-month.sh --since 2026-01-01 --until 2026-01-31
```
Live output (captured at [examples/deductible-by-month.txt](examples/deductible-by-month.txt)):
```
# TVA déductible by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA ded CA3 line
------------------------------------------------------------------------------
2025-10 20.0000 2 6.08 1.22 ligne 20
2026-01 0.0000 2 58.43 0.00 ligne 17 + 24 (autoliquidation) — verify with line-detail
2026-01 20.0000 8 345.02 69.00 ligne 20
2026-02 20.0000 2 765.00 153.00 ligne 20
------------------------------------------------------------------------------
TOTAL 1174.53 223.22
```
The 2026-02 ligne 20 number (765 € HT / 153 € TVA) is dominated by Darnis Operations (510 + 255). The 2026-01 row at 0 % is mixed (Wise BE autoliquidation 50 € + La Poste FR exempt 8.43 €) — workflow 2 splits them properly.
## Workflow 2 — Per-line audit (CA3 bucket assignment)
```bash
./scripts/deductible-line-detail.sh # all-time
./scripts/deductible-line-detail.sh --year 2026
./scripts/deductible-line-detail.sh --since 2026-02-01 --until 2026-02-28
```
Live output (captured at [examples/deductible-line-detail.txt](examples/deductible-line-detail.txt)) — supplier name and country annotated:
```
date ref supplier cnty tx HT TVA CA3 bucket
------------------------------------------------------------------------------------------------------------------------
2026-01-26 FAF2026001 Wise Europe SA BE 0.00 50.00 0.00 ligne 17+24 (autoliquidation intra-UE)
2026-02-28 FAF2026009 Darnis Operations FR 20.00 255.00 51.00 ligne 20/19 (déductible 20.0%)
...
2026-01-12 FAF2026006 La Poste FR 0.00 8.43 0.00 FR exempt / HT seulement
...
# Aggregated by CA3 bucket:
FR exempt / HT seulement count= 1 HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) count= 1 HT= 50.00 TVA= 0.00
ligne 20/19 (déductible 20.0%) count= 12 HT= 1116.10 TVA= 223.22
```
## Tying back to the CA3 — combined view
For any given month, the **net TVA à reverser** computation is:
```
net = TVA collectée (from tva-by-month.sh)
- TVA déductible (from deductible-by-month.sh)
- autoliquidation neutralized (ligne 17 = ligne 24, cancels)
```
For Arcodange today:
- TVA collectée = 0 € (all KissMetrics, all E2 autoliquidation 259-1°)
- TVA déductible = 223.22 € (mainly 20 % FR suppliers)
- **Net = TVA crédit de 223.22 €** — Arcodange is in TVA credit, can request reimbursement or carry forward.
That's the kind of summary a `dolibarr-tva-summary` (or `arcodange-tva-monthly-report`) skill could produce by composing this skill + `dolibarr-tva-reconciliation`. V5 candidate.
## Wise BE specific gotcha
The Wise Europe SA supplier (BE) currently has **no `tva_intra` field populated** ([dolibarr-thirdparty-completeness](../dolibarr-thirdparty-completeness/SKILL.md) flags this). Article 196 directive TVA requires the EU supplier's VAT number on Arcodange's books to substantiate the autoliquidation entry. **Mandatory remediation** before the next TVA declaration: add Wise's BE VAT number (BE0833 281 858 per their public registration) on the thirdparty record.
## Out of scope
- **Writes.** The declaration goes through impots.gouv.fr.
- **Customs / import TVA on goods.** Service-only Arcodange today; if goods imports happen, customs declarations are out of scope.
- **TVA sur encaissements** (régime spécial — TVA due on the payment date rather than invoice date for services). Not Arcodange's regime today.
- **Composition with collectée** (the net-to-remit summary). V5 candidate — a `dolibarr-tva-summary` skill.

View File

@@ -0,0 +1,16 @@
# TVA déductible by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA ded CA3 line
------------------------------------------------------------------------------
2025-10 20.0000 2 6.08 1.22 ligne 20
2026-01 0.0000 2 58.43 0.00 ligne 17 + 24 (autoliquidation) — verify with line-detail
2026-01 20.0000 8 345.02 69.00 ligne 20
2026-02 20.0000 2 765.00 153.00 ligne 20
------------------------------------------------------------------------------
TOTAL 1174.53 223.22
# Notes:
# - This is TVA déductible (supplier side). For TVA collectée (customer side),
# use dolibarr-tva-reconciliation/scripts/tva-by-month.sh.
# - tva_tx==0 lines may be either truly exempt (e.g. La Poste timbres)
# or autoliquidation intra-UE (e.g. Wise). Run deductible-line-detail.sh.

View File

@@ -0,0 +1,22 @@
date ref supplier cnty tx HT TVA CA3 bucket
------------------------------------------------------------------------------------------------------------------------
2026-01-26 FAF2026001 Wise Europe SA BE 0.00 50.00 0.00 ligne 17+24 (autoliquidation intra-UE)
2026-02-28 FAF2026009 Darnis Operations FR 20.00 255.00 51.00 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 16.95 3.39 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 23.30 4.66 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 6.36 1.27 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 1.08 0.22 ligne 20/19 (déductible 20.0%)
2026-01-04 FAF2026003 YOLAW FR 20.00 1.66 0.33 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026004 Medialex FR 20.00 124.00 24.80 ligne 20/19 (déductible 20.0%)
2026-01-06 FAF2026005 Qonto FR 20.00 169.00 33.80 ligne 20/19 (déductible 20.0%)
2025-10-24 FAF2025001 OVH FR 20.00 4.99 1.00 ligne 20/19 (déductible 20.0%)
2025-10-24 FAF2025001 OVH FR 20.00 1.09 0.22 ligne 20/19 (déductible 20.0%)
2026-01-12 FAF2026006 La Poste FR 0.00 8.43 0.00 FR exempt / HT seulement
2026-01-17 FAF2026007 Infogreffe FR 20.00 2.67 0.53 ligne 20/19 (déductible 20.0%)
2026-02-28 FAF2026008 Darnis Operations FR 20.00 510.00 102.00 ligne 20/19 (déductible 20.0%)
------------------------------------------------------------------------------------------------------------------------
# Aggregated by CA3 bucket:
FR exempt / HT seulement count= 1 HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) count= 1 HT= 50.00 TVA= 0.00
ligne 20/19 (déductible 20.0%) count= 12 HT= 1116.10 TVA= 223.22

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Monthly TVA déductible aggregation for Arcodange — the supplier-side
# counterpart to dolibarr-tva-reconciliation.
#
# Usage:
# deductible-by-month.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Groups every visible supplier invoice line by (year-month, tva_tx) and sums
# HT and TVA. The TVA figure feeds the CA3 lignes 19 / 20 (déductible).
#
# Mapping to French CA3:
# - tva_tx == 20 → ligne 20 (TVA déductible normale 20 %)
# - tva_tx == 10 → ligne 19 (taux intermédiaire)
# - tva_tx == 5.5 → ligne 19 (taux réduit)
# - tva_tx == 2.1 → ligne 19 (taux particulier)
# - tva_tx == 0 → likely autoliquidation intra-UE (achats à l'étranger)
# → goes on ligne 17 (auto-collected) AND ligne 24 (déductible)
# run deductible-line-detail.sh for the per-supplier breakdown
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""; UNTIL=""; YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) YEAR="$2"; SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "deductible-by-month.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t deduc.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/inv.json"
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/inv.json")
mkdir -p "${WORK}/detail"
for id in ${IDS}; do "${DOL_CURL}" "/supplierinvoices/${id}" > "${WORK}/detail/${id}.json"; done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, collections, datetime
work, since, until, year_arg = sys.argv[1:5]
def in_window(ts):
if not ts: return False
d = datetime.date.fromtimestamp(int(ts))
if since and d < datetime.date.fromisoformat(since): return False
if until and d > datetime.date.fromisoformat(until): return False
return True
agg = collections.defaultdict(lambda: {"ht":0.0,"tva":0.0,"count":0})
for fn in sorted(os.listdir(os.path.join(work,"detail"))):
try: inv = json.load(open(os.path.join(work,"detail",fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
ym = datetime.date.fromtimestamp(ts).strftime("%Y-%m")
lines = inv.get("lines") or []
if lines:
for line in lines:
tx = round(float(line.get("tva_tx") or 0), 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
agg[(ym, tx)]["ht"] += ht
agg[(ym, tx)]["tva"] += tva
agg[(ym, tx)]["count"] += 1
else:
# Fallback: aggregate at invoice level (no lines available)
# Derive an effective tva_tx if possible: TVA / HT × 100
ht = float(inv.get("total_ht") or 0)
tva = float(inv.get("total_tva") or 0)
tx = round((tva / ht * 100), 4) if ht else 0.0
agg[(ym, tx)]["ht"] += ht
agg[(ym, tx)]["tva"] += tva
agg[(ym, tx)]["count"] += 1
def ca3_line(tx):
if tx == 20: return "ligne 20"
if tx == 10: return "ligne 19 (10 %)"
if tx == 5.5: return "ligne 19 (5,5 %)"
if tx == 2.1: return "ligne 19 (2,1 %)"
if tx == 0: return "ligne 17 + 24 (autoliquidation) — verify with line-detail"
return f"manual @ {tx}%"
scope = f"window={since or '-inf'} → {until or '+inf'}"
if year_arg: scope = f"year {year_arg}"
print(f"# TVA déductible by month × rate — {scope}")
print()
print(f"{'month':<8} {'tva_tx':>7} {'count':>5} {'basis HT':>12} {'TVA ded':>10} CA3 line")
print("-" * 78)
total_ht = total_tva = 0.0
for key in sorted(agg):
ym, tx = key
s = agg[key]
print(f"{ym:<8} {tx:>7.4f} {s['count']:>5} {s['ht']:>12.2f} {s['tva']:>10.2f} {ca3_line(tx)}")
total_ht += s["ht"]
total_tva += s["tva"]
print("-" * 78)
print(f"{'TOTAL':>16} {' ':>13} {total_ht:>12.2f} {total_tva:>10.2f}")
print()
print("# Notes:")
print("# - This is TVA déductible (supplier side). For TVA collectée (customer side),")
print("# use dolibarr-tva-reconciliation/scripts/tva-by-month.sh.")
print("# - tva_tx==0 lines may be either truly exempt (e.g. La Poste timbres)")
print("# or autoliquidation intra-UE (e.g. Wise). Run deductible-line-detail.sh.")
PY

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Per-line TVA déductible breakdown with supplier country classification.
#
# Usage:
# deductible-line-detail.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# For each supplier-invoice line: date, supplier name, country, tva_tx,
# HT, TVA, and a French CA3 bucket assignment. Plus a summary per bucket.
#
# Buckets:
# - FR domestic with TVA → ligne 20 / 19 (TVA déductible standard)
# - EU intra-UE no TVA → ligne 17 (auto-collected) + ligne 24 (déductible) — autoliquidation
# - Extra-EU no TVA → import — likely requires customs TVA declaration, see ligne 7
# - FR with TVA == 0 → exempt or HT-only invoice (e.g. timbres La Poste, AMF)
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""; UNTIL=""; YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) YEAR="$2"; SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "deductible-line-detail.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t deducline.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/inv.json"
IDS=$( python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/inv.json")
SOCIDS=$(python3 -c "import json,sys; print(' '.join(sorted({str(r.get('socid')) for r in json.load(open(sys.argv[1])) if r.get('socid')})))" "${WORK}/inv.json")
mkdir -p "${WORK}/inv" "${WORK}/tp"
for id in ${IDS}; do "${DOL_CURL}" "/supplierinvoices/${id}" > "${WORK}/inv/${id}.json"; done
for s in ${SOCIDS}; do "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"; done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, datetime, collections
work, since, until, year_arg = sys.argv[1:5]
EU = set("AT BE BG HR CY CZ DK EE FI DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
tp = {}
for fn in os.listdir(os.path.join(work,"tp")):
try: d = json.load(open(os.path.join(work,"tp",fn)))
except json.JSONDecodeError: continue
tp[fn[:-len(".json")]] = {
"name": d.get("name") or d.get("ref") or "-",
"country": d.get("country_code") or "",
}
def bucket(country, tx, ht, tva):
if tx and tx > 0:
return f"ligne 20/19 (déductible {tx}%)"
if country == "FR":
return "FR exempt / HT seulement"
if country in EU:
return "ligne 17+24 (autoliquidation intra-UE)"
if country:
return "ligne 7 (import hors UE)"
return "(country missing)"
def in_window(ts):
if not ts: return False
d = datetime.date.fromtimestamp(int(ts))
if since and d < datetime.date.fromisoformat(since): return False
if until and d > datetime.date.fromisoformat(until): return False
return True
print(f"{'date':<10} {'ref':<14} {'supplier':<28} {'cnty':<4} {'tx':>5} {'HT':>10} {'TVA':>8} CA3 bucket")
print("-" * 120)
agg = collections.defaultdict(lambda: {"ht":0.0,"tva":0.0,"n":0})
for fn in sorted(os.listdir(os.path.join(work,"inv"))):
try: inv = json.load(open(os.path.join(work,"inv",fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
dt = datetime.date.fromtimestamp(ts)
ref = inv.get("ref") or "-"
sid = str(inv.get("socid") or "")
sup = tp.get(sid, {}).get("name", f"socid={sid}")
cnty = tp.get(sid, {}).get("country", "")
lines = inv.get("lines") or []
if not lines:
# Aggregate at invoice level
ht = float(inv.get("total_ht") or 0); tva = float(inv.get("total_tva") or 0)
tx = round((tva / ht * 100), 4) if ht else 0.0
lines = [{"tva_tx": tx, "total_ht": ht, "total_tva": tva}]
for line in lines:
tx = round(float(line.get("tva_tx") or 0), 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
b = bucket(cnty, tx, ht, tva)
agg[b]["ht"] += ht; agg[b]["tva"] += tva; agg[b]["n"] += 1
print(f"{dt} {ref:<14} {sup[:28]:<28} {cnty:<4} {tx:>5.2f} {ht:>10.2f} {tva:>8.2f} {b}")
print("-" * 120)
print()
print("# Aggregated by CA3 bucket:")
for b, s in sorted(agg.items()):
print(f" {b:<45} count={s['n']:>3} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
PY

View File

@@ -8,6 +8,8 @@ requires:
# dolibarr-tva-reconciliation — monthly TVA basis preparation
**CLI shortcuts:** `bin/arcodange tva collect` · `bin/arcodange tva collect-detail` (or the combined `bin/arcodange tva summary`)
Builds the numbers the CA3 (régime réel normal) or CA12 (réel simplifié) declaration needs, straight from Dolibarr invoice lines. **Read-only**: this skill prepares the basis; the actual declaration goes through impots.gouv.fr.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.

View File

@@ -0,0 +1,87 @@
---
name: dolibarr-tva-summary
description: One-shot CA3-ready monthly French TVA summary for Arcodange — composes `dolibarr-tva-reconciliation` (TVA collectée customer-side) and `dolibarr-tva-deductible` (TVA déductible supplier-side) into a single per-month report with bucket-by-bucket breakdown and a net verdict (TVA à reverser, crédit de TVA, or équilibre). Each month shows both sides with their CA3 bucket assignments (A1 / A4 / E2 for collectée; 19 / 20 / 17+24 for déductible) plus the net = collectée déductible. Today Arcodange is in cumulative TVA credit of 223.22 € (0 € collectée under KissMetrics autoliquidation 259-1° CGI vs 223.22 € déductible on supplier invoices). Use when the user asks "résumé TVA du mois", "combien à reverser ce mois", "préparer la CA3", "crédit ou dette TVA", "synthèse TVA mensuelle". Depends on `dolibarr`, internally consumes the same endpoints as the two TVA sibling skills. SKIP for per-line audit (use the sibling skills directly), for writes (the declaration itself goes through impots.gouv.fr), and for non-Arcodange TVA regimes.
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-tva-summary — CA3-ready monthly TVA report
The single workflow that combines [dolibarr-tva-reconciliation](../dolibarr-tva-reconciliation/SKILL.md) (TVA collectée — customer side) and [dolibarr-tva-deductible](../dolibarr-tva-deductible/SKILL.md) (TVA déductible — supplier side) into one table per month with a net verdict.
**CLI shortcut:** `bin/arcodange tva summary [--year YYYY] [--since … --until …]`
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## What it computes
For each month in the window:
```
net = TVA collectée TVA déductible
```
Then categorizes:
- `net > 0`**TVA À REVERSER** (Arcodange doit cet écart à l'État sur la CA3 du mois)
- `net < 0`**CRÉDIT DE TVA** (l'État doit cet écart à Arcodange — report ou remboursement)
- `net = 0` → équilibre
For each side, the output breaks down by CA3 bucket so you can transcribe directly:
- **Collectée**: A1 (domestic with TVA), A4 (autoliquidation intra-UE), E2 (export hors UE)
- **Déductible**: ligne 19/20 (20 % standard / taux réduits), ligne 17+24 (autoliquidation intra-UE), ligne 7 (import hors UE), FR exempt
## Workflow
```bash
bin/arcodange tva summary # all-time
bin/arcodange tva summary --year 2026
bin/arcodange tva summary --since 2026-01-01 --until 2026-01-31
```
Live output for the all-time window (captured at [examples/tva-summary.txt](examples/tva-summary.txt)):
```
=== 2026-01 ===
Customer side (TVA collectée) basis HT= 510.00 TVA= 0.00
E2 (export hors UE) HT= 510.00 TVA= 0.00
Supplier side (TVA déductible) basis HT= 403.45 TVA= 69.00
FR exempt / HT seulement HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) HT= 50.00 TVA= 0.00
ligne 19/20 (20.0% déductible) HT= 345.02 TVA= 69.00
--- Net du mois : collectée déductible = 0.00 69.00 = -69.00 → CRÉDIT DE TVA : 69.00 €
=== 2026-02 ===
Customer side (TVA collectée) basis HT= 7650.00 TVA= 0.00
E2 (export hors UE) HT= 7650.00 TVA= 0.00
Supplier side (TVA déductible) basis HT= 765.00 TVA= 153.00
ligne 19/20 (20.0% déductible) HT= 765.00 TVA= 153.00
--- Net du mois : collectée déductible = 0.00 153.00 = -153.00 → CRÉDIT DE TVA : 153.00 €
=== CUMUL window=-inf → +inf ===
TVA collectée totale : 0.00
TVA déductible totale : 223.22
Net cumulé : -223.22 → CRÉDIT DE TVA cumulé : 223.22 €
```
## Reading the result for the CA3 declaration
For each month:
1. **Lignes E2 / A4 / A1** on the CA3 form ← from the customer-side breakdown (HT amounts).
2. **Lignes 19 / 20 / 17 / 24** ← from the supplier-side breakdown (TVA amounts).
3. **TVA nette** = collectée line totals déductible line totals.
4. If credit, request reimbursement or carry forward to the next month.
Today Arcodange is in **continuous credit** because of the 259-1° CGI export posture (collectée = 0 systematically against KissMetrics) while normal operating expenses generate déductible. As long as Arcodange has only extra-EU export customers, this stays the steady state.
## When this skill stops being the right answer
- **A French B2B client is invoiced** (with TVA collectée > 0). Then the A1 bucket starts populating and the net can flip positive. Still the right skill, just different numbers.
- **A goods import** lands a customs ligne 7 entry. The deductible-line-detail breakdown will surface it but verifying matches against customs paperwork is out of scope.
- **TVA sur encaissements** (régime spécial). The current skill uses invoice date as the period anchor; encaissements would need to swap that for payment date. Not Arcodange's regime today.
## Out of scope
- **Writing the declaration on impots.gouv.fr.** Manual step always.
- **Préfinancement TVA / régime simplifié CA12.** Different aggregation cadence; could be a sibling skill if Arcodange's regime changes.
- **Other periodic taxes** (CFE, CVAE, IS). Different data sources.

View File

@@ -0,0 +1,29 @@
# Arcodange TVA monthly summary — window=-inf → +inf
# (composition of dolibarr-tva-reconciliation + dolibarr-tva-deductible)
=== 2025-10 ===
Customer side (TVA collectée) basis HT= 0.00 TVA= 0.00
Supplier side (TVA déductible) basis HT= 6.08 TVA= 1.22
ligne 19/20 (20.0% déductible) HT= 6.08 TVA= 1.22
--- Net du mois : collectée déductible = 0.00 1.22 = -1.22 → CRÉDIT DE TVA : 1.22 €
=== 2026-01 ===
Customer side (TVA collectée) basis HT= 510.00 TVA= 0.00
E2 (export hors UE) HT= 510.00 TVA= 0.00
Supplier side (TVA déductible) basis HT= 403.45 TVA= 69.00
FR exempt / HT seulement HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) HT= 50.00 TVA= 0.00
ligne 19/20 (20.0% déductible) HT= 345.02 TVA= 69.00
--- Net du mois : collectée déductible = 0.00 69.00 = -69.00 → CRÉDIT DE TVA : 69.00 €
=== 2026-02 ===
Customer side (TVA collectée) basis HT= 7650.00 TVA= 0.00
E2 (export hors UE) HT= 7650.00 TVA= 0.00
Supplier side (TVA déductible) basis HT= 765.00 TVA= 153.00
ligne 19/20 (20.0% déductible) HT= 765.00 TVA= 153.00
--- Net du mois : collectée déductible = 0.00 153.00 = -153.00 → CRÉDIT DE TVA : 153.00 €
=== CUMUL window=-inf → +inf ===
TVA collectée totale : 0.00
TVA déductible totale : 223.22
Net cumulé : -223.22 → CRÉDIT DE TVA cumulé : 223.22 €

View File

@@ -0,0 +1,183 @@
#!/usr/bin/env bash
# CA3-ready monthly TVA summary for Arcodange — collectée déductible = net.
#
# Usage:
# tva-summary.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Composes:
# - customer-side invoice lines (TVA collectée, CA3 A1 / A4 / E2)
# - supplier-side invoice lines (TVA déductible, CA3 19 / 20 / 17+24)
# Outputs one table per month plus a cumulative net line.
#
# Net interpretation:
# - net > 0 → TVA à reverser à l'État
# - net < 0 → crédit de TVA, demande de remboursement / report
# - net = 0 → équilibre
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""; UNTIL=""; YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) YEAR="$2"; SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "tva-summary.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t tvasum.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# 1. Customer side (invoices)
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/inv.json"
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/inv.json")
SOCIDS=$(python3 -c "import json,sys; print(' '.join(sorted({str(r.get('socid')) for r in json.load(open(sys.argv[1])) if r.get('socid')})))" "${WORK}/inv.json")
mkdir -p "${WORK}/inv" "${WORK}/tp"
for id in ${IDS}; do "${DOL_CURL}" "/invoices/${id}" > "${WORK}/inv/${id}.json"; done
for s in ${SOCIDS}; do "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"; done
# 2. Supplier side (supplier invoices)
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/sinv.json"
SIDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/sinv.json")
SSOCIDS=$(python3 -c "import json,sys; print(' '.join(sorted({str(r.get('socid')) for r in json.load(open(sys.argv[1])) if r.get('socid')})))" "${WORK}/sinv.json")
mkdir -p "${WORK}/sinv"
for id in ${SIDS}; do "${DOL_CURL}" "/supplierinvoices/${id}" > "${WORK}/sinv/${id}.json"; done
for s in ${SSOCIDS}; do
[[ -f "${WORK}/tp/${s}.json" ]] || "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"
done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, collections, datetime
work, since, until, year_arg = sys.argv[1:5]
EU = set("AT BE BG HR CY CZ DK EE FI DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
# Thirdparty country map
tp_cnty = {}
for fn in os.listdir(os.path.join(work, "tp")):
try: d = json.load(open(os.path.join(work, "tp", fn)))
except json.JSONDecodeError: continue
tp_cnty[fn[:-len(".json")]] = d.get("country_code") or ""
def in_window(ts):
if not ts: return False
d = datetime.date.fromtimestamp(int(ts))
if since and d < datetime.date.fromisoformat(since): return False
if until and d > datetime.date.fromisoformat(until): return False
return True
# Customer-side buckets
def bucket_collected(country, tx):
if tx and tx > 0:
return f"A1+8 ({tx}% collectée)"
if country == "FR": return "A1 0% (FR atypique)"
if country in EU: return "A4 (autoliquidation intra-UE)"
if country: return "E2 (export hors UE)"
return "(country missing)"
# Supplier-side buckets
def bucket_deductible(country, tx):
if tx and tx > 0: return f"ligne 19/20 ({tx}% déductible)"
if country == "FR": return "FR exempt / HT seulement"
if country in EU: return "ligne 17+24 (autoliquidation intra-UE)"
if country: return "ligne 7 (import hors UE)"
return "(country missing)"
# Aggregate per month
months = collections.defaultdict(lambda: {
"collected": {"ht": 0.0, "tva": 0.0, "by_bucket": collections.defaultdict(lambda: {"ht":0.0,"tva":0.0})},
"deductible":{"ht": 0.0, "tva": 0.0, "by_bucket": collections.defaultdict(lambda: {"ht":0.0,"tva":0.0})},
})
# Customer side
for fn in sorted(os.listdir(os.path.join(work, "inv"))):
try: inv = json.load(open(os.path.join(work, "inv", fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
ym = datetime.date.fromtimestamp(ts).strftime("%Y-%m")
sid = str(inv.get("socid") or "")
cnty = tp_cnty.get(sid, "")
for line in inv.get("lines") or []:
tx = round(float(line.get("tva_tx") or 0), 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
b = bucket_collected(cnty, tx)
months[ym]["collected"]["ht"] += ht
months[ym]["collected"]["tva"] += tva
months[ym]["collected"]["by_bucket"][b]["ht"] += ht
months[ym]["collected"]["by_bucket"][b]["tva"] += tva
# Supplier side
for fn in sorted(os.listdir(os.path.join(work, "sinv"))):
try: inv = json.load(open(os.path.join(work, "sinv", fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
ym = datetime.date.fromtimestamp(ts).strftime("%Y-%m")
sid = str(inv.get("socid") or "")
cnty = tp_cnty.get(sid, "")
lines = inv.get("lines") or []
if not lines:
ht = float(inv.get("total_ht") or 0); tva = float(inv.get("total_tva") or 0)
tx = round((tva / ht * 100), 4) if ht else 0.0
lines = [{"tva_tx": tx, "total_ht": ht, "total_tva": tva}]
for line in lines:
tx = round(float(line.get("tva_tx") or 0), 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
b = bucket_deductible(cnty, tx)
months[ym]["deductible"]["ht"] += ht
months[ym]["deductible"]["tva"] += tva
months[ym]["deductible"]["by_bucket"][b]["ht"] += ht
months[ym]["deductible"]["by_bucket"][b]["tva"] += tva
scope = f"window={since or '-inf'} → {until or '+inf'}"
if year_arg: scope = f"year {year_arg}"
print(f"# Arcodange TVA monthly summary — {scope}")
print(f"# (composition of dolibarr-tva-reconciliation + dolibarr-tva-deductible)")
print()
cum_collected = cum_deductible = 0.0
for ym in sorted(months):
m = months[ym]
col_tva = m["collected"]["tva"]
ded_tva = m["deductible"]["tva"]
net = col_tva - ded_tva
cum_collected += col_tva
cum_deductible += ded_tva
print(f"=== {ym} ===")
print(f" Customer side (TVA collectée) basis HT={m['collected']['ht']:>10.2f} TVA={col_tva:>8.2f}")
for b, s in sorted(m["collected"]["by_bucket"].items()):
print(f" {b:<42} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
print(f" Supplier side (TVA déductible) basis HT={m['deductible']['ht']:>10.2f} TVA={ded_tva:>8.2f}")
for b, s in sorted(m["deductible"]["by_bucket"].items()):
print(f" {b:<42} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
if abs(net) < 0.005:
verdict = "(équilibre)"
elif net > 0:
verdict = f"→ TVA À REVERSER : {net:.2f} €"
else:
verdict = f"→ CRÉDIT DE TVA : {-net:.2f} €"
print(f" --- Net du mois : collectée déductible = {col_tva:.2f} {ded_tva:.2f} = {net:>8.2f} {verdict}")
print()
cum_net = cum_collected - cum_deductible
print(f"=== CUMUL {scope} ===")
print(f" TVA collectée totale : {cum_collected:>10.2f}")
print(f" TVA déductible totale : {cum_deductible:>10.2f}")
print(f" Net cumulé : {cum_net:>10.2f}", end="")
if abs(cum_net) < 0.005:
print(" (équilibre)")
elif cum_net > 0:
print(f" → TVA À REVERSER cumulée : {cum_net:.2f} €")
else:
print(f" → CRÉDIT DE TVA cumulé : {-cum_net:.2f} €")
PY

View File

@@ -10,6 +10,21 @@ DOLIBARR_URL=https://erp.arcodange.lab
DOLIBARR_API_KEY=<get from Dolibarr UI: Users → ai_agent → API key>
DOLIBARR_USER=ai_agent
DOLIBARR_PASSWORD=<the ai_agent password, only needed for occasional UI login>
# Required by arcodange-bank-reco only (omit if you only use dolibarr-* skills)
QONTO_LOGIN=arcodange-XXXXX
QONTO_SECRET_KEY=<from Qonto Settings → Integrations → API>
QONTO_ORG_SLUG=arcodange-XXXXX # same as login in most cases
WISE_API_TOKEN=<from wise.com/settings/api-tokens>
WISE_PROFILE_ID=<numeric id of the BUSINESS profile — bank probe prints it>
# Optional: only needed if Wise ever opens the EU statement endpoint
WISE_SCA_KEY_PATH=~/.config/arcodange-erp/wise-sca-private.pem
# Required by arcodange-email-ingest only
ZOHO_CLIENT_ID=<from api-console.zoho.com self-client>
ZOHO_CLIENT_SECRET=<same>
ZOHO_REFRESH_TOKEN=<exchanged from one-time code via /oauth/v2/token>
ZOHO_DC=eu # eu | com | in | au
EOF
chmod 600 .claude/skills/dolibarr/.env
```
@@ -30,6 +45,8 @@ In the Dolibarr UI (https://erp.arcodange.lab/ → **Setup → Users & Groups
- [ ] **Tiers** → Voir tous les tiers (et pas seulement ceux liés à l'utilisateur courant)
- [ ] **Factures** → Lire les factures
- [ ] **Factures** → Voir toutes les factures (et pas seulement celles liées à l'utilisateur courant)
- [ ] **Factures fournisseurs** → Lire les factures fournisseurs (required by `dolibarr-tva-deductible`)
- [ ] **Factures fournisseurs** → Voir toutes les factures fournisseurs
Save. Future modules used by `dolibarr-*` sibling skills (Paiements, Produits, …) need the same treatment.

View File

@@ -16,9 +16,16 @@ The API key for `ai_agent` is **read-only by design**. Never attempt writes from
From the repo root:
```bash
bin/arcodange whoami | jq -r .login # → ai_agent
bin/arcodange ping # → {"success":{"code":200,"dolibarr_version":"22.0.4",...}}
bin/arcodange curl /invoices/12 # raw read-only call
```
The `bin/arcodange` CLI is the human entry point; under the hood it delegates to the same per-skill scripts. Direct script access still works:
```bash
./.claude/skills/dolibarr/scripts/dol-curl.sh /users/info | jq -r .login
# → ai_agent
```
If you see `ai_agent`, auth works. If not, check `.env` (next section) and `dol-curl.sh`'s error message.
@@ -94,6 +101,7 @@ Read-only endpoints we've validated against this instance. Live captures are und
| `GET /thirdparties` | 200 | List thirdparties | `mode` MUST be integer (`mode=1` = customers). String fails 400. | (use `/{id}` below) |
| `GET /thirdparties/{id}` | 200 | Thirdparty detail | KissMetrics is `id=1` in this instance | [thirdparty_km.json](examples/thirdparty_km.json) |
| `GET /products` / `/products/{id}` | 200 | Product catalogue | `KM-audit` is `id=1`, `KM-cloud-devops` is `id=2` | [products_list.json](examples/products_list.json) |
| `GET /supplierinvoices` / `/{id}` | 200 | Supplier (fournisseur) invoices | Lines are included inline on the detail endpoint (same shape as `/invoices/{id}`). Sub-endpoint `/lines` returns **HTTP 403** for `ai_agent` — not needed since inline lines work. | [supplierinvoices_list.json](examples/supplierinvoices_list.json) · [supplierinvoice_detail.json](examples/supplierinvoice_detail.json) |
| `GET /invoices/templates/{id}` | 200 | Recurring invoice template detail | **No list endpoint** — probe ids 1..N. Empty ids return HTTP 200 with `id=null` (sentinel for "doesn't exist"). Fields: `ref`, `socid`, `frequency`, `unit_frequency`, `nb_gen_done`, `date_when`, `date_last_gen`, `suspended`, `auto_validate`, `lines`. | [invoice_template_km.json](examples/invoice_template_km.json) |
| `GET /documents/download` | 200 | Download a stored document as base64 | `modulepart=facture&original_file=<REF>/<REF>.pdf` (URL-encode slashes). Returns `{filename, content-type, filesize, content}` with `content` base64. | [document_download_meta.json](examples/document_download_meta.json) |
@@ -139,7 +147,12 @@ Not available on this account (intentionally): `/setup/modules` (admin-only), `/
- Workflow skill for monthly TVA basis (CA3 / CA12 preparation): [dolibarr-tva-reconciliation](../dolibarr-tva-reconciliation/SKILL.md).
- Workflow skill for recurring invoice templates: [dolibarr-recurring-templates](../dolibarr-recurring-templates/SKILL.md).
- Workflow skill for point-in-time state archival: [dolibarr-data-snapshot](../dolibarr-data-snapshot/SKILL.md).
- Future workflow skills follow the `dolibarr-<topic>` convention. Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow.
- Workflow skill for thirdparty completeness audit (any client / supplier): [dolibarr-thirdparty-completeness](../dolibarr-thirdparty-completeness/SKILL.md).
- Workflow skill for supplier-side TVA déductible (CA3 lignes 19 / 20 / 17+24): [dolibarr-tva-deductible](../dolibarr-tva-deductible/SKILL.md).
- Workflow skill for composite CA3-ready TVA summary (collectée + déductible + net): [dolibarr-tva-summary](../dolibarr-tva-summary/SKILL.md).
- **Bank-side reconciliation** (Qonto + Wise ↔ Dolibarr matching): [arcodange-bank-reco](../arcodange-bank-reco/SKILL.md).
- **Email ingestion** (Zoho Mail → supplier-invoice draft for Dolibarr): [arcodange-email-ingest](../arcodange-email-ingest/SKILL.md).
- Future workflow skills follow the `dolibarr-<topic>` convention (ERP-internal) or `arcodange-<topic>` (cross-system). Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow.
## Out of scope

View File

@@ -0,0 +1,16 @@
{
"_captured_when": "2026-05-29 — ai_agent permission baseline. Lines come through inline on /supplierinvoices/{id} so this 403 is informational only and doesn't block any current workflow. If a future skill needs the dedicated /lines endpoint, ai_agent needs an additional permission grant.",
"_curl": "curl -H 'DOLAPIKEY: <key>' https://erp.arcodange.lab/api/index.php/supplierinvoices/1/lines",
"_http_status": 403,
"error": {
"code": 403,
"message": "Forbidden"
},
"debug": {
"source": "api_supplier_invoices.class.php:614 at call stage",
"stages": {
"success": ["get", "route", "negotiate", "authenticate", "validate"],
"failure": ["call", "message"]
}
}
}

View File

@@ -0,0 +1,257 @@
{
"module": null,
"id": "1",
"entity": "1",
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": null,
"contacts_ids_internal": null,
"linkedObjectsIds": [],
"canvas": null,
"fk_project": null,
"contact_id": null,
"user": null,
"origin_type": null,
"origin_id": null,
"ref": "FAF2026001",
"ref_ext": "",
"statut": "2",
"status": "2",
"country_id": null,
"country_code": null,
"state_id": null,
"region_id": null,
"mode_reglement_id": "2",
"cond_reglement_id": null,
"demand_reason_id": null,
"transport_mode_id": null,
"shipping_method_id": null,
"shipping_method": null,
"fk_multicurrency": "0",
"multicurrency_code": "EUR",
"multicurrency_tx": "1.00000000",
"multicurrency_total_ht": "50.00000000",
"multicurrency_total_tva": "0.00000000",
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": "50.00000000",
"last_main_doc": null,
"fk_account": "1",
"note_public": "",
"note_private": "",
"total_ht": "50.00000000",
"total_tva": "0.00000000",
"total_localtax1": "0.00000000",
"total_localtax2": "0.00000000",
"total_ttc": "50.00000000",
"lines": [
{
"module": null,
"id": "1",
"entity": null,
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": null,
"contacts_ids_internal": null,
"linkedObjectsIds": null,
"canvas": null,
"origin_type": null,
"origin_id": null,
"ref": null,
"ref_ext": null,
"statut": null,
"status": null,
"state_id": null,
"region_id": null,
"demand_reason_id": null,
"transport_mode_id": null,
"shipping_method": null,
"multicurrency_tx": null,
"multicurrency_total_ht": "50.00000000",
"multicurrency_total_tva": "0.00000000",
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": "50.00000000",
"last_main_doc": null,
"fk_account": null,
"total_ht": "50.00000000",
"total_tva": "0.00000000",
"total_localtax1": "0.00000000",
"total_localtax2": "0.00000000",
"total_ttc": "50.00000000",
"lines": null,
"actiontypecode": null,
"civility_code": null,
"date_creation": null,
"date_validation": null,
"date_modification": null,
"tms": null,
"date_cloture": null,
"user_author": null,
"user_creation": null,
"user_creation_id": null,
"user_valid": null,
"user_validation": null,
"user_validation_id": null,
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"parent_element": "facture_fourn",
"fk_parent_attribute": "fk_facture_fourn",
"fk_unit": null,
"date_debut_prevue": null,
"date_debut_reel": null,
"date_fin_prevue": null,
"date_fin_reel": null,
"weight": null,
"weight_units": null,
"length": null,
"length_units": null,
"width": null,
"width_units": null,
"height": null,
"height_units": null,
"surface": null,
"surface_units": null,
"volume": null,
"volume_units": null,
"multilangs": null,
"product_type": "1",
"fk_product": null,
"desc": "Ouverture compte -&nbsp;<em>Exon&eacute;ration de TVA &ndash; Article 261 C du CGI</em>",
"description": "Ouverture compte -&nbsp;<em>Exon&eacute;ration de TVA &ndash; Article 261 C du CGI</em>",
"product_ref": null,
"product_label": null,
"product_barcode": null,
"product_desc": null,
"fk_product_type": null,
"qty": "1",
"duree": null,
"remise_percent": "0",
"info_bits": "0",
"special_code": "0",
"subprice": "50.00000000",
"subprice_ttc": null,
"tva_tx": "0.0000",
"multicurrency_subprice": "50.00000000",
"multicurrency_subprice_ttc": null,
"ref_supplier": "",
"pu_ht": "50.00000000",
"pu_ttc": "50.00000000",
"fk_facture_fourn": "1",
"label": null,
"date_start": "",
"date_end": "",
"fk_code_ventilation": null,
"situation_percent": null,
"fk_prev_id": null,
"vat_src_code": "",
"localtax1_tx": "0.0000",
"localtax2_tx": "0.0000",
"pa_ht": null,
"fk_remise_except": null,
"fk_parent_line": null,
"rang": "1",
"localtax1_type": "0",
"localtax2_type": "0",
"libelle": null,
"fk_accounting_account": "9"
}
],
"actiontypecode": null,
"name": null,
"lastname": null,
"firstname": null,
"civility_id": null,
"civility_code": null,
"date_creation": null,
"date_validation": null,
"date_modification": null,
"tms": 1771929935,
"date_cloture": null,
"user_author": null,
"user_creation": null,
"user_creation_id": "2",
"user_valid": null,
"user_validation": null,
"user_validation_id": "2",
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"title": null,
"type": 0,
"subtype": 0,
"fk_soc": null,
"socid": "2",
"paye": "1",
"date": 1769382000,
"date_lim_reglement": null,
"cond_reglement_code": null,
"cond_reglement_label": null,
"cond_reglement_doc": null,
"mode_reglement_code": "VIR",
"revenuestamp": null,
"totaldeposits": null,
"totalcreditnotes": null,
"sumpayed": null,
"sumpayed_multicurrency": null,
"sumdeposit": null,
"sumdeposit_multicurrency": null,
"sumcreditnote": null,
"sumcreditnote_multicurrency": null,
"remaintopay": null,
"nbofopendirectdebitorcredittransfer": null,
"creditnote_ids": [],
"stripechargedone": null,
"stripechargeerror": null,
"description": null,
"ref_client": null,
"situation_cycle_ref": null,
"close_code": null,
"close_note": null,
"postactionmessages": null,
"fk_incoterms": "0",
"label_incoterms": null,
"location_incoterms": "",
"ref_supplier": "PLAN_ORDER_CHECKOUT-invoice-23917601",
"libelle": "",
"label": "",
"fk_statut": "2",
"paid": "1",
"datec": 1771931434,
"date_echeance": 1769382000,
"amount": 0,
"remise": 0,
"tva": null,
"localtax1": null,
"localtax2": null,
"propalid": null,
"vat_reverse_charge": 0,
"fournisseur": null,
"fk_facture_source": null,
"fac_rec": null,
"fk_fac_rec_source": null,
"fk_user_valid": null
}

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -22,7 +22,7 @@ concurrency:
url: https://vault.arcodange.lab
caCertificate: ${{ secrets.HOMELAB_CA_CERT }}
jwtGiteaOIDC: ${{ needs.gitea_vault_auth.outputs.gitea_vault_jwt }}
role: gitea_cicd_webapp
role: gitea_cicd_erp
method: jwt
path: gitea_jwt
secrets: |

View File

@@ -1,5 +1,29 @@
# ERP
## CLI — `bin/arcodange`
Read-only operational CLI for the Arcodange Dolibarr at `erp.arcodange.lab`. One entry point, subcommands per domain:
```sh
bin/arcodange ping # Dolibarr version + liveness
bin/arcodange whoami # confirm auth as ai_agent
bin/arcodange invoice list # KissMetrics invoices with payment state
bin/arcodange invoice audit 12 # JSON facts + PDF mandatory-mention audit
bin/arcodange payments state # per-invoice TTC vs payments reconciliation
bin/arcodange payments timeline --year 2026 # cash receipts with cumulative balance
bin/arcodange tva summary # CA3-ready collectée déductible per month
bin/arcodange thirdparty audit-all # completeness audit, country-aware
bin/arcodange templates inspect 1 # recurring template health (frequency, next fire, …)
bin/arcodange snapshot --out /tmp/erp.json # full state dump with content_hash
bin/arcodange help # full command tree
```
**Read-only by design.** The underlying API key (`ai_agent`) has no write permissions; corrections go through the Dolibarr UI.
**Credentials.** Reads `.claude/skills/dolibarr/.env` (mode 600, gitignored). Setup instructions: [.claude/skills/dolibarr/README.md](.claude/skills/dolibarr/README.md).
**Source of behaviour.** Each subcommand delegates to a script under `.claude/skills/<skill>/scripts/`. The skills' `SKILL.md` files document the business logic and are also discoverable by Claude Code via skill triggers.
## Dolibarr
### Premiers démarrages

395
bin/arcodange Executable file
View File

@@ -0,0 +1,395 @@
#!/usr/bin/env bash
# arcodange — operational CLI for the Arcodange Dolibarr ERP. Read-only on prod;
# host-guarded WRITE ops on the sandbox via `arcodange sandbox ...`.
#
# Usage: arcodange <command> [subcommand] [args]
#
# Run `arcodange help` for the full command list.
#
# This is a thin dispatcher: every subcommand delegates to a script under
# .claude/skills/<skill>/scripts/. The skills (markdown SKILL.md files)
# remain the source of behaviour documentation; this CLI is the human-
# friendly entry point so you don't have to spell out 5-component paths.
set -euo pipefail
# --- Locate the project root ----------------------------------------------
# Strategy:
# 1. git rev-parse --show-toplevel (works when CWD is inside the repo).
# 2. Walk up from this script's directory to find a sibling .claude/skills.
# Either approach handles being run from a worktree without surprises.
if SROOT=$(git rev-parse --show-toplevel 2>/dev/null) && [[ -d "${SROOT}/.claude/skills" ]]; then
:
else
SROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
if [[ ! -d "${SROOT}/.claude/skills" ]]; then
echo "arcodange: cannot find project root (no .claude/skills/ found)" >&2
exit 2
fi
fi
SKILLS="${SROOT}/.claude/skills"
DOLC="${SKILLS}/dolibarr/scripts/dol-curl.sh"
# --- Help text -----------------------------------------------------------
usage() {
cat <<'EOF'
arcodange — read-only Arcodange Dolibarr CLI.
USAGE
arcodange <command> [subcommand] [args...]
COMMANDS
tva French TVA monthly preparation
collect [--year|--since|--until] TVA collectée by month × rate (CA3 A1/A4/E2)
collect-detail [--year|--since|--until] Per-line audit, customer side
deductible [--year|--since|--until] TVA déductible by month × rate (CA3 19/20/17+24)
deductible-detail [--year|--since|--until] Per-line audit, supplier side
summary [--year|--since|--until] Composite CA3-ready monthly summary
invoice Customer invoices (KissMetrics)
list [--since YYYY-MM-DD] Table of KM invoices with payment state
audit <invoice-id> JSON facts + PDF mandatory-mention audit
thirdparty Clients + suppliers completeness
audit <socid> Country-aware audit for one thirdparty
audit-all [--clients-only|--suppliers-only] Audit every visible thirdparty
payments Cash receipts (KissMetrics-side)
state [--since YYYY-MM-DD] Per-invoice TTC vs payments reconciliation
timeline [--year|--since|--until] Payment timeline with cumulative balance
by-month [--year|--all-clients] Monthly aggregation
templates Recurring invoice templates
list [--max-id N] Enumerate templates (probes ids)
inspect <template-id> Full template audit with health checks
snapshot [--out FILE|--print-only] Bundle full read-only state into one JSON
bank Bank-side data (Qonto + Wise) + Dolibarr reconciliation
probe Auth + discovery (org slug, profile id, balance ids)
qonto-transactions [--month|--since|--until] Qonto transactions table (incoming + outgoing)
wise-transactions [--month|--since|--until|--type|--enrich] Wise activities (incoming + outgoing)
match [--month|--since|--until|--window-days N|--enrich] Match bank ↔ Dolibarr (split buckets)
balance Live balances + Dolibarr cross-check per fk_account
curl <qonto|wise> <path> Raw read-only curl through bank-curl.sh
email Supplier-invoice emails from the Zoho mailbox
list [--folder|--limit|--candidates-only|--all-folders] List candidates
inspect <messageId> [--folder|--save-pdf|--json] Parse PDFs + draft Dolibarr entry
curl <path> Raw read-only curl through zoho-curl.sh
sandbox WRITE ops on erp-sandbox ONLY (host-guarded; JSON on stdin)
thirdparty Create a client/supplier fiche
invoice Customer/supplier invoice + product/service lines
payment Record a règlement (transaction_id=bank tx) → bank_transaction_id
creditnote Create an avoir — customer or supplier (kind)
accounts List bank accounts (id/label) to pick account_id
write <METHOD> <path> [body] Raw host-guarded write
checkpoint status|refresh|provision|relink-env Manage the iso-prod checkpoint
promote Replay a reviewed change-set sandbox -> prod (ADR-0003)
plan <manifest.json> Human-readable review of the change-set
apply <manifest.json> [--target sandbox|prod] Replay it (prod is key+confirm gated)
whoami GET /users/info — confirm auth
ping GET /status — liveness + Dolibarr version
curl <path> Raw read-only curl through dol-curl.sh
help [command] Show this help (or subcommand help)
CREDENTIALS
Reads .claude/skills/dolibarr/.env (mode 600, gitignored). See
.claude/skills/dolibarr/README.md for one-time setup.
EXAMPLES
arcodange ping
arcodange whoami
arcodange invoice list
arcodange invoice audit 12
arcodange tva summary --year 2026
arcodange thirdparty audit-all
arcodange snapshot --out /tmp/erp.json
echo '{"name":"OVH","role":"supplier"}' | arcodange sandbox thirdparty
EOF
}
# --- Dispatch ------------------------------------------------------------
cmd="${1:-help}"
shift || true
case "${cmd}" in
tva)
sub="${1:-help}"; shift || true
case "${sub}" in
collect) exec "${SKILLS}/dolibarr-tva-reconciliation/scripts/tva-by-month.sh" "$@" ;;
collect-detail) exec "${SKILLS}/dolibarr-tva-reconciliation/scripts/tva-line-detail.sh" "$@" ;;
deductible) exec "${SKILLS}/dolibarr-tva-deductible/scripts/deductible-by-month.sh" "$@" ;;
deductible-detail) exec "${SKILLS}/dolibarr-tva-deductible/scripts/deductible-line-detail.sh" "$@" ;;
summary) exec "${SKILLS}/dolibarr-tva-summary/scripts/tva-summary.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange tva — French TVA monthly preparation.
collect TVA collectée by month × rate (CA3 A1/A4/E2)
collect-detail Per-line audit, customer side
deductible TVA déductible by month × rate (CA3 19/20/17+24)
deductible-detail Per-line audit, supplier side
summary CA3-ready monthly net summary (collectée déductible)
All accept --year YYYY, --since YYYY-MM-DD, --until YYYY-MM-DD.
EOF
;;
*) echo "arcodange tva: unknown subcommand '${sub}' (try 'arcodange tva help')" >&2; exit 2 ;;
esac
;;
invoice)
sub="${1:-help}"; shift || true
case "${sub}" in
list) exec "${SKILLS}/dolibarr-invoice-audit/scripts/list-km-invoices.sh" "$@" ;;
audit) exec "${SKILLS}/dolibarr-invoice-audit/scripts/audit-invoice.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange invoice — customer (KissMetrics) invoice operations.
list [--since YYYY-MM-DD] Table of KM invoices with payment state
audit <invoice-id> JSON facts + PDF mandatory-mention audit
EOF
;;
*) echo "arcodange invoice: unknown subcommand '${sub}' (try 'arcodange invoice help')" >&2; exit 2 ;;
esac
;;
thirdparty)
sub="${1:-help}"; shift || true
case "${sub}" in
audit) exec "${SKILLS}/dolibarr-thirdparty-completeness/scripts/audit-thirdparty.sh" "$@" ;;
audit-all) exec "${SKILLS}/dolibarr-thirdparty-completeness/scripts/audit-all-thirdparties.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange thirdparty — clients + suppliers completeness.
audit <socid> Country-aware audit for one thirdparty
audit-all [--clients-only|--suppliers-only] Audit every visible thirdparty
EOF
;;
*) echo "arcodange thirdparty: unknown subcommand '${sub}'" >&2; exit 2 ;;
esac
;;
payments)
sub="${1:-help}"; shift || true
case "${sub}" in
state) exec "${SKILLS}/dolibarr-payments-state/scripts/km-payment-state.sh" "$@" ;;
timeline) exec "${SKILLS}/dolibarr-payments-state/scripts/km-payment-timeline.sh" "$@" ;;
by-month) exec "${SKILLS}/dolibarr-payments-state/scripts/payments-by-month.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange payments — KissMetrics cash-receipt tracking.
state [--since YYYY-MM-DD] Per-invoice TTC vs payments reconciliation
timeline [--year|--since|--until] Payment timeline with cumulative balance
by-month [--year|--all-clients] Monthly aggregation
EOF
;;
*) echo "arcodange payments: unknown subcommand '${sub}'" >&2; exit 2 ;;
esac
;;
templates)
sub="${1:-help}"; shift || true
case "${sub}" in
list) exec "${SKILLS}/dolibarr-recurring-templates/scripts/list-templates.sh" "$@" ;;
inspect) exec "${SKILLS}/dolibarr-recurring-templates/scripts/inspect-template.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange templates — recurring invoice templates.
list [--max-id N] Enumerate templates (probes ids)
inspect <template-id> Full audit with health checks
EOF
;;
*) echo "arcodange templates: unknown subcommand '${sub}'" >&2; exit 2 ;;
esac
;;
snapshot)
exec "${SKILLS}/dolibarr-data-snapshot/scripts/snapshot.sh" "$@"
;;
bank)
sub="${1:-help}"; shift || true
case "${sub}" in
probe) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-probe.sh" "$@" ;;
qonto-transactions) exec "${SKILLS}/arcodange-bank-reco/scripts/qonto-transactions.sh" "$@" ;;
wise-transactions) exec "${SKILLS}/arcodange-bank-reco/scripts/wise-transactions.sh" "$@" ;;
match) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-match.sh" "$@" ;;
balance) exec "${SKILLS}/arcodange-bank-reco/scripts/bank-balance.sh" "$@" ;;
curl)
if [[ $# -lt 2 ]]; then
echo "arcodange bank curl: usage: bank curl <qonto|wise> <path>" >&2; exit 2
fi
exec "${SKILLS}/arcodange-bank-reco/scripts/bank-curl.sh" "$@"
;;
help|-h|--help)
cat <<'EOF'
arcodange bank — bank-side data (Qonto + Wise) and Dolibarr reconciliation.
probe Auth + discovery (org slug, profile id, balance ids)
qonto-transactions [--month|--since|--until] Qonto transactions table
wise-transactions [--month|--since|--until|--type|--enrich] Wise activities (incoming + outgoing)
match [--month|--since|--until|--window-days N] Match bank ↔ Dolibarr (3 buckets)
balance Live balances + Dolibarr cross-check per fk_account
curl <qonto|wise> <path> Raw read-only curl through bank-curl.sh
Requires QONTO_LOGIN, QONTO_SECRET_KEY, QONTO_ORG_SLUG, WISE_API_TOKEN,
WISE_PROFILE_ID in .env. See arcodange-bank-reco/SKILL.md for setup.
EOF
;;
*) echo "arcodange bank: unknown subcommand '${sub}' (try 'arcodange bank help')" >&2; exit 2 ;;
esac
;;
email)
sub="${1:-help}"; shift || true
case "${sub}" in
list) exec "${SKILLS}/arcodange-email-ingest/scripts/email-list.sh" "$@" ;;
inspect) exec "${SKILLS}/arcodange-email-ingest/scripts/email-inspect.sh" "$@" ;;
curl) exec "${SKILLS}/arcodange-email-ingest/scripts/zoho-curl.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange email — supplier-invoice ingestion from the Zoho mailbox.
list [--folder PATH|--limit N|--candidates-only|--all-folders]
List messages (default: /Inbox/books)
inspect <messageId> [--folder PATH|--save-pdf DIR|--json]
Parse PDF attachments, propose Dolibarr supplier-invoice draft
curl <path> Raw read-only call through zoho-curl.sh
Requires ZOHO_CLIENT_ID, ZOHO_CLIENT_SECRET, ZOHO_REFRESH_TOKEN, ZOHO_DC in .env.
See arcodange-email-ingest/SKILL.md for OAuth setup.
EOF
;;
*) echo "arcodange email: unknown subcommand '${sub}' (try 'arcodange email help')" >&2; exit 2 ;;
esac
;;
sandbox)
sub="${1:-help}"; shift || true
case "${sub}" in
thirdparty) exec "${SKILLS}/dolibarr-sandbox-write/scripts/thirdparty-create.sh" "$@" ;;
invoice) exec "${SKILLS}/dolibarr-sandbox-write/scripts/invoice-create.sh" "$@" ;;
payment) exec "${SKILLS}/dolibarr-sandbox-write/scripts/payment-record.sh" "$@" ;;
creditnote) exec "${SKILLS}/dolibarr-sandbox-write/scripts/creditnote-create.sh" "$@" ;;
accounts) exec "${SKILLS}/dolibarr-sandbox-write/scripts/bank-accounts.sh" "$@" ;;
write) exec "${SKILLS}/dolibarr-sandbox-write/scripts/dol-write.sh" "$@" ;;
checkpoint)
export ARCO_ROOT="${SROOT}"
csub="${1:-status}"; shift || true
case "${csub}" in
status) exec "${SKILLS}/dolibarr-sandbox-checkpoint/scripts/checkpoint-status.sh" "$@" ;;
refresh) exec "${SKILLS}/dolibarr-sandbox-checkpoint/scripts/checkpoint-refresh.sh" "$@" ;;
provision) exec "${SKILLS}/dolibarr-sandbox-checkpoint/scripts/checkpoint-provision.sh" "$@" ;;
relink-env) exec "${SKILLS}/dolibarr-sandbox-checkpoint/scripts/checkpoint-relink-env.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange sandbox checkpoint — manage the erp-sandbox iso-prod checkpoint (ADR-0003).
status Liveness + whether the write agent is armed (key authenticates)
refresh --yes [--db-only] Re-seed iso-prod from prod (DESTRUCTIVE; wipes the agent too)
provision Re-create ai_agent_sandbox (Playwright; you log in) + relink .env
relink-env Rewrite the write skill .env from test/.ai_agent_sandbox.key + verify
Typical reset: arcodange sandbox checkpoint refresh --yes then ... provision
(provision opens a browser for the admin login — use the PROD admin creds, iso-prod — and auto-relinks the .env)
EOF
;;
*) echo "arcodange sandbox checkpoint: unknown '${csub}' (try 'arcodange sandbox checkpoint help')" >&2; exit 2 ;;
esac
;;
help|-h|--help)
cat <<'EOF'
arcodange sandbox — WRITE operations against erp-sandbox (rehearsal ONLY).
Every write goes through dol-write.sh, which REFUSES any non-sandbox host.
Each subcommand reads a JSON object on stdin (or a file path arg).
thirdparty client/supplier fiche
echo '{"name":"OVH","role":"supplier"}' | arcodange sandbox thirdparty
invoice customer/supplier invoice with product/service lines (+ ref_supplier)
echo '{"socid":42,"validate":true,"lines":[{"desc":"X","qty":1,"price_ht":100,"tva":20,"type":"service"}]}' | arcodange sandbox invoice
payment règlement on a validated invoice
echo '{"invoice_id":19,"mode":"VIR","account_id":1}' | arcodange sandbox payment
creditnote avoir (credit note) referencing a source invoice
echo '{"socid":42,"source_invoice":19,"validate":true,"lines":[...]}' | arcodange sandbox creditnote
write raw host-guarded write arcodange sandbox write POST /thirdparties '{"name":".."}'
checkpoint manage the iso-prod checkpoint (status|refresh|provision|relink-env)
arcodange sandbox checkpoint status
Needs .claude/skills/dolibarr-sandbox-write/.env (DOLIBARR_SANDBOX_URL + _API_KEY).
See dolibarr-sandbox-write/SKILL.md and dolibarr-sandbox-checkpoint/SKILL.md.
EOF
;;
*) echo "arcodange sandbox: unknown subcommand '${sub}' (try 'arcodange sandbox help')" >&2; exit 2 ;;
esac
;;
promote)
sub="${1:-help}"; shift || true
case "${sub}" in
plan) exec "${SKILLS}/dolibarr-sandbox-write/scripts/promote-plan.sh" "$@" ;;
apply) exec "${SKILLS}/dolibarr-sandbox-write/scripts/promote-apply.sh" "$@" ;;
help|-h|--help)
cat <<'EOF'
arcodange promote — replay a reviewed sandbox change-set onto a target.
A manifest is a JSON array of write ops with symbolic refs (@name) instead of
ids, so the same file replays on sandbox or prod (an invoice refs @tp1, its
just-created thirdparty). See dolibarr-sandbox-write/examples/promote-manifest.json.
plan <manifest.json> Human-readable review of the change-set
apply <manifest.json> [--target sandbox|prod]
Replay it (sandbox = rehearse; prod = real)
PROD apply is gated: requires DOLIBARR_PROD_WRITE_KEY + ARCO_PROMOTE_CONFIRM=
I-UNDERSTAND-THIS-WRITES-PROD in the environment (the prod key is never stored).
EOF
;;
*) echo "arcodange promote: unknown subcommand '${sub}' (try 'arcodange promote help')" >&2; exit 2 ;;
esac
;;
whoami)
exec "${DOLC}" /users/info
;;
ping)
exec "${DOLC}" /status
;;
curl)
if [[ $# -lt 1 ]]; then
echo "arcodange curl: missing path. Example: arcodange curl /invoices/12" >&2
exit 2
fi
exec "${DOLC}" "$@"
;;
help|-h|--help|"")
if [[ $# -gt 0 ]]; then
exec "$0" "$1" help
fi
usage
;;
*)
echo "arcodange: unknown command '${cmd}'" >&2
echo " Try 'arcodange help' for the list." >&2
exit 2
;;
esac

87
chart/files/backup-job.sh Executable file
View File

@@ -0,0 +1,87 @@
#!/bin/sh
# In-container backup logic for Dolibarr — the single source of truth shared by the
# manual orchestrator (ops/backup/dolibarr-backup.sh) and the scheduled CronJob
# (chart/templates/backup-cronjob.yaml). Driven entirely by environment:
# BUCKET PREFIX DB PGHOST (config)
# PGUSER PGPASSWORD (DB creds, from vso-db-credentials)
# AWS_ACCESS_KEY_ID AWS_SECRET_ACCESS_KEY AWS_ENDPOINTS (S3 creds)
# Dumps the DB (pg_dump -Fc) + tars the documents mounted at /docs, pushes both to
# s3://$BUCKET/$PREFIX/{db,docs}/, then prunes to a tiered retention.
#
# Skip-if-unchanged: each half carries a content fingerprint at $PREFIX/.fp-{db,docs};
# a half is dumped+uploaded ONLY if its fingerprint differs from the last run, so a
# quiet ERP day re-uploads nothing. DB fingerprint = count + max(tms) over every
# tms-bearing table (catches insert/update/delete); docs = path|size|mtime per file.
set -eu
apk add --no-cache aws-cli tar gzip findutils >/dev/null 2>&1 || { echo "ABORT apk add"; exit 1; }
: "${BUCKET:?}"; : "${PREFIX:?}"; : "${DB:?}"; : "${PGHOST:?}"
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
# GCS / S3-compatible stores reject aws-cli v2.23+ default integrity checksums
# ("SignatureDoesNotMatch / Invalid argument") — only sign/validate when required.
export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
S3() { aws --endpoint-url "$AWS_ENDPOINTS" s3 "$@"; }
PSQL() { PGPASSWORD="$PGPASSWORD" psql -h "$PGHOST" -U "$PGUSER" -d "$DB" -tAc "$1"; }
TS=$(date -u +%Y-%m-%dT%H-%M-%SZ)
echo "timestamp=$TS db=$DB -> s3://$BUCKET/$PREFIX"
# --- fingerprints: has either half changed since last time? ---
# Restrict to durable BUSINESS content — ignore volatile noise that changes every
# cron tick / page view (else a quiet ERP would never skip):
# DB: exclude llx_const, llx_user (login/counter churn), session/cron tables
# docs: exclude */temp/* (Dolibarr stats cache regenerated constantly)
# Excludes are identical for the fingerprint AND the upload, so "unchanged" means
# "the backed-up set is unchanged".
DENY="'llx_const','llx_user','llx_session','llx_cronjob','llx_user_param'"
GEN=$(PSQL "select coalesce(string_agg(format('select count(*) c, coalesce(max(tms)::text,''0'') m from %I', table_name), ' union all '), 'select 0 c, ''0'' m') from information_schema.columns where column_name='tms' and table_schema='public' and table_name not in ($DENY)")
FP_DB=$(PSQL "$GEN" | sort | md5sum | cut -d' ' -f1)
FP_DOCS=$(find /docs -type f -not -path '*/temp/*' -printf '%p|%s|%T@\n' 2>/dev/null | sort | md5sum | cut -d' ' -f1)
LAST_DB=$(S3 cp "s3://$BUCKET/$PREFIX/.fp-db" - 2>/dev/null || true)
LAST_DOCS=$(S3 cp "s3://$BUCKET/$PREFIX/.fp-docs" - 2>/dev/null || true)
uploaded=0
if [ "$FP_DB" != "$LAST_DB" ]; then
pg_dump -h "$PGHOST" -U "$PGUSER" -d "$DB" -Fc -f /tmp/db.dump
S3 cp /tmp/db.dump "s3://$BUCKET/$PREFIX/db/$TS.dump"
printf '%s' "$FP_DB" | S3 cp - "s3://$BUCKET/$PREFIX/.fp-db"
echo "db: backed up ($(wc -c < /tmp/db.dump) bytes)"; uploaded=1
else
echo "db: unchanged — skipped"
fi
if [ "$FP_DOCS" != "$LAST_DOCS" ]; then
tar -C /docs --exclude='*/temp/*' -czf /tmp/docs.tar.gz . 2>/dev/null
S3 cp /tmp/docs.tar.gz "s3://$BUCKET/$PREFIX/docs/$TS.tar.gz"
printf '%s' "$FP_DOCS" | S3 cp - "s3://$BUCKET/$PREFIX/.fp-docs"
echo "docs: backed up ($(wc -c < /tmp/docs.tar.gz) bytes)"; uploaded=1
else
echo "docs: unchanged — skipped"
fi
# --- tiered retention prune (daily 30d / monthly 12m / yearly ~10y); always runs ---
cat > /tmp/prune.py <<'PY'
import sys, datetime
keys=[k.strip() for k in open(sys.argv[1]) if k.strip()]
now=datetime.datetime.strptime(sys.argv[2][:10], "%Y-%m-%d").date()
def d(k):
try: return datetime.datetime.strptime(k[:10], "%Y-%m-%d").date()
except Exception: return None
dated=sorted([(d(k),k) for k in keys if d(k)], key=lambda x:x[0])
keep=set(); bymonth={}; byyear={}
for dt,k in dated:
age=(now-dt).days
if age <= 30: keep.add(k)
elif age <= 365: bymonth[(dt.year,dt.month)]=k
elif age <= 3660: byyear[dt.year]=k
keep |= set(bymonth.values()) | set(byyear.values())
for dt,k in dated:
if k not in keep: print(k)
PY
for SUB in db docs; do
S3 ls "s3://$BUCKET/$PREFIX/$SUB/" | awk '{print $4}' > /tmp/keys.$SUB || true
python3 /tmp/prune.py "/tmp/keys.$SUB" "$TS" > /tmp/del.$SUB || true
while read -r DK; do
[ -n "$DK" ] && S3 rm "s3://$BUCKET/$PREFIX/$SUB/$DK" && echo "pruned $SUB/$DK"
done < /tmp/del.$SUB
done
echo "DONE (uploaded=$uploaded)."

View File

@@ -10,12 +10,12 @@ BEGIN
WHERE schemaname = 'public'
LIMIT 1;
-- Si le propriétaire actuel est différent de erp_role
IF current_schema_owner <> 'erp_role' THEN
-- Si le propriétaire actuel est différent de {{ .Values.db.ownerRole }}
IF current_schema_owner <> '{{ .Values.db.ownerRole }}' THEN
-- Construire et exécuter la requête REASSIGN OWNED BY
EXECUTE format('REASSIGN OWNED BY %I TO %I', current_schema_owner, 'erp_role');
RAISE NOTICE 'Ownership of all objects in schema "public" has been reassigned from % to %', current_schema_owner, 'erp_role';
EXECUTE format('REASSIGN OWNED BY %I TO %I', current_schema_owner, '{{ .Values.db.ownerRole }}');
RAISE NOTICE 'Ownership of all objects in schema "public" has been reassigned from % to %', current_schema_owner, '{{ .Values.db.ownerRole }}';
ELSE
RAISE NOTICE 'No change needed; the owner of schema "public" is already %', 'erp_role';
RAISE NOTICE 'No change needed; the owner of schema "public" is already %', '{{ .Values.db.ownerRole }}';
END IF;
END $$;

View File

@@ -0,0 +1,77 @@
{{- if .Values.backup.enabled }}
# Dedicated Dolibarr backup (ops/backup/README.md): DB + documents -> offsite GCS,
# tiered retention, skip-if-unchanged. Disabled by default — enable once the S3
# creds VaultStaticSecret below resolves (the `auth` Vault role must be allowed to
# read kvv2/{{ .Values.backup.vaultS3Path }}).
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "erp.fullname" . }}-backup-job
labels:
{{- include "erp.labels" . | nindent 4 }}
data:
backup-job.sh: |
{{- .Files.Get "files/backup-job.sh" | nindent 4 }}
---
apiVersion: secrets.hashicorp.com/v1beta1
kind: VaultStaticSecret
metadata:
name: {{ include "erp.fullname" . }}-backup-s3
namespace: {{ .Release.Namespace }}
spec:
type: kv-v2
mount: kvv2
# kvv2/<path> must hold AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_ENDPOINTS
# (the GCS HMAC creds — same shape as longhorn-gcs-backup-credentials).
path: {{ .Values.backup.vaultS3Path }}
destination:
name: dolibarr-backup-s3
create: true
refreshAfter: 24h
vaultAuthRef: auth
---
apiVersion: batch/v1
kind: CronJob
metadata:
name: {{ include "erp.fullname" . }}-backup
labels:
{{- include "erp.labels" . | nindent 4 }}
spec:
schedule: {{ .Values.backup.schedule | quote }}
concurrencyPolicy: Forbid
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
template:
spec:
restartPolicy: Never
volumes:
- name: docs
persistentVolumeClaim:
claimName: {{ include "erp.fullname" . }}
readOnly: true
- name: job
configMap:
name: {{ include "erp.fullname" . }}-backup-job
containers:
- name: backup
image: {{ .Values.backup.image | quote }}
envFrom:
- secretRef:
name: dolibarr-backup-s3
env:
- { name: BUCKET, value: {{ .Values.backup.bucket | quote }} }
- { name: PREFIX, value: {{ printf "erp/%s" .Values.env | quote }} }
- { name: DB, value: {{ .Values.db.name | quote }} }
- { name: PGHOST, value: {{ .Values.backup.pgHost | quote }} }
- name: PGUSER
valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } }
- name: PGPASSWORD
valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } }
volumeMounts:
- { name: docs, mountPath: /docs, readOnly: true }
- { name: job, mountPath: /job }
command: ["/bin/sh", "/job/backup-job.sh"]
{{- end }}

View File

@@ -10,8 +10,8 @@ data:
DOLI_DB_HOST_PORT: !!str 5432
# DOLI_DB_USER: root
# DOLI_DB_PASSWORD: root
DOLI_DB_NAME: erp
DOLI_URL_ROOT: 'https://erp.arcodange.lab'
DOLI_DB_NAME: {{ .Values.db.name }}
DOLI_URL_ROOT: 'https://{{ .Values.host }}'
# DOLI_ADMIN_LOGIN: 'admin'
# DOLI_ADMIN_PASSWORD: 'admininitialpassword'
DOLI_ENABLE_MODULES: Societe,Facture
@@ -20,4 +20,9 @@ data:
PHP_INI_DATE_TIMEZONE: Europe/Paris
DOLI_AUTH: dolibarr
DOLI_CRON: !!str 0
{{- if ne .Values.env "prod" }}
# Non-prod (sandbox): dev mode — render real errors inline instead of the generic
# prod "technical error" banner. Prod keeps the image default (DOLI_PROD=1).
DOLI_PROD: !!str 0
{{- end }}
# DOLI_INSTANCE_UNIQUE_ID: <random salt for encryption>

View File

@@ -7,4 +7,4 @@ data:
{{- .Files.Get "scripts/update_conf_db_credentials.sh" | nindent 4 }}
update_table_ownership.sql: |
{{- .Files.Get "scripts/update_ownership.sql" | nindent 4 }}
{{- tpl (.Files.Get "scripts/update_ownership.sql") . | nindent 4 }}

View File

@@ -7,7 +7,7 @@ spec:
method: kubernetes
mount: kubernetes
kubernetes:
role: erp
role: {{ .Values.vault.k8sRole }}
serviceAccount: {{ include "erp.serviceAccountName" . }}
audiences:
- vault

View File

@@ -9,7 +9,7 @@ spec:
mount: postgres
# Path to the secret
path: creds/erp
path: {{ .Values.vault.dynamicPath }}
# Where to store the secrets, VSO will create the secret
destination:

View File

@@ -10,7 +10,7 @@ spec:
mount: kvv2
# path of the secret
path: erp/config
path: {{ .Values.vault.staticPath }}
# dest k8s secret
destination:

40
chart/values-sandbox.yaml Normal file
View File

@@ -0,0 +1,40 @@
# Sandbox overlay — to be combined with values.yaml:
# helm install erp-sandbox chart/ -f chart/values.yaml -f chart/values-sandbox.yaml \
# --namespace erp-sandbox --create-namespace
#
# Activates Phase D of the multi-env evolution (cf. PR thread). Prerequisites:
# - factory/postgres/iac/terraform.tfvars: erp has envs = ["prod", "sandbox"]
# - tools/hashicorp-vault/iac/modules/app_roles: env parameter applied
# - arcodange-org/erp/iac/main.tf: for_each over local.envs (Phase D commit)
# - ArgoCD: Application "erp-sandbox" registered (Phase E)
#
# Derived names follow the elision rule: env=sandbox → suffix "-sandbox".
env: sandbox
instance: erp-sandbox
host: erp-sandbox.arcodange.lab
db:
name: erp-sandbox
ownerRole: erp_sandbox_role
vault:
k8sRole: erp-sandbox
dynamicPath: creds/erp-sandbox
staticPath: erp-sandbox/config
# Ingress annotations + hosts — override to point at the sandbox FQDN
ingress:
enabled: true
annotations:
traefik.ingress.kubernetes.io/router.entrypoints: websecure
traefik.ingress.kubernetes.io/router.tls: "true"
traefik.ingress.kubernetes.io/router.tls.certresolver: letsencrypt
traefik.ingress.kubernetes.io/router.tls.domains.0.main: arcodange.lab
traefik.ingress.kubernetes.io/router.tls.domains.0.sans: erp-sandbox.arcodange.lab
traefik.ingress.kubernetes.io/router.middlewares: localIp@file
hosts:
- host: erp-sandbox.arcodange.lab
paths:
- path: /
pathType: Prefix

View File

@@ -2,6 +2,27 @@
# This is a YAML-formatted file.
# Declare variables to be passed into your templates.
# ----------------------------------------------------------------------------
# Multi-environment coordinates (default = prod, elision rule applies).
# Override in values-<env>.yaml for any non-prod instance — see SKILL.md
# of the factory runbook (doc/runbooks/new-web-app/conventions.md).
# By the elision rule, env=prod produces names identical to single-env apps;
# env=sandbox produces "<app>-sandbox" everywhere except the Postgres owner
# role which uses snake-case "<app>_sandbox_role".
# ----------------------------------------------------------------------------
env: prod
instance: erp # derived id: env=prod → erp, else <app>-<env>
host: erp.arcodange.lab # internal hostname for this instance
db:
name: erp # PostgreSQL database name (matches factory tfvars)
ownerRole: erp_role # Postgres owner role; snake-case <app>_role for prod / <app>_<env>_role for non-prod (matches factory/postgres/iac)
vault:
k8sRole: erp # VaultAuth role (postgres/iac issues this per instance)
dynamicPath: creds/erp # path under postgres/ mount for short-lived DB creds
staticPath: erp/config # path under kvv2/ mount for the static admin config
replicaCount: 1
image:
@@ -111,3 +132,15 @@ nodeSelector: {}
tolerations: []
affinity: {}
# Dedicated offsite backup of the Dolibarr DB + documents (see ops/backup/README.md).
# DISABLED by default — enable once the S3 creds VaultStaticSecret resolves (the
# `auth` Vault role must be granted read on kvv2/<vaultS3Path>). The manual
# orchestrator ops/backup/dolibarr-backup.sh works today without this.
backup:
enabled: false
schedule: "0 3 * * *" # daily 03:00 UTC
bucket: arcodange-backup
pgHost: "192.168.1.202" # direct Postgres host (matches ops/sandbox + ops/backup)
image: postgres:16-alpine
vaultS3Path: erp/backup # kvv2/<this> → AWS_ACCESS_KEY_ID / AWS_SECRET_ACCESS_KEY / AWS_ENDPOINTS

View File

@@ -1,32 +1,66 @@
locals {
app = {
name = "erp"
name = "erp"
product_name = "dolibarr" # unused
}
# Environments this app is deployed to. By the elision rule (factory runbook
# conventions.md / ADR-0002) env=prod renders identical to the single-env
# baseline; non-prod envs get a "<name>-<env>" instance id from the module.
envs = toset(["prod", "sandbox"])
}
module "app_roles" {
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
name = local.app.name
source = "git::ssh://git@192.168.1.202:2222/arcodange-org/tools.git//hashicorp-vault/iac/modules/app_roles?depth=1&ref=main"
for_each = local.envs
name = local.app.name
env = each.key
}
resource "random_password" "admin_initial_password" {
length = 32
for_each = local.envs
length = 32
}
resource "random_uuid" "dolibarr_id" { # used for encryption as well as when buying modules
for_each = local.envs
lifecycle {
prevent_destroy = true
}
}
resource "vault_kv_secret_v2" "dolibarr_admin_setup" {
mount = module.app_roles.mount_paths.kvv2
name = format("%sconfig", module.app_roles.kvv2_path_prefix)
data_json = jsonencode(
{
DOLI_ADMIN_LOGIN = "admin",
DOLI_ADMIN_PASSWORD = random_password.admin_initial_password.result
DOLI_INSTANCE_UNIQUE_ID = random_uuid.dolibarr_id.result
}
for_each = local.envs
mount = module.app_roles[each.key].mount_paths.kvv2
name = format("%sconfig", module.app_roles[each.key].kvv2_path_prefix)
data_json = jsonencode(
{
DOLI_ADMIN_LOGIN = "admin",
DOLI_ADMIN_PASSWORD = random_password.admin_initial_password[each.key].result
DOLI_INSTANCE_UNIQUE_ID = random_uuid.dolibarr_id[each.key].result
}
)
}
# State migration (ADR-0002 Phase D): re-key the pre-existing single-env resources
# into the for_each map under the "prod" key so that introducing for_each does NOT
# plan a destroy+create. This is critical for random_uuid.dolibarr_id — it carries
# prevent_destroy = true (it is the prod Dolibarr encryption id + paid-module
# binding), so a missing moved block would HARD-FAIL the apply rather than silently
# losing it. The module's own internal moved (role -> role[0]) chains with the
# module re-key here.
moved {
from = module.app_roles
to = module.app_roles["prod"]
}
moved {
from = random_password.admin_initial_password
to = random_password.admin_initial_password["prod"]
}
moved {
from = random_uuid.dolibarr_id
to = random_uuid.dolibarr_id["prod"]
}
moved {
from = vault_kv_secret_v2.dolibarr_admin_setup
to = vault_kv_secret_v2.dolibarr_admin_setup["prod"]
}

91
ops/backup/README.md Normal file
View File

@@ -0,0 +1,91 @@
# Dolibarr dedicated backup
A backup strategy **dedicated to Dolibarr**, because the accounting data and the
issued documents are critical and legally retained **10 years** — they warrant more
than the generic platform backup.
## Why this exists (the gap it closes)
On 2026-06-30 an audit of the Longhorn external backup found that **the erp documents
volume had never been backed up offsite** (`lastBackupAt = never`): its Longhorn
volume is enrolled only in the `default` recurring-job group, but the single backup
job (`thrice-a-month-backup`) has `groups=[]`, so it serves *no* group — the erp
volume (and erp-sandbox) fell through the crack. Only in-cluster Longhorn replicas
protected `/var/www/documents` (issued invoice PDFs, supplier pieces, contracts, ECM)
— which does not survive a cluster loss / corruption / power-cut.
This tool backs up **both halves** of Dolibarr state to the existing object store
(`s3://arcodange-backup`, GCS via the S3-compatible API), under `erp/<env>/`:
| half | how | key |
|---|---|---|
| Postgres DB | `pg_dump -Fc` (restorable) | `erp/<env>/db/<ts>.dump` |
| documents PVC | `tar -czf` of `/var/www/documents` (RWX, mounted read-only) | `erp/<env>/docs/<ts>.tar.gz` |
then prunes to a **tiered retention**: daily for 30 days, monthly for 12 months,
yearly for ~10 years.
**Skip-if-unchanged:** each half carries a content fingerprint at `erp/<env>/.fp-{db,docs}`
and is dumped+uploaded only if it **differs** from the last run — so a quiet ERP day
re-uploads nothing. The fingerprint is over **durable business content only**: the DB
side is `count + max(tms)` over every `tms` table *except* volatile ones (`llx_const`,
`llx_user`, sessions/cron), and the documents side excludes `*/temp/*` (Dolibarr's
constantly-regenerated stats cache) — from both the fingerprint *and* the tar.
## Safety (mirrors `ops/sandbox/sandbox-lifecycle.sh`)
- **prod is read-only**: `pg_dump` and `tar` only read; the only writes go to the
backup bucket, never to prod. The DB is read with the env's *own* dynamic creds
(`vso-db-credentials`); prod and sandbox never cross.
- **S3 creds are never exposed**: the GCS HMAC secret is copied into a *transient*
secret in the app namespace (values stay base64), deleted on exit. The whole
in-container script is shipped base64 — no secret is ever printed.
## Usage
```sh
# one-shot backup + prune (run from anywhere; needs kubectl on the lab cluster)
ops/backup/dolibarr-backup.sh backup --env prod
ops/backup/dolibarr-backup.sh backup --env sandbox
# what's in the store
ops/backup/dolibarr-backup.sh list --env prod
```
`chart/files/backup-job.sh` is the in-container logic (env-driven: `BUCKET PREFIX
DB PGHOST` + the mounted DB/S3 creds) — the single source of truth shared by this
orchestrator and the scheduled CronJob (see "Automation" below).
**Status:** the first real prod backup was taken 2026-06-30
(`erp/prod/db/…` 1.2 MB, `erp/prod/docs/…` 12.5 MB). Proven end-to-end live on the
sandbox (dump + tar + GCS upload + retention prune).
## Restore (manual, for now)
```sh
# DB: aws s3 cp s3://arcodange-backup/erp/<env>/db/<ts>.dump - | pg_restore -h <host> -U <user> -d <db> --clean
# docs: aws s3 cp s3://arcodange-backup/erp/<env>/docs/<ts>.tar.gz - | tar -C /var/www/documents -xzf -
```
The sandbox iso-prod refresh (`ops/sandbox/sandbox-lifecycle.sh`) is the natural
restore-drill bench. A `restore` subcommand is wired next.
## Automation — the CronJob (gated on creds)
The recurring form ships in the chart (`chart/templates/backup-cronjob.yaml`,
`backup.enabled=false` by default): a daily **CronJob** (ConfigMap-mounted
`backup-job.sh`) with its **own** S3 creds via a `VaultStaticSecret` — no
cross-namespace borrowing of the Longhorn secret. To activate:
1. store the GCS HMAC creds (`AWS_ACCESS_KEY_ID` / `AWS_SECRET_ACCESS_KEY` /
`AWS_ENDPOINTS`, same shape as `longhorn-gcs-backup-credentials`) at
`kvv2/<backup.vaultS3Path>` (default `erp/backup`);
2. grant the erp `auth` Vault role read on that path (a `tools` change) if its
policy doesn't already cover it;
3. set `backup.enabled: true` (+ tune `schedule`).
Until then, run the orchestrator above on demand / from a host cron — it works
today by borrowing the Longhorn creds transiently.
> The generic Longhorn gap (the orphaned `default` group) should be fixed too, as a
> platform concern — but this dedicated, offsite, 10-year-retention backup is the
> one that matches Dolibarr's legal criticality.

171
ops/backup/dolibarr-backup.sh Executable file
View File

@@ -0,0 +1,171 @@
#!/usr/bin/env bash
#
# dolibarr-backup.sh — dedicated, offsite backup for the Arcodange Dolibarr ERP.
#
# Critical-data-aware (10-year accounting retention) and INDEPENDENT of the generic
# Longhorn platform backup — which today does NOT cover the erp volume (its volume
# sits in the orphaned `default` recurring-job group, lastBackupAt=never). Backs up
# BOTH halves of Dolibarr state to the existing object store (s3://arcodange-backup
# on GCS), under erp/<env>/:
# - the Postgres DB (pg_dump -Fc, restorable) -> erp/<env>/db/<ts>.dump
# - the documents PVC (/var/www/documents, RWX, ro) -> erp/<env>/docs/<ts>.tar.gz
# then prunes to a tiered retention: daily 30d, monthly 12m, yearly 10y.
#
# Safety, mirroring ops/sandbox/sandbox-lifecycle.sh:
# - the DB is read with the app's OWN dynamic creds (vso-db-credentials), scoped
# to its env; prod and sandbox never cross.
# - S3 creds are a TRANSIENT copy of the Longhorn GCS secret (deleted on exit);
# no secret value is ever printed.
# - the whole in-container script is shipped base64 (no nested-heredoc/quoting).
#
# Usage:
# dolibarr-backup.sh backup [--env prod|sandbox] # one-shot backup + prune
# dolibarr-backup.sh list [--env prod|sandbox] # what's in the store
# dolibarr-backup.sh restore --db <key> --env <e> --yes # restore DB (DESTRUCTIVE)
# dolibarr-backup.sh restore --docs <key> --env <e> --yes # restore documents
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
PG_IMAGE="postgres:16-alpine"
PGHOST="192.168.1.202" # direct Postgres (NOT pgbouncer)
BUCKET="${ARCO_BACKUP_BUCKET:-arcodange-backup}"
S3_SRC_NS="longhorn-system" # where the GCS HMAC creds live today
S3_SRC_SECRET="longhorn-gcs-backup-credentials"
TMP_S3_SECRET="dolibarr-backup-s3-temp"
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
CMD="${1:-}"; shift || true
ENV="prod"; KEY=""; KIND=""; YES=0
while [[ $# -gt 0 ]]; do
case "$1" in
--env) ENV="${2:?}"; shift 2 ;;
--db) KIND="db"; KEY="${2:?}"; shift 2 ;;
--docs) KIND="docs"; KEY="${2:?}"; shift 2 ;;
--yes) YES=1; shift ;;
*) die "unknown arg '$1'" ;;
esac
done
case "$ENV" in
prod) NS="erp"; DB="erp" ;;
sandbox) NS="erp-sandbox"; DB="erp-sandbox" ;;
*) die "--env must be prod|sandbox" ;;
esac
PVC="$NS"
PREFIX="${ARCO_BACKUP_PREFIX:-erp/${ENV}}"
# in-container preamble: install tools, export region, define S3()
read -r -d '' PREAMBLE <<'SH' || true
set -eu
apk add --no-cache aws-cli tar gzip >/dev/null 2>&1 || { echo "ABORT apk add"; exit 1; }
export AWS_DEFAULT_REGION="${AWS_DEFAULT_REGION:-us-east-1}"
# GCS / S3-compatible stores reject aws-cli v2.23+ default integrity checksums
# ("SignatureDoesNotMatch / Invalid argument"); only sign/validate when required.
export AWS_REQUEST_CHECKSUM_CALCULATION=when_required
export AWS_RESPONSE_CHECKSUM_VALIDATION=when_required
aws --version 2>&1 | head -1
S3() { aws --endpoint-url "$AWS_ENDPOINTS" s3 "$@"; }
SH
copy_s3_secret() {
command -v python3 >/dev/null || die "python3 required to copy the S3 secret without exposing it"
kubectl get secret "$S3_SRC_SECRET" -n "$S3_SRC_NS" -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_S3_SECRET','namespace':'$NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('AWS_ACCESS_KEY_ID','AWS_SECRET_ACCESS_KEY','AWS_ENDPOINTS')}; print(json.dumps(d))" \
| kubectl apply -f - >/dev/null
}
cleanup_secret() { kubectl delete secret "$TMP_S3_SECRET" -n "$NS" --ignore-not-found >/dev/null 2>&1 || true; }
# b64-encode an in-container script (host vars already substituted by the caller)
b64() { printf '%s' "$1" | base64 | tr -d '\n'; }
run_backup() {
trap cleanup_secret EXIT
log "Copying GCS creds into a transient secret in $NS (values stay base64)"
copy_s3_secret
log "Backup ${ENV}: DB=$DB PVC=$PVC -> s3://$BUCKET/$PREFIX/{db,docs}/"
local B64; B64="$(b64 "$(cat "${SCRIPT_DIR}/../../chart/files/backup-job.sh")")"
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-backup, namespace: $NS }
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 600
template:
spec:
restartPolicy: Never
volumes:
- name: docs
persistentVolumeClaim: { claimName: $PVC, readOnly: true }
containers:
- name: backup
image: $PG_IMAGE
envFrom:
- secretRef: { name: $TMP_S3_SECRET }
env:
- { name: BUCKET, value: "$BUCKET" }
- { name: PREFIX, value: "$PREFIX" }
- { name: DB, value: "$DB" }
- { name: PGHOST, value: "$PGHOST" }
- { name: PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
- { name: PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
volumeMounts:
- { name: docs, mountPath: /docs, readOnly: true }
command: ["/bin/sh","-c"]
args: ["echo $B64 | base64 -d | sh"]
EOF
kubectl wait --for=condition=complete job/dolibarr-backup -n "$NS" --timeout=300s >/dev/null 2>&1 \
|| die "backup Job did not complete — kubectl logs -n $NS job/dolibarr-backup"
kubectl logs -n "$NS" job/dolibarr-backup | sed 's/^/ /'
kubectl delete job dolibarr-backup -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
log "Backup complete."
}
run_list() {
trap cleanup_secret EXIT; copy_s3_secret
local SCRIPT
SCRIPT="$(cat <<EOF
$PREAMBLE
echo "db/:"; S3 ls "s3://$BUCKET/$PREFIX/db/" || echo " (empty)"
echo "docs/:"; S3 ls "s3://$BUCKET/$PREFIX/docs/" || echo " (empty)"
EOF
)"
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: dolibarr-backup-list, namespace: $NS }
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 300
template:
spec:
restartPolicy: Never
containers:
- name: list
image: $PG_IMAGE
envFrom: [ { secretRef: { name: $TMP_S3_SECRET } } ]
command: ["/bin/sh","-c"]
args: ["echo $(b64 "$SCRIPT") | base64 -d | sh"]
EOF
kubectl wait --for=condition=complete job/dolibarr-backup-list -n "$NS" --timeout=180s >/dev/null 2>&1 || true
kubectl logs -n "$NS" job/dolibarr-backup-list 2>/dev/null | sed 's/^/ /'
kubectl delete job dolibarr-backup-list -n "$NS" --ignore-not-found >/dev/null 2>&1 || true
cleanup_secret; trap - EXIT
}
case "$CMD" in
backup) run_backup ;;
list) run_list ;;
restore)
[[ -n "$KEY" && -n "$KIND" ]] || die "restore needs --db <key> or --docs <key>"
[[ "$YES" == "1" ]] || die "restore is DESTRUCTIVE on '$ENV' — re-run with --yes"
die "restore: wired in the chart Job (next iteration) — key=$KEY kind=$KIND env=$ENV"
;;
*) echo "usage: $0 {backup|list|restore} [--env prod|sandbox] [--db|--docs <key>] [--yes]" >&2; exit 2 ;;
esac

76
ops/sandbox/README.md Normal file
View File

@@ -0,0 +1,76 @@
# erp-sandbox lifecycle ops
Tooling to make `erp-sandbox` **iso-prod** and to reset it, implementing
[ADR-0003](https://gitea.arcodange.lab/arcodange-org/factory/src/branch/main/vibe/ADR/0003-sandbox-state-lifecycle.md)
(sandbox state lifecycle). The sandbox exists so AI agents can rehearse Dolibarr
**write** operations against a faithful copy of prod, with a structural guarantee
that the rehearsal path can never mutate prod.
## The prod-integrity guarantee (why this is safe)
| Layer | Enforcement |
| --- | --- |
| prod is **read-only** during a refresh | `pg_dump` runs with `default_transaction_read_only=on` |
| the restore can only write the sandbox | it uses the sandbox's own dynamic creds — a member of `erp_sandbox_role`, which **owns only `erp-sandbox`** |
| no database is dropped/created | wipe is `DROP OWNED BY erp_sandbox_role CASCADE`; reload is `pg_restore` (no `CREATEDB`, no superuser) |
| prod is structurally undroppable | `DROP DATABASE` needs ownership; `erp_sandbox_role` does not own prod `erp` (owned by `erp_role`) |
The only prod-capable credential on the platform is the `superuser=true` provider
in `factory postgres/iac/providers.tf`, used **only** in the human-gated
`postgres.yaml` CI. This tooling never touches it.
## Usage
```sh
./sandbox-lifecycle.sh refresh-from-prod # clone prod DB (data + config) into erp-sandbox
./sandbox-lifecycle.sh sync-documents # copy mycompany/ uploads (company logo, PDFs)
./sandbox-lifecycle.sh refresh # both, in order
```
`refresh-from-prod` pauses ArgoCD self-heal on the `erp-sandbox` Application (else
self-heal reverts the scale-down within seconds and the seed would run with the app
still connected), scales the sandbox pod to 0, dumps the full prod `public` schema
(read-only), wipes the sandbox's app objects, restores, scales back up and re-arms
self-heal. App restore and self-heal restoration are guarded by an EXIT trap, so an
interrupt can't leave the sandbox scaled to 0 with self-heal off. It dumps the
**whole** schema (not just `llx_*`) so app helper functions and their triggers (e.g.
`update_modified_column_tms()`) come over; it filters out the provisioner-owned
`user_lookup` pgbouncer function from the restore TOC because that object already
exists per-environment and is not app data.
> Note: `pg_restore` runs with an explicit `-U` (the sandbox role) — without it the
> postgres image connects as its OS user `root` and auth-fails. Its exit code is not
> trusted (it returns non-zero on the harmless "schema public already exists"
> notice); success is verified by counting restored `llx_*` tables.
> Heads-up: a full refresh is **iso-prod**, so it overwrites `llx_user` with prod's
> and wipes the `ai_agent_sandbox` write user + its API key (and resets
> `DOLI_INSTANCE_UNIQUE_ID` to prod's, invalidating any prior key). After a refresh,
> re-run `test/provisionSandbox.ts` to recreate the agent (it re-grants its rights,
> incl. `banque lire`) and refresh the `dolibarr-sandbox-write` skill `.env` from the
> new key file.
## Two fidelity caveats (by design — see ADR-0003)
1. **Encryption.** Dolibarr ties some encrypted fields (notably API keys) to
`DOLI_INSTANCE_UNIQUE_ID`. The sandbox has its **own** uuid, so prod-encrypted
values won't decrypt there. This is why the write-scoped `ai_agent_sandbox`
API key must be **generated inside the sandbox** (see `../../test/` POC),
not copied from prod. Most data is plaintext and unaffected.
2. **Uploaded files live on the PVC, not the DB.** A DB refresh copies the logo
*const* (`MAIN_INFO_SOCIETE_LOGO`) but not the image; `sync-documents` copies
the `documents/mycompany` tree so the logo + attachments actually render.
## BDD reset loop (E4)
For repeated rehearsals, `refresh-from-prod` is the "reset to prod state". A
faster checkpoint/reset that avoids re-reading prod each time (cache a golden
dump on a small PVC, then `DROP OWNED + pg_restore` from it) is the documented
next optimization — see ADR-0003 §Decision/Consequences.
## Hardening backlog
- Replace the transient copy of prod's read+write creds with a **dedicated
read-only Postgres role** (issued via a Vault dynamic role) so the dump path is
least-privilege by construction, not just by `default_transaction_read_only`.
- Provision a golden-cache PVC for fast BDD resets.

153
ops/sandbox/sandbox-lifecycle.sh Executable file
View File

@@ -0,0 +1,153 @@
#!/usr/bin/env bash
#
# sandbox-lifecycle.sh — seed / refresh the erp-sandbox Dolibarr from prod, and
# sync its uploaded documents, with prod integrity guaranteed structurally.
#
# Implements ADR-0003 (factory vibe/ADR/0003-sandbox-state-lifecycle.md):
# - prod is read ONLY (pg_dump runs in a default_transaction_read_only session);
# - the restore writes ONLY to erp-sandbox, using the sandbox's own dynamic
# credentials (a member of erp_sandbox_role, which owns only the sandbox DB),
# so it is structurally incapable of touching prod 'erp' (owned by erp_role);
# - no DROP/CREATE DATABASE, no CREATEDB, no superuser — wipe is
# `DROP OWNED BY erp_sandbox_role CASCADE`, reload is `pg_restore`.
#
# The only prod-capable credential on the platform is the superuser provider in
# factory postgres/iac, exercised solely in the human-gated postgres.yaml CI —
# this script never uses it.
#
# Requires: kubectl (context on the lab cluster), and a postgres:16 image
# reachable by the cluster. Run from anywhere.
#
# Usage:
# ./sandbox-lifecycle.sh refresh-from-prod # iso-prod seed: prod DB -> erp-sandbox
# ./sandbox-lifecycle.sh sync-documents # copy mycompany/ uploads (logo, PDFs)
# ./sandbox-lifecycle.sh refresh # refresh-from-prod + sync-documents
#
set -euo pipefail
PROD_NS="erp"
SB_NS="erp-sandbox"
PROD_DB="erp"
SB_DB="erp-sandbox"
SB_ROLE="erp_sandbox_role" # snake-case owner role (ADR-0002 elision rule)
PGHOST="192.168.1.202" # direct Postgres (NOT pgbouncer — pooler breaks pg_dump)
PG_IMAGE="postgres:16-alpine"
DOC_ROOT="/var/www/documents" # dolibarr_main_data_root
TMP_PROD_SECRET="prod-db-ro-temp" # transient copy of prod creds, deleted on exit
log() { printf '\033[1;36m==>\033[0m %s\n' "$*"; }
die() { printf '\033[1;31mABORT:\033[0m %s\n' "$*" >&2; exit 1; }
sb_pod() { kubectl get pod -n "$SB_NS" -l app.kubernetes.io/instance=erp-sandbox -o name 2>/dev/null | head -1; }
prod_pod() { kubectl get pod -n "$PROD_NS" -l app.kubernetes.io/instance=erp -o name 2>/dev/null | head -1; }
cleanup_secret() { kubectl delete secret "$TMP_PROD_SECRET" -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true; }
# erp-sandbox is ArgoCD-managed with self-heal ON, which reverts `kubectl scale
# --replicas=0` within seconds — so without pausing it the seed runs while Dolibarr
# is still connected, and the restore collides with the app re-creating tables.
# Pause self-heal for the duration so the scale-down holds; always re-arm it.
ARGOCD_NS="argocd"; ARGOCD_APP="erp-sandbox"
set_selfheal() { kubectl patch application "$ARGOCD_APP" -n "$ARGOCD_NS" --type merge \
-p "{\"spec\":{\"syncPolicy\":{\"automated\":{\"selfHeal\":$1,\"prune\":true}}}}" >/dev/null 2>&1 || true; }
# Safety net (EXIT trap): whatever happens, bring the app back, re-arm self-heal, drop the secret.
restore_state() { set_selfheal true; kubectl scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null 2>&1 || true; cleanup_secret; }
refresh_from_prod() {
command -v python3 >/dev/null || die "python3 required to copy the prod secret without exposing it"
trap restore_state EXIT
log "Pausing ArgoCD self-heal so the scale-to-0 holds (else it is reverted in seconds)"
set_selfheal false
log "Copying prod DB creds into a transient, read-only-intent secret in $SB_NS (values stay base64)"
kubectl get secret vso-db-credentials -n "$PROD_NS" -o json \
| python3 -c "import json,sys; d=json.load(sys.stdin); d['metadata']={'name':'$TMP_PROD_SECRET','namespace':'$SB_NS'}; d.pop('status',None); d['data']={k:d['data'][k] for k in ('username','password')}; print(json.dumps(d))" \
| kubectl apply -f - >/dev/null
log "Scaling erp-sandbox to 0 (exclusive DB access for the restore)"
kubectl scale deploy erp-sandbox -n "$SB_NS" --replicas=0 >/dev/null
kubectl wait --for=delete pod -l app.kubernetes.io/instance=erp-sandbox -n "$SB_NS" --timeout=120s >/dev/null 2>&1 || true
log "Running the seed Job (pg_dump prod read-only -> DROP OWNED -> pg_restore into sandbox)"
kubectl delete job sandbox-seed -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
kubectl apply -f - >/dev/null <<EOF
apiVersion: batch/v1
kind: Job
metadata: { name: sandbox-seed, namespace: $SB_NS }
spec:
backoffLimit: 0
ttlSecondsAfterFinished: 900
template:
spec:
restartPolicy: Never
containers:
- name: seed
image: $PG_IMAGE
env:
- { name: PROD_PGUSER, valueFrom: { secretKeyRef: { name: $TMP_PROD_SECRET, key: username } } }
- { name: PROD_PGPASSWORD, valueFrom: { secretKeyRef: { name: $TMP_PROD_SECRET, key: password } } }
- { name: SB_PGUSER, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: username } } }
- { name: SB_PGPASSWORD, valueFrom: { secretKeyRef: { name: vso-db-credentials, key: password } } }
- { name: PGHOST, value: "$PGHOST" }
- { name: PGSSLMODE, value: "disable" }
command: ["/bin/sh","-c"]
args:
- |
set -eu
SBDB=\$(PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc 'select current_database()')
[ "\$SBDB" = "$SB_DB" ] || { echo "ABORT: target is '\$SBDB' not $SB_DB"; exit 1; }
echo "source=$PROD_DB (read-only) target=$SB_DB ok"
# 1. dump prod — full public schema (incl. helper functions + triggers), read-only session
PGPASSWORD=\$PROD_PGPASSWORD PGOPTIONS='-c default_transaction_read_only=on' \\
pg_dump -h "\$PGHOST" -U "\$PROD_PGUSER" -d $PROD_DB -n public -Fc -f /tmp/golden.dump
# drop provisioner-owned infra (pgbouncer user_lookup) from the TOC: it already
# exists in the sandbox and is not app data, so restoring it conflicts.
pg_restore -l /tmp/golden.dump | grep -vi 'user_lookup' > /tmp/golden.toc
echo "dump: \$(ls -l /tmp/golden.dump | awk '{print \$5}') bytes, tables=\$(grep -c 'TABLE DATA ' /tmp/golden.toc)"
# 2. wipe sandbox app objects (everything owned by the app role; infra untouched)
PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -v ON_ERROR_STOP=1 \\
-c "DROP OWNED BY $SB_ROLE CASCADE;"
# 3. restore golden, owned by the sandbox role. MUST pass -U: without it
# pg_restore connects as the container's OS user (root) and auth-fails.
# pg_restore also exits non-zero on the harmless "schema public already
# exists" notice, so its exit code is NOT trustworthy — verify by count below.
Q() { PGPASSWORD=\$SB_PGPASSWORD psql -h "\$PGHOST" -U "\$SB_PGUSER" -d $SB_DB -tAc "\$1"; }
PGPASSWORD=\$SB_PGPASSWORD \\
pg_restore -h "\$PGHOST" -U "\$SB_PGUSER" -L /tmp/golden.toc --no-owner --role=$SB_ROLE -d $SB_DB /tmp/golden.dump 2>/tmp/restore.err \\
&& echo "restore: clean" || echo "restore: pg_restore rc=\$? — verifying by table count, not exit code"
# 4. verify — FAIL the Job if the restore did not actually populate the schema
N=\$(Q "select count(*) from pg_tables where schemaname='public' and tablename like 'llx_%'")
[ "\$N" -ge 250 ] || { echo "ABORT: only \$N llx tables after restore — restore failed. Last errors:"; tail -5 /tmp/restore.err; exit 1; }
echo "llx tables=\$N company=\$(Q "select value from llx_const where name='MAIN_INFO_SOCIETE_NOM'") lang=\$(Q "select value from llx_const where name='MAIN_LANG_DEFAULT'") owner=\$(Q "select tableowner from pg_tables where tablename='llx_societe'")"
echo "DONE."
EOF
kubectl wait --for=condition=complete job/sandbox-seed -n "$SB_NS" --timeout=300s >/dev/null 2>&1 \
|| die "seed Job did not complete — see: kubectl logs -n $SB_NS job/sandbox-seed"
kubectl logs -n "$SB_NS" job/sandbox-seed | sed 's/^/ /'
kubectl delete job sandbox-seed -n "$SB_NS" --ignore-not-found >/dev/null 2>&1 || true
log "Restoring app (replicas=1) + re-arming ArgoCD self-heal"
set_selfheal true
kubectl scale deploy erp-sandbox -n "$SB_NS" --replicas=1 >/dev/null
cleanup_secret; trap - EXIT
log "Refresh complete. Run 'sync-documents' to also copy the company logo + uploads."
}
sync_documents() {
local pp sp
pp=$(prod_pod); sp=$(sb_pod)
[ -n "$pp" ] || die "no prod erp pod found"
[ -n "$sp" ] || die "no erp-sandbox pod found"
log "Syncing $DOC_ROOT/mycompany (logo + uploads) ${pp##*/} -> ${sp##*/} via tar pipe"
kubectl exec -n "$PROD_NS" "${pp#pod/}" -- tar -C "$DOC_ROOT" -cf - mycompany 2>/dev/null \
| kubectl exec -i -n "$SB_NS" "${sp#pod/}" -- tar -C "$DOC_ROOT" -xf -
log "Documents synced. (For a one-shot logo only, scope the tar to mycompany/logos.)"
}
case "${1:-}" in
refresh-from-prod) refresh_from_prod ;;
sync-documents) sync_documents ;;
refresh) refresh_from_prod; sync_documents ;;
*) echo "usage: $0 {refresh-from-prod|sync-documents|refresh}" >&2; exit 2 ;;
esac

View File

@@ -1,5 +1,27 @@
# Copy this template to one of:
# .env — production target, loaded by main.ts
# .env.sandbox — sandbox target, loaded by provisionSandbox.ts
# Both are gitignored. Never commit real secret values.
# --- Target ---
# prod: https://erp.arcodange.lab (.env)
# sandbox: https://erp-sandbox.arcodange.lab (.env.sandbox)
DOLIBARR_ADDRESS=https://erp.arcodange.lab
DOLI_DB_PASSWORD=
DOLI_ADMIN_LOGIN=admin
DOLI_ADMIN_PASSWORD=""
DOLI_DB_PASSWORD=""
ROOT_FOLDER=$HOME/erp
# Populate the passwords from the cluster secrets, e.g. (prod shown):
# DOLI_ADMIN_PASSWORD <- kubectl get secret secretkv -n erp -o jsonpath='{.data.DOLI_ADMIN_PASSWORD}' | base64 -d
# DOLI_DB_PASSWORD <- kubectl get secret vso-db-credentials -n erp -o jsonpath='{.data.password}' | base64 -d
#
# NOTE for a sandbox SEEDED from prod (ops/sandbox/sandbox-lifecycle.sh): the seed
# clones prod's admin password into the sandbox, so .env.sandbox's
# DOLI_ADMIN_PASSWORD must be PROD's admin password (-n erp), not the sandbox
# secretkv. The DB password is the sandbox's own (-n erp-sandbox).
# Optional: fix the provisioned user's password (else one is generated and only
# the API key is emitted to .ai_agent_sandbox.key).
# AI_AGENT_SANDBOX_PASSWORD=""

6
test/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
# Secrets — never commit. Covers .env (prod, main.ts) and .env.sandbox
# (sandbox, provisionSandbox.ts), plus any generated *.key.
.env*
!.env.example
.ai_agent_sandbox.key
*.key

106
test/README.md Normal file
View File

@@ -0,0 +1,106 @@
# test — Dolibarr UI automation (Deno + Playwright)
A small Deno + Playwright POC that drives the Dolibarr admin UI in the `fr-FR`
locale. Playwright fills the same forms a human admin would, so the automation
works even where the REST API can't (e.g. generating an API key, which is
encrypted with the instance's own `DOLI_INSTANCE_UNIQUE_ID`).
## Layout
- `main.ts` — original entrypoint (first install, company/display/module setup).
- `provisionSandbox.ts` — entrypoint that provisions the `erp-sandbox` instance
for the AI agent (enable REST API, create a write-scoped user, generate its
API key).
- `scripts/login.ts` — admin login / logout / whoami helpers.
- `scripts/forms.ts``fillForm`, `toggleOnOff`, CKEditor/ACE helpers.
- `scripts/admin/moduleSetup.ts``configureModule`, `enableApiModule`.
- `scripts/admin/userSetup.ts``createUser`, `assignRights`, `generateApiKey`.
## Configure
Copy `.env.example` to `.env` and fill it in. `.env`, `*.key`, and
`.ai_agent_sandbox.key` are gitignored — never commit secrets.
```sh
cp .env.example .env
```
## Lock the installer (after a fresh install via `main.ts`)
Dolibarr keeps its web installer reachable until an `install.lock` file exists.
After a fresh install (the `main.ts` flow), create it in the target pod — for the
sandbox:
```sh
kubectl -n erp-sandbox exec \
"$(kubectl get pod -n erp-sandbox -l app.kubernetes.io/instance=erp-sandbox -o name)" -- \
/bin/sh -c 'touch /var/www/documents/install.lock && chown www-data:www-data /var/www/documents/install.lock'
```
The path is the Dolibarr **data root** (`/var/www/documents`, a PVC) — that's where
Dolibarr checks, and being on the PVC the lock persists across pod restarts. For
prod, swap to `-n erp -l app.kubernetes.io/instance=erp`. A sandbox **seeded** from
prod still needs this: the seed (see `../ops/sandbox/`) copies the DB +
`documents/mycompany`, not `install.lock`.
## Provision the sandbox
Provisions `erp-sandbox.arcodange.lab`: enables the REST API module, creates the
write-scoped `ai_agent_sandbox` user, grants it its write rights, and has
Dolibarr generate the user's API key. The key is written to
`test/.ai_agent_sandbox.key` (gitignored) — it is never printed.
```sh
cd test
deno run --allow-all provisionSandbox.ts
```
Populate `.env` from the `erp-sandbox` namespace secrets first. `secretkv`
carries the app env (including `DOLI_ADMIN_PASSWORD`); `vso-db-credentials`
carries the database password:
```sh
# Admin password (key DOLI_ADMIN_PASSWORD inside the secretkv secret)
kubectl get secret secretkv -n erp-sandbox \
-o jsonpath='{.data.DOLI_ADMIN_PASSWORD}' | base64 -d
# Database password (key `password` inside vso-db-credentials)
kubectl get secret vso-db-credentials -n erp-sandbox \
-o jsonpath='{.data.password}' | base64 -d
```
Set in `.env`:
```sh
DOLIBARR_ADDRESS=https://erp-sandbox.arcodange.lab
DOLI_ADMIN_LOGIN=admin
DOLI_ADMIN_PASSWORD="<from secretkv above>"
DOLI_DB_PASSWORD="<from vso-db-credentials above>"
# Optional — otherwise a random password is generated and only the API key emitted:
# AI_AGENT_SANDBOX_PASSWORD="<choose one>"
```
### After it runs
The generated API key lands in `test/.ai_agent_sandbox.key`. Next step (not
automated by this POC): load it into the `dolibarr` skill's sandbox config /
Vault at `kvv2/erp-sandbox/ai_agent`.
> [!IMPORTANT]
> The sandbox Dolibarr is not installed/provisioned yet (empty DB, fresh install
> wizard). Until the install wizard has been completed against the sandbox,
> `provisionSandbox.ts` will not have a UI to drive, and the selectors in
> `moduleSetup.ts` / `userSetup.ts` are best-effort (Dolibarr 22 conventions,
> not verified live). Confirm them on the first real run.
### Write rights granted
The `ai_agent_sandbox` user is created non-admin and granted read + create on:
| Module | rights ids |
| ---------------- | ---------------------------------- |
| facture | lire=11, creer=12 |
| societe | lire=121, creer=122 |
| societe contact | lire=281, creer=282 |
| fournisseur | lire=1181, facture lire=1231, facture creer=1232 |
| produit | lire=31, creer=32 |

26
test/deno.lock generated
View File

@@ -1,7 +1,8 @@
{
"version": "4",
"version": "5",
"specifiers": {
"jsr:@std/dotenv@*": "0.225.2",
"npm:@types/node@*": "24.2.0",
"npm:playwright@^1.48.2": "1.48.2"
},
"jsr": {
@@ -10,18 +11,33 @@
}
},
"npm": {
"@types/node@24.2.0": {
"integrity": "sha512-3xyG3pMCq3oYCNg7/ZP+E1ooTaGB4cG8JWRsqqOYQdbWNY4zbaV0Ennrd7stjiJEFZCaybcIgpTjJWHRfBSIDw==",
"dependencies": [
"undici-types"
]
},
"fsevents@2.3.2": {
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA=="
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"os": ["darwin"],
"scripts": true
},
"playwright-core@1.48.2": {
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA=="
"integrity": "sha512-sjjw+qrLFlriJo64du+EK0kJgZzoQPsabGF4lBvsid+3CNIZIYLgnMj9V6JY5VhM2Peh20DJWIVpVljLLnlawA==",
"bin": true
},
"playwright@1.48.2": {
"integrity": "sha512-NjYvYgp4BPmiwfe31j4gHLa3J7bD2WiBz8Lk2RoSsmX38SVIARZ18VYjxLjAcDsAhA+F4iSEXTSGgjua0rrlgQ==",
"dependencies": [
"fsevents",
"playwright-core"
]
],
"optionalDependencies": [
"fsevents"
],
"bin": true
},
"undici-types@7.10.0": {
"integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag=="
}
},
"workspace": {

154
test/provisionSandbox.ts Normal file
View File

@@ -0,0 +1,154 @@
import { loadSync } from "jsr:@std/dotenv";
// Sandbox provisioning loads its OWN .env.sandbox; prod config stays in .env (main.ts).
loadSync({ envPath: ".env.sandbox", export: true });
import { chromium } from "playwright";
import path from "node:path";
import login from "./scripts/login.ts";
import moduleSetup from "./scripts/admin/moduleSetup.ts";
import userSetup from "./scripts/admin/userSetup.ts";
/*
provisionSandbox.ts — separate entrypoint (does NOT touch main.ts).
Provisions the erp-sandbox Dolibarr so the AI agent can write to it:
1. enable the REST API / Web services module,
2. create a write-scoped `ai_agent_sandbox` user (non-admin),
3. grant it the write rights it needs,
4. have Dolibarr generate its API key and emit it safely to
test/.ai_agent_sandbox.key (gitignored).
Run:
cd test && deno run --allow-all provisionSandbox.ts
CANNOT be run end-to-end yet: the sandbox Dolibarr is not installed/provisioned
(empty DB, fresh install wizard). Selector correctness in moduleSetup.ts /
userSetup.ts is therefore best-effort and must be verified on the first real
run, AFTER the install wizard has been completed against the sandbox.
NEXT STEP (not implemented here): load the generated key into the dolibarr
skill's sandbox config / Vault at kvv2/erp-sandbox/ai_agent. We intentionally
do NOT write to Vault from this POC.
*/
/*
Write rights to grant `ai_agent_sandbox` (stable Dolibarr rights ids, verified
against prod Dolibarr 22.0.4). Each module's read (lire) + create (creer):
facture: lire=11, creer=12
societe: lire=121, creer=122, client voir (see-all)=262
societe contact: lire=281, creer=282
fournisseur: lire=1181, facture lire=1231, facture creer=1232
produit: lire=31, creer=32
banque: lire=111
*/
const WRITE_IDS = [
11, // facture lire
12, // facture creer
121, // societe lire
122, // societe creer
262, // societe client "voir" (see-all) — REQUIRED for the /thirdparties LIST
// endpoint; without it the list 404s (the voir_tous ACL trap, cf. the
// dolibarr skill SKILL.md). Other modules' lists work with plain `lire`.
281, // societe contact lire
282, // societe contact creer
1181, // fournisseur lire
1231, // fournisseur facture lire
1232, // fournisseur facture creer
31, // produit lire
32, // produit creer
111, // banque lire — list bank accounts (GET /bankaccounts) to pick account_id
];
const KEY_FILE = ".ai_agent_sandbox.key";
/*
Initialisation — mirrors main.ts but targeted at the sandbox. The default
address points at the sandbox; admin creds come from env (the same
DOLI_ADMIN_LOGIN / DOLI_ADMIN_PASSWORD vars main.ts uses, populated from the
erp-sandbox namespace secrets — see test/README.md "Provision the sandbox").
*/
const dolibarrAddress = Deno.env.get("DOLIBARR_ADDRESS") ||
"https://erp-sandbox.arcodange.lab";
const debug = true;
const DBpassword = Deno.env.get("DOLI_DB_PASSWORD") || "undefined";
const adminCredentials = {
username: Deno.env.get("DOLI_ADMIN_LOGIN") || "admin",
password: Deno.env.get("DOLI_ADMIN_PASSWORD") || "undefined",
};
// The new user's login is fixed; its password comes from env or is generated.
const AI_AGENT_LOGIN = "ai_agent_sandbox";
const aiAgentPassword = Deno.env.get("AI_AGENT_SANDBOX_PASSWORD") ||
generatePassword();
const rootFolderPath = Deno.env.get("ROOT_FOLDER") ||
path.join(Deno.cwd(), "..");
const imgFolderPath = Deno.env.get("IMG_FOLDER") ||
path.join(rootFolderPath, "static/img");
const configFolderPath = Deno.env.get("CONFIG_FOLDER") ||
path.join(rootFolderPath, "static/config");
/* Generate a reasonably strong random password (used only if none provided). */
function generatePassword(): string {
const bytes = new Uint8Array(18);
crypto.getRandomValues(bytes);
// URL-safe base64 → no shell-hostile characters.
return btoa(String.fromCharCode(...bytes))
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=+$/, "");
}
const browser = await chromium.launch({
headless: false,
logger: {
isEnabled: (_name, _severity) => debug,
log: (name, severity, message, args) =>
console.warn(`${severity}| ${name} :: ${message} __ ${args}`),
},
});
const context = await browser.newContext({ locale: "fr-FR" });
const page = await context.newPage();
const globalCtx = {
dolibarrAddress,
DBpassword,
adminCredentials,
debug,
rootFolderPath,
imgFolderPath,
configFolderPath,
browser,
page,
context,
};
try {
await login.doAdminLogin(globalCtx);
console.log(`connected as ${await login.whoAmI(globalCtx)}`);
await moduleSetup.enableApiModule(globalCtx);
console.log("REST API module enabled");
const userId = await userSetup.createUser(globalCtx, {
login: AI_AGENT_LOGIN,
password: aiAgentPassword,
lastname: "AI Agent (sandbox)",
admin: false,
});
console.log(`created user '${AI_AGENT_LOGIN}' (id=${userId})`);
await userSetup.assignRights(globalCtx, userId, WRITE_IDS);
console.log(`granted ${WRITE_IDS.length} write rights to id=${userId}`);
const apiKey = await userSetup.generateApiKey(globalCtx, userId);
// Emit the key safely: write it to a gitignored file rather than printing it.
await Deno.writeTextFile(KEY_FILE, apiKey + "\n");
console.log(`AI_AGENT_SANDBOX API KEY WRITTEN TO test/${KEY_FILE}`);
} catch (error) {
console.error(error);
} finally {
await browser.close();
}

View File

@@ -1,19 +1,20 @@
// info-box
import { Page } from "playwright";
import { Locator, Page } from "playwright";
import forms from "../forms.ts";
import login from "../login.ts";
// span.info-box-title has-text /moduleName/
// span.fa-cog[title="Configuration"]
type ModuleCtx = {
page: Page;
dolibarrAddress: string;
adminCredentials: { username: string; password: string };
};
async function configureModule(
globalCtx: {
page: Page;
dolibarrAddress: string;
adminCredentials: { username: string; password: string };
},
globalCtx: ModuleCtx,
{
moduleName,
enabled,
@@ -42,6 +43,61 @@ async function configureModule(
}
}
/*
Enable Dolibarr's REST API / Web services module.
Activation maps to llx_const.MAIN_MODULE_API=1; in the UI this is the module
card on /admin/modules.php (kanban mode). In fr_FR the card title is
"API/Web services REST (serveur)" (family "Interfaces").
NOTE: confirm the exact card title on the first real run against an installed
sandbox — the label has not been verified live here. To stay resilient we try
the known fr_FR label first (anchored exact match, same as configureModule),
and if that card is not present we fall back to matching ANY module card whose
title contains "API ... REST" / "REST ... API" in either order.
*/
async function enableApiModule(globalCtx: ModuleCtx): Promise<void> {
const {page, dolibarrAddress}= globalCtx;
// fr_FR label as observed in the module catalogue. Confirm on first real run.
const knownLabel = "API/Web services REST (serveur)";
await login.doAdminLogin(globalCtx);
await page.goto(`${dolibarrAddress}/admin/modules.php?mode=commonkanban`);
const exactCard = page.locator('.info-box', {
has: page.locator('.info-box-title',{
hasText: new RegExp('^'+knownLabel+'\n?$','i')
})
});
if(await exactCard.count() > 0) {
await forms.toggleOnOff(exactCard, true);
return;
}
// Fallback: the exact fr_FR label was not found (different Dolibarr version,
// locale, or wording). Match any card whose title looks like the REST API
// module regardless of word order.
const fuzzyCard = page.locator('.info-box', {
has: page.locator('.info-box-title',{
hasText: /API.*REST|REST.*API/i
})
});
if(await fuzzyCard.count() === 0) {
throw new Error(
`REST API module card not found on ${dolibarrAddress}/admin/modules.php `+
`(tried exact label "${knownLabel}" and /API.*REST|REST.*API/i). `+
`Confirm the card title in the running sandbox.`,
);
}
// If several cards match the fuzzy pattern, toggle the first one only.
await forms.toggleOnOff(fuzzyCard.first() as Locator, true);
}
export default {
configureModule,
enableApiModule,
}

View File

@@ -0,0 +1,297 @@
/*
User provisioning for the Dolibarr admin UI, driven by Playwright.
Why the UI and not raw SQL:
- Passwords use MAIN_SECURITY_HASH_ALGO=password_hash (bcrypt), so we let
Dolibarr hash them by submitting the create form.
- API keys are stored ENCRYPTED with the instance key DOLI_INSTANCE_UNIQUE_ID.
Each instance (incl. the sandbox) has its own uuid, so a key can only be
produced correctly by that instance — we MUST generate it via the UI and
read back the resulting value, never INSERT a raw key.
IMPORTANT — selectors below are best-effort and have NOT been verified against
a live, installed sandbox (the sandbox Dolibarr is not provisioned yet). Field
names / icon controls are taken from Dolibarr 22 conventions and must be
confirmed on the first real run. Spots that are guessed are marked GUESS:.
*/
import type { Page } from "playwright";
import forms from "../forms.ts";
import login from "../login.ts";
type UserCtx = {
page: Page;
dolibarrAddress: string;
adminCredentials: { username: string; password: string };
// imgFolderPath is required by forms.fillForm's signature even though no
// file input is used here; provisionSandbox.ts supplies it from globalCtx.
imgFolderPath: string;
};
type NewUser = {
login: string;
password: string;
lastname?: string;
admin?: boolean;
};
// The text fields filled via forms.fillForm (the admin flag is a checkbox we
// handle separately, so it is intentionally not part of this type).
type UserFormFields = {
login: string;
password: string;
lastname?: string;
};
// fr_FR Dolibarr user create form (/user/card.php?action=create&mode=create).
// GUESS: confirm these input names on first real run. `login` and `lastname`
// are stable across Dolibarr versions; the password field has been `password`
// for the manual-entry case (the "generate automatically" option is a separate
// radio/checkbox we leave untouched so our explicit value is used).
const newUserInputNames = new Map<keyof UserFormFields, string>([
["login", "login"],
["lastname", "lastname"],
["password", "password"],
]);
/*
Create a user via /user/card.php?action=create and return its numeric id.
The admin flag is a checkbox (name="admin"); fillForm has no checkbox handler
so we toggle it explicitly. After submit Dolibarr redirects to the user card
whose URL carries ?id=<n> — we parse the id from there (falling back to a
hidden input on the page).
*/
async function createUser(
globalCtx: UserCtx,
{ login: userLogin, password, lastname, admin = false }: NewUser,
): Promise<number> {
const { page, dolibarrAddress, imgFolderPath } = globalCtx;
await login.doAdminLogin(globalCtx);
// Idempotent: if the login already exists (e.g. a re-run), return its id rather
// than submitting a duplicate create (which Dolibarr rejects).
const existingId = await findUserId(globalCtx, userLogin);
if (existingId !== undefined) return existingId;
await page.goto(`${dolibarrAddress}/user/card.php?action=create&mode=create`);
// Fill text inputs (login, lastname, password) without submitting yet.
const formFields: UserFormFields = {
login: userLogin,
password,
lastname,
};
await forms.fillForm(
{ page, imgFolderPath },
formFields,
newUserInputNames,
0,
);
// Admin checkbox — only present when the logged-in user is themselves admin.
// GUESS: input[name="admin"]; on some setups it's a <select> instead.
const adminCheckbox = page.locator('input[name="admin"]');
if (await adminCheckbox.count() > 0) {
if (admin) {
await adminCheckbox.check();
} else {
// Leave unchecked (default), but be explicit/defensive.
if (await adminCheckbox.isChecked()) await adminCheckbox.uncheck();
}
}
// Submit the create form. The create page has a single primary submit button
// ("Créer utilisateur"); :nth-match index 1 mirrors the fillForm convention.
await page.click(':nth-match(input[type="submit"], 1)');
// After creation Dolibarr lands on the user card. Parse the id.
await page.waitForLoadState("domcontentloaded");
const id = await readUserIdFromPage(page);
if (id === undefined) {
throw new Error(
`Could not determine the new user id for login "${userLogin}" after ` +
`submitting the create form (current URL: ${page.url()}).`,
);
}
return id;
}
/*
Read the numeric user id from the current user card: first from the URL
(?id=<n>), then from a hidden input[name="id"] as a fallback.
*/
async function readUserIdFromPage(page: Page): Promise<number | undefined> {
const fromUrl = new URL(page.url()).searchParams.get("id");
if (fromUrl && /^\d+$/.test(fromUrl)) return Number(fromUrl);
// GUESS: a hidden input carrying the id (common on Dolibarr card pages).
const hidden = page.locator('input[name="id"]');
if (await hidden.count() > 0) {
const val = await hidden.first().getAttribute("value");
if (val && /^\d+$/.test(val)) return Number(val);
}
return undefined;
}
/*
Find an existing user's numeric id by login via the user list, so createUser is
idempotent across re-runs. Matches the table row containing the login, then reads
the id from that row's user-card link.
*/
async function findUserId(
globalCtx: UserCtx,
userLogin: string,
): Promise<number | undefined> {
const { page, dolibarrAddress } = globalCtx;
await page.goto(
`${dolibarrAddress}/user/list.php?search_login=${encodeURIComponent(userLogin)}`,
);
await page.waitForLoadState("domcontentloaded");
const cardLink = page
.locator("tr", { hasText: userLogin })
.locator('a[href*="/user/card.php?id="]')
.first();
if (await cardLink.count() === 0) return undefined;
const href = (await cardLink.getAttribute("href")) ?? "";
const m = href.match(/[?&]id=(\d+)/);
return m ? Number(m[1]) : undefined;
}
/*
Grant a set of rights to a user on /user/perms.php?id=<userId>.
Dolibarr renders each grantable permission as a link whose href carries
action=addrights&rights=<id> (and a matching action=delrights to revoke).
A right that is already granted shows the delrights link instead, so we treat
the presence of an addrights link as "not yet granted" and click it; if it's
absent we assume the right is already on and skip it (defensive/idempotent).
*/
async function assignRights(
globalCtx: UserCtx,
userId: number,
rightIds: number[],
): Promise<void> {
const { page, dolibarrAddress } = globalCtx;
await login.doAdminLogin(globalCtx);
for (const rightId of rightIds) {
// Re-navigate per right: Dolibarr reloads the perms table after each grant,
// which would otherwise stale the locators.
await page.goto(`${dolibarrAddress}/user/perms.php?id=${userId}`);
// The grant control is an <a> linking to action=addrights&rights=<id>. The
// trailing "&" anchors the id so rights=12 does NOT substring-match rights=121
// / rights=1232 etc. (Dolibarr hrefs are ...&rights=<id>&confirm=yes...).
const addLink = page.locator(
`a[href*="action=addrights"][href*="rights=${rightId}&"]`,
);
if (await addLink.count() === 0) {
// Either already granted (only a delrights link is shown) or the row is
// not visible for this user. Skip rather than fail the whole run.
continue;
}
await addLink.first().click();
await page.waitForLoadState("domcontentloaded");
}
}
/*
Generate (or read) the user's API key on /user/card.php?id=<userId> in edit
mode and RETURN it as a string.
With the API module enabled the user card edit form shows an api_key text
input plus a "generate" control (a dice/refresh icon-link, typically
id="generate_api_key" or an <a> with action=...generate...apikey). We trigger
generation, then read the value back out of the api_key input. If a key is
already present we read and return it without regenerating (idempotent).
The caller is responsible for handling the returned secret safely — DO NOT log
it at debug verbosity.
*/
async function generateApiKey(
globalCtx: UserCtx,
userId: number,
): Promise<string> {
const { page, dolibarrAddress } = globalCtx;
await login.doAdminLogin(globalCtx);
await page.goto(`${dolibarrAddress}/user/card.php?id=${userId}&action=edit`);
// GUESS: the API key field is input[name="api_key"].
const apiKeyInput = page.locator('input[name="api_key"]');
if (await apiKeyInput.count() === 0) {
throw new Error(
`API key input (input[name="api_key"]) not found on the user card for ` +
`id=${userId}. Is the REST API module enabled? Confirm the field name ` +
`on the running sandbox.`,
);
}
// If a key is already set, reuse it.
const existing = (await apiKeyInput.first().inputValue()).trim();
if (existing.length > 0) return existing;
// Trigger generation. GUESS: a generate control next to the field. We try a
// few likely selectors in order and click the first that exists.
const generateSelectors = [
"#generate_api_key",
'a[href*="action=generateapikey"]',
'a[href*="generate"][href*="apikey"]',
// Icon-only fallbacks commonly used by Dolibarr "generate" buttons.
"span.fa-dice",
"a.fa-refresh",
];
let clicked = false;
for (const sel of generateSelectors) {
const ctrl = page.locator(sel);
if (await ctrl.count() > 0) {
await ctrl.first().click();
clicked = true;
break;
}
}
if (!clicked) {
throw new Error(
`No API-key generate control found near input[name="api_key"] for ` +
`id=${userId} (tried ${generateSelectors.join(", ")}). Confirm the ` +
`generate control on the running sandbox.`,
);
}
// The generate control fills the input client-side; poll the input value
// (via the locator, no browser globals) until it is non-empty or we time out.
let key = "";
const deadline = Date.now() + 10_000;
while (Date.now() < deadline) {
key = (await apiKeyInput.first().inputValue()).trim();
if (key.length > 0) break;
await page.waitForTimeout(200);
}
if (key.length === 0) {
throw new Error(
`API key field was still empty after triggering generation for ` +
`id=${userId}.`,
);
}
// Persist: the generate control only fills the field client-side. Submit the
// edit form so Dolibarr saves api_key to the DB — otherwise it stays NULL and
// the generated key never authenticates.
const saveBtn = page.locator('input[name="save"], button[name="save"]');
if (await saveBtn.count() > 0) {
await saveBtn.first().click();
} else {
await page.click(':nth-match(input[type="submit"], 1)');
}
await page.waitForLoadState("domcontentloaded");
return key;
}
export default {
createUser,
assignRights,
generateApiKey,
};