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:
106
.claude/skills/dolibarr-payments-state/scripts/km-payment-state.sh
Executable file
106
.claude/skills/dolibarr-payments-state/scripts/km-payment-state.sh
Executable 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
|
||||
Reference in New Issue
Block a user