add dolibarr api skills for read-only inspection

First two of an expected family of dolibarr-* skills:

- dolibarr/: platform reference — DOLAPIKEY auth, the voir_tous ACL
  trap, endpoint catalogue, the dol-curl.sh wrapper, .env credentials
  layout (gitignored, mode 600). Every future workflow skill depends
  on this one.
- dolibarr-invoice-audit/: first workflow — list KissMetrics invoices,
  audit one invoice end-to-end (JSON facts + PDF mandatory-mention
  checklist against the French legal corpus), audit the KissMetrics
  thirdparty record.

Live captures in examples/ include real audit findings to surface
to the Arcodange × KissMetrics cohort review: PDFs are missing
capital social, L.441-10 penalties, 40 € indemnity, L.123-22 / R.123-237;
KissMetrics thirdparty has no EIN (idprof1..6 all empty);
static/config/company.json holds placeholder values and a wrong
forme juridique (claims SAS, the real Dolibarr is SARL).

.gitignore hardened with *.credentials, secrets/, *.key, and an
explicit .claude/skills/**/.env pattern.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:43:39 +02:00
parent e90ac2df80
commit bbfa50c3eb
18 changed files with 2811 additions and 1 deletions

View File

@@ -0,0 +1,152 @@
#!/usr/bin/env bash
# Audit one Arcodange invoice end-to-end.
#
# Usage:
# audit-invoice.sh <invoice-id>
#
# JSON side: pulls /invoices/{id} + /thirdparties/{socid}, prints facts
# (ref, dates, HT, TVA, TTC, paye flag, mode_reglement, cond_reglement).
#
# PDF side: pulls /documents/download, base64-decodes, runs pdftotext,
# then checks STRUCTURAL PRESENCE of mandatory French invoice mentions.
# We do NOT match against static/config/company.json — that file holds
# placeholder values today; Dolibarr renders the real legal data from
# its own setup. So we check shape, not exact strings.
#
# Mandatory-mention checklist (codified for SARL / SAS / EURL Arcodange):
# - Forme juridique (any of SARL / SAS / EURL / SA / SCI / SASU)
# - SIRET (14-digit number, optionally space-separated)
# - TVA intracom (FR + 2 chars + 9 digits)
# - RCS Évry / RCS Evry / R.C.S. Évry
# - NAF-APE code (NNNNL pattern)
# - Capital social ("capital" anywhere)
# - TVA 259-1° CGI (autoliquidation for non-EU prestations de services)
# - L.441-10 — late-payment penalties (BCE+10 or 12,15 %)
# - 40 € indemnité forfaitaire (Décret 2012-1115)
# - L.123-22 / R.123-237 identification mentions
#
# Exits 0 if every check passes, 1 otherwise.
#
# Requires: curl, python3, jq, pdftotext (brew install poppler).
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-invoice.sh: missing invoice id. Usage: audit-invoice.sh <id>" >&2
exit 2
fi
INVOICE_ID="$1"
command -v pdftotext >/dev/null || { echo "audit-invoice.sh: pdftotext not found (brew install poppler)" >&2; exit 2; }
command -v jq >/dev/null || { echo "audit-invoice.sh: jq not found" >&2; exit 2; }
# --- JSON side -------------------------------------------------------------
INV_JSON="$("${DOL_CURL}" "/invoices/${INVOICE_ID}")"
SOCID=$( echo "${INV_JSON}" | jq -r .socid)
REF=$( echo "${INV_JSON}" | jq -r .ref)
DATE_TS=$(echo "${INV_JSON}" | jq -r '.date // 0 | tonumber')
DUE_TS=$( echo "${INV_JSON}" | jq -r '.date_lim_reglement // 0 | tonumber')
HT=$( echo "${INV_JSON}" | jq -r .total_ht)
TVA=$( echo "${INV_JSON}" | jq -r .total_tva)
TTC=$( echo "${INV_JSON}" | jq -r .total_ttc)
PAYE=$( echo "${INV_JSON}" | jq -r .paye)
COND=$( echo "${INV_JSON}" | jq -r '.cond_reglement_code // "-"')
PDF_PATH=$(echo "${INV_JSON}" | jq -r .last_main_doc)
DATE_HUMAN=$(python3 -c "import datetime,sys; ts=int(sys.argv[1]); print(datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts else '-')" "${DATE_TS}")
DUE_HUMAN=$( python3 -c "import datetime,sys; ts=int(sys.argv[1]); print(datetime.datetime.fromtimestamp(ts).strftime('%Y-%m-%d') if ts else '-')" "${DUE_TS}")
TP_JSON="$("${DOL_CURL}" "/thirdparties/${SOCID}")"
TP_NAME=$(echo "${TP_JSON}" | jq -r '.name // .ref')
PAYE_HUMAN=$( [[ "${PAYE}" == "1" ]] && echo "PAID" || echo "UNPAID" )
# Heuristic: TVA == 0 -> likely reverse-charge (KM is in the US, 259-1° CGI).
TVA_NOTE=""
if [[ "${TVA}" == "0" || "${TVA}" == "0.00000000" || "${TVA}" == "0.00" ]]; then
TVA_NOTE="(TVA=0 → reverse-charge expected; the 259-1° CGI mention MUST be on the PDF)"
fi
cat <<EOF
================================================================================
Invoice ${INVOICE_ID} — ${REF}
================================================================================
Thirdparty : ${TP_NAME} (socid=${SOCID})
Invoice date : ${DATE_HUMAN}
Due date : ${DUE_HUMAN} (cond_reglement_code=${COND})
Total HT : ${HT}
Total TVA : ${TVA} ${TVA_NOTE}
Total TTC : ${TTC}
Payment state : ${PAYE_HUMAN}
PDF : ${PDF_PATH}
EOF
if [[ -z "${PDF_PATH}" || "${PDF_PATH}" == "null" ]]; then
echo
echo " /!\\ No PDF attached (last_main_doc is null). Skipping mandatory-mention audit."
echo
exit 1
fi
# --- PDF side --------------------------------------------------------------
# original_file must be URL-encoded (the slash between dir and filename too).
ENCODED_PDF=$(python3 -c "import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1], safe=''))" "${PDF_PATH#facture/}")
PDF_DOC_JSON="$("${DOL_CURL}" "/documents/download?modulepart=facture&original_file=${ENCODED_PDF}")"
PDF_B64="$(echo "${PDF_DOC_JSON}" | jq -r .content)"
if [[ -z "${PDF_B64}" || "${PDF_B64}" == "null" ]]; then
echo "audit-invoice.sh: no base64 content returned for ${PDF_PATH}" >&2
exit 1
fi
PDF_TMP="$(mktemp -t dolaudit.XXXXXX.pdf)"
TXT_TMP="$(mktemp -t dolaudit.XXXXXX.txt)"
trap 'rm -f "${PDF_TMP}" "${TXT_TMP}"' EXIT
echo "${PDF_B64}" | base64 -d > "${PDF_TMP}"
pdftotext -layout "${PDF_TMP}" "${TXT_TMP}"
pass_count=0
fail_count=0
check() {
# check "label" "extended-regex-pattern"
local label="$1" pat="$2"
if grep -E -q -- "${pat}" "${TXT_TMP}"; then
printf ' [OK] %s\n' "${label}"
pass_count=$((pass_count+1))
else
printf ' [XX] %s (looked for: %s)\n' "${label}" "${pat}"
fail_count=$((fail_count+1))
fi
}
echo
echo " Mandatory-mention audit — structural presence on the PDF:"
check "Forme juridique (SARL/SAS/EURL/SA/SCI/SASU)" "(SARL|SAS|EURL|SCI|SASU|SA[^[:alpha:]])"
check "SIRET (14 digits)" "SIRET[[:space:]:]*[0-9]{14}"
check "Numéro TVA intracom (FR + 11 chars)" "(TVA|TVA intra)[[:space:]:]*FR[0-9A-Z]{11}"
check "RCS / R.C.S. Évry" "(R\\.?C\\.?S\\.?|RCS).*[EÉ]vry"
check "NAF-APE code" "(NAF|APE)[[:space:]:-]*[0-9]{4}[A-Z]"
check "Capital social" "[Cc]apital"
check "TVA 259-1° CGI / autoliquidation" "(259-?1|autoliquidation|reverse[- ]charge)"
check "L.441-10 — BCE+10 or 12,15 %" "(L\\.?441-10|BCE[[:space:]]*\\+[[:space:]]*10|12[.,]15[[:space:]]*%)"
check "40 € indemnité forfaitaire" "(40[[:space:]]*€|indemnit[eé] forfaitaire|D[eé]cret 2012-1115)"
check "L.123-22 / R.123-237" "(L\\.?123-22|R\\.?123-237)"
# Surface the real legal identifiers extracted from the PDF — useful for
# cross-checking against the cohort review without re-running pdftotext.
echo
echo " Real identifiers extracted from the PDF (informational):"
SIRET_FOUND=$( grep -E -o "SIRET[[:space:]:]*[0-9]{14}" "${TXT_TMP}" | head -1 || true)
TVA_FOUND=$( grep -E -o "(TVA|TVA intra)[[:space:]:]*FR[0-9A-Z]{11}" "${TXT_TMP}" | head -1 || true)
APE_FOUND=$( grep -E -o "(NAF|APE)[[:space:]:-]*[0-9]{4}[A-Z]" "${TXT_TMP}" | head -1 || true)
FORM_FOUND=$( grep -E -o "(SARL|SAS|EURL|SCI|SASU|Soci[eé]t[eé] [^[:cntrl:]]{0,40})" "${TXT_TMP}" | head -1 || true)
echo " ${FORM_FOUND:-(forme juridique not found)}"
echo " ${SIRET_FOUND:-(SIRET not found)}"
echo " ${TVA_FOUND:-(TVA intracom not found)}"
echo " ${APE_FOUND:-(NAF-APE not found)}"
echo
echo " ${pass_count} pass / ${fail_count} fail"
[[ ${fail_count} -eq 0 ]]

