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