add dolibarr-payments-state skill for cash receipt tracking

V2 in the dolibarr-* family. Three workflows:

- km-payment-state.sh: per-invoice reconciliation (TTC vs sum of
  payments) with OK / PARTIAL / UNPAID / OVERPAID classification.
  More honest than the `paye` boolean for deferred-cycle agreements.
- km-payment-timeline.sh: all KM payments sorted by date with
  cumulative balance — the foundation for cohort-review deferred
  9-month-cycle tracking (actual cash receipts vs contractual schedule).
- payments-by-month.sh: monthly aggregation, KM-scoped by default
  or --all-clients for accounting basis.

Also updates dolibarr/SKILL.md endpoint catalogue with
/invoices/{id}/payments (note the date-as-string vs unix-epoch quirk)
and /bankaccounts, plus captures the corresponding examples.

V1 baseline of live data: KM is fully reconciled across 5 invoices
(1 avoir + 4 regular), 8160 € total cash receipts spread Feb/Mar/Apr 2026,
all on WISE EURO (BE).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-28 18:52:48 +02:00
parent 00ddf41f5c
commit e7abfd5e22
10 changed files with 844 additions and 1 deletions

View File

@@ -0,0 +1,106 @@
#!/usr/bin/env bash
# Per-invoice payment state for KissMetrics.
#
# For each KM invoice (socid=1): total HT, sum of payments, balance, status.
# Surfaces partials (paye=0 but payments > 0) and unpaid (no payments).
#
# Usage:
# km-payment-state.sh [--since YYYY-MM-DD]
#
# Exit 0 if every KM invoice in the date window is fully reconciled
# (balance == 0). Exit 1 if there's any partial / unpaid.
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
KM_SOCID=1
SINCE=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
-h|--help) sed -n '2,12p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "km-payment-state.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
SINCE_EPOCH=0
if [[ -n "${SINCE}" ]]; then
if SINCE_EPOCH=$(date -j -f "%Y-%m-%d" "${SINCE}" "+%s" 2>/dev/null); then :
else SINCE_EPOCH=$(date -d "${SINCE}" "+%s"); fi
fi
# 1. Pull KM invoices
INV_TMP="$(mktemp -t kmpay.inv.XXXXXX.json)"
trap 'rm -f "${INV_TMP}"' EXIT
"${DOL_CURL}" '/invoices?limit=100&sortfield=t.datef&sortorder=DESC' > "${INV_TMP}"
# 2. For each KM invoice in the window, pull its payments
KM_IDS=$(python3 -c "
import json,sys
rows = json.load(open(sys.argv[1]))
since = int(sys.argv[2])
km_socid = sys.argv[3]
ids = [str(r['id']) for r in rows if str(r.get('socid'))==km_socid and (since==0 or int(r.get('date') or 0)>=since)]
print(' '.join(ids))
" "${INV_TMP}" "${SINCE_EPOCH}" "${KM_SOCID}")
PAY_DIR="$(mktemp -d -t kmpay.XXXXXX)"
trap 'rm -rf "${INV_TMP}" "${PAY_DIR}"' EXIT
for id in ${KM_IDS}; do
"${DOL_CURL}" "/invoices/${id}/payments" > "${PAY_DIR}/${id}.json"
done
# 3. Reconcile in python
python3 - "${INV_TMP}" "${PAY_DIR}" "${SINCE_EPOCH}" "${KM_SOCID}" <<'PY'
import json, sys, os, datetime
inv_path, pay_dir, since, km_socid = sys.argv[1], sys.argv[2], int(sys.argv[3]), sys.argv[4]
invoices = json.load(open(inv_path))
km = [r for r in invoices if str(r.get("socid"))==km_socid]
km = [r for r in km if since==0 or int(r.get("date") or 0)>=since]
km.sort(key=lambda r: int(r.get("date") or 0), reverse=True)
print(f"{'id':>4} {'ref':<24} {'date':<10} {'HT':>10} {'paid':>10} {'balance':>10} {'state':<10} payments")
print("-" * 130)
fails = 0
sum_ht = sum_paid = sum_bal = 0.0
for r in km:
iid = r["id"]
pays = []
p = os.path.join(pay_dir, f"{iid}.json")
if os.path.exists(p):
try: pays = json.load(open(p))
except json.JSONDecodeError: pays = []
paid = sum(float(x.get("amount") or 0) for x in pays)
ht = float(r.get("total_ht") or 0)
ttc = float(r.get("total_ttc") or 0)
# Reconcile against TTC (the contractual payable), not HT.
balance = ttc - paid
if abs(balance) < 0.005:
state = "OK"
elif paid == 0:
state = "UNPAID"
fails += 1
elif abs(paid) < abs(ttc):
state = "PARTIAL"
fails += 1
else:
state = "OVERPAID"
fails += 1
ts = int(r.get("date") or 0)
dt = datetime.datetime.fromtimestamp(ts).strftime("%Y-%m-%d") if ts else "-"
pay_summary = "; ".join(
f"{x['amount']}@{x['date'].split(' ')[0]}({x.get('type','?')})"
for x in pays
) or "(none)"
print(f"{iid:>4} {r['ref']:<24} {dt:<10} {ht:>10.2f} {paid:>10.2f} {balance:>10.2f} {state:<10} {pay_summary}")
sum_ht += ht
sum_paid += paid
sum_bal += balance
print("-" * 130)
print(f"{'TOTAL':>40} {sum_ht:>10.2f} {sum_paid:>10.2f} {sum_bal:>10.2f}")
print(f"# {len(km)} invoice(s), {fails} not fully reconciled")
sys.exit(0 if fails == 0 else 1)
PY

View File

@@ -0,0 +1,94 @@
#!/usr/bin/env bash
# Timeline of all payments against KissMetrics invoices, sorted by date.
#
# Usage:
# km-payment-timeline.sh [--year YYYY] [--since YYYY-MM-DD] [--until YYYY-MM-DD]
#
# Shows, for each KM payment: date, invoice ref, amount, type, bank account,
# and a running cumulative balance. Useful for the deferred-cycle tracking
# (compare actual cash receipts against the contractual schedule).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
KM_SOCID=1
SINCE=""
UNTIL=""
while [[ $# -gt 0 ]]; do
case "$1" in
--since) SINCE="$2"; shift 2 ;;
--until) UNTIL="$2"; shift 2 ;;
--year) SINCE="$2-01-01"; UNTIL="$2-12-31"; shift 2 ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "km-payment-timeline.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t kmtimeline.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/invoices?limit=100&sortfield=t.datef&sortorder=DESC' > "${WORK}/inv.json"
"${DOL_CURL}" '/bankaccounts' > "${WORK}/banks.json"
# Pull per-invoice payments only for KM invoices
KM_IDS=$(python3 -c "
import json, sys
d = json.load(open(sys.argv[1]))
print(' '.join(str(r['id']) for r in d if str(r.get('socid'))==sys.argv[2]))
" "${WORK}/inv.json" "${KM_SOCID}")
mkdir -p "${WORK}/pay"
for id in ${KM_IDS}; do
"${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/pay/${id}.json"
done
python3 - "${WORK}" "${SINCE}" "${UNTIL}" "${KM_SOCID}" <<'PY'
import json, sys, os, datetime
work, since, until, km_socid = sys.argv[1:5]
invoices = {str(r["id"]): r for r in json.load(open(os.path.join(work,"inv.json")))}
banks = {str(b["id"]): b for b in json.load(open(os.path.join(work,"banks.json")))}
def parse_iso(s):
return datetime.datetime.strptime(s, "%Y-%m-%d %H:%M:%S")
def in_window(dt):
if since and dt.date() < datetime.date.fromisoformat(since): return False
if until and dt.date() > datetime.date.fromisoformat(until): return False
return True
rows = []
for fn in os.listdir(os.path.join(work,"pay")):
iid = fn[:-len(".json")]
inv = invoices.get(iid)
if not inv or str(inv.get("socid")) != km_socid: continue
try: pays = json.load(open(os.path.join(work,"pay",fn)))
except json.JSONDecodeError: continue
for p in pays:
dt = parse_iso(p["date"])
if not in_window(dt): continue
rows.append((dt, inv["ref"], float(p.get("amount") or 0), p.get("type",""), p.get("ref",""), p.get("fk_bank_line","")))
rows.sort(key=lambda r: r[0])
# We don't have a direct fk_bank_line → fk_account mapping from /bankaccounts
# alone (would need /bankaccounts/{id}/lines). Show fk_bank_line as-is and
# annotate with the IBAN-bearing account when only one account holds receipts.
# Receivable accounts in this instance: QON1, WIS2, CCA1.
print(f"{'date':<10} {'invoice':<22} {'amount':>10} {'type':<6} {'ref':<20} {'bank_line':<10} running")
print("-" * 110)
running = 0.0
for dt, ref, amt, typ, pref, fbl in rows:
running += amt
print(f"{dt.date()} {ref:<22} {amt:>10.2f} {typ:<6} {pref:<20} bl={fbl:<8} {running:>10.2f}")
print("-" * 110)
print(f"# {len(rows)} payment(s), net cash receipts: {running:.2f}")
print()
print("# Bank accounts known to this Dolibarr (label / IBAN-leading):")
for bid, b in sorted(banks.items(), key=lambda kv: int(kv[0])):
iban = b.get("iban") or "-"
label = b.get("label") or b.get("ref") or "-"
print(f"# id={bid} ref={b.get('ref','-'):<6} label={label:<32} country={b.get('country_code','-'):<3} iban={iban[:18]+'...' if len(iban)>18 else iban}")
PY

View File

@@ -0,0 +1,80 @@
#!/usr/bin/env bash
# Monthly cash-receipt aggregation for KissMetrics payments.
#
# Usage:
# payments-by-month.sh [--year YYYY] [--all-clients]
#
# By default only sums KissMetrics payments (socid=1). With --all-clients,
# iterates every visible invoice. Useful as the foundation for the
# cohort-review deferred-cycle dashboard (sum-by-month + count-by-month).
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
DOL_CURL="${SCRIPT_DIR}/../../dolibarr/scripts/dol-curl.sh"
KM_SOCID=1
ALL=0
YEAR=""
while [[ $# -gt 0 ]]; do
case "$1" in
--year) YEAR="$2"; shift 2 ;;
--all-clients) ALL=1; shift ;;
-h|--help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
*) echo "payments-by-month.sh: unknown arg: $1" >&2; exit 2 ;;
esac
done
WORK="$(mktemp -d -t pmonth.XXXXXX)"
trap 'rm -rf "${WORK}"' EXIT
"${DOL_CURL}" '/invoices?limit=100&sortfield=t.datef&sortorder=DESC' > "${WORK}/inv.json"
# Filter invoice ids
if [[ "${ALL}" == "1" ]]; then
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1]))))" "${WORK}/inv.json")
else
IDS=$(python3 -c "import json,sys; print(' '.join(str(r['id']) for r in json.load(open(sys.argv[1])) if str(r.get('socid'))==sys.argv[2]))" "${WORK}/inv.json" "${KM_SOCID}")
fi
mkdir -p "${WORK}/pay"
for id in ${IDS}; do
"${DOL_CURL}" "/invoices/${id}/payments" > "${WORK}/pay/${id}.json"
done
python3 - "${WORK}" "${YEAR}" "${ALL}" "${KM_SOCID}" <<'PY'
import json, sys, os, collections, datetime
work, year, all_clients, km_socid = sys.argv[1], sys.argv[2], sys.argv[3]=="1", sys.argv[4]
invoices = {str(r["id"]): r for r in json.load(open(os.path.join(work,"inv.json")))}
monthly = collections.defaultdict(lambda: {"count":0, "amount":0.0})
for fn in sorted(os.listdir(os.path.join(work,"pay"))):
iid = fn[:-len(".json")]
inv = invoices.get(iid)
if not inv: continue
if not all_clients and str(inv.get("socid")) != km_socid: continue
try: pays = json.load(open(os.path.join(work,"pay",fn)))
except json.JSONDecodeError: continue
for p in pays:
d = datetime.datetime.strptime(p["date"], "%Y-%m-%d %H:%M:%S").date()
if year and d.strftime("%Y") != year: continue
key = d.strftime("%Y-%m")
monthly[key]["count"] += 1
monthly[key]["amount"] += float(p.get("amount") or 0)
scope = "all clients" if all_clients else f"KissMetrics (socid={km_socid})"
print(f"# Monthly cash receipts — scope: {scope}" + (f" — year {year}" if year else ""))
print()
print(f"{'month':<8} {'count':>5} {'amount':>12} cumulative")
print("-" * 50)
cum = 0.0
for key in sorted(monthly):
s = monthly[key]
cum += s["amount"]
print(f"{key:<8} {s['count']:>5} {s['amount']:>12.2f} {cum:>10.2f}")
print("-" * 50)
total_count = sum(v["count"] for v in monthly.values())
total_amount = sum(v["amount"] for v in monthly.values())
print(f"{'TOTAL':<8} {total_count:>5} {total_amount:>12.2f}")
PY