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:
108
.claude/skills/dolibarr-tva-deductible/scripts/deductible-line-detail.sh
Executable file
108
.claude/skills/dolibarr-tva-deductible/scripts/deductible-line-detail.sh
Executable 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
|
||||
Reference in New Issue
Block a user