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:
2026-05-29 00:01:06 +02:00
parent d34cba3fa0
commit f19b1d2ef2
16 changed files with 1554 additions and 1 deletions

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