add dolibarr-thirdparty-completeness and dolibarr-tva-deductible

V4 bundle — two more sibling skills, both read-only, both depending
on the dolibarr base skill.

dolibarr-thirdparty-completeness:
- audit-thirdparty.sh <socid>: country-aware completeness audit for
  any thirdparty (FR: SIREN + SIRET + tva_intra; EU non-FR: tva_intra;
  extra-EU: national tax id). Generalizes the V1 KM-hardcoded script.
- audit-all-thirdparties.sh: loops over /thirdparties and surfaces a
  compact table of gaps. --clients-only / --suppliers-only flags.
- Live baseline finds 5/10 thirdparties with mandatory gaps:
  KissMetrics (US tax id), Wise Europe SA (BE tva_intra), Medialex
  (FR SIRET + tva_intra), Qonto (SIRET), Infogreffe (SIRET).

dolibarr-tva-deductible:
- deductible-by-month.sh: TVA déductible aggregated per period × rate.
- deductible-line-detail.sh: per supplier-invoice line with country-
  based CA3 bucket assignment (ligne 20 for 20 % FR, ligne 19 for
  reduced rates, ligne 17+24 for intra-UE autoliquidation).
- Live baseline: 223.22 € total TVA déductible across 13 lines.
  Wise Europe SA correctly identified as intra-UE autoliquidation;
  La Poste correctly identified as FR exempt (timbres).
- Mirrors dolibarr-tva-reconciliation on the supplier side. Together
  they give the two numbers a CA3 monthly declaration needs.

Also extends dolibarr/SKILL.md endpoint catalogue with /supplierinvoices
(noting the 403 on the /lines sub-endpoint — inline lines on the detail
endpoint make this a non-issue). dolibarr/README.md gains two new
permission checkboxes for Factures fournisseurs.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-29 06:14:28 +02:00
parent fe9e8274f1
commit 585b7beb03
18 changed files with 5736 additions and 0 deletions

View File

