add 3 dolibarr-* skills: tva-reconciliation, recurring-templates, data-snapshot #3

Merged
arcodange merged 1 commits from claude/dolibarr-tva-recurring-snapshot into main 2026-05-29 00:10:54 +02:00
16 changed files with 1554 additions and 1 deletions

View File

@@ -0,0 +1,114 @@
---
name: dolibarr-data-snapshot
description: Snapshot the read-only state of the Arcodange Dolibarr instance into a single content-addressable JSON file — status, thirdparties (list + per-id detail), invoices (list + per-id detail + per-id payments), recurring templates, products, bank accounts. Each snapshot includes a `content_hash` (sha256 of the data, EXCLUDING the captured-at timestamp) so two snapshots of identical state hash identically — drift detection is one comparison. Use cases: cohort-review evidence packs, archival before a known-risky change, time-series drift detection between two dates, point-in-time forensics. Use when the user asks "snapshot Dolibarr", "dump the state", "archiver l'ERP", "preuve cohort", "diff entre deux dates". Depends on the `dolibarr` skill. SKIP for one-shot reads (use the other workflow skills directly), for PDF / binary attachments (intentionally excluded — would bloat the snapshot), for write-side changes (this is read-only forensics), and for snapshotting NON-Dolibarr state (bank statements, k8s, etc. — those would be sibling skills).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-data-snapshot — point-in-time JSON dump of Dolibarr read state
One script: [`snapshot.sh`](scripts/snapshot.sh). Pulls every read-only endpoint the `dolibarr-*` family uses and bundles into a single JSON file with a content hash. Read-only, no side effects.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## What's in the snapshot
```json
{
"schema_version": "1",
"captured_at": "2026-05-28T21:58:50Z",
"instance": "erp.arcodange.lab",
"content_hash": "sha256:6b94cd312d55a693d3c533ae6c9a5abef2734dd5bca8d4b1bdd5ca6ea6fc1f9a",
"data": {
"status": { ... GET /status ... },
"thirdparties": { "list": [ ... GET /thirdparties ... ], "detail": { "1": { ... GET /thirdparties/1 ... }, ... } },
"invoices": { "list": [ ... ], "detail": { "12": { ... }, ... }, "payments": { "12": [ ... ], ... } },
"recurring_templates": { "1": { ... GET /invoices/templates/1 ... }, ... },
"products": [ ... GET /products ... ],
"bank_accounts": [ ... GET /bankaccounts ... ]
}
}
```
**`content_hash` is the sha256 of `data` only** — it deliberately excludes `captured_at`, `schema_version`, `instance`, and the hash field itself. So two snapshots taken at different moments but reflecting **identical Dolibarr state** have the same `content_hash`. That's what makes drift detection trivial:
```bash
jq -r .content_hash snap-2026-05.json snap-2026-06.json
# Same hash → no data changed between the two captures.
# Different hash → use `jq` / `diff` to find what moved.
```
## Usage
```bash
./scripts/snapshot.sh # writes ./snapshot-YYYY-MM-DDTHHMMSSZ.json
./scripts/snapshot.sh --out /tmp/baseline.json
./scripts/snapshot.sh --print-only # stdout, no file (pipe-friendly)
./scripts/snapshot.sh --max-template-id 100 # raise the template-probe upper bound
```
Live output of the current Dolibarr (captured at [examples/snapshot-summary.txt](examples/snapshot-summary.txt) — the actual JSON is too big to commit verbatim, ~246 KB):
```
wrote ./snapshot-2026-05-28T215850Z.json (246186 bytes)
sha256:6b94cd312d55a693d3c533ae6c9a5abef2734dd5bca8d4b1bdd5ca6ea6fc1f9a
```
Contents (V1 baseline):
- 10 thirdparties (KissMetrics + 9 others — prospects / suppliers / etc.)
- 5 invoices, all KM (1 avoir + 4 regular)
- 5 per-invoice payment arrays
- 1 recurring template (Kiss Metrics Invoice — frequency=0, see `dolibarr-recurring-templates`)
- 2 products (KM-audit, KM-cloud-devops)
- 3 bank accounts (QONTO, WISE EURO, G.RADUREAU CCA)
## What's intentionally excluded
- **PDF attachments.** `/documents/download` returns base64 bodies up to ~MB each. Including them would 10×100× the snapshot size. Workflow skills (`dolibarr-invoice-audit`) fetch PDFs on-demand.
- **`users/info`** — leaks the `ai_agent` account internals. Out of scope for a read-only state dump.
- **`/setup/modules`** — admin-only, not available to `ai_agent`.
- **Anything that requires writing** (cron triggers, etc.).
- **`/payments` list-all** — returns 501 (see base skill catalogue); we get payment data via per-invoice fetches.
## Use cases
### 1. Cohort-review evidence pack
```bash
./scripts/snapshot.sh --out evidence/dolibarr-2026-05-28.json
# Send the file as proof of the billing state at that moment.
# The content_hash signs the data.
```
### 2. Drift detection between two dates
```bash
./scripts/snapshot.sh --out snap-may.json
# ... a month passes ...
./scripts/snapshot.sh --out snap-jun.json
jq -r .content_hash snap-may.json snap-jun.json
# Different → something moved. Find what:
diff <(jq -S .data snap-may.json) <(jq -S .data snap-jun.json) | head -50
```
### 3. Archive before a known-risky change
Before manually firing the next M-N invoice, regenerating the PDF template, or any UI change with billing consequences:
```bash
./scripts/snapshot.sh --out before-change-$(date -u +%Y%m%d).json
# Make the change ...
./scripts/snapshot.sh --out after-change-$(date -u +%Y%m%d).json
# Diff to confirm only the intended state moved.
```
## Performance
On the current Arcodange instance (5 invoices, 10 thirdparties, 1 template), the snapshot completes in **~2 seconds** with one HTTP call per top-level resource + N calls for per-id fetches. At ~30 thirdparties + ~100 invoices + ~10 templates, expect ~150 calls and ~10 s.
## Out of scope
- **Snapshotting bank statements** (Qonto / Wise CSV exports). Different data source — would be a sibling skill (`arcodange-bank-snapshot` or similar).
- **Snapshotting Kubernetes state** of the ERP deployment. Sibling skill candidate (`arcodange-k8s-snapshot`).
- **Schema migrations / drift in the Dolibarr DB itself.** That requires `dolibarr-postgres-readonly` or similar; out of scope here.

View File

@@ -0,0 +1,24 @@
# dolibarr-data-snapshot - V1 baseline summary
#
# The actual JSON snapshot file is ~246 KB and is intentionally NOT committed.
# This file is the structural digest captured at the same moment.
captured_at: 2026-05-28T22:00:15Z
instance: erp.arcodange.lab
content_hash: sha256:6b94cd312d55a693d3c533ae6c9a5abef2734dd5bca8d4b1bdd5ca6ea6fc1f9a
schema_ver: 1
section count
------------------------------------------------
status 1 (dolibarr_version=22.0.4)
thirdparties.list 10
thirdparties.detail 10
invoices.list 5
invoices.detail 5
invoices.payments 5
recurring_templates 1 (ids: ['1'])
products 2
bank_accounts 3
# Stable content_hash check: run snapshot.sh twice quickly. Both content_hash
# values should be identical when Dolibarr state has not changed between runs.

View File