View File

@@ -0,0 +1,72 @@
#!/usr/bin/env bash
# Audit the KissMetrics thirdparty record (socid=1) for completeness.
#
# Usage:
# audit-km-thirdparty.sh
#
# Checks the contact fields and the idprof1..idprof6 slots where the US EIN
# should live for a non-EU customer. Today EIN is missing — this script's
# exit-1 IS the finding to surface to the cohort review.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
KM_SOCID=1
TMP_JSON="$(mktemp -t doltp.XXXXXX.json)"
trap 'rm -f "${TMP_JSON}"' EXIT
"${DOL_CURL}" "/thirdparties/${KM_SOCID}" > "${TMP_JSON}"
python3 - "${TMP_JSON}" <<'PY'
import json, sys
with open(sys.argv[1]) as f:
d = json.load(f)
print("=" * 80)
print(f" KissMetrics thirdparty audit — socid={d.get('id')}")
print("=" * 80)
required = [
("name", d.get("name")),
("client flag", "yes" if str(d.get("client")) == "1" else None),
("country_code", d.get("country_code")),
("state_id", d.get("state_id")),
("address", d.get("address")),
("zip", d.get("zip")),
("town", d.get("town")),
("email", d.get("email")),
("phone", d.get("phone")),
("url", d.get("url")),
]
idprofs = [(f"idprof{i}", d.get(f"idprof{i}")) for i in range(1, 7)]
ein_present = any(v not in (None, "", "0") for _, v in idprofs)
passes = fails = 0
def check(label, value):
global passes, fails
ok = value not in (None, "", "0")
print(f" [{'OK' if ok else 'XX'}] {label:<18} = {value!r}")
if ok: passes += 1
else: fails += 1
for label, value in required:
check(label, value)
print()
print(" idprof1..idprof6 (EIN slot for non-EU customers):")
for label, value in idprofs:
print(f" {label:<10} = {value!r}")
print(f" [{'OK' if ein_present else 'XX'}] EIN present (any idprof populated)")
if ein_present: passes += 1
else: fails += 1
ao = d.get("array_options") or {}
print()
print(f" array_options (Dolibarr extrafields): {ao if ao else '(none)'}")
print()
print(f" {passes} pass / {fails} fail")
sys.exit(0 if fails == 0 else 1)
PY

