Files
erp/.claude/skills/dolibarr-tva-summary/scripts/tva-summary.sh
Gabriel Radureau 1c0ba8ea75 add bin/arcodange CLI and dolibarr-tva-summary skill
Two changes that go together: now operators can run every read-only
workflow without going through Claude. The skills (SKILL.md files)
remain the source of behaviour documentation and Claude triggers;
bin/arcodange is the human-facing entry point.

bin/arcodange:
- Bash dispatcher at the project root. Subcommands per domain:
  tva {collect, collect-detail, deductible, deductible-detail, summary},
  invoice {list, audit}, thirdparty {audit, audit-all},
  payments {state, timeline, by-month},
  templates {list, inspect},
  snapshot, whoami, ping, curl, help.
- Locates the project root via `git rev-parse` so it works from any
  CWD (including from a worktree).
- Per-subcommand `help` text. Unknown commands exit 2 with a hint.
- Reuses the existing per-skill scripts under .claude/skills/<name>/
  scripts/ via `exec` (zero behaviour drift, full credit to the
  existing tested code).

dolibarr-tva-summary:
- Composes dolibarr-tva-reconciliation (TVA collectée customer-side)
  and dolibarr-tva-deductible (TVA déductible supplier-side) into a
  single CA3-ready monthly summary with per-month net verdict
  (TVA à reverser / crédit de TVA / équilibre) and a cumulative line.
- Live baseline: Arcodange en crédit de TVA de 223.22 € cumulé
  (0 € collectée 259-1° CGI vs 223.22 € déductible).
- Exposed as `arcodange tva summary [--year|--since|--until]`.

Each existing skill's SKILL.md gets a one-line "CLI shortcut" near
the top so the human path is discoverable from any skill page.
The project root README.md gets a CLI section as the primary
operator entry point.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-29 11:30:18 +02:00

184 lines
7.7 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env bash
# CA3-ready monthly TVA summary for Arcodange — collectée déductible = net.
#
# Usage:
# tva-summary.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Composes:
# - customer-side invoice lines (TVA collectée, CA3 A1 / A4 / E2)
# - supplier-side invoice lines (TVA déductible, CA3 19 / 20 / 17+24)
# Outputs one table per month plus a cumulative net line.
#
# Net interpretation:
# - net > 0 → TVA à reverser à l'État
# - net < 0 → crédit de TVA, demande de remboursement / report
# - net = 0 → équilibre
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 "tva-summary.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t tvasum.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
# 1. Customer side (invoices)
"${DOL_CURL}" '/invoices?limit=500&sortfield=t.datef&sortorder=ASC' > "${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}" "/invoices/${id}" > "${WORK}/inv/${id}.json"; done
for s in ${SOCIDS}; do "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"; done
# 2. Supplier side (supplier invoices)
"${DOL_CURL}" '/supplierinvoices?limit=500' > "${WORK}/sinv.json"
SIDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if r.get('id')))" "${WORK}/sinv.json")
SSOCIDS=$(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}/sinv.json")
mkdir -p "${WORK}/sinv"
for id in ${SIDS}; do "${DOL_CURL}" "/supplierinvoices/${id}" > "${WORK}/sinv/${id}.json"; done
for s in ${SSOCIDS}; do
[[ -f "${WORK}/tp/${s}.json" ]] || "${DOL_CURL}" "/thirdparties/${s}" > "${WORK}/tp/${s}.json"
done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${YEAR}" <<'PY'
import json, sys, os, collections, datetime
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())
# Thirdparty country map
tp_cnty = {}
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_cnty[fn[:-len(".json")]] = d.get("country_code") or ""
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
# Customer-side buckets
def bucket_collected(country, tx):
if tx and tx > 0:
return f"A1+8 ({tx}% collectée)"
if country == "FR": return "A1 0% (FR atypique)"
if country in EU: return "A4 (autoliquidation intra-UE)"
if country: return "E2 (export hors UE)"
return "(country missing)"
# Supplier-side buckets
def bucket_deductible(country, tx):
if tx and tx > 0: return f"ligne 19/20 ({tx}% déductible)"
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)"
# Aggregate per month
months = collections.defaultdict(lambda: {
"collected": {"ht": 0.0, "tva": 0.0, "by_bucket": collections.defaultdict(lambda: {"ht":0.0,"tva":0.0})},
"deductible":{"ht": 0.0, "tva": 0.0, "by_bucket": collections.defaultdict(lambda: {"ht":0.0,"tva":0.0})},
})
# Customer side
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
ym = datetime.date.fromtimestamp(ts).strftime("%Y-%m")
sid = str(inv.get("socid") or "")
cnty = tp_cnty.get(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)
b = bucket_collected(cnty, tx)
months[ym]["collected"]["ht"] += ht
months[ym]["collected"]["tva"] += tva
months[ym]["collected"]["by_bucket"][b]["ht"] += ht
months[ym]["collected"]["by_bucket"][b]["tva"] += tva
# Supplier side
for fn in sorted(os.listdir(os.path.join(work, "sinv"))):
try: inv = json.load(open(os.path.join(work, "sinv", 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")
sid = str(inv.get("socid") or "")
cnty = tp_cnty.get(sid, "")
lines = inv.get("lines") or []
if not lines:
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_deductible(cnty, tx)
months[ym]["deductible"]["ht"] += ht
months[ym]["deductible"]["tva"] += tva
months[ym]["deductible"]["by_bucket"][b]["ht"] += ht
months[ym]["deductible"]["by_bucket"][b]["tva"] += tva
scope = f"window={since or '-inf'} → {until or '+inf'}"
if year_arg: scope = f"year {year_arg}"
print(f"# Arcodange TVA monthly summary — {scope}")
print(f"# (composition of dolibarr-tva-reconciliation + dolibarr-tva-deductible)")
print()
cum_collected = cum_deductible = 0.0
for ym in sorted(months):
m = months[ym]
col_tva = m["collected"]["tva"]
ded_tva = m["deductible"]["tva"]
net = col_tva - ded_tva
cum_collected += col_tva
cum_deductible += ded_tva
print(f"=== {ym} ===")
print(f" Customer side (TVA collectée) basis HT={m['collected']['ht']:>10.2f} TVA={col_tva:>8.2f}")
for b, s in sorted(m["collected"]["by_bucket"].items()):
print(f" {b:<42} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
print(f" Supplier side (TVA déductible) basis HT={m['deductible']['ht']:>10.2f} TVA={ded_tva:>8.2f}")
for b, s in sorted(m["deductible"]["by_bucket"].items()):
print(f" {b:<42} HT={s['ht']:>10.2f} TVA={s['tva']:>8.2f}")
if abs(net) < 0.005:
verdict = "(équilibre)"
elif net > 0:
verdict = f"→ TVA À REVERSER : {net:.2f} €"
else:
verdict = f"→ CRÉDIT DE TVA : {-net:.2f} €"
print(f" --- Net du mois : collectée déductible = {col_tva:.2f} {ded_tva:.2f} = {net:>8.2f} {verdict}")
print()
cum_net = cum_collected - cum_deductible
print(f"=== CUMUL {scope} ===")
print(f" TVA collectée totale : {cum_collected:>10.2f}")
print(f" TVA déductible totale : {cum_deductible:>10.2f}")
print(f" Net cumulé : {cum_net:>10.2f}", end="")
if abs(cum_net) < 0.005:
print(" (équilibre)")
elif cum_net > 0:
print(f" → TVA À REVERSER cumulée : {cum_net:.2f} €")
else:
print(f" → CRÉDIT DE TVA cumulé : {-cum_net:.2f} €")
PY