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