View File

@@ -0,0 +1,73 @@
#!/usr/bin/env bash
# List Arcodange → KissMetrics invoices with payment state.
#
# Usage:
# list-km-invoices.sh [--since YYYY-MM-DD]
#
# KissMetrics is the thirdparty with socid=1 in this Dolibarr. If that ever
# changes, update KM_SOCID below or detect it dynamically.
#
# Credit notes (AVOIRs) are flagged: ref starting with "AVC" or negative HT.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
KM_SOCID=1
SINCE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
-h|--help)
sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'
exit 0
;;
*) echo "list-km-invoices.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
SINCE_EPOCH=0
if [[ -n "${SINCE}" ]]; then
if SINCE_EPOCH=$(date -j -f "%Y-%m-%d" "${SINCE}" "+%s" 2>/dev/null); then :
else SINCE_EPOCH=$(date -d "${SINCE}" "+%s"); fi
fi
TMP_JSON="$(mktemp -t dollist.XXXXXX.json)"
trap 'rm -f "${TMP_JSON}"' EXIT
"${DOL_CURL}" '/invoices?limit=100&sortfield=t.datef&sortorder=DESC' > "${TMP_JSON}"
python3 - "${TMP_JSON}" "${KM_SOCID}" "${SINCE_EPOCH}" <<'PY'
import json, sys, datetime
with open(sys.argv[1]) as f:
rows = json.load(f)
km_socid = sys.argv[2]
since_ts = int(sys.argv[3])
km = [r for r in rows if str(r.get("socid")) == km_socid]
km.sort(key=lambda r: int(r.get("date") or 0), reverse=True)
print(f"{'id':>4} {'ref':<24} {'date':<10} {'HT':>10} {'TVA':>8} {'TTC':>10} {'paid':<5} {'AVC':<5} pdf")
print("-" * 112)
total_ht = total_ttc = 0.0
shown = 0
for r in km:
ts = int(r.get("date") or 0)
if since_ts and ts < since_ts:
continue
shown += 1
dt = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "-"
ht = float(r.get("total_ht") or 0)
tva = float(r.get("total_tva") or 0)
ttc = float(r.get("total_ttc") or 0)
paid = "yes" if str(r.get("paye")) == "1" else "no"
avc = "AVOIR" if (r.get("ref","").startswith("AVC") or ht < 0) else ""
pdf = r.get("last_main_doc") or "-"
print(f"{r['id']:>4} {r['ref']:<24} {dt:<10} {ht:>10.2f} {tva:>8.2f} {ttc:>10.2f} {paid:<5} {avc:<5} {pdf}")
total_ht += ht
total_ttc += ttc
print("-" * 112)
print(f"{'TOTAL':>40} {total_ht:>10.2f} {' ':>8} {total_ttc:>10.2f}")
print(f"# {shown} invoice(s) shown, socid={km_socid}")
PY