Two guards, both from real incidents in the same session. 1. invoice-create.sh — chronology (CGI art. 289). Dolibarr assigns the number at validation, in creation order, so issuing a document dated BEFORE the last one already issued gives a higher number to an earlier date. The July plan walked straight into it: the M3 deferred part is due 2026-10-23 and must be issued at D-60 (24/08) to stay under the L.441-10 I ceiling, while the M4 fixed part is dated 23/08 — issue them in the wrong order and the numbering breaks. The guard reads the last issued document of the same kind and refuses an earlier date, with ARCO_ALLOW_BACKDATE as a loud, documented override. Verified: refuses a 01/07 invoice against FAC008 (23/07), accepts 23/08. 2. test/scripts/guard.ts — production opt-in. The sandbox-only guard had no way to express a deliberate production run, so any prod work meant bypassing it entirely (which is how guards die). Production now requires BOTH ARCO_ALLOW_PRODUCTION=<exact host> and ARCO_PROD_CONFIRM=I-UNDERSTAND-THIS-WRITES-PROD, and prints a banner. Nothing reaches prod by inheriting an ambient variable. Also fixes a misleading "(sandbox verified)" log that printed even on prod. grantAgentRight.ts joins the repo (it was never committed) and gains --revoke, so a temporarily elevated right can be handed back — used today to attach a payment in production and revoked immediately after. Co-Authored-By: Claude Opus 5 (1M context) <[email protected]> Claude-Session: https://claude.ai/code/session_01VRShc4QhLLU73FLHx9vskh
231 lines
11 KiB
Bash
Executable File
231 lines
11 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
# Create a customer or supplier invoice (facture) with product/service lines in
|
|
# the SANDBOX, optionally validating it — IDEMPOTENT (erp#44): replaying the
|
|
# same create returns the existing invoice instead of minting a duplicate.
|
|
#
|
|
# 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? } ]
|
|
#
|
|
# Idempotency (erp#44 — Learning #4 of the 2026-07-11 rehearsal, where a manifest
|
|
# that failed mid-run could not be replayed because op 1 would have re-created
|
|
# the DARNIS invoice). BEFORE any POST, list the thirdparty's invoices of the
|
|
# same kind and dedupe:
|
|
# - supplier kind WITH ref_supplier : by (socid, ref_supplier) — the supplier's
|
|
# own ref is the natural key. A match whose total_ttc DIFFERS from the
|
|
# requested lines ABORTS (same key, different content = a data conflict to
|
|
# resolve by a human, not a dedupe).
|
|
# - customer kind (or supplier without ref_supplier) : by (socid, date,
|
|
# total_ttc ±0.02, line fingerprint) where the fingerprint is the multiset of
|
|
# (desc, qty, subprice, tva_tx) per line — desc HTML-unescaped and
|
|
# whitespace-collapsed, since Dolibarr returns stored descriptions
|
|
# HTML-encoded.
|
|
# - 2+ matches ABORT (ambiguous — the target already has duplicates; never
|
|
# guess). Credit notes (type=2) are never dedupe candidates.
|
|
# On a match: if `validate:true` was requested and the match is still a DRAFT
|
|
# (statut=0 — e.g. run 1 died between create and validate), it is validated now,
|
|
# so a replayed manifest CONVERGES instead of leaving a half-done op behind.
|
|
# A listing failure other than 404 ABORTS — POSTing blind would mint duplicates.
|
|
#
|
|
# Emits {id, ref, ref_supplier, total_ht, total_ttc, statut, deduped} on stdout
|
|
# — deduped:true means no invoice was created (the id is the pre-existing one).
|
|
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
|
|
|
|
TMPD="$(mktemp -d -t invcre.XXXXXX)"; trap 'rm -rf "${TMPD}"' EXIT
|
|
cat > "${TMPD}/map.py" <<'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")
|
|
dt = datetime.datetime.strptime(ds, "%Y-%m-%d") if ds else datetime.datetime.now()
|
|
epoch = int(dt.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"]
|
|
# Expected total_ttc, Dolibarr-style (per-line rounding), for the dedupe key.
|
|
total_ttc = round(sum(round(float(L["qty"]) * float(L["subprice"]), 2)
|
|
* (1 + float(L["tva_tx"]) / 100.0) for L in lines), 2)
|
|
dedupe = {"supplier": supplier, "socid": str(d["socid"]),
|
|
"ref_supplier": str(d.get("ref_supplier") or "") if supplier else "",
|
|
"date": dt.strftime("%Y-%m-%d"), "total_ttc": total_ttc,
|
|
"lines": [[L["desc"], L["qty"], L["subprice"], L["tva_tx"]] for L in lines]}
|
|
print(endpoint)
|
|
print(json.dumps(body))
|
|
print("1" if d.get("validate") else "0")
|
|
print(json.dumps(dedupe))
|
|
PY
|
|
|
|
MAPPED="$(printf '%s' "${INPUT}" | python3 "${TMPD}/map.py")"
|
|
ENDPOINT="$(sed -n 1p <<<"${MAPPED}")"
|
|
BODY="$(sed -n 2p <<<"${MAPPED}")"
|
|
VALIDATE="$(sed -n 3p <<<"${MAPPED}")"
|
|
DEDUPE="$(sed -n 4p <<<"${MAPPED}")"
|
|
printf '%s' "${DEDUPE}" > "${TMPD}/dedupe.json"
|
|
|
|
# Final read-back: shared by the create and dedupe paths.
|
|
emit_summary() { # $1 = invoice id, $2 = deduped true|false
|
|
"${W}" GET "${ENDPOINT}/$1" | DEDUPED="$2" python3 -c "import json,sys,os
|
|
d=json.load(sys.stdin)
|
|
o={k:d.get(k) for k in ('id','ref','ref_supplier','total_ht','total_ttc','statut')}
|
|
o['deduped']=os.environ['DEDUPED']=='true'
|
|
print(json.dumps(o))"
|
|
}
|
|
|
|
# --- Dedupe lookup (erp#44): list this thirdparty's invoices of the same kind ---
|
|
SOCID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['socid'])" < "${TMPD}/dedupe.json")"
|
|
set +e
|
|
"${W}" GET "${ENDPOINT}?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 lists can answer 404 (dolibarr skill gotcha)
|
|
else
|
|
cat "${TMPD}/list.err" >&2
|
|
echo "invoice-create.sh: could not list ${ENDPOINT} for socid ${SOCID} — refusing to POST blind (dedupe impossible)" >&2
|
|
exit 1
|
|
fi
|
|
fi
|
|
|
|
cat > "${TMPD}/match.py" <<'PY'
|
|
import json, sys, html, datetime
|
|
ded = json.load(open(sys.argv[1]))
|
|
try:
|
|
rows = json.load(open(sys.argv[2]))
|
|
except Exception:
|
|
rows = []
|
|
rows = [r for r in (rows if isinstance(rows, list) else [])
|
|
if str(r.get("type", "0")) != "2"] # credit notes never dedupe a create
|
|
|
|
def norm_desc(s):
|
|
return " ".join(html.unescape(str(s or "")).split())
|
|
def fnum(v, nd):
|
|
try: return round(float(v), nd)
|
|
except Exception: return None
|
|
def fingerprint(lines):
|
|
return sorted((norm_desc(l[0]), fnum(l[1], 3), fnum(l[2], 2), fnum(l[3], 3))
|
|
for l in lines)
|
|
|
|
TOL = 0.02
|
|
want_fp = fingerprint(ded["lines"])
|
|
hits = []
|
|
if ded["supplier"] and ded["ref_supplier"]:
|
|
key = ded["ref_supplier"].strip()
|
|
hits = [r for r in rows if str(r.get("ref_supplier") or "").strip() == key]
|
|
if len(hits) == 1 and ded["lines"]:
|
|
got = fnum(hits[0].get("total_ttc"), 2)
|
|
if got is not None and abs(got - ded["total_ttc"]) > TOL:
|
|
sys.exit("invoice-create.sh: ABORT — supplier invoice with ref_supplier "
|
|
"%r already exists on socid %s (id %s) but its total_ttc %.2f "
|
|
"differs from the requested %.2f. Same key, different content "
|
|
"is a conflict to resolve, not a dedupe." %
|
|
(key, ded["socid"], hits[0].get("id"), got, ded["total_ttc"]))
|
|
else:
|
|
for r in rows:
|
|
try:
|
|
rdate = datetime.datetime.fromtimestamp(int(r.get("date"))).strftime("%Y-%m-%d")
|
|
except Exception:
|
|
continue
|
|
if rdate != ded["date"]:
|
|
continue
|
|
got = fnum(r.get("total_ttc"), 2)
|
|
if got is None or abs(got - ded["total_ttc"]) > TOL:
|
|
continue
|
|
rl = r.get("lines")
|
|
if not isinstance(rl, list):
|
|
sys.exit("invoice-create.sh: ABORT — candidate invoice id %s matches "
|
|
"(date, total) but carries no inline lines to fingerprint; "
|
|
"refusing to guess" % r.get("id"))
|
|
if fingerprint([[l.get("desc"), l.get("qty"), l.get("subprice"), l.get("tva_tx")]
|
|
for l in rl]) == want_fp:
|
|
hits.append(r)
|
|
if len(hits) > 1:
|
|
sys.exit("invoice-create.sh: ABORT — dedupe key matches %d invoices on socid %s "
|
|
"(ids %s): the target already holds duplicates; refusing to guess."
|
|
% (len(hits), ded["socid"], ", ".join(str(h.get("id")) for h in hits)))
|
|
if hits:
|
|
print(json.dumps({"id": int(hits[0]["id"]), "statut": str(hits[0].get("statut"))}))
|
|
PY
|
|
MATCH="$(python3 "${TMPD}/match.py" "${TMPD}/dedupe.json" "${TMPD}/list.json")"
|
|
|
|
if [[ -n "${MATCH}" ]]; then
|
|
ID="$(python3 -c "import json,sys; print(json.load(sys.stdin)['id'])" <<<"${MATCH}")"
|
|
STATUT="$(python3 -c "import json,sys; print(json.load(sys.stdin)['statut'])" <<<"${MATCH}")"
|
|
echo "invoice-create.sh: invoice already exists on socid ${SOCID} (id ${ID}) — deduped, no POST" >&2
|
|
# Converge an interrupted run: validate the matched draft if validation was asked.
|
|
if [[ "${VALIDATE}" == "1" && "${STATUT}" == "0" ]]; then
|
|
echo "invoice-create.sh: matched invoice ${ID} is still a draft — validating it now (completes the interrupted op)" >&2
|
|
"${W}" POST "${ENDPOINT}/${ID}/validate" '{}' >/dev/null
|
|
fi
|
|
emit_summary "${ID}" true
|
|
exit 0
|
|
fi
|
|
|
|
# --- Create (no match) ----------------------------------------------------------
|
|
# --- Chronology guard (CGI art. 289: numbering must be chronological) --------
|
|
# Dolibarr assigns the next number at validation, in creation order — so issuing
|
|
# a document dated BEFORE the last one already issued yields a higher number on
|
|
# an earlier date, which is a numbering break. This bites whenever two documents
|
|
# of the same cycle are issued on different days (a deferred part issued after
|
|
# the next fixed part, for instance). Refuse rather than create the break.
|
|
NEW_DATE="$(python3 -c "import json,sys; print(json.loads(sys.argv[1])['date'])" "${BODY}")"
|
|
LAST="$("${W}" GET "${ENDPOINT}?sortfield=t.rowid&sortorder=DESC&limit=1" 2>/dev/null \
|
|
| python3 -c "
|
|
import json,sys
|
|
try:
|
|
d=json.load(sys.stdin)
|
|
if isinstance(d,list) and d: print(f\"{d[0].get('date','0')}|{d[0].get('ref','?')}\")
|
|
else: print('0|-')
|
|
except Exception: print('0|-')" 2>/dev/null || echo "0|-")"
|
|
LAST_DATE="${LAST%%|*}"; LAST_REF="${LAST##*|}"
|
|
if [[ "${LAST_DATE}" =~ ^[0-9]+$ ]] && (( LAST_DATE > 0 )) && (( NEW_DATE < LAST_DATE )); then
|
|
if [[ "${ARCO_ALLOW_BACKDATE:-}" != "I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY" ]]; then
|
|
printf 'invoice-create.sh: REFUSED — chronology break.\n' >&2
|
|
printf ' new document dated %s, but %s is already issued at %s.\n' \
|
|
"$(date -r "${NEW_DATE}" +%d/%m/%Y 2>/dev/null || echo "${NEW_DATE}")" \
|
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null || echo "${LAST_DATE}")" >&2
|
|
printf ' Numbering follows creation order, so this would give a higher number to an\n' >&2
|
|
printf ' earlier date (CGI art. 289). Issue in chronological order, or set\n' >&2
|
|
printf ' ARCO_ALLOW_BACKDATE=I-UNDERSTAND-THIS-BREAKS-CHRONOLOGY to override.\n' >&2
|
|
exit 1
|
|
fi
|
|
printf 'invoice-create.sh: WARNING — backdating past %s (%s), override accepted.\n' \
|
|
"${LAST_REF}" "$(date -r "${LAST_DATE}" +%d/%m/%Y 2>/dev/null)" >&2
|
|
fi
|
|
|
|
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
|
|
emit_summary "${ID}" false
|