@@ -0,0 +1,113 @@
---
name: dolibarr-tva-deductible
description: Prepare the TVA déductible side of the French monthly declaration (CA3 lignes 19 / 20 / 17 / 24) from Arcodange supplier invoices (`/supplierinvoices`). Two workflows — (1) per-period basis × rate aggregation (HT and TVA déductible grouped by year-month and tva_tx) ready to transcribe onto CA3 lignes 20 (standard 20 %), 19 (taux réduits), 17+24 (autoliquidation intra-UE); (2) per-line audit trail with supplier country classification (FR domestic / EU intra-UE / extra-EU import). Mirrors `dolibarr-tva-reconciliation` on the supplier side — TVA collectée vs TVA déductible = TVA nette à reverser. Today on Arcodange the TVA déductible totals ~223 €/all-time across 12 supplier-side 20 % lines plus 1 autoliquidation intra-UE entry (Wise BE) and 1 FR exempt line (timbres La Poste). Use when the user asks "préparer TVA déductible", "achats du mois", "TVA à récupérer", "réconciliation TVA fournisseur", "combien de TVA nette à reverser". Depends on the `dolibarr` skill. SKIP for TVA collectée (handled by `dolibarr-tva-reconciliation`), for the customer-side audit (different skills), and for writes (the declaration goes through impots.gouv.fr).
requires:
bins: ["curl", "jq", "python3"]
auth: true
---
# dolibarr-tva-deductible — supplier-side TVA monthly preparation
The mirror of [dolibarr-tva-reconciliation](../dolibarr-tva-reconciliation/SKILL.md) on the supplier side. Together they give you the two numbers a French CA3 needs:
- **TVA collectée** (`dolibarr-tva-reconciliation`) — what Arcodange invoiced and is obligated to remit.
- **TVA déductible** (this skill) — what Arcodange paid to suppliers and can deduct.
The net to remit is the difference. **Read-only**: the declaration itself goes through impots.gouv.fr.
Depends on the [dolibarr](../dolibarr/SKILL.md) base skill.
## CA3 mapping (supplier side)
| Bucket | Condition | CA3 line(s) |
|---|---|---|
| **20 % standard** | `tva_tx == 20` | ligne 20 |
| **10 % intermediate** | `tva_tx == 10` | ligne 19 |
| **5.5 % reduced** | `tva_tx == 5.5` | ligne 19 |
| **2.1 % special** | `tva_tx == 2.1` | ligne 19 |
| **Intra-UE autoliquidation** | `tva_tx == 0` AND supplier in EU (excl. FR) | ligne 17 (TVA auto-collectée) + ligne 24 (TVA déductible) |
| **FR exempt / HT-only** | `tva_tx == 0` AND supplier country == FR | (e.g. timbres La Poste — no TVA line) |
| **Extra-EU import** | `tva_tx == 0` AND supplier outside EU | ligne 7 (import TVA — usually via customs declaration) |
## API gotchas to know
- **`/supplierinvoices/{id}/lines` returns HTTP 403** for `ai_agent`. We don't need it — lines are also included inline on `/supplierinvoices/{id}`. (Reference: [../dolibarr/examples/acl_403_supplier_lines.json](../dolibarr/examples/acl_403_supplier_lines.json).)
- **`/supplierinvoices` list works fine** — no `voir_tous` gap on supplier invoices the way V1 hit on customer ones.
## Workflow 1 — Aggregate per month × rate (the CA3 basis)
```bash
./scripts/deductible-by-month.sh # all-time
./scripts/deductible-by-month.sh --year 2026
./scripts/deductible-by-month.sh --since 2026-01-01 --until 2026-01-31
```
Live output (captured at [examples/deductible-by-month.txt](examples/deductible-by-month.txt)):
```
# TVA déductible by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA ded CA3 line
------------------------------------------------------------------------------
2025-10 20.0000 2 6.08 1.22 ligne 20
2026-01 0.0000 2 58.43 0.00 ligne 17 + 24 (autoliquidation) — verify with line-detail
2026-01 20.0000 8 345.02 69.00 ligne 20
2026-02 20.0000 2 765.00 153.00 ligne 20
------------------------------------------------------------------------------
TOTAL 1174.53 223.22
```
The 2026-02 ligne 20 number (765 € HT / 153 € TVA) is dominated by Darnis Operations (510 + 255). The 2026-01 row at 0 % is mixed (Wise BE autoliquidation 50 € + La Poste FR exempt 8.43 €) — workflow 2 splits them properly.
## Workflow 2 — Per-line audit (CA3 bucket assignment)
```bash
./scripts/deductible-line-detail.sh # all-time
./scripts/deductible-line-detail.sh --year 2026
./scripts/deductible-line-detail.sh --since 2026-02-01 --until 2026-02-28
```
Live output (captured at [examples/deductible-line-detail.txt](examples/deductible-line-detail.txt)) — supplier name and country annotated:
```
date ref supplier cnty tx HT TVA CA3 bucket
------------------------------------------------------------------------------------------------------------------------
2026-01-26 FAF2026001 Wise Europe SA BE 0.00 50.00 0.00 ligne 17+24 (autoliquidation intra-UE)
2026-02-28 FAF2026009 Darnis Operations FR 20.00 255.00 51.00 ligne 20/19 (déductible 20.0%)
...
2026-01-12 FAF2026006 La Poste FR 0.00 8.43 0.00 FR exempt / HT seulement
...
# Aggregated by CA3 bucket:
FR exempt / HT seulement count= 1 HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) count= 1 HT= 50.00 TVA= 0.00
ligne 20/19 (déductible 20.0%) count= 12 HT= 1116.10 TVA= 223.22
```
## Tying back to the CA3 — combined view
For any given month, the **net TVA à reverser** computation is:
```
net = TVA collectée (from tva-by-month.sh)
- TVA déductible (from deductible-by-month.sh)
- autoliquidation neutralized (ligne 17 = ligne 24, cancels)
```
For Arcodange today:
- TVA collectée = 0 € (all KissMetrics, all E2 autoliquidation 259-1°)
- TVA déductible = 223.22 € (mainly 20 % FR suppliers)
- **Net = TVA crédit de 223.22 €** — Arcodange is in TVA credit, can request reimbursement or carry forward.
That's the kind of summary a `dolibarr-tva-summary` (or `arcodange-tva-monthly-report`) skill could produce by composing this skill + `dolibarr-tva-reconciliation`. V5 candidate.
## Wise BE specific gotcha
The Wise Europe SA supplier (BE) currently has **no `tva_intra` field populated** ([dolibarr-thirdparty-completeness](../dolibarr-thirdparty-completeness/SKILL.md) flags this). Article 196 directive TVA requires the EU supplier's VAT number on Arcodange's books to substantiate the autoliquidation entry. **Mandatory remediation** before the next TVA declaration: add Wise's BE VAT number (BE0833 281 858 per their public registration) on the thirdparty record.
## Out of scope
- **Writes.** The declaration goes through impots.gouv.fr.
- **Customs / import TVA on goods.** Service-only Arcodange today; if goods imports happen, customs declarations are out of scope.
- **TVA sur encaissements** (régime spécial — TVA due on the payment date rather than invoice date for services). Not Arcodange's regime today.
- **Composition with collectée** (the net-to-remit summary). V5 candidate — a `dolibarr-tva-summary` skill.

View File

