#!/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