From 35b227eb6dbf8e56601f2ca5ff2f9dffa3e099ec Mon Sep 17 00:00:00 2001 From: Gabriel Radureau Date: Wed, 15 Jul 2026 19:49:13 +0200 Subject: [PATCH] =?UTF-8?q?feat(write-skill):=20client-dossier=20ops=20?= =?UTF-8?q?=E2=80=94=20thirdparty=20update=20(allowlisted)=20+=20idempoten?= =?UTF-8?q?t=20contacts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Part of erp#65 (phase 1). Ledger grammar "thirdparty complete" gets its op: allowlisted non-ledger fields, per-field diff read-back. Contacts are born idempotent (dedupe by email then name). Promote ops wired both targets, offline stub tests, SKILL.md workflows, KM dossier manifest (unsigned-contract truth fix + EIN-to-collect note). Co-Authored-By: Claude Fable 5 --- .../skills/dolibarr-sandbox-write/SKILL.md | 69 +++++++-- .../2026-07-15-km-dossier/README.md | 52 +++++++ .../2026-07-15-km-dossier/manifest.json | 24 ++++ .../scripts/contact-create.sh | 131 ++++++++++++++++++ .../scripts/promote-apply.sh | 29 +++- .../scripts/promote-plan.sh | 10 ++ .../scripts/thirdparty-update.sh | 108 +++++++++++++++ .../dolibarr-sandbox-write/tests/run-tests.sh | 92 +++++++++++- .../tests/stub-dol-write.sh | 70 +++++++++- 9 files changed, 562 insertions(+), 23 deletions(-) create mode 100644 .claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/README.md create mode 100644 .claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/manifest.json create mode 100755 .claude/skills/dolibarr-sandbox-write/scripts/contact-create.sh create mode 100755 .claude/skills/dolibarr-sandbox-write/scripts/thirdparty-update.sh diff --git a/.claude/skills/dolibarr-sandbox-write/SKILL.md b/.claude/skills/dolibarr-sandbox-write/SKILL.md index b5688e7..067ee9b 100644 --- a/.claude/skills/dolibarr-sandbox-write/SKILL.md +++ b/.claude/skills/dolibarr-sandbox-write/SKILL.md @@ -4,16 +4,19 @@ 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 + (auto-coded), update an existing fiche's dossier (allowlisted non-ledger fields + with a before/after read-back diff), add contacts idempotently (dedupe by email + then name), 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 + Use when the user asks to "create a thirdparty / supplier / client fiche", + "compléter / mettre à jour la fiche client", "add a contact to a thirdparty", + "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. + separate, human-gated replay). 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)" @@ -62,7 +65,8 @@ 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. +Each script reads a JSON object on **stdin** (or a file path — or inline JSON — +as `$1`; `thirdparty-update.sh` also takes `` first) and emits ids as JSON. ### 1 · Thirdparty (fiche client/fournisseur) — `scripts/thirdparty-create.sh` @@ -148,13 +152,49 @@ come out negative. `kind:"supplier"` targets `/supplierinvoices` (carry (`AVC…` for customer, `AVF…` for supplier). Emits `{id, ref, ref_supplier, total_ht, total_ttc, fk_facture_source, statut}`. +### 5 · Thirdparty dossier update — `scripts/thirdparty-update.sh` + +```sh +scripts/thirdparty-update.sh 1 '{"email":"evan@kissmetrics.io","note_public":"…"}' +scripts/thirdparty-update.sh 1 fiche.json # or a file; or "-" for stdin +echo '{"socid":1,"fields":{"zip":"33713"}}' | scripts/thirdparty-update.sh # promote form +``` +Updates an **existing** fiche's dossier — **allowlisted fields only**: `name`, +`name_alias`, `address`, `zip`, `town`, `state_id`, `region_id`, `country_id`, +`country_code`, `email`, `url`, `phone`, `typent_id`, `effectif_id`, `note_public`, +`note_private`, `idprof1`…`idprof6`, `tva_intra`. That is the ledger grammar's +"thirdparty complete" — identity / address / typology / notes / national ids. +Anything else (`code_client`, `client`/`fournisseur` roles, remises, payment +conditions…) is **refused before any request**, exit non-zero, offender named. +Read-back contract: GET before → PUT → GET after; emits +`{"id":N,"changed":{field:{"before":…,"after":…}}}` restricted to the requested +fields that actually changed, and exits non-zero if a requested field did not +take. Re-applying the same update yields `"changed": {}` — idempotency you can see. + +### 6 · Contact — `scripts/contact-create.sh` + +```sh +echo '{"socid":1,"lastname":"Rootering","firstname":"Hendrik","poste":"COO", + "email":"hendrik@kissmetrics.io"}' | scripts/contact-create.sh +``` +**Idempotent from day one** (the erp#44 pattern): before any POST it lists the +thirdparty's contacts and dedupes **by case-insensitive email, then by +(lastname, firstname)**; a match emits `{"id": , "deduped": true}` and +exits 0 without writing. Otherwise POST `/contacts` → `{"id": , +"deduped": false}`. Required: `socid`, `lastname`. Optional: `firstname`, `poste` +(job title), `email`, `phone` (stored as `phone_pro`), `phone_mobile`, +`phone_perso`, `address`, `zip`, `town`, `country_id`, `note_public`, +`note_private`. Unknown fields are refused, never dropped. + ## 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`. +earlier in the run). See `examples/promote-manifest.json`. Op kinds: `thirdparty`, +`thirdparty_update` (input: `socid` + `fields`), `contact`, `invoice`, `creditnote`, +`payment` — each mapping to its workflow script above. ```sh scripts/promote-plan.sh change.json # 1. human-readable review @@ -207,5 +247,18 @@ sandbox KissMetrics on `--target sandbox` and the prod one on `--target prod`. - **Avoirs (credit notes)** → `creditnote-create.sh` (customer invoice `type=2` referencing `source_invoice`; amounts negative, ref `AVC…`). Supplier avoirs are a follow-up. +- **Dossier updates are allowlisted by design.** The ledger grammar's "thirdparty + complete" completes a fiche with identity/address/typology/notes/idprof1-6/ + tva_intra — never with ledger-side state. `thirdparty-update.sh` refuses + anything else (`code_client`, roles, payment conditions…) *before* any request: + a typo'd field must fail loudly, not silently mutate the ledger. Offline proof + in `tests/run-tests.sh`. +- **Contacts dedupe by (socid, email) then (socid, lastname+firstname)**, both + case-insensitive — `contact-create.sh` answers `{"id": …, "deduped": true}` + instead of minting a duplicate, so replaying a manifest is always safe. +- **`poste`, not `soc2`.** The job-title field on a Dolibarr contact is `poste`; + `soc2` (seen in WIP operator payloads) is not a Dolibarr field and the API + would drop it silently — `contact-create.sh` refuses it with a pointer to + `poste`. - **CLI:** all of these are also `arcodange sandbox {thirdparty|invoice|payment|creditnote|write}` (JSON on stdin) — `arcodange sandbox help` for the list. diff --git a/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/README.md b/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/README.md new file mode 100644 index 0000000..73cfc9e --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/README.md @@ -0,0 +1,52 @@ +# Replay pack — KM client dossier 2026-07-15 + +Completes the KissMetrics fiche (socid 1) with the full contractual dossier and creates +the principal contact — erp#65 phase 1, triggered by operator direction 2026-07-15 +(« il faut ces informations relatives au client KM dans Dolibarr »). Two ops: + +1. **`thirdparty_update` socid 1** — identity/address/typology + the contract dossier in + `note_public` (allowlisted dossier fields only; the op refuses anything ledger-side). +2. **`contact`** — Hendrik Rootering, COO, `hendrik@kissmetrics.io` (idempotent: dedupes + by email, then lastname+firstname, so a replay can never mint a duplicate). + +## Provenance — and the two truth fixes + +Source: the operator's prepared payloads in erp trunk `test/kissmetrics_update.json` + +`test/hendrik_contact_fix.json`, cross-checked against the 2026-07-15 contract-facts +extraction (erp#53 comment). Two deliberate deviations from the WIP payloads: + +- **The contract is NOT signed.** The WIP note said « contrat cadre signé 2026-04-23 » — + the repo proves otherwise: the contract is *effect-dated* 2026-04-23 (rétro-daté), the + eIDAS signature is **in progress** (signable set SAFE TO SIGN 2026-06-28; PR + kissmetrics_contract_proposal#1 merged 2026-07-15, branch gone → repo state = `main`). + The note also now carries: invoicing in **USD** (EUR lock 1.1650 removed 2026-06-28), + the 4 % window = 6 months **post-launch** (not post-signature), CCIP-CA + CMAP dispute + chain, Kissmetrics Holdings Inc wires, and **« US EIN : à collecter (→ idprof1) »** — + the EIN is a [HUMAN] item (W-8BEN-E exchange is the natural moment); until then the + `dolibarr-thirdparty-completeness` audit keeps showing exactly that one gap. +- **`poste`, not `soc2`.** The WIP contact payload carried the job title in `soc2`, which + is not a Dolibarr field (the API silently drops it). The manifest uses `poste`; + `contact-create.sh` refuses `soc2` outright. + +## How to promote + +```sh +cd .claude/skills/dolibarr-sandbox-write +scripts/promote-plan.sh replay-packs/2026-07-15-km-dossier/manifest.json # review +scripts/promote-apply.sh replay-packs/2026-07-15-km-dossier/manifest.json --target sandbox # rehearse +# idempotency proof: apply twice — run 2 must print changed=0 (no-op) + deduped=true +``` + +**Prod step = the orchestrator's, human-gated — not this pack's job.** The prod replay +(`--target prod`) needs `DOLIBARR_PROD_WRITE_KEY` in the environment (never stored) and +`ARCO_PROMOTE_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD`, per ADR-0003. Pair with +`dolibarr-data-snapshot` before/after, then re-run `dolibarr-thirdparty-completeness` on +socid 1 — expected result: only the EIN gap remains. + +## Rehearsal record (sandbox, 2026-07-15) + +Run 1: fiche diff = `changed=1 [note_public]` (the sandbox already carried the WIP +payload — the diff IS the truth fix) + contact Hendrik created (id 3). Run 2: +`changed=0 (no-op)` + `deduped=true` (same id 3). Read-back: all 13 dossier fields +correct; note carries « SIGNATURE eIDAS EN COURS », « US EIN : à collecter », +« FACTURÉ EN USD ». Full outputs in the phase-1 PR on erp#65. diff --git a/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/manifest.json b/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/manifest.json new file mode 100644 index 0000000..a89654c --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/replay-packs/2026-07-15-km-dossier/manifest.json @@ -0,0 +1,24 @@ +[ + { "op": "thirdparty_update", "ref": "km", + "input": { + "socid": 1, + "fields": { + "name": "KissMetrics", + "name_alias": "KissMetrics Inc.", + "address": "2850 34th Street North, 307", + "zip": "33713", + "town": "St. Petersburg", + "state_id": "1167", + "region_id": "297", + "country_id": "11", + "country_code": "US", + "email": "evan@kissmetrics.io", + "typent_id": "3", + "effectif_id": "1", + "note_public": "Client Arcodange. Delaware corp, CEO Evan Sforzo, COO Hendrik Rootering (contact principal, Slack-first). Contrat cadre à effet du 2026-04-23 (rétro-daté) — SIGNATURE eIDAS EN COURS (set signable SAFE TO SIGN 2026-06-28 ; PR kissmetrics_contract_proposal#1 mergée 2026-07-15). 6 mois actifs (→2026-10-23), différé jusqu'à 2027-01-23, base engagée $33,000. Rémunération : $5 500/mois ($2 500 fixe net 30 + $3 000 différé +3 mois), FACTURÉ EN USD, réglé EUR au taux BCE du jour du paiement (lock 1.1650 supprimé le 2026-06-28). 4 % cash collected sur clients net-new signés dans les 6 mois post-launch (launch ~juil. 2026, date à confirmer), sur les 12 premiers mois de chaque client, payé au fil des encaissements net 30, sans plafond. Capacité ~10 j-h/mois au référentiel 500 €/j, Arcodange organise à sa discrétion. Droit français, T. com. Paris (CCIP-CA), médiation CMAP. Wires possibles via l'affilié Kissmetrics Holdings Inc (Art. 1). W-8BEN-E à déposer chez KM ; US EIN : à collecter (→ idprof1). Repo : arcodange-org/kissmetrics_contract_proposal (main). Darnis Operations = apport d'affaires (10 % an 1, 5 % perpétuel, sur fee standard différé inclus, 4 % exclu)." + } } }, + + { "op": "contact", "ref": "hendrik", + "input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik", + "poste": "COO", "email": "hendrik@kissmetrics.io" } } +] diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/contact-create.sh b/.claude/skills/dolibarr-sandbox-write/scripts/contact-create.sh new file mode 100755 index 0000000..85200d2 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/scripts/contact-create.sh @@ -0,0 +1,131 @@ +#!/usr/bin/env bash +# Create a contact (socpeople) on a thirdparty in the SANDBOX — IDEMPOTENT from +# day one (the erp#44 pattern, applied at birth). +# +# Input: a JSON object on stdin (or a file path / inline JSON in $1): +# socid (required) the thirdparty the contact belongs to +# lastname (required) +# firstname, poste (job title), email, phone (stored as phone_pro), +# phone_pro, phone_perso, phone_mobile, address, zip, town, country_id, +# note_public, note_private (all optional) +# Unknown fields are REFUSED, never silently dropped — notably the WIP operator +# payloads' `soc2` is NOT a Dolibarr field: the job title is `poste`. +# +# Idempotency: BEFORE any POST, list the target's existing contacts +# (GET /contacts?thirdparty_ids=) and dedupe +# 1. by case-insensitive email, then +# 2. by case-insensitive (lastname, firstname). +# On a match: emit {"id": , "deduped": true} and exit 0 without +# POSTing. Otherwise POST /contacts and emit {"id": , "deduped": false}. +# +# The Dolibarr API answers HTTP 404 (not []) when a thirdparty has no contacts — +# that failure, and only that one, is treated as "no contacts yet". Any other +# listing failure ABORTS: assuming "empty" on e.g. a 403 would mint duplicates. +# +# All requests go through dol-write.sh (or $DOL_WRITE), which refuses any host +# that is not the sandbox. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" + +SRC="${1:-}" +if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)" +elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")" +else INPUT="${SRC}"; fi # inline JSON + +TMPD="$(mktemp -d -t ctcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT + +# --- 1. Validate + map the POST body (before ANY request) ---------------------- +cat > "${TMPD}/validate.py" <<'PY' +import json, sys +ALLOWED = ["socid", "lastname", "firstname", "poste", "email", "phone", + "phone_pro", "phone_perso", "phone_mobile", "address", "zip", "town", + "country_id", "note_public", "note_private"] +try: + d = json.loads(sys.stdin.read() or "null") +except Exception as e: + sys.exit("contact-create.sh: input is not valid JSON: %s" % e) +if not isinstance(d, dict): + sys.exit("contact-create.sh: input must be a JSON object") +if "soc2" in d: + sys.exit("contact-create.sh: REFUSED — 'soc2' is not a Dolibarr contact field " + "(the API would silently drop it); the job title field is 'poste'") +bad = sorted(k for k in d if k not in ALLOWED) +if bad: + sys.exit("contact-create.sh: REFUSED — unknown field(s): %s\n allowed: %s" + % (", ".join(bad), ", ".join(ALLOWED))) +socid = str(d.get("socid", "")) +if not socid.isdigit(): + sys.exit("contact-create.sh: 'socid' is required (numeric), got %r" % socid) +if not str(d.get("lastname", "")).strip(): + sys.exit("contact-create.sh: 'lastname' is required") +body = {"socid": socid, "lastname": str(d["lastname"]).strip()} +for k in ALLOWED: + if k in ("socid", "lastname", "phone"): + continue + v = d.get(k) + if v not in (None, ""): + body[k] = v +# `phone` is the office line — Dolibarr contacts store it as phone_pro. +if d.get("phone") not in (None, "") and "phone_pro" not in body: + body["phone_pro"] = d["phone"] +print(socid) +print(json.dumps(body, ensure_ascii=False)) +PY +MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py")" +SOCID="$(sed -n 1p <<<"${MAPPED}")" +BODY="$(sed -n 2p <<<"${MAPPED}")" +printf '%s' "${BODY}" > "${TMPD}/body.json" + +# --- 2. Dedupe against the target's existing contacts -------------------------- +set +e +"${W}" GET "/contacts?thirdparty_ids=${SOCID}&limit=500" \ + > "${TMPD}/list.json" 2> "${TMPD}/list.err" +rc=$? +set -e +if [[ ${rc} -ne 0 ]]; then + if grep -q "HTTP 404" "${TMPD}/list.err"; then + printf '[]' > "${TMPD}/list.json" # empty contact list answers 404, not [] + else + cat "${TMPD}/list.err" >&2 + echo "contact-create.sh: could not list contacts for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2 + exit 1 + fi +fi + +cat > "${TMPD}/match.py" <<'PY' +import json, sys +body = json.load(open(sys.argv[1])) +try: + rows = json.load(open(sys.argv[2])) +except Exception: + rows = [] +rows = rows if isinstance(rows, list) else [] +def norm(v): return str(v or "").strip().lower() +email, ln, fn = norm(body.get("email")), norm(body.get("lastname")), norm(body.get("firstname")) +hit = None +if email: + for r in rows: + if norm(r.get("email")) == email: + hit = r; break +if hit is None: + for r in rows: + if norm(r.get("lastname")) == ln and norm(r.get("firstname")) == fn: + hit = r; break +if hit is not None: + print(json.dumps({"id": int(hit["id"]), "deduped": True})) +PY +MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/body.json" "${TMPD}/list.json")" +if [[ -n "${MATCH}" ]]; then + echo "contact-create.sh: contact already exists on socid ${SOCID} — deduped, no POST" >&2 + printf '%s\n' "${MATCH}" + exit 0 +fi + +# --- 3. POST the new contact ---------------------------------------------------- +NEWID="$("${W}" POST /contacts "${BODY}")" +if [[ ! "${NEWID}" =~ ^[0-9]+$ ]]; then + echo "contact-create.sh: contact POST did not return an id: ${NEWID}" >&2 + exit 1 +fi +printf '{"id": %s, "deduped": false}\n' "${NEWID}" diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh index d1dbc8e..55d34a4 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/promote-apply.sh @@ -21,7 +21,12 @@ while [[ $# -gt 0 ]]; do esac done case "${TARGET}" in - sandbox) export DOL_WRITE="${SCRIPT_DIR}/dol-write.sh" ;; + # sandbox honors a pre-set DOL_WRITE so the offline tests can inject + # tests/stub-dol-write.sh (the erp#37 hook the op scripts already honor); + # the default is the host-guarded dol-write.sh. + sandbox) export DOL_WRITE="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" ;; + # prod NEVER inherits — always the gated dol-prod-write.sh (env-only key + # + ARCO_PROMOTE_CONFIRM), so no environment trick can reroute a prod apply. prod) export DOL_WRITE="${SCRIPT_DIR}/dol-prod-write.sh" ;; *) echo "promote-apply.sh: --target must be sandbox|prod" >&2; exit 2 ;; esac @@ -32,7 +37,8 @@ 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"} + "creditnote": "creditnote-create.sh", "payment": "payment-record.sh", + "thirdparty_update": "thirdparty-update.sh", "contact": "contact-create.sh"} refmap = {} import urllib.parse @@ -99,13 +105,28 @@ for i, op in enumerate(ops, 1): sys.stderr.write(r.stdout + r.stderr + "\n") sys.exit("promote-apply: op %d (%s) FAILED" % (i, t)) out = r.stdout.strip() + parsed = None try: - rid = json.loads(out).get("id") + parsed = json.loads(out) except Exception: + pass + if isinstance(parsed, dict): + rid = parsed.get("id") + else: 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)) + # Surface the idempotency evidence inline: contact dedupes and + # thirdparty-update read-back diffs are the proof a re-apply is a no-op. + extra = "" + if isinstance(parsed, dict): + if parsed.get("deduped"): + extra += " deduped=true (already on target — no write)" + ch = parsed.get("changed") + if isinstance(ch, dict): + extra += " changed=%d%s" % (len(ch), + (" [%s]" % ", ".join(sorted(ch))) if ch else " (no-op)") + print(" [%d/%d] %-17s %-8s -> id=%s%s" % (i, len(ops), t, ("@" + ref) if ref else "", rid, extra)) print("OK — promote complete. ref -> id: %s" % json.dumps(refmap)) PY diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh b/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh index 54d4804..e76868f 100755 --- a/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh +++ b/.claude/skills/dolibarr-sandbox-write/scripts/promote-plan.sh @@ -35,6 +35,16 @@ for i, op in enumerate(ops, 1): 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")) + elif t == "thirdparty_update": + flds = inp.get("fields") or {} + print(" socid=%s update %d dossier field(s): %s" % (inp.get("socid"), len(flds), + ", ".join(sorted(flds)) if flds else "NONE (will be refused)")) + elif t == "contact": + name = " ".join(x for x in (inp.get("firstname"), inp.get("lastname")) if x) or "?" + print(" socid=%s contact %s%s%s (idempotent: dedupe by email, then lastname+firstname)" + % (inp.get("socid"), name, + (" — %s" % inp["poste"]) if inp.get("poste") else "", + (" <%s>" % inp["email"]) if inp.get("email") else "")) print("\nNext:") print(" promote-apply.sh --target sandbox # rehearse the replay (safe)") print(" promote-apply.sh --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY") diff --git a/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-update.sh b/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-update.sh new file mode 100755 index 0000000..70d1b06 --- /dev/null +++ b/.claude/skills/dolibarr-sandbox-write/scripts/thirdparty-update.sh @@ -0,0 +1,108 @@ +#!/usr/bin/env bash +# Update an EXISTING thirdparty's DOSSIER fields in the SANDBOX — allowlisted. +# +# The ledger grammar's "thirdparty complete": a fiche is completed with identity / +# address / typology / notes / national ids — never with ledger-side state. This +# script therefore REFUSES any field outside the allowlist below (code_client, +# client/fournisseur roles, remises, payment conditions, bank details… are +# creation- or ledger-side concerns, not dossier updates). Refusal happens BEFORE +# any request is sent, exits non-zero, and names the offending key(s). +# +# Usage: +# thirdparty-update.sh +# thirdparty-update.sh - # fields JSON on stdin +# thirdparty-update.sh # promote form: {"socid":N,"fields":{…}} on stdin +# +# Read-back contract (anti-silent-partial-apply): +# GET before → PUT → GET after. stdout is a JSON diff of the REQUESTED fields +# that actually changed: {"id":N,"changed":{field:{"before":…,"after":…}}}. +# A requested field whose read-back differs from the requested value makes the +# script exit non-zero — a write that "didn't take" is an error, never a silent +# partial apply. Re-running the same update yields "changed": {} (idempotent). +# +# All requests go through dol-write.sh (or $DOL_WRITE), which refuses any host +# that is not the sandbox. +set -euo pipefail +SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +W="${DOL_WRITE:-${SCRIPT_DIR}/dol-write.sh}" + +SOCID_ARG="${1:-}" +SRC="${2:-}" +if [[ -n "${SOCID_ARG}" ]]; then + if [[ -z "${SRC}" || "${SRC}" == "-" ]]; then INPUT="$(cat)" + elif [[ -f "${SRC}" ]]; then INPUT="$(cat "${SRC}")" + else INPUT="${SRC}"; fi # inline JSON +else + INPUT="$(cat)" # promote form: {"socid":N,"fields":{…}} +fi + +TMPD="$(mktemp -d -t tpupd.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT + +# --- 1. Validate against the dossier allowlist (before ANY request) ------------ +cat > "${TMPD}/validate.py" <<'PY' +import json, sys +# The dossier allowlist — identity / address / typology / notes / national ids. +# Everything else is refused: ledger-side or creation-side fields have their own +# ops, and silently forwarding them would let a typo mutate the ledger. +ALLOWED = ["name", "name_alias", "address", "zip", "town", "state_id", + "region_id", "country_id", "country_code", "email", "url", "phone", + "typent_id", "effectif_id", "note_public", "note_private", + "idprof1", "idprof2", "idprof3", "idprof4", "idprof5", "idprof6", + "tva_intra"] +socid_arg = sys.argv[1] +try: + d = json.loads(sys.stdin.read() or "null") +except Exception as e: + sys.exit("thirdparty-update.sh: input is not valid JSON: %s" % e) +if socid_arg: + socid, fields = socid_arg, d +else: + if not isinstance(d, dict) or "socid" not in d or "fields" not in d: + sys.exit("thirdparty-update.sh: stdin form must be {\"socid\":N,\"fields\":{...}}") + socid, fields = d["socid"], d["fields"] +socid = str(socid) +if not socid.isdigit(): + sys.exit("thirdparty-update.sh: socid must be numeric, got %r" % socid) +if not isinstance(fields, dict) or not fields: + sys.exit("thirdparty-update.sh: no fields to update (fields must be a non-empty JSON object)") +bad = sorted(k for k in fields if k not in ALLOWED) +if bad: + sys.exit("thirdparty-update.sh: REFUSED — field(s) outside the dossier allowlist: %s\n" + " allowed: %s" % (", ".join(bad), ", ".join(ALLOWED))) +print(socid) +print(json.dumps(fields, ensure_ascii=False)) +PY +MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/validate.py" "${SOCID_ARG}")" +SOCID="$(sed -n 1p <<<"${MAPPED}")" +BODY="$(sed -n 2p <<<"${MAPPED}")" +printf '%s' "${BODY}" > "${TMPD}/requested.json" + +# --- 2. GET before → PUT → GET after ------------------------------------------- +"${W}" GET "/thirdparties/${SOCID}" > "${TMPD}/before.json" +"${W}" PUT "/thirdparties/${SOCID}" "${BODY}" > /dev/null +"${W}" GET "/thirdparties/${SOCID}" > "${TMPD}/after.json" + +# --- 3. Per-field diff of the requested fields; fail if a field didn't take ---- +cat > "${TMPD}/diff.py" <<'PY' +import json, sys +before = json.load(open(sys.argv[1])) +after = json.load(open(sys.argv[2])) +req = json.load(open(sys.argv[3])) +socid = int(sys.argv[4]) +def s(v): # Dolibarr returns most scalars as strings; None ≡ "" + return "" if v is None else str(v) +changed, failed = {}, [] +for k, want in req.items(): + b, a = before.get(k), after.get(k) + if s(a) != s(want): + failed.append((k, want, a)) + if s(b) != s(a): + changed[k] = {"before": b, "after": a} +print(json.dumps({"id": socid, "changed": changed}, ensure_ascii=False)) +if failed: + for k, want, got in failed: + sys.stderr.write("thirdparty-update.sh: field %r did NOT take — requested %r, read back %r\n" + % (k, want, got)) + sys.exit(1) +PY +python3 "${TMPD}/diff.py" "${TMPD}/before.json" "${TMPD}/after.json" "${TMPD}/requested.json" "${SOCID}" diff --git a/.claude/skills/dolibarr-sandbox-write/tests/run-tests.sh b/.claude/skills/dolibarr-sandbox-write/tests/run-tests.sh index 4d127ff..543a9a9 100755 --- a/.claude/skills/dolibarr-sandbox-write/tests/run-tests.sh +++ b/.claude/skills/dolibarr-sandbox-write/tests/run-tests.sh @@ -1,20 +1,37 @@ #!/usr/bin/env bash -# Offline tests for payment-record.sh's transaction_id normalization — the writer -# side of the varchar(50) fix (Dolibarr's num_payment columns truncate at 50 chars, -# Qonto ids run ~67). Uses tests/stub-dol-write.sh via the DOL_WRITE env hook, so -# NOTHING is written to the sandbox or prod. +# Offline tests for the write skill — everything runs against +# tests/stub-dol-write.sh via the DOL_WRITE env hook, so NOTHING is written to +# the sandbox or prod (zero credentials, zero network). +# +# payment-record.sh (the erp#37 varchar(50) fix): # 1. Long Qonto id → POST carries the UUID suffix; JSON reports it; stderr says so. # 2. Wise numeric id → passes through untouched, no normalization notice. # 3. Id still >50 chars after normalization → refused BEFORE any POST, error # cites varchar(50) (never a silent truncation). +# client-dossier ops (erp#65 phase 1): +# 4. thirdparty-update.sh refuses a non-allowlisted field (code_client) BEFORE +# any PUT, naming the offender. +# 5. contact-create.sh dedupes on a case-insensitive email match → no POST, +# {"deduped": true}; and refuses the WIP payloads' `soc2` (→ `poste`). +# 6. happy path: thirdparty_update + contact through promote-apply +# --target sandbox (stubbed); a second apply is a proven no-op +# (changed=0 for the fiche, deduped=true for the contact). set -euo pipefail SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" PR="${SCRIPT_DIR}/../scripts/payment-record.sh" +TU="${SCRIPT_DIR}/../scripts/thirdparty-update.sh" +CC="${SCRIPT_DIR}/../scripts/contact-create.sh" +PA="${SCRIPT_DIR}/../scripts/promote-apply.sh" +PP="${SCRIPT_DIR}/../scripts/promote-plan.sh" STUB="${SCRIPT_DIR}/stub-dol-write.sh" fail() { echo "FAIL: $*" >&2; exit 1; } bash -n "${PR}" || fail "bash -n payment-record.sh" +bash -n "${TU}" || fail "bash -n thirdparty-update.sh" +bash -n "${CC}" || fail "bash -n contact-create.sh" +bash -n "${PA}" || fail "bash -n promote-apply.sh" +bash -n "${PP}" || fail "bash -n promote-plan.sh" bash -n "${STUB}" || fail "bash -n stub-dol-write.sh" STATE="$(mktemp -d -t prtest.XXXXXX)" @@ -56,3 +73,70 @@ printf '{"invoice_id":13,"kind":"supplier","account_id":1,"amount":96,"transacti grep -q 'varchar(50)' "${STATE}/stderr3" || fail "overlong-id: error must cite the varchar(50) constraint" echo "OK: payment-record normalization tests passed (long→short, wise untouched, >50 refused pre-POST)" + +# --- Case 4: thirdparty-update refuses a non-allowlisted field pre-PUT --- +S4="$(mktemp -d -t tutest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}"' EXIT +rc=0 +printf '{"name":"KissMetrics","code_client":"CL9999"}' \ + | DOL_WRITE="${STUB}" STUB_STATE="${S4}" bash "${TU}" 1 - >/dev/null 2>"${S4}/stderr" || rc=$? +[[ "${rc}" -ne 0 ]] || fail "allowlist: payload with code_client must be refused (exit non-zero)" +[[ ! -f "${S4}/put_body.json" ]] || fail "allowlist: refusal must happen BEFORE any PUT" +grep -q 'code_client' "${S4}/stderr" || fail "allowlist: the error must name the offending field" +grep -qi 'allowlist' "${S4}/stderr" || fail "allowlist: the error must say it is an allowlist refusal" +echo "OK: thirdparty-update allowlist — code_client refused pre-PUT, offender named" + +# --- Case 5: contact-create dedupes by case-insensitive email → no POST --- +S5="$(mktemp -d -t cctest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}"' EXIT +printf '%s' '[{"id":"41","socid":"1","lastname":"ROOTERING","firstname":"hendrik","poste":"COO","email":"Hendrik@KissMetrics.IO"}]' \ + > "${S5}/contacts.json" +OUT="$(printf '{"socid":"1","lastname":"Rootering","firstname":"Hendrik","poste":"COO","email":"hendrik@kissmetrics.io"}' \ + | DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" 2>/dev/null)" \ + || fail "contact-dedupe: expected success, got $?" +python3 -c " +import json +o = json.loads('''${OUT}''') +assert o == {'id': 41, 'deduped': True}, o +" || fail "contact-dedupe: must return the existing id with deduped:true, got: ${OUT}" +[[ ! -f "${S5}/contact_post_body.json" ]] || fail "contact-dedupe: must NOT POST when a match exists" +# 5b — the WIP payloads' soc2 is not a Dolibarr field: refuse, point to poste +rc=0 +printf '{"socid":"1","lastname":"Rootering","soc2":"COO"}' \ + | DOL_WRITE="${STUB}" STUB_STATE="${S5}" bash "${CC}" >/dev/null 2>"${S5}/stderr5b" || rc=$? +[[ "${rc}" -ne 0 ]] || fail "soc2: must be refused (exit non-zero)" +grep -q 'poste' "${S5}/stderr5b" || fail "soc2: the error must point to 'poste'" +[[ ! -f "${S5}/contact_post_body.json" ]] || fail "soc2: refusal must happen BEFORE any POST" +echo "OK: contact-create dedupe — email match returns existing id, no POST; soc2 refused → poste" + +# --- Case 6: happy path — both ops through promote-apply; re-apply is a no-op --- +S6="$(mktemp -d -t patest.XXXXXX)"; trap 'rm -rf "${STATE}" "${S4}" "${S5}" "${S6}"' EXIT +cat > "${S6}/manifest.json" <<'JSON' +[ + { "op": "thirdparty_update", "ref": "tp", + "input": { "socid": 1, + "fields": { "email": "evan@kissmetrics.io", "note_public": "NEW NOTE — dossier v2" } } }, + { "op": "contact", "ref": "ct", + "input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik", + "poste": "COO", "email": "hendrik@kissmetrics.io" } } +] +JSON +bash "${PP}" "${S6}/manifest.json" >/dev/null || fail "promote-plan: must render the new op kinds" +OUT1="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \ + || fail "promote-apply run 1: expected success, got $?" +grep -q 'thirdparty_update' <<<"${OUT1}" || fail "run 1: thirdparty_update op must be reported" +grep -q 'changed=2' <<<"${OUT1}" || fail "run 1: both fields must read back as changed, got: ${OUT1}" +grep -q 'deduped' <<<"${OUT1}" && fail "run 1: nothing must dedupe on a fresh target" +grep -q -- '-> id=88' <<<"${OUT1}" || fail "run 1: contact must be created (id 88), got: ${OUT1}" +grep -q '"note_public": "NEW NOTE — dossier v2"' "${S6}/put_body.json" \ + || fail "run 1: PUT body must carry the new note, got: $(cat "${S6}/put_body.json")" +grep -q '"poste": "COO"' "${S6}/contact_post_body.json" \ + || fail "run 1: contact POST must carry poste=COO, got: $(cat "${S6}/contact_post_body.json")" +rm -f "${S6}/contact_post_body.json" +OUT2="$(DOL_WRITE="${STUB}" STUB_STATE="${S6}" bash "${PA}" "${S6}/manifest.json" --target sandbox 2>/dev/null)" \ + || fail "promote-apply run 2: expected success, got $?" +grep -q 'changed=0 (no-op)' <<<"${OUT2}" || fail "run 2: thirdparty_update must be a no-op, got: ${OUT2}" +grep -q 'deduped=true' <<<"${OUT2}" || fail "run 2: contact must dedupe, got: ${OUT2}" +grep -q -- '-> id=88' <<<"${OUT2}" || fail "run 2: dedupe must return the run-1 id" +[[ ! -f "${S6}/contact_post_body.json" ]] || fail "run 2: must NOT POST a duplicate contact" +echo "OK: promote-apply happy path — run 1 applies (changed=2, contact id 88), run 2 is a no-op (changed=0, deduped)" + +echo "OK: all offline tests passed" diff --git a/.claude/skills/dolibarr-sandbox-write/tests/stub-dol-write.sh b/.claude/skills/dolibarr-sandbox-write/tests/stub-dol-write.sh index b1483d4..4550f4d 100755 --- a/.claude/skills/dolibarr-sandbox-write/tests/stub-dol-write.sh +++ b/.claude/skills/dolibarr-sandbox-write/tests/stub-dol-write.sh @@ -1,18 +1,74 @@ #!/usr/bin/env bash # Offline stand-in for dol-write.sh, used ONLY by tests/run-tests.sh (injected via -# the DOL_WRITE env hook). Records the POST body under $STUB_STATE, then serves it -# back as the payments list on GET so payment-record.sh can correlate. Never talks -# to any host — sandbox and prod are both out of reach by construction. +# the DOL_WRITE env hook). Never talks to any host — sandbox and prod are both out +# of reach by construction. Dispatch by endpoint: +# +# GET /thirdparties/ → serves $STUB_STATE/thirdparty.json (or a canned +# "before" fiche on first read) +# PUT /thirdparties/ → records put_body.json/put_endpoint, merges the body +# into thirdparty.json (so the read-after sees it) +# GET /contacts… → serves $STUB_STATE/contacts.json; mimics Dolibarr's +# empty-list behavior (HTTP 404 + non-zero) when absent +# POST /contacts… → records contact_post_body.json, appends the contact +# (id 88) to contacts.json, echoes 88 +# POST → payment behavior: records post_body.json/post_endpoint, +# echoes 77 (unchanged from the erp#37 tests) +# GET → payments list served back from post_body.json set -euo pipefail STATE="${STUB_STATE:?stub-dol-write.sh: STUB_STATE not set}" METHOD="$1"; ENDPOINT="$2"; BODY="${3:-}" -case "${METHOD}" in - POST) - printf '%s' "${BODY}" > "${STATE}/post_body.json" +case "${METHOD} ${ENDPOINT}" in + "GET /thirdparties/"*) + if [[ -f "${STATE}/thirdparty.json" ]]; then + cat "${STATE}/thirdparty.json" + else + printf '%s' '{"id":"1","name":"KissMetrics","name_alias":null,"email":"","note_public":"OLD NOTE (pre-dossier)","zip":null,"town":null}' + fi + ;; + "PUT /thirdparties/"*) + printf '%s' "${BODY}" > "${STATE}/put_body.json" + printf '%s\n' "${ENDPOINT}" > "${STATE}/put_endpoint" + python3 - "${STATE}" "${BODY}" <<'PY' +import json, os, sys +state, body = sys.argv[1], json.loads(sys.argv[2]) +p = os.path.join(state, "thirdparty.json") +cur = (json.load(open(p)) if os.path.exists(p) + else {"id": "1", "name": "KissMetrics", "name_alias": None, "email": "", + "note_public": "OLD NOTE (pre-dossier)", "zip": None, "town": None}) +cur.update(body) +json.dump(cur, open(p, "w"), ensure_ascii=False) +print(json.dumps(cur, ensure_ascii=False)) +PY + ;; + "GET /contacts"*) + if [[ -f "${STATE}/contacts.json" ]]; then + cat "${STATE}/contacts.json" + else + # Dolibarr's list endpoints answer 404 (not []) when nothing matches. + printf '%s' '{"error":{"code":404,"message":"No contact found"}}' + echo "stub-dol-write.sh: HTTP 404 on GET ${ENDPOINT}" >&2 + exit 1 + fi + ;; + "POST /contacts"*) + printf '%s' "${BODY}" > "${STATE}/contact_post_body.json" + python3 - "${STATE}" "${BODY}" <<'PY' +import json, os, sys +state, body = sys.argv[1], json.loads(sys.argv[2]) +p = os.path.join(state, "contacts.json") +rows = json.load(open(p)) if os.path.exists(p) else [] +body = dict(body); body["id"] = "88" +rows.append(body) +json.dump(rows, open(p, "w"), ensure_ascii=False) +PY + echo "88" + ;; + POST\ *) + printf '%s' "${BODY}" > "${STATE}/post_body.json" printf '%s\n' "${ENDPOINT}" > "${STATE}/post_endpoint" echo "77" ;; - GET) + GET\ *) python3 - "${STATE}/post_body.json" <<'PY' import json, sys body = json.load(open(sys.argv[1])) -- 2.54.0