@@ -0,0 +1,16 @@
# TVA déductible by month × rate — window=-inf → +inf
month tva_tx count basis HT TVA ded CA3 line
------------------------------------------------------------------------------
2025-10 20.0000 2 6.08 1.22 ligne 20
2026-01 0.0000 2 58.43 0.00 ligne 17 + 24 (autoliquidation) — verify with line-detail
2026-01 20.0000 8 345.02 69.00 ligne 20
2026-02 20.0000 2 765.00 153.00 ligne 20
------------------------------------------------------------------------------
TOTAL 1174.53 223.22
# Notes:
# - This is TVA déductible (supplier side). For TVA collectée (customer side),
# use dolibarr-tva-reconciliation/scripts/tva-by-month.sh.
# - tva_tx==0 lines may be either truly exempt (e.g. La Poste timbres)
# or autoliquidation intra-UE (e.g. Wise). Run deductible-line-detail.sh.

View File

@@ -0,0 +1,22 @@
date ref supplier cnty tx HT TVA CA3 bucket
------------------------------------------------------------------------------------------------------------------------
2026-01-26 FAF2026001 Wise Europe SA BE 0.00 50.00 0.00 ligne 17+24 (autoliquidation intra-UE)
2026-02-28 FAF2026009 Darnis Operations FR 20.00 255.00 51.00 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 16.95 3.39 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 23.30 4.66 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 6.36 1.27 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026002 Greffe du tribunal de commer FR 20.00 1.08 0.22 ligne 20/19 (déductible 20.0%)
2026-01-04 FAF2026003 YOLAW FR 20.00 1.66 0.33 ligne 20/19 (déductible 20.0%)
2026-01-09 FAF2026004 Medialex FR 20.00 124.00 24.80 ligne 20/19 (déductible 20.0%)
2026-01-06 FAF2026005 Qonto FR 20.00 169.00 33.80 ligne 20/19 (déductible 20.0%)
2025-10-24 FAF2025001 OVH FR 20.00 4.99 1.00 ligne 20/19 (déductible 20.0%)
2025-10-24 FAF2025001 OVH FR 20.00 1.09 0.22 ligne 20/19 (déductible 20.0%)
2026-01-12 FAF2026006 La Poste FR 0.00 8.43 0.00 FR exempt / HT seulement
2026-01-17 FAF2026007 Infogreffe FR 20.00 2.67 0.53 ligne 20/19 (déductible 20.0%)
2026-02-28 FAF2026008 Darnis Operations FR 20.00 510.00 102.00 ligne 20/19 (déductible 20.0%)
------------------------------------------------------------------------------------------------------------------------
# Aggregated by CA3 bucket:
FR exempt / HT seulement count= 1 HT= 8.43 TVA= 0.00
ligne 17+24 (autoliquidation intra-UE) count= 1 HT= 50.00 TVA= 0.00
ligne 20/19 (déductible 20.0%) count= 12 HT= 1116.10 TVA= 223.22

View File

