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>
99 lines
3.9 KiB
Bash
Executable File
99 lines
3.9 KiB
Bash
Executable File
#!/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
|