feat(write-skill): client-dossier ops — thirdparty update (allowlisted) + idempotent contacts #66

Merged
arcodange merged 1 commits from arcodange/client-dossier-ops into main 2026-07-15 19:51:22 +02:00
9 changed files with 562 additions and 23 deletions
Showing only changes of commit 35b227eb6d - Show all commits
+61 -8
View File
@@ -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 `<socid>` 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":"[email protected]","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":"[email protected]"}' | 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": <existing>, "deduped": true}` and
exits 0 without writing. Otherwise POST `/contacts` → `{"id": <new>,
"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.
@@ -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, `[email protected]` (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.
@@ -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": "[email protected]",
"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": "[email protected]" } }
]
@@ -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=<socid>) and dedupe
# 1. by case-insensitive email, then
# 2. by case-insensitive (lastname, firstname).
# On a match: emit {"id": <existing>, "deduped": true} and exit 0 without
# POSTing. Otherwise POST /contacts and emit {"id": <new>, "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}"
@@ -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
@@ -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 <manifest> --target sandbox # rehearse the replay (safe)")
print(" promote-apply.sh <manifest> --target prod # WRITES PROD — needs DOLIBARR_PROD_WRITE_KEY")
@@ -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 <socid> <json-file-or-inline-json>
# thirdparty-update.sh <socid> - # 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}"
@@ -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":"[email protected]"}]' \
> "${S5}/contacts.json"
OUT="$(printf '{"socid":"1","lastname":"Rootering","firstname":"Hendrik","poste":"COO","email":"[email protected]"}' \
| 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": "[email protected]", "note_public": "NEW NOTE — dossier v2" } } },
{ "op": "contact", "ref": "ct",
"input": { "socid": "1", "lastname": "Rootering", "firstname": "Hendrik",
"poste": "COO", "email": "[email protected]" } }
]
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"
@@ -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/<id> → serves $STUB_STATE/thirdparty.json (or a canned
# "before" fiche on first read)
# PUT /thirdparties/<id> → 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 <anything else> → payment behavior: records post_body.json/post_endpoint,
# echoes 77 (unchanged from the erp#37 tests)
# GET <anything else> → 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)
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]))