add dolibarr-tva-reconciliation, dolibarr-recurring-templates, dolibarr-data-snapshot
V3 bundle — three sibling skills under .claude/skills/, all read-only,
all depending on the dolibarr base skill.
dolibarr-tva-reconciliation:
- tva-by-month.sh: HT + TVA grouped by (year-month × tva_tx), ready
for CA3 / CA12 transcription.
- tva-line-detail.sh: per-line audit trail with country-based bucket
assignment (A1 domestic / A4 intra-UE autoliquidation / E2 export
hors UE). Documents the French TVA mental model.
- Today every Arcodange line is E2 (KissMetrics, US, autoliquidation
259-1° CGI). The skill scales for the day a French B2B is invoiced.
dolibarr-recurring-templates:
- list-templates.sh: probes /invoices/templates/{id} since there's no
list endpoint. Stops after 5 consecutive empty responses.
- inspect-template.sh: full audit per template, with health checks.
- Surfaces that the "Kiss Metrics Invoice" template has frequency=0
and nb_gen_done=0 — it is NOT auto-firing. Every KM invoice today
was manually duplicated. Cohort-review implication: the deferred
9-month cycle depends on Gabriel clicking "Generate" each month,
not on a Dolibarr cron.
dolibarr-data-snapshot:
- snapshot.sh: bundles every read endpoint the dolibarr-* family uses
into one JSON with a content_hash (sha256 of data only, excluding
timestamp — so identical state hashes identically across runs).
- Use cases: cohort evidence packs, drift detection, archival before
a known-risky UI change.
- V1 baseline summary captured at examples/snapshot-summary.txt
(the ~246 KB snapshot file itself is intentionally not committed).
Also extends dolibarr/SKILL.md endpoint catalogue with
/invoices/templates/{id} (and its no-list-endpoint quirk + the
id-null sentinel for missing ids), plus links to the three new
sibling skills.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
90
.claude/skills/dolibarr-recurring-templates/SKILL.md
Normal file
90
.claude/skills/dolibarr-recurring-templates/SKILL.md
Normal 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.
|
||||
@@ -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
|
||||
@@ -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
|
||||
130
.claude/skills/dolibarr-recurring-templates/scripts/inspect-template.sh
Executable file
130
.claude/skills/dolibarr-recurring-templates/scripts/inspect-template.sh
Executable 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
|
||||
78
.claude/skills/dolibarr-recurring-templates/scripts/list-templates.sh
Executable file
78
.claude/skills/dolibarr-recurring-templates/scripts/list-templates.sh
Executable 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
|
||||
Reference in New Issue
Block a user