@@ -0,0 +1,174 @@
#!/usr/bin/env bash
# Snapshot the read-only state of the Arcodange Dolibarr into one JSON file.
#
# Usage:
# snapshot.sh [--out PATH] # default: ./snapshot-YYYY-MM-DDTHHMMSS.json
# snapshot.sh --print-only # write to stdout instead of a file
#
# The snapshot is content-addressable: it includes a SHA-256 of the
# serialized payload (computed AFTER stable key-sorting) so two snapshots
# of the same state hash identically. Useful for:
# - cohort review evidence packs (sign + send)
# - drift detection between dates (diff two snapshots)
# - archival before a known-risky change
#
# What's included (everything the dolibarr-* family reads):
# - status (Dolibarr version)
# - thirdparties (full list + detail)
# - invoices (full list + per-invoice detail + per-invoice payments)
# - recurring invoice templates (probed 1..MAX_TEMPLATE_ID)
# - products
# - bank accounts
#
# Excluded by design:
# - PDF attachments (binary, would bloat the snapshot ~50KB each)
# - users/info (would leak ai_agent details)
# - any non-read endpoints
#
# Requires: curl, jq, python3 (with hashlib — standard lib).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
MAX_TEMPLATE_ID=20
EMPTY_TPL_TOLERANCE=5
OUT=""
PRINT_ONLY=0
while [[ $# -gt 0 ]]; do
case "$1" in
--out) OUT="$2"; shift 2 ;;
--print-only) PRINT_ONLY=1; shift ;;
--max-template-id) MAX_TEMPLATE_ID="$2"; shift 2 ;;
-h|--help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "snapshot.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t dolsnap.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
fetch_into() {
local out_file="$1" path="$2"
"${DOL_CURL}" "${path}" > "${out_file}" 2>/dev/null || {
# On HTTP error, dol-curl prints body+stderr; capture body for record.
"${DOL_CURL}" "${path}" > "${out_file}" 2>&1 || true
}
}
# 1. Liveness + status
fetch_into "${WORK}/status.json" /status
# 2. Thirdparties (list + detail)
fetch_into "${WORK}/tps_list.json" /thirdparties
TP_IDS=$(python3 -c "
import json,sys
try: d = json.load(open(sys.argv[1]))
except: d = []
if isinstance(d, list): print(' '.join(str(t['id']) for t in d if t.get('id')))
" "${WORK}/tps_list.json")
mkdir -p "${WORK}/tps"
for id in ${TP_IDS}; do fetch_into "${WORK}/tps/${id}.json" "/thirdparties/${id}"; done
# 3. Invoices (list + detail + payments)
fetch_into "${WORK}/inv_list.json" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC'
INV_IDS=$(python3 -c "
import json,sys
try: d = json.load(open(sys.argv[1]))
except: d = []
print(' '.join(str(i['id']) for i in d if i.get('id')))
" "${WORK}/inv_list.json")
mkdir -p "${WORK}/inv" "${WORK}/pay"
for id in ${INV_IDS}; do
fetch_into "${WORK}/inv/${id}.json" "/invoices/${id}"
fetch_into "${WORK}/pay/${id}.json" "/invoices/${id}/payments"
done
# 4. Recurring templates (probe)
mkdir -p "${WORK}/tpl"
CONSECUTIVE_EMPTY=0
for tid in $(seq 1 "${MAX_TEMPLATE_ID}"); do
fetch_into "${WORK}/tpl/${tid}.json" "/invoices/templates/${tid}"
REAL=$(python3 -c "import json,sys
try: d=json.load(open(sys.argv[1])); print('1' if d.get('id') else '0')
except: print('0')" "${WORK}/tpl/${tid}.json")
if [[ "${REAL}" == "1" ]]; then
CONSECUTIVE_EMPTY=0
else
CONSECUTIVE_EMPTY=$((CONSECUTIVE_EMPTY+1))
rm "${WORK}/tpl/${tid}.json"
[[ ${CONSECUTIVE_EMPTY} -ge ${EMPTY_TPL_TOLERANCE} ]] && break
fi
done
# 5. Products + bank accounts
fetch_into "${WORK}/products.json" /products
fetch_into "${WORK}/bankaccounts.json" /bankaccounts
# 6. Compose the snapshot
python3 - "${WORK}" <<'PY' > "${WORK}/snapshot.json"
import json, os, sys, datetime, hashlib
work = sys.argv[1]
def load(path, default):
try: return json.load(open(path))
except (FileNotFoundError, json.JSONDecodeError): return default
def load_dir(dirname):
out = {}
full = os.path.join(work, dirname)
if not os.path.isdir(full): return out
for fn in sorted(os.listdir(full)):
if not fn.endswith(".json"): continue
key = fn[:-len(".json")]
out[key] = load(os.path.join(full, fn), None)
return out
data = {
"status": load(os.path.join(work, "status.json"), {}),
"thirdparties": {
"list": load(os.path.join(work, "tps_list.json"), []),
"detail": load_dir("tps"),
},
"invoices": {
"list": load(os.path.join(work, "inv_list.json"), []),
"detail": load_dir("inv"),
"payments": load_dir("pay"),
},
"recurring_templates": load_dir("tpl"),
"products": load(os.path.join(work, "products.json"), []),
"bank_accounts": load(os.path.join(work, "bankaccounts.json"), []),
}
# content_hash is the sha256 of `data` only — excludes timestamp + metadata,
# so two snapshots of identical Dolibarr state hash identically.
# (Drift detection is then: compare content_hash, done.)
content_serialized = json.dumps(data, sort_keys=True, ensure_ascii=False).encode("utf-8")
content_hash = "sha256:" + hashlib.sha256(content_serialized).hexdigest()
payload = {
"schema_version": "1",
"captured_at": datetime.datetime.now(datetime.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ"),
"instance": "erp.arcodange.lab",
"content_hash": content_hash,
"data": data,
}
print(json.dumps(payload, indent=2, ensure_ascii=False, sort_keys=True))
PY
# 7. Output
if [[ "${PRINT_ONLY}" == "1" ]]; then
cat "${WORK}/snapshot.json"
else
if [[ -z "${OUT}" ]]; then
OUT="./snapshot-$(date -u +%Y-%m-%dT%H%M%SZ).json"
fi
cp "${WORK}/snapshot.json" "${OUT}"
SIZE=$(stat -f %z "${OUT}" 2>/dev/null || stat -c %s "${OUT}")
HASH=$(python3 -c "import json,sys; print(json.load(open(sys.argv[1]))['content_hash'])" "${OUT}")
echo "wrote ${OUT} (${SIZE} bytes)"
echo " ${HASH}"
fi

View File

@@ -0,0 +1,90 @@
---
name: dolibarr-recurring-templates
description: Inspect Arcodange recurring invoice templates (modèles de factures récurrentes) — the templates that auto-generate monthly invoices for KissMetrics and any future recurring billing. Two workflows — (1) enumerate all templates (the API has no list endpoint, this skill probes ids 1..N and stops after consecutive empties); (2) inspect one template end-to-end (identification, schedule frequency / unit / next-fire / last-fire / counts, customer, payment terms, lines with French legal mentions, child invoices generated from it). Surfaces health issues: `frequency=0` (NOT auto-firing — every child was manually duplicated), `nb_gen_done=0` while children exist (mismatched counter), `date_when` in the past (overdue), `suspended=1` (paused). Use when the user asks "le template KM va-t-il auto-générer la prochaine facture", "quand fire la prochaine M-N", "audit des templates récurrents", "pourquoi la facture n'a pas été émise automatiquement". Depends on the `dolibarr` skill. SKIP for one-off invoice audits (handled by `dolibarr-invoice-audit`), for payment tracking (`dolibarr-payments-state`), for TVA basis (`dolibarr-tva-reconciliation`), or for writing/triggering a template fire (the API is read-only — manual fire goes through the Dolibarr UI).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-recurring-templates — modèles de factures récurrentes
Recurring invoice templates are the Dolibarr objects that drive automated monthly (or arbitrary-frequency) billing. This skill answers: **does the template actually fire on schedule? when's the next one? and what does it generate?**
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## API quirks worth knowing
The Dolibarr API exposes templates only via `GET /invoices/templates/{id}` — there's **no list endpoint**. Two consequences:
1. **Enumerating requires probing.** `list-templates.sh` probes `id=1`, then `id=2`, etc., until it hits 5 consecutive empty responses.
2. **"Empty" looks like 200.** A non-existent id returns HTTP **200** with a hollow object (`id=null`, `ref=null`). The script treats `id=null` as the sentinel — Dolibarr returns mostly-null fields for missing templates rather than a clean 404.
(Captured for reference at [../dolibarr/examples/invoice_template_km.json](../dolibarr/examples/invoice_template_km.json).)
## Workflow 1 — Enumerate templates
```bash
./scripts/list-templates.sh # probe 1..50, default
./scripts/list-templates.sh --max-id 200 # raise the upper bound
```
Live output (captured at [examples/list-templates.txt](examples/list-templates.txt)):
```
# Probed ids 1..6, found 1 template(s) (stopped after 5 consecutive empties)
id ref socid freq gen next last sus auto_val total_ht
-------------------------------------------------------------------------------------------------------------------
1 Kiss Metrics Invoice 1 OFF (0) 0/∞ - - 0 0 5100.00000000
```
Columns:
- **freq** — `OFF (0)` if `frequency=0` (no auto-fire). Otherwise `every Nu` (e.g. `every 1m` = monthly).
- **gen** — `nb_gen_done / nb_gen_max`. `∞` means unbounded.
- **next** / **last**`date_when` / `date_last_gen`.
- **sus** — `suspended` flag.
## Workflow 2 — Inspect one template (full audit)
```bash
./scripts/inspect-template.sh 1
echo "exit: $?" # 0 if no health issues, 1 otherwise
```
Live output (captured at [examples/inspect-template-1.txt](examples/inspect-template-1.txt)):
```
================================================================================
Template 1 — Kiss Metrics Invoice
================================================================================
Customer : socid=1
Schedule : frequency=0 m (OFF — manual generation only)
Counts : generated=0 / max=unbounded
Next fire date : (unset)
Last fire date : (none)
Suspended : False
...
Lines:
- ref=KM-cloud-devops qty=10 subprice=510.00000000 tva=0.0000 HT=5100.00000000
TVA non applicable Article 259-1 du CGI Prestation de services localisée hors de France (USA)
Generated children (by note_private match on 'Kiss Metrics Invoice'):
- 2026-02-24 id= 12 FAC002-CL0001002 HT=5100.00000000 paye=1
- 2026-02-24 id= 13 FAC003-CL0001003 HT=2550.00000000 paye=1
Health checks:
[!!] frequency=0 — template is NOT auto-generating; every child was created manually
[!!] nb_gen_done=0 but 2 child invoice(s) match by note — they were duplicated, not auto-generated
```
**The Kiss Metrics Invoice template is OFF.** It exists, it has the right lines and the 259-1° CGI mention, it points at the right bank account (Wise), but its `frequency=0` and `nb_gen_done=0` mean **Dolibarr is not auto-generating M3 / M4 / etc.** Each cohort-month invoice today is a manual duplication from the Dolibarr UI.
**Cohort-review implication:** the deferred 9-month cycle depends on someone (Gabriel) clicking "Generate" each month. If you want it automated, the template's frequency needs to be set to `1m` (monthly), the `date_when` set to the next fire date, and ideally `auto_validate=1`. That's a UI configuration change — not done by this read-only skill.
**Children matching by note_private** is a heuristic: when an invoice is generated from a template, Dolibarr writes `"Généré depuis la facture modèle récurrente <ref>"` to `note_private`. We grep for the template ref in that field. False positives are rare in practice but possible; treat it as a strong signal, not proof.
## Out of scope
- **Triggering a fire.** Manual fire is a UI button; programmatic fire would need `POST /invoices/templates/{id}/createrecurringinvoices` (or similar), which isn't read-only.
- **Changing frequency / auto-validate / suspended.** UI-only from this skill's perspective.
- **Templates for things other than invoices** (recurring quotes, recurring orders) — would be sibling skills.

View File

@@ -0,0 +1,26 @@
================================================================================
Template 1 — Kiss Metrics Invoice
================================================================================
Customer : socid=1
Schedule : frequency=0 m (OFF — manual generation only)
Counts : generated=0 / max=unbounded
Next fire date : (unset)
Last fire date : (none)
Suspended : False
Auto-validate : False
Generate PDF : True
Payment terms : mode=VIR cond=10DENDMONTH (Due in 10 days, end of month)
Totals : HT=5100.00000000 TVA=0.00000000 TTC=5100.00000000
Bank account : fk_account=2
Lines:
- ref=KM-cloud-devops qty=10 subprice=510.00000000 tva=0.0000 HT=5100.00000000
TVA non applicable Article 259-1 du CGI Prestation de services localisée hors de France (USA)
Generated children (by note_private match on 'Kiss Metrics Invoice'):
- 2026-02-24 id= 12 FAC002-CL0001002 HT=5100.00000000 paye=1
- 2026-02-24 id= 13 FAC003-CL0001003 HT=2550.00000000 paye=1
Health checks:
[!!] frequency=0 — template is NOT auto-generating; every child was created manually
[!!] nb_gen_done=0 but 2 child invoice(s) match by note — they were duplicated, not auto-generated

View File

@@ -0,0 +1,5 @@
# Probed ids 1..6, found 1 template(s) (stopped after 5 consecutive empties)
id ref socid freq gen next last sus auto_val total_ht
-------------------------------------------------------------------------------------------------------------------
1 Kiss Metrics Invoice 1 OFF (0) 0/∞ - - 0 0 5100.00000000

View File

@@ -0,0 +1,130 @@
#!/usr/bin/env bash
# Inspect one recurring invoice template end-to-end.
#
# Usage:
# inspect-template.sh <id>
#
# Prints the template's identification, schedule (frequency / unit /
# next-fire / last-fire / counts), customer, payment terms, lines, and
# a HEALTH section that calls out common issues:
# - frequency == 0 → template NOT actually firing (manual generation only)
# - nb_gen_done == 0 but children invoices exist → manual duplications
# - date_when in the past → overdue / paused
# - suspended == 1 → explicit pause
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 "inspect-template.sh: missing id. Usage: inspect-template.sh <id>" >&2
exit 2
fi
ID="$1"
WORK="$(mktemp -d -t inspect.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" "/invoices/templates/${ID}" > "${WORK}/template.json"
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/inv.json"
python3 - "${WORK}" "${ID}" <<'PY'
import json, sys, os, datetime, html
work, tid = sys.argv[1], sys.argv[2]
t = json.load(open(os.path.join(work, "template.json")))
if not t.get("id"):
print(f"inspect-template.sh: no template at id={tid}", file=sys.stderr)
sys.exit(1)
socid = t.get("socid") or ""
# Fetch the customer name if we can
tp_name = "-"
if socid:
import subprocess
try:
out = subprocess.check_output([
os.path.join(os.path.dirname(__file__) if False else os.environ.get("DOL_CURL","./dol-curl.sh"),
), f"/thirdparties/{socid}"
], stderr=subprocess.DEVNULL)
tp_name = json.loads(out).get("name") or "-"
except Exception: pass
freq_n = int(t.get("frequency") or 0)
freq_u = t.get("unit_frequency") or ""
nb_done = int(t.get("nb_gen_done") or 0)
nb_max = int(t.get("nb_gen_max") or 0)
date_when = t.get("date_when") or ""
date_last = t.get("date_last_gen") or ""
suspended = str(t.get("suspended") or "0") == "1"
auto_val = str(t.get("auto_validate") or "0") == "1"
gen_pdf = str(t.get("generate_pdf") or "0") == "1"
print("=" * 80)
print(f" Template {tid} — {t.get('ref') or t.get('title') or '(unnamed)'}")
print("=" * 80)
print(f" Customer : socid={socid}")
print(f" Schedule : frequency={freq_n} {freq_u} {'(OFF — manual generation only)' if freq_n == 0 else ''}")
print(f" Counts : generated={nb_done} / max={nb_max or 'unbounded'}")
print(f" Next fire date : {date_when or '(unset)'}")
print(f" Last fire date : {date_last or '(none)'}")
print(f" Suspended : {suspended}")
print(f" Auto-validate : {auto_val}")
print(f" Generate PDF : {gen_pdf}")
print(f" Payment terms : mode={t.get('mode_reglement_code') or '-'} cond={t.get('cond_reglement_code') or '-'} ({t.get('cond_reglement_doc') or '-'})")
print(f" Totals : HT={t.get('total_ht','-')} TVA={t.get('total_tva','-')} TTC={t.get('total_ttc','-')}")
print(f" Bank account : fk_account={t.get('fk_account') or '-'}")
print()
print(" Lines:")
for L in t.get("lines") or []:
desc = html.unescape(L.get("desc") or "")
print(f" - ref={L.get('product_ref','-')} qty={L.get('qty','-')} subprice={L.get('subprice','-')} tva={L.get('tva_tx','-')} HT={L.get('total_ht','-')}")
if desc:
for ln in desc.splitlines():
if ln.strip():
print(f" {ln.strip()}")
# Children: invoices that quote this template in note_private
invoices = json.load(open(os.path.join(work, "inv.json")))
title_ref = (t.get("ref") or t.get("title") or "").strip()
children = []
for inv in invoices:
np = (inv.get("note_private") or "").lower()
if title_ref and title_ref.lower() in np:
children.append(inv)
print()
print(f" Generated children (by note_private match on '{title_ref}'):")
if not children:
print(" (none found — surprising if nb_gen_done > 0 or if you expected this to fire)")
else:
for inv in sorted(children, key=lambda x: int(x.get("date") or 0)):
ts = int(inv.get("date") or 0)
dt = datetime.date.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "-"
print(f" - {dt} id={inv['id']:>4} {inv['ref']:<24} HT={inv.get('total_ht','-'):>10} paye={inv.get('paye','-')}")
# Health
print()
print(" Health checks:")
issues = []
if freq_n == 0:
issues.append("frequency=0 — template is NOT auto-generating; every child was created manually")
if nb_done == 0 and children:
issues.append(f"nb_gen_done=0 but {len(children)} child invoice(s) match by note — they were duplicated, not auto-generated")
if date_when and date_when != "":
try:
when = datetime.date.fromisoformat(date_when[:10])
if when < datetime.date.today():
issues.append(f"date_when ({date_when[:10]}) is in the past — next fire is overdue or paused")
except ValueError: pass
if suspended:
issues.append("suspended=1 — template explicitly paused")
if not issues:
print(" [OK] Template looks consistent.")
for i in issues:
print(f" [!!] {i}")
sys.exit(0 if not issues else 1)
PY

View File

@@ -0,0 +1,78 @@
#!/usr/bin/env bash
# Enumerate Arcodange recurring invoice templates.
#
# The Dolibarr API doesn't expose a list endpoint for templates. We probe
# ids 1..MAX and stop after N consecutive empty hits. An "empty" template
# has `id == null` (Dolibarr returns 200 with a hollow object for unknown ids).
#
# Usage:
# list-templates.sh [--max-id N] # default 50
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
MAX_ID=50
EMPTY_TOLERANCE=5 # stop after this many consecutive empties
while [[ $# -gt 0 ]]; do
case "$1" in
--max-id) MAX_ID="$2"; shift 2 ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "list-templates.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t dtmpl.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
CONSECUTIVE_EMPTY=0
FOUND=()
for id in $(seq 1 "${MAX_ID}"); do
if ! "${DOL_CURL}" "/invoices/templates/${id}" > "${WORK}/${id}.json" 2>/dev/null; then
# Network / auth error — bail rather than mis-classify
echo "list-templates.sh: error fetching id=${id}, stopping probe" >&2
break
fi
REAL=$(python3 -c "import json,sys; d=json.load(open(sys.argv[1])); print('1' if d.get('id') else '0')" "${WORK}/${id}.json")
if [[ "${REAL}" == "1" ]]; then
FOUND+=("${id}")
CONSECUTIVE_EMPTY=0
else
CONSECUTIVE_EMPTY=$((CONSECUTIVE_EMPTY+1))
rm "${WORK}/${id}.json"
if [[ ${CONSECUTIVE_EMPTY} -ge ${EMPTY_TOLERANCE} ]]; then
break
fi
fi
done
echo "# Probed ids 1..${id}, found ${#FOUND[@]} template(s) (stopped after ${EMPTY_TOLERANCE} consecutive empties)"
echo
if [[ ${#FOUND[@]} -eq 0 ]]; then
echo "# (no recurring templates found)"
exit 0
fi
python3 - "${WORK}" "${FOUND[@]}" <<'PY'
import json, sys, os, datetime
work = sys.argv[1]
ids = sys.argv[2:]
print(f"{'id':>3} {'ref':<28} {'socid':>5} {'freq':<10} {'gen':>6} {'next':<12} {'last':<12} {'sus':>3} {'auto_val':>8} {'total_ht':>10}")
print("-" * 115)
for iid in ids:
d = json.load(open(os.path.join(work, f"{iid}.json")))
freq_n = int(d.get("frequency") or 0)
freq_u = d.get("unit_frequency") or ""
freq_str = f"every {freq_n}{freq_u}" if freq_n else "OFF (0)"
nb_done = d.get("nb_gen_done") or "0"
nb_max = d.get("nb_gen_max") or "0"
gen = f"{nb_done}/{nb_max}" if nb_max != "0" else f"{nb_done}/∞"
nextd = d.get("date_when") or "-"
lastd = d.get("date_last_gen") or "-"
sus = d.get("suspended") or "0"
autoval = d.get("auto_validate") or "0"
print(f"{iid:>3} {(d.get('ref') or d.get('title') or '-'):<28} {str(d.get('socid') or '-'):>5} {freq_str:<10} {gen:>6} {nextd[:10]:<12} {lastd[:10]:<12} {sus:>3} {autoval:>8} {d.get('total_ht','-'):>10}")
PY

View File

@@ -0,0 +1,83 @@
---
name: dolibarr-tva-reconciliation
description: Prepare monthly French TVA declarations (CA3 / CA12) from Arcodange Dolibarr invoices. Two workflows — (1) per-period basis × rate aggregation (HT and TVA collectée grouped by year-month and tva_tx) ready to transcribe onto CA3 lines A1 / A4 / E2; (2) per-line audit trail with thirdparty country classification (FR domestic A1, EU intra-UE autoliquidation A4, non-EU export hors UE E2), so the operator can see WHY each line lands in its bucket. Handles credit notes (AVOIRs net out at the basis level). Currently in production for Arcodange: all KissMetrics invoices are autoliquidation 259-1° (extra-UE → E2, basis only, TVA collectée = 0). Use when the user asks "préparer la TVA du mois", "déclaration TVA mensuelle", "base CA3 ligne E2 / A4", "réconciliation TVA", "combien de TVA à reverser". Depends on the `dolibarr` skill for connection. SKIP for the legal-mention audit (different skill), for the deferred-cycle / cash receipts (handled by `dolibarr-payments-state` — TVA on encaissements is a separate concern), for writes (the declaration itself goes through impots.gouv.fr, not Dolibarr).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-tva-reconciliation — monthly TVA basis preparation
Builds the numbers the CA3 (régime réel normal) or CA12 (réel simplifié) declaration needs, straight from Dolibarr invoice lines. **Read-only**: this skill prepares the basis; the actual declaration goes through impots.gouv.fr.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## French TVA mental model (the buckets this skill outputs)
Dolibarr stores `tva_tx` per invoice line. Combined with the thirdparty's country code, each line belongs to one **CA3 bucket**:
| Bucket | Condition | What goes there |
|---|---|---|
| **A1** | `tva_tx > 0` (any country) | Domestic operations with collected TVA. Basis on line A1, TVA on line 08 (20 %) / 09 (10 % / 5.5 % / 2.1 %). |
| **A4** | `tva_tx == 0` AND thirdparty country in EU (excl. FR) | Intra-UE B2B services / goods on autoliquidation. Basis on A4, no TVA collectée. |
| **E2** | `tva_tx == 0` AND thirdparty country outside EU | Export of services hors UE (article 259-1° CGI for services / 262 for goods). Basis on E2, no TVA collectée. |
| **A1 (domestic 0%)** | `tva_tx == 0` AND country == FR | Atypical — domestic 0 %. Worth a manual check. |
Today **Arcodange is in a single bucket**: KissMetrics is the only client, US-based → **E2 export hors UE** with the 259-1° CGI mention. As soon as a French B2B is invoiced, A1 will start populating.
Credit notes (AVOIRs) are first-class: negative HT correctly nets the basis. A canceled-and-reissued cycle (like FAC001-CL00001 / AVC001 / FAC001-CL0001001 from V1) sums to the right net basis.
## Workflow 1 — Aggregate per month × rate (the CA3 basis)
```bash
./scripts/tva-by-month.sh # all-time
./scripts/tva-by-month.sh --year 2026 # full year
./scripts/tva-by-month.sh --since 2026-04-01 --until 2026-04-30 # one month
```
Live output (captured at [examples/tva-by-month.txt](examples/tva-by-month.txt)):
```
# TVA basis by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA CA3 line
----------------------------------------------------------------------
2026-01 0.0000 4 510.00 0.00 (see tva-line-detail for cnty)
2026-02 0.0000 11 7650.00 0.00 (see tva-line-detail for cnty)
----------------------------------------------------------------------
TOTAL 8160.00 0.00
```
All zero TVA collectée — consistent with the autoliquidation posture. The basis number is what you'd transcribe onto E2 (after the country breakdown from workflow 2 confirms it's all extra-UE).
## Workflow 2 — Per-line audit trail (the CA3 bucket assignment)
```bash
./scripts/tva-line-detail.sh # all-time
./scripts/tva-line-detail.sh --year 2026
./scripts/tva-line-detail.sh --since 2026-04-01 --until 2026-04-30
```
Live output (captured at [examples/tva-line-detail.txt](examples/tva-line-detail.txt)) shows each line with its country, rate, HT, TVA, and CA3 bucket. Plus a summary per bucket at the bottom — that's the line-by-line evidence that confirms the workflow-1 aggregate.
**Use this when** : you want the *why* behind a line in the monthly basis. If workflow 1 shows a 0 % bucket but workflow 2 says one line is FR domestic 0 %, that's worth flagging — it shouldn't be there.
## Tying back to impots.gouv.fr
The CA3 form is filed monthly (régime réel normal) by the 19th of the month following the operation. Transcription map for Arcodange's current single-bucket case:
| Form line | From this skill |
|---|---|
| E2 (Exportations) | sum of HT in the "E2 export hors UE" bucket |
| Total | sum across all buckets |
| TVA collectée | 0 (autoliquidation) |
| TVA déductible | from the suppliers side — out of scope here (no `/fournisseurfactures` workflow yet) |
For now the **TVA déductible** part (deductible TVA on Arcodange's expenses) is NOT covered — there's no supplier-invoice workflow in this skill family yet. Once Arcodange starts logging supplier invoices in Dolibarr, a sibling `dolibarr-tva-deductible` skill would complete the loop.
## Out of scope
- **TVA déductible / supplier invoices.** Different endpoint family (`/supplierinvoices`). V4 candidate.
- **TVA sur encaissements** (régime spécial for services where TVA is due on cash receipts, not invoice date). Not Arcodange's regime today; if it ever becomes one, this skill needs to swap `invoice.date` for `payment.date` as the period anchor.
- **CA12 quarterly form / TVA forfaitaire.** Different aggregation cadence — would need a `--quarter` option.
- **Writes to impots.gouv.fr.** Always a manual step in the official portal.

View File

@@ -0,0 +1,13 @@
# TVA basis by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA CA3 line
----------------------------------------------------------------------
2026-01 0.0000 3 510.00 0.00 (see tva-line-detail for cnty)
2026-02 0.0000 2 7650.00 0.00 (see tva-line-detail for cnty)
----------------------------------------------------------------------
TOTAL 8160.00 0.00
# Notes:
# - Lines with tva_tx==0 require country lookup to choose CA3 E2 (export hors UE)
# vs A4 (autoliquidation intra-UE). Run tva-line-detail.sh for that breakdown.
# - Credit notes (AVOIRs) show as negative HT and are correctly netted at basis.

View File

@@ -0,0 +1,11 @@
date invoice client cnty tx HT TVA CA3 bucket
--------------------------------------------------------------------------------------------------------------
2026-01-30 AVC001-CL0001001 tp-1 US 0.00 -510.00 0.00 E2 (export hors UE)
2026-01-30 FAC001-CL0001001 tp-1 US 0.00 510.00 0.00 E2 (export hors UE)
2026-02-24 FAC002-CL0001002 tp-1 US 0.00 5100.00 0.00 E2 (export hors UE)
2026-02-24 FAC003-CL0001003 tp-1 US 0.00 2550.00 0.00 E2 (export hors UE)
2026-01-30 FAC001-CL00001 tp-1 US 0.00 510.00 0.00 E2 (export hors UE)
--------------------------------------------------------------------------------------------------------------
# Aggregated by CA3 bucket:
E2 (export hors UE) count= 5 HT= 8160.00 TVA= 0.00

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Monthly TVA basis aggregation for Arcodange — ready to transcribe onto
# CA3 (régime réel normal) or CA12 (réel simplifié) declarations.
#
# Usage:
# tva-by-month.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Groups every visible invoice line by (year-month, tva_tx). Sums HT and TVA.
# Surfaces credit notes correctly (negative HT cancels out at the basis level).
#
# Mapping to French TVA forms (régime réel normal CA3):
# - tva_tx == 0 AND client country != FR
# → ligne A4 "Autres opérations imposables" if reverse-charge intra-UE
# → ligne E2 "Exportations hors UE" if extra-UE
# (the line script tva-line-detail.sh distinguishes these)
# - tva_tx == 20 → A1 base + 8 TVA collectée
# - tva_tx == 10 → A1 (taux réduit) base + 9 TVA collectée
# - tva_tx == 5.5 → A1 base + 9B TVA collectée
# - tva_tx == 2.1 → A1 base + 9C TVA collectée
#
# Today Arcodange is monorange autoliquidation 259-1° on KM (extra-UE), so
# everything lands on E2. As soon as a French B2B client is invoiced, the
# 20 % bucket will populate.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""
UNTIL=""
YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) YEAR="$2"; SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,30p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "tva-by-month.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t tvamonth.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/inv.json"
# Per-invoice detail fetch (the list endpoint doesn't include line-level tva_tx)
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1]))))" "${WORK}/inv.json")
mkdir -p "${WORK}/detail"
for id in ${IDS}; do
"${DOL_CURL}" "/invoices/${id}" > "${WORK}/detail/${id}.json"
done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, collections, datetime
work, since, until, year_arg = sys.argv[1:5]
def in_window(ts):
if not ts: return False
d = datetime.date.fromtimestamp(int(ts))
if since and d < datetime.date.fromisoformat(since): return False
if until and d > datetime.date.fromisoformat(until): return False
return True
# key: (yyyy-mm, tva_tx) -> {ht, tva, count}
agg = collections.defaultdict(lambda: {"ht":0.0,"tva":0.0,"count":0})
for fn in sorted(os.listdir(os.path.join(work,"detail"))):
try: inv = json.load(open(os.path.join(work,"detail",fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
ym = datetime.date.fromtimestamp(ts).strftime("%Y-%m")
for line in inv.get("lines") or []:
tx = float(line.get("tva_tx") or 0)
# Normalize tva_tx — Dolibarr returns "0.0000" / "20.0000" strings as numbers
tx = round(tx, 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
agg[(ym, tx)]["ht"] += ht
agg[(ym, tx)]["tva"] += tva
agg[(ym, tx)]["count"] += 1
scope = f"window={since or '-inf'} → {until or '+inf'}"
if year_arg: scope = f"year {year_arg}"
print(f"# TVA basis by month × rate — {scope}")
print()
print(f"{'month':<8} {'tva_tx':>7} {'count':>5} {'basis HT':>12} {'TVA':>10} CA3 line")
print("-" * 70)
total_ht = total_tva = 0.0
for key in sorted(agg):
ym, tx = key
s = agg[key]
line = "(see tva-line-detail for cnty)" if tx == 0 else ("A1+8" if tx==20 else f"A1+? @ {tx}%")
print(f"{ym:<8} {tx:>7.4f} {s['count']:>5} {s['ht']:>12.2f} {s['tva']:>10.2f} {line}")
total_ht += s["ht"]
total_tva += s["tva"]
print("-" * 70)
print(f"{'TOTAL':>16} {' ':>13} {total_ht:>12.2f} {total_tva:>10.2f}")
print()
print("# Notes:")
print("# - Lines with tva_tx==0 require country lookup to choose CA3 E2 (export hors UE)")
print("# vs A4 (autoliquidation intra-UE). Run tva-line-detail.sh for that breakdown.")
print("# - Credit notes (AVOIRs) show as negative HT and are correctly netted at basis.")
PY

View File

@@ -0,0 +1,98 @@
#!/usr/bin/env bash
# Per-line TVA breakdown with the country classification needed to choose
# the right CA3 line (E2 export hors UE vs A4 autoliquidation intra-UE vs
# domestic A1).
#
# Usage:
# tva-line-detail.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Pulls each invoice's lines + the thirdparty's country_code to assign each
# line to a CA3 bucket. Useful as the audit trail behind tva-by-month.sh.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
SINCE=""; UNTIL=""; YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) YEAR="$2"; SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "tva-line-detail.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t tvaline.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${WORK}/inv.json"
# Per-invoice detail + per-thirdparty country
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1]))))" "${WORK}/inv.json")
mkdir -p "${WORK}/inv" "${WORK}/tp"
SOCIDS=$(python3 -c "import json,sys; print(' '.join(sorted({str(r.get('socid')) for r in json.load(open(sys.argv[1])) if r.get('socid')})))" "${WORK}/inv.json")
for id in ${IDS}; do "${DOL_CURL}" "/invoices/${id}" > "${WORK}/inv/${id}.json"; done
for s in ${SOCIDS}; do "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"; done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, datetime
work, since, until, year_arg = sys.argv[1:5]
# EU member states (excl. FR — FR lines are "domestic")
EU = set("AT BE BG HR CY CZ DK EE FI DE GR HU IE IT LV LT LU MT NL PL PT RO SK SI ES SE".split())
tp = {}
for fn in os.listdir(os.path.join(work,"tp")):
try: d = json.load(open(os.path.join(work,"tp",fn)))
except json.JSONDecodeError: continue
tp[fn[:-len(".json")]] = d.get("country_code") or ""
def bucket(country, tx):
if tx and tx > 0: return f"A1 (domestic {tx}%)"
if country == "FR": return "A1 (domestic, 0%? check)"
if country in EU: return "A4 (autoliquidation intra-UE)"
if country: return "E2 (export hors UE)"
return "(unknown country)"
def in_window(ts):
if not ts: return False
d = datetime.date.fromtimestamp(int(ts))
if since and d < datetime.date.fromisoformat(since): return False
if until and d > datetime.date.fromisoformat(until): return False
return True
print(f"{'date':<10} {'invoice':<24} {'client':<15} {'cnty':<4} {'tx':>5} {'HT':>10} {'TVA':>8} CA3 bucket")
print("-" * 110)
all_lines = []
for fn in sorted(os.listdir(os.path.join(work,"inv"))):
try: inv = json.load(open(os.path.join(work,"inv",fn)))
except json.JSONDecodeError: continue
ts = int(inv.get("date") or 0)
if not in_window(ts): continue
dt = datetime.date.fromtimestamp(ts)
sid = str(inv.get("socid") or "")
cnty = tp.get(sid, "")
client = "tp-"+sid
for line in inv.get("lines") or []:
tx = round(float(line.get("tva_tx") or 0), 4)
ht = float(line.get("total_ht") or 0)
tva = float(line.get("total_tva") or 0)
buck = bucket(cnty, tx)
all_lines.append((dt, inv["ref"], client, cnty, tx, ht, tva, buck))
print(f"{dt} {inv['ref']:<24} {client:<15} {cnty:<4} {tx:>5.2f} {ht:>10.2f} {tva:>8.2f} {buck}")
print("-" * 110)
# Summary per bucket
import collections
buckets = collections.defaultdict(lambda: {"ht":0.0,"tva":0.0,"n":0})
for r in all_lines:
b = r[7]
buckets[b]["ht"] += r[5]; buckets[b]["tva"] += r[6]; buckets[b]["n"] += 1
print()
print("# Aggregated by CA3 bucket:")
for b, s in sorted(buckets.items()):
print(f" {b:<35} count={s['n']:>3} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
PY

View File

@@ -93,7 +93,8 @@ Read-only endpoints we've validated against this instance. Live captures are und
| `GET /bankaccounts` / `/{id}` | 200 | List bank accounts / detail | Returns `ref`, `label`, `iban`, `country_code`. `fk_bank_line` on a payment doesn't directly map to the account id — see `dolibarr-payments-state` skill for the lookup. | [bankaccounts_list.json](examples/bankaccounts_list.json) | | `GET /bankaccounts` / `/{id}` | 200 | List bank accounts / detail | Returns `ref`, `label`, `iban`, `country_code`. `fk_bank_line` on a payment doesn't directly map to the account id — see `dolibarr-payments-state` skill for the lookup. | [bankaccounts_list.json](examples/bankaccounts_list.json) |
| `GET /thirdparties` | 200 | List thirdparties | `mode` MUST be integer (`mode=1` = customers). String fails 400. | (use `/{id}` below) | | `GET /thirdparties` | 200 | List thirdparties | `mode` MUST be integer (`mode=1` = customers). String fails 400. | (use `/{id}` below) |
| `GET /thirdparties/{id}` | 200 | Thirdparty detail | KissMetrics is `id=1` in this instance | [thirdparty_km.json](examples/thirdparty_km.json) | | `GET /thirdparties/{id}` | 200 | Thirdparty detail | KissMetrics is `id=1` in this instance | [thirdparty_km.json](examples/thirdparty_km.json) |
| `GET /products` / `/products/{id}` | 200 | Product catalogue | `KM-audit` is `id=1` | — | | `GET /products` / `/products/{id}` | 200 | Product catalogue | `KM-audit` is `id=1`, `KM-cloud-devops` is `id=2` | [products_list.json](examples/products_list.json) |
| `GET /invoices/templates/{id}` | 200 | Recurring invoice template detail | **No list endpoint** — probe ids 1..N. Empty ids return HTTP 200 with `id=null` (sentinel for "doesn't exist"). Fields: `ref`, `socid`, `frequency`, `unit_frequency`, `nb_gen_done`, `date_when`, `date_last_gen`, `suspended`, `auto_validate`, `lines`. | [invoice_template_km.json](examples/invoice_template_km.json) |
| `GET /documents/download` | 200 | Download a stored document as base64 | `modulepart=facture&original_file=<REF>/<REF>.pdf` (URL-encode slashes). Returns `{filename, content-type, filesize, content}` with `content` base64. | [document_download_meta.json](examples/document_download_meta.json) | | `GET /documents/download` | 200 | Download a stored document as base64 | `modulepart=facture&original_file=<REF>/<REF>.pdf` (URL-encode slashes). Returns `{filename, content-type, filesize, content}` with `content` base64. | [document_download_meta.json](examples/document_download_meta.json) |
Not available on this account (intentionally): `/setup/modules` (admin-only), `/invoices/templates` (requires a path arg — different route shape; V2). Not available on this account (intentionally): `/setup/modules` (admin-only), `/invoices/templates` (requires a path arg — different route shape; V2).
@@ -135,6 +136,9 @@ Not available on this account (intentionally): `/setup/modules` (admin-only), `/
- Workflow skill for invoice + thirdparty audits: [dolibarr-invoice-audit](../dolibarr-invoice-audit/SKILL.md). - Workflow skill for invoice + thirdparty audits: [dolibarr-invoice-audit](../dolibarr-invoice-audit/SKILL.md).
- Workflow skill for payment-state and cash-receipt tracking: [dolibarr-payments-state](../dolibarr-payments-state/SKILL.md). - Workflow skill for payment-state and cash-receipt tracking: [dolibarr-payments-state](../dolibarr-payments-state/SKILL.md).
- Workflow skill for monthly TVA basis (CA3 / CA12 preparation): [dolibarr-tva-reconciliation](../dolibarr-tva-reconciliation/SKILL.md).
- Workflow skill for recurring invoice templates: [dolibarr-recurring-templates](../dolibarr-recurring-templates/SKILL.md).
- Workflow skill for point-in-time state archival: [dolibarr-data-snapshot](../dolibarr-data-snapshot/SKILL.md).
- Future workflow skills follow the `dolibarr-<topic>` convention. Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow. - Future workflow skills follow the `dolibarr-<topic>` convention. Each one depends on this skill for connection + permissions + endpoint reference; each one keeps its triggers focused on its specific business workflow.
## Out of scope ## Out of scope

View File

@@ -0,0 +1,261 @@
{
"module": null,
"id": "1",
"entity": "1",
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": [],
"contacts_ids_internal": null,
"linkedObjectsIds": [],
"fk_project": null,
"contact_id": null,
"user": null,
"origin_type": null,
"origin_id": null,
"ref": "Kiss Metrics Invoice",
"ref_ext": null,
"statut": null,
"status": null,
"country_id": null,
"country_code": null,
"state_id": null,
"region_id": null,
"mode_reglement_id": "2",
"cond_reglement_id": "10",
"demand_reason_id": null,
"transport_mode_id": null,
"shipping_method_id": null,
"shipping_method": null,
"fk_multicurrency": "0",
"multicurrency_code": "EUR",
"multicurrency_tx": "1.00000000",
"multicurrency_total_ht": "5100.00000000",
"multicurrency_total_tva": "0.00000000",
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": "5100.00000000",
"last_main_doc": null,
"fk_account": "2",
"note_public": null,
"note_private": null,
"total_ht": "5100.00000000",
"total_tva": "0.00000000",
"total_localtax1": "0.00000000",
"total_localtax2": "0.00000000",
"total_ttc": "5100.00000000",
"lines": [
{
"module": null,
"id": "1",
"entity": null,
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": null,
"contacts_ids_internal": null,
"linkedObjectsIds": null,
"origin_type": null,
"origin_id": null,
"ref": "KM-cloud-devops",
"ref_ext": null,
"statut": null,
"status": null,
"state_id": null,
"region_id": null,
"demand_reason_id": null,
"transport_mode_id": null,
"shipping_method": null,
"multicurrency_tx": null,
"multicurrency_total_ht": "5100.00000000",
"multicurrency_total_tva": "0.00000000",
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": "5100.00000000",
"last_main_doc": null,
"fk_account": null,
"total_ht": "5100.00000000",
"total_tva": "0.00000000",
"total_localtax1": null,
"total_localtax2": null,
"total_ttc": "5100.00000000",
"lines": null,
"actiontypecode": null,
"civility_code": null,
"date_creation": null,
"date_validation": null,
"date_modification": null,
"tms": null,
"date_cloture": null,
"user_author": null,
"user_creation": null,
"user_creation_id": null,
"user_valid": null,
"user_validation": null,
"user_validation_id": null,
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"parent_element": "facturerec",
"fk_parent_attribute": "fk_facture",
"rowid": "1",
"fk_unit": null,
"date_debut_prevue": null,
"date_debut_reel": null,
"date_fin_prevue": null,
"date_fin_reel": null,
"weight": null,
"weight_units": null,
"length": null,
"length_units": null,
"width": null,
"width_units": null,
"height": null,
"height_units": null,
"surface": null,
"surface_units": null,
"volume": null,
"volume_units": null,
"multilangs": null,
"product_type": "1",
"fk_product": "2",
"desc": "TVA non applicable &ndash; Article 259-1 du CGI &ndash; Prestation de services localis&eacute;e hors de France (USA)",
"description": "TVA non applicable &ndash; Article 259-1 du CGI &ndash; Prestation de services localis&eacute;e hors de France (USA)",
"product_ref": "KM-cloud-devops",
"product_label": "KissMetrics - Cloud Devops - 1 day",
"product_barcode": null,
"product_desc": "",
"fk_product_type": "1",
"qty": "10",
"duree": null,
"remise_percent": "0",
"info_bits": "0",
"special_code": "0",
"subprice": "510.00000000",
"subprice_ttc": null,
"tva_tx": "0.0000",
"multicurrency_subprice": "510.00000000",
"multicurrency_subprice_ttc": null,
"label": null,
"libelle": "KissMetrics - Cloud Devops - 1 day",
"price": "510.00000000",
"vat_src_code": "",
"localtax1_tx": "0.0000",
"localtax2_tx": "0.0000",
"localtax1_type": "0",
"localtax2_type": "0",
"remise": null,
"revenuestamp": null,
"date_start_fill": "0",
"date_end_fill": "0",
"buy_price_ht": null,
"buyprice": "0.00000000",
"pa_ht": "0.00000000",
"marge_tx": "",
"marque_tx": "100",
"fk_user_author": null,
"fk_accounting_account": null,
"fk_facture": "1",
"fk_parent_line": null,
"fk_product_fournisseur_price": null,
"fk_fournprice": null,
"rang": "1",
"fk_contract_line": null
}
],
"actiontypecode": null,
"name": null,
"lastname": null,
"firstname": null,
"civility_id": null,
"civility_code": null,
"date_creation": null,
"date_validation": null,
"date_modification": null,
"tms": null,
"date_cloture": null,
"user_author": "1",
"user_creation": null,
"user_creation_id": "1",
"user_valid": null,
"user_validation": null,
"user_validation_id": null,
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"title": "Kiss Metrics Invoice",
"type": 0,
"subtype": null,
"fk_soc": null,
"socid": "1",
"paye": null,
"date": null,
"date_lim_reglement": "",
"cond_reglement_code": "10DENDMONTH",
"cond_reglement_label": null,
"cond_reglement_doc": "Due in 10 days, end of month",
"mode_reglement_code": "VIR",
"revenuestamp": null,
"totaldeposits": null,
"totalcreditnotes": null,
"sumpayed": null,
"sumpayed_multicurrency": null,
"sumdeposit": null,
"sumdeposit_multicurrency": null,
"sumcreditnote": null,
"sumcreditnote_multicurrency": null,
"remaintopay": null,
"nbofopendirectdebitorcredittransfer": null,
"creditnote_ids": null,
"stripechargedone": null,
"stripechargeerror": null,
"description": null,
"ref_client": null,
"situation_cycle_ref": null,
"close_code": null,
"close_note": null,
"postactionmessages": null,
"fk_incoterms": null,
"label_incoterms": null,
"location_incoterms": null,
"titre": "Kiss Metrics Invoice",
"multicurrency_subprice": null,
"number": null,
"total": null,
"tva": null,
"date_last_gen": "",
"date_when": "",
"nb_gen_done": "0",
"nb_gen_max": "0",
"frequency": "0",
"unit_frequency": "m",
"rule_for_lines_dates": "prepaid",
"rang": null,
"special_code": null,
"usenewprice": "0",
"fk_societe_rib": null,
"suspended": "0",
"auto_validate": "0",
"generate_pdf": "1",
"usenewcurrencyrate": null
}

View File

@@ -0,0 +1,336 @@
[
{
"module": null,
"id": "1",
"entity": "1",
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": null,
"contacts_ids_internal": null,
"linkedObjectsIds": null,
"canvas": "",
"origin_type": null,
"ref": "KM-audit",
"ref_ext": null,
"status": "0",
"country_id": null,
"country_code": "",
"state_id": null,
"region_id": null,
"barcode_type": null,
"barcode_type_coder": null,
"shipping_method": null,
"fk_multicurrency": null,
"multicurrency_code": null,
"multicurrency_tx": null,
"multicurrency_total_ht": null,
"multicurrency_total_tva": null,
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": null,
"last_main_doc": null,
"note_public": null,
"note_private": "",
"total_ht": null,
"total_tva": null,
"total_localtax1": null,
"total_localtax2": null,
"total_ttc": null,
"actiontypecode": null,
"civility_code": null,
"date_creation": 1769899106,
"date_validation": null,
"date_modification": 1771861714,
"tms": null,
"date_cloture": null,
"user_author": null,
"user_creation": null,
"user_creation_id": null,
"user_valid": null,
"user_validation": null,
"user_validation_id": null,
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"label": "KissMetrics - Cloud Infrastructure Audit - 1 day - Jan26",
"description": "",
"other": null,
"type": "1",
"price": "510.00000000",
"price_formated": null,
"price_ttc": "612.00000000",
"price_ttc_formated": null,
"price_min": "0.00000000",
"price_min_ttc": "0.00000000",
"price_base_type": "HT",
"price_label": null,
"multiprices": [],
"multiprices_ttc": [],
"multiprices_base_type": [],
"multiprices_default_vat_code": [],
"multiprices_min": [],
"multiprices_min_ttc": [],
"multiprices_tva_tx": [],
"prices_by_qty": [],
"prices_by_qty_list": [],
"level": null,
"multilangs": [],
"default_vat_code": null,
"tva_tx": "20.0000",
"remise_percent": null,
"localtax1_tx": "0.0000",
"localtax2_tx": "0.0000",
"localtax1_type": "0",
"localtax2_type": "0",
"desc_supplier": null,
"vatrate_supplier": null,
"default_vat_code_supplier": null,
"fourn_multicurrency_price": null,
"fourn_multicurrency_unitprice": null,
"fourn_multicurrency_tx": null,
"fourn_multicurrency_id": null,
"fourn_multicurrency_code": null,
"packaging": null,
"lifetime": null,
"qc_frequency": null,
"cost_price": null,
"pmp": "0.00000000",
"seuil_stock_alerte": "0",
"desiredstock": "0",
"duration_value": 1,
"duration_unit": "d",
"duration": "1d",
"fk_default_workstation": null,
"tosell": null,
"status_buy": "0",
"tobuy": null,
"finished": "0",
"fk_default_bom": null,
"product_fourn_price_id": null,
"buyprice": null,
"tobatch": null,
"status_batch": "0",
"sell_or_eat_by_mandatory": "0",
"batch_mask": "",
"customcode": "",
"url": "https://arcodange.fr/honoraires/#tjm=N4IgbiBcCMA0IGMogKwBYAmAzBWAMAHCPBlKAA4BOA9gLYCWAzvQHYDmUA2iBvW/QBcAhgBsABNQQBTISxABdeAAshjRiKlcQGtkIQBPMQmoZNinkP2MoeEkwHI8AWmgoxDFgFcBU6/B9CtAAqUpS01pCctgDMsACcsNFoiQSwaLZoAEywKLFxePIAvoVAA=",
"weight": null,
"weight_units": 0,
"length": null,
"length_units": 0,
"width": null,
"width_units": 0,
"height": null,
"height_units": 0,
"surface": null,
"surface_units": 0,
"volume": null,
"volume_units": 0,
"net_measure": null,
"net_measure_units": null,
"accountancy_code_sell": "7061",
"accountancy_code_sell_intra": "7062",
"accountancy_code_sell_export": "7063",
"accountancy_code_buy": "6041",
"accountancy_code_buy_intra": "6042",
"accountancy_code_buy_export": "6043",
"barcode": null,
"stats_proposal_supplier": [],
"stats_expedition": [],
"stats_mo": [],
"stats_bom": [],
"stats_facturerec": [],
"stats_facture_fournisseur": [],
"stats_facturefournrec": [],
"fk_default_warehouse": null,
"fk_price_expression": null,
"fourn_qty": null,
"fk_unit": null,
"price_autogen": "0",
"sousprods": [],
"res": null,
"is_object_used": null,
"is_sousproduit_qty": null,
"is_sousproduit_incdec": null,
"mandatory_period": "0",
"stockable_product": "1"
},
{
"module": null,
"id": "2",
"entity": "1",
"import_key": null,
"array_options": [],
"array_languages": null,
"contacts_ids": null,
"contacts_ids_internal": null,
"linkedObjectsIds": null,
"canvas": "",
"origin_type": null,
"ref": "KM-cloud-devops",
"ref_ext": null,
"status": "1",
"country_id": null,
"country_code": "",
"state_id": null,
"region_id": null,
"barcode_type": null,
"barcode_type_coder": null,
"shipping_method": null,
"fk_multicurrency": null,
"multicurrency_code": null,
"multicurrency_tx": null,
"multicurrency_total_ht": null,
"multicurrency_total_tva": null,
"multicurrency_total_localtax1": null,
"multicurrency_total_localtax2": null,
"multicurrency_total_ttc": null,
"last_main_doc": null,
"note_public": null,
"note_private": "",
"total_ht": null,
"total_tva": null,
"total_localtax1": null,
"total_localtax2": null,
"total_ttc": null,
"actiontypecode": null,
"civility_code": null,
"date_creation": 1769902706,
"date_validation": null,
"date_modification": 1771862352,
"tms": null,
"date_cloture": null,
"user_author": null,
"user_creation": null,
"user_creation_id": null,
"user_valid": null,
"user_validation": null,
"user_validation_id": null,
"user_closing_id": null,
"user_modification": null,
"user_modification_id": null,
"fk_user_creat": null,
"fk_user_modif": null,
"specimen": 0,
"totalpaid": null,
"extraparams": [],
"product": null,
"cond_reglement_supplier_id": null,
"deposit_percent": null,
"retained_warranty_fk_cond_reglement": null,
"warehouse_id": null,
"label": "KissMetrics - Cloud Devops - 1 day",
"description": "",
"other": null,
"type": "1",
"price": "510.00000000",
"price_formated": null,
"price_ttc": "612.00000000",
"price_ttc_formated": null,
"price_min": "0.00000000",
"price_min_ttc": "0.00000000",
"price_base_type": "HT",
"price_label": null,
"multiprices": [],
"multiprices_ttc": [],
"multiprices_base_type": [],
"multiprices_default_vat_code": [],
"multiprices_min": [],
"multiprices_min_ttc": [],
"multiprices_tva_tx": [],
"prices_by_qty": [],
"prices_by_qty_list": [],
"level": null,
"multilangs": [],
"default_vat_code": null,
"tva_tx": "20.0000",
"remise_percent": null,
"localtax1_tx": "0.0000",
"localtax2_tx": "0.0000",
"localtax1_type": "0",
"localtax2_type": "0",
"desc_supplier": null,
"vatrate_supplier": null,
"default_vat_code_supplier": null,
"fourn_multicurrency_price": null,
"fourn_multicurrency_unitprice": null,
"fourn_multicurrency_tx": null,
"fourn_multicurrency_id": null,
"fourn_multicurrency_code": null,
"packaging": null,
"lifetime": null,
"qc_frequency": null,
"cost_price": null,
"pmp": "0.00000000",
"seuil_stock_alerte": "0",
"desiredstock": "0",
"duration_value": 1,
"duration_unit": "d",
"duration": "1d",
"fk_default_workstation": null,
"tosell": null,
"status_buy": "0",
"tobuy": null,
"finished": "0",
"fk_default_bom": "0",
"product_fourn_price_id": null,
"buyprice": null,
"tobatch": null,
"status_batch": "0",
"sell_or_eat_by_mandatory": "0",
"batch_mask": "",
"customcode": "",
"url": "https://arcodange.fr/honoraires/#tjm=N4IgbiBcCMA0IGMogKwBYAmAzBWAMAHCPBlKAA4BOA9gLYCWAzvQHYDmUA2iBvW/QBcAhgBsABNQQBTISxABdeAAshjRiKlcQGtkIQBPMQmoZNinkP2MoeEkwHI8AWmgoxDFgFcBU6/B9CtAAqUpS01pCctgDMsACcsNFoiQSwaLZoAEywKLFxePIAvoVAA=",
"weight": null,
"weight_units": 0,
"length": null,
"length_units": 0,
"width": null,
"width_units": 0,
"height": null,
"height_units": 0,
"surface": null,
"surface_units": 0,
"volume": null,
"volume_units": 0,
"net_measure": null,
"net_measure_units": null,
"accountancy_code_sell": "7061",
"accountancy_code_sell_intra": "7062",
"accountancy_code_sell_export": "7063",
"accountancy_code_buy": "6041",
"accountancy_code_buy_intra": "6042",
"accountancy_code_buy_export": "6043",
"barcode": null,
"stats_proposal_supplier": [],
"stats_expedition": [],
"stats_mo": [],
"stats_bom": [],
"stats_facturerec": [],
"stats_facture_fournisseur": [],
"stats_facturefournrec": [],
"fk_default_warehouse": null,
"fk_price_expression": null,
"fourn_qty": null,
"fk_unit": null,
"price_autogen": "0",
"sousprods": [],
"res": null,
"is_object_used": null,
"is_sousproduit_qty": null,
"is_sousproduit_incdec": null,
"mandatory_period": "0",
"stockable_product": "1"
}
]