@@ -0,0 +1,112 @@
#!/usr/bin/env bash
# Monthly TVA déductible aggregation for Arcodange — the supplier-side
# counterpart to dolibarr-tva-reconciliation.
#
# Usage:
# deductible-by-month.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Groups every visible supplier invoice line by (year-month, tva_tx) and sums
# HT and TVA. The TVA figure feeds the CA3 lignes 19 / 20 (déductible).
#
# Mapping to French CA3:
# - tva_tx == 20 → ligne 20 (TVA déductible normale 20 %)
# - tva_tx == 10 → ligne 19 (taux intermédiaire)
# - tva_tx == 5.5 → ligne 19 (taux réduit)
# - tva_tx == 2.1 → ligne 19 (taux particulier)
# - tva_tx == 0 → likely autoliquidation intra-UE (achats à l'étranger)
# → goes on ligne 17 (auto-collected) AND ligne 24 (déductible)
# run deductible-line-detail.sh for the per-supplier breakdown
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,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "deductible-by-month.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t deduc.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/inv.json"
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/inv.json")
mkdir -p "${WORK}/detail"
for id in ${IDS}; do "${DOL_CURL}" "/supplierinvoices/${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
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")
lines = inv.get("lines") or []
if lines:
for line in lines:
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)
agg[(ym, tx)]["ht"] += ht
agg[(ym, tx)]["tva"] += tva
agg[(ym, tx)]["count"] += 1
else:
# Fallback: aggregate at invoice level (no lines available)
# Derive an effective tva_tx if possible: TVA / HT × 100
ht = float(inv.get("total_ht") or 0)
tva = float(inv.get("total_tva") or 0)
tx = round((tva / ht * 100), 4) if ht else 0.0
agg[(ym, tx)]["ht"] += ht
agg[(ym, tx)]["tva"] += tva
agg[(ym, tx)]["count"] += 1
def ca3_line(tx):
if tx == 20: return "ligne 20"
if tx == 10: return "ligne 19 (10 %)"
if tx == 5.5: return "ligne 19 (5,5 %)"
if tx == 2.1: return "ligne 19 (2,1 %)"
if tx == 0: return "ligne 17 + 24 (autoliquidation) — verify with line-detail"
return f"manual @ {tx}%"
scope = f"window={since or '-inf'} → {until or '+inf'}"
if year_arg: scope = f"year {year_arg}"
print(f"# TVA déductible by month × rate — {scope}")
print()
print(f"{'month':<8} {'tva_tx':>7} {'count':>5} {'basis HT':>12} {'TVA ded':>10} CA3 line")
print("-" * 78)
total_ht = total_tva = 0.0
for key in sorted(agg):
ym, tx = key
s = agg[key]
print(f"{ym:<8} {tx:>7.4f} {s['count']:>5} {s['ht']:>12.2f} {s['tva']:>10.2f} {ca3_line(tx)}")
total_ht += s["ht"]
total_tva += s["tva"]
print("-" * 78)
print(f"{'TOTAL':>16} {' ':>13} {total_ht:>12.2f} {total_tva:>10.2f}")
print()
print("# Notes:")
print("# - This is TVA déductible (supplier side). For TVA collectée (customer side),")
print("# use dolibarr-tva-reconciliation/scripts/tva-by-month.sh.")
print("# - tva_tx==0 lines may be either truly exempt (e.g. La Poste timbres)")
print("# or autoliquidation intra-UE (e.g. Wise). Run deductible-line-detail.sh.")
PY

View File

@@ -0,0 +1,108 @@
#!/usr/bin/env bash
# Per-line TVA déductible breakdown with supplier country classification.
#
# Usage:
# deductible-line-detail.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# For each supplier-invoice line: date, supplier name, country, tva_tx,
# HT, TVA, and a French CA3 bucket assignment. Plus a summary per bucket.
#
# Buckets:
# - FR domestic with TVA → ligne 20 / 19 (TVA déductible standard)
# - EU intra-UE no TVA → ligne 17 (auto-collected) + ligne 24 (déductible) — autoliquidation
# - Extra-EU no TVA → import — likely requires customs TVA declaration, see ligne 7
# - FR with TVA == 0 → exempt or HT-only invoice (e.g. timbres La Poste, AMF)
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,15p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "deductible-line-detail.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t deducline.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/inv.json"
IDS=$( python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/inv.json")
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")
mkdir -p "${WORK}/inv" "${WORK}/tp"
for id in ${IDS}; do "${DOL_CURL}" "/supplierinvoices/${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, collections
work, since, until, year_arg = sys.argv[1:5]
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")]] = {
"name": d.get("name") or d.get("ref") or "-",
"country": d.get("country_code") or "",
}
def bucket(country, tx, ht, tva):
if tx and tx > 0:
return f"ligne 20/19 (déductible {tx}%)"
if country == "FR":
return "FR exempt / HT seulement"
if country in EU:
return "ligne 17+24 (autoliquidation intra-UE)"
if country:
return "ligne 7 (import hors UE)"
return "(country missing)"
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} {'ref':<14} {'supplier':<28} {'cnty':<4} {'tx':>5} {'HT':>10} {'TVA':>8} CA3 bucket")
print("-" * 120)
agg = collections.defaultdict(lambda: {"ht":0.0,"tva":0.0,"n":0})
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)
ref = inv.get("ref") or "-"
sid = str(inv.get("socid") or "")
sup = tp.get(sid, {}).get("name", f"socid={sid}")
cnty = tp.get(sid, {}).get("country", "")
lines = inv.get("lines") or []
if not lines:
# Aggregate at invoice level
ht = float(inv.get("total_ht") or 0); tva = float(inv.get("total_tva") or 0)
tx = round((tva / ht * 100), 4) if ht else 0.0
lines = [{"tva_tx": tx, "total_ht": ht, "total_tva": tva}]
for line in lines:
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)
b = bucket(cnty, tx, ht, tva)
agg[b]["ht"] += ht; agg[b]["tva"] += tva; agg[b]["n"] += 1
print(f"{dt} {ref:<14} {sup[:28]:<28} {cnty:<4} {tx:>5.2f} {ht:>10.2f} {tva:>8.2f} {b}")
print("-" * 120)
print()
print("# Aggregated by CA3 bucket:")
for b, s in sorted(agg.items()):
print(f" {b:<45} count={s['n']:>3} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
PY