From 2626e513a4fe9a7d3abb4976f8ee826838300f73 Mon Sep 17 00:00:00 2001 From: def Date: Fri, 29 May 2026 01:26:13 +0000 Subject: [PATCH] Bump to v2.0.6 health revenue dashboard --- PROJECT_STATE.md | 10 ++ README.md | 10 ++ VERSION | 2 +- backend/health.py | 247 ++++++++++++++++++++++++++++++++++++++++++ templates/health.html | 53 ++++++++- 5 files changed, 319 insertions(+), 3 deletions(-) diff --git a/PROJECT_STATE.md b/PROJECT_STATE.md index 7286b2c..bd3d0f2 100644 --- a/PROJECT_STATE.md +++ b/PROJECT_STATE.md @@ -1,3 +1,13 @@ +## v2.0.6 health revenue dashboard - 2026-05-29 UTC + +- Added Square / Revenue Health panel to /health. +- Added confirmed payment totals for today, this month, and this year. +- Added all-confirmed payment totals alongside Square-only totals. +- Added latest confirmed payment summary. +- Added Receivables Aging panel showing unpaid invoice totals and aging buckets. +- Added matching revenue and receivables data to /health.json. +- Cleaned health labels from Operations Bal / Treasury Bal to Operations Balance / Treasury Balance. + ## v2.0.5 final invoice workflow - 2026-05-29 UTC - Create Invoice now supports a real invoice line description, quantity, unit cost, and optional 13% HST checkbox. diff --git a/README.md b/README.md index 68ce9fc..6f8c758 100644 --- a/README.md +++ b/README.md @@ -1,3 +1,13 @@ +## v2.0.6 health revenue dashboard - 2026-05-29 UTC + +- Added Square / Revenue Health panel to /health. +- Added confirmed payment totals for today, this month, and this year. +- Added all-confirmed payment totals alongside Square-only totals. +- Added latest confirmed payment summary. +- Added Receivables Aging panel showing unpaid invoice totals and aging buckets. +- Added matching revenue and receivables data to /health.json. +- Cleaned health labels from Operations Bal / Treasury Bal to Operations Balance / Treasury Balance. + ## v2.0.5 final invoice workflow - 2026-05-29 UTC - Create Invoice now supports a real invoice line description, quantity, unit cost, and optional 13% HST checkbox. diff --git a/VERSION b/VERSION index c2f6de9..cea0e15 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -v2.0.5 +v2.0.6 diff --git a/backend/health.py b/backend/health.py index 87cf477..7269056 100644 --- a/backend/health.py +++ b/backend/health.py @@ -562,10 +562,255 @@ def _crypto_reconcile_status(app): } + +# OTB_HEALTH_REVENUE_HELPERS_V0_6_0 +def _health_db_rows(app, query, params=None): + """Run a small health/reporting SQL query and return dict rows.""" + connector = app.config.get("OTB_HEALTH_DB_CONNECTOR") + if not connector: + return [] + + conn = None + cur = None + try: + conn = connector() + try: + cur = conn.cursor(dictionary=True) + except TypeError: + cur = conn.cursor() + + cur.execute(query, params or ()) + rows = cur.fetchall() or [] + + if rows and not isinstance(rows[0], dict): + cols = [d[0] for d in (cur.description or [])] + rows = [dict(zip(cols, r)) for r in rows] + + return rows + finally: + try: + if cur: + cur.close() + except Exception: + pass + try: + if conn: + conn.close() + except Exception: + pass + + +def _money_cad(value): + try: + return "${:,.2f} CAD".format(float(value or 0)) + except Exception: + return "$0.00 CAD" + + +def _health_period_bounds(): + """Return Toronto business-day/month/year bounds converted to UTC-naive DB timestamps.""" + from datetime import datetime, timezone + try: + from zoneinfo import ZoneInfo + tz = ZoneInfo("America/Toronto") + except Exception: + tz = timezone.utc + + now_local = datetime.now(tz) + day_start = now_local.replace(hour=0, minute=0, second=0, microsecond=0) + month_start = day_start.replace(day=1) + year_start = day_start.replace(month=1, day=1) + + if month_start.month == 12: + next_month = month_start.replace(year=month_start.year + 1, month=1) + else: + next_month = month_start.replace(month=month_start.month + 1) + + next_year = year_start.replace(year=year_start.year + 1) + next_day = day_start.replace(day=day_start.day) # placeholder before timedelta import + + from datetime import timedelta + next_day = day_start + timedelta(days=1) + + def utc_naive(dt): + return dt.astimezone(timezone.utc).replace(tzinfo=None).strftime("%Y-%m-%d %H:%M:%S") + + return { + "today": (utc_naive(day_start), utc_naive(next_day)), + "month": (utc_naive(month_start), utc_naive(next_month)), + "year": (utc_naive(year_start), utc_naive(next_year)), + } + + +def _payment_period_summary(app, start_utc, end_utc, extra_where="", extra_params=None): + value_expr = "COALESCE(received_amount_cad, cad_value_at_payment, expected_amount_cad, payment_amount, 0)" + query = f""" + SELECT + COUNT(*) AS payment_count, + COALESCE(SUM({value_expr}), 0) AS cad_total + FROM payments + WHERE payment_status = 'confirmed' + AND COALESCE(received_at, updated_at, created_at) >= %s + AND COALESCE(received_at, updated_at, created_at) < %s + {extra_where} + """ + params = [start_utc, end_utc] + if extra_params: + params.extend(extra_params) + + rows = _health_db_rows(app, query, params) + row = rows[0] if rows else {} + return { + "count": int(row.get("payment_count") or 0), + "cad_total": float(row.get("cad_total") or 0), + "cad_total_display": _money_cad(row.get("cad_total") or 0), + } + + +def _revenue_health(app): + """Revenue/confirmed payment summary for /health.""" + try: + bounds = _health_period_bounds() + + square_where = "AND payment_method = 'square'" + crypto_where = "AND UPPER(COALESCE(payment_currency,'')) IN ('USDC','ETH','ETHO','EGAZ','ETI','ALT','ETHO','CAD')" + + out = { + "available": True, + "square": {}, + "all_confirmed": {}, + "crypto": {}, + } + + for key, (start_utc, end_utc) in bounds.items(): + out["square"][key] = _payment_period_summary(app, start_utc, end_utc, square_where) + out["all_confirmed"][key] = _payment_period_summary(app, start_utc, end_utc) + out["crypto"][key] = _payment_period_summary(app, start_utc, end_utc, crypto_where) + + recent = _health_db_rows(app, """ + SELECT + id, + invoice_id, + payment_method, + payment_currency, + COALESCE(received_amount_cad, cad_value_at_payment, expected_amount_cad, payment_amount, 0) AS cad_total, + COALESCE(received_at, updated_at, created_at) AS payment_time + FROM payments + WHERE payment_status = 'confirmed' + ORDER BY COALESCE(received_at, updated_at, created_at) DESC, id DESC + LIMIT 1 + """) + if recent: + r = recent[0] + out["last_payment"] = { + "id": r.get("id"), + "invoice_id": r.get("invoice_id"), + "method": r.get("payment_method") or "unknown", + "currency": r.get("payment_currency") or "", + "cad_total": float(r.get("cad_total") or 0), + "cad_total_display": _money_cad(r.get("cad_total") or 0), + "payment_time": str(r.get("payment_time") or ""), + } + else: + out["last_payment"] = None + + return out + except Exception as exc: + return { + "available": False, + "error": str(exc), + } + + +def _receivables_aging(app): + """Accounts receivable aging from unpaid invoice balances.""" + try: + rows = _health_db_rows(app, """ + SELECT + COUNT(*) AS invoice_count, + COALESCE(SUM(GREATEST(total_amount - amount_paid, 0)), 0) AS total_unpaid, + + COALESCE(SUM(CASE + WHEN due_at IS NULL OR due_at >= UTC_TIMESTAMP() + THEN GREATEST(total_amount - amount_paid, 0) ELSE 0 END), 0) AS current_total, + SUM(CASE + WHEN due_at IS NULL OR due_at >= UTC_TIMESTAMP() + THEN 1 ELSE 0 END) AS current_count, + + COALESCE(SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 1 AND 30 + THEN GREATEST(total_amount - amount_paid, 0) ELSE 0 END), 0) AS d1_30_total, + SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 1 AND 30 + THEN 1 ELSE 0 END) AS d1_30_count, + + COALESCE(SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 31 AND 60 + THEN GREATEST(total_amount - amount_paid, 0) ELSE 0 END), 0) AS d31_60_total, + SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 31 AND 60 + THEN 1 ELSE 0 END) AS d31_60_count, + + COALESCE(SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 61 AND 90 + THEN GREATEST(total_amount - amount_paid, 0) ELSE 0 END), 0) AS d61_90_total, + SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) BETWEEN 61 AND 90 + THEN 1 ELSE 0 END) AS d61_90_count, + + COALESCE(SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) > 90 + THEN GREATEST(total_amount - amount_paid, 0) ELSE 0 END), 0) AS d90_plus_total, + SUM(CASE + WHEN due_at < UTC_TIMESTAMP() + AND DATEDIFF(UTC_TIMESTAMP(), due_at) > 90 + THEN 1 ELSE 0 END) AS d90_plus_count + + FROM invoices + WHERE status NOT IN ('paid','cancelled','draft') + AND GREATEST(total_amount - amount_paid, 0) > 0 + """) + + row = rows[0] if rows else {} + + def bucket(prefix): + return { + "count": int(row.get(prefix + "_count") or 0), + "cad_total": float(row.get(prefix + "_total") or 0), + "cad_total_display": _money_cad(row.get(prefix + "_total") or 0), + } + + return { + "available": True, + "invoice_count": int(row.get("invoice_count") or 0), + "total_unpaid": float(row.get("total_unpaid") or 0), + "total_unpaid_display": _money_cad(row.get("total_unpaid") or 0), + "current": bucket("current"), + "d1_30": bucket("d1_30"), + "d31_60": bucket("d31_60"), + "d61_90": bucket("d61_90"), + "d90_plus": bucket("d90_plus"), + } + except Exception as exc: + return { + "available": False, + "error": str(exc), + } + + def register_health_routes(app): @app.route("/health", methods=["GET"]) def health_page(): health, http_code = _health_payload(app) + health["revenue_health"] = _revenue_health(app) + health["receivables_aging"] = _receivables_aging(app) health["operations_balances"] = _wallet_balances( os.environ.get("OTB_OPERATIONS_WALLET", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5"), "Operations Bal", @@ -612,6 +857,8 @@ def register_health_routes(app): @app.route("/health.json", methods=["GET"]) def health_json(): health, http_code = _health_payload(app) + health["revenue_health"] = _revenue_health(app) + health["receivables_aging"] = _receivables_aging(app) health["operations_balances"] = _wallet_balances( os.environ.get("OTB_OPERATIONS_WALLET", "0x44f6c44C42e6ae0392E7289F032384C0d37F56D5"), "Operations Bal", diff --git a/templates/health.html b/templates/health.html index 00a7b3e..7127c06 100644 --- a/templates/health.html +++ b/templates/health.html @@ -207,8 +207,57 @@

Used %: {{ health.disk_root.used_percent }}%

+ + +
+

Square / Revenue Health

+ {% if health.revenue_health and health.revenue_health.available %} +

Square confirmed payments

+

Today: {{ health.revenue_health.square.today.count }} payment{% if health.revenue_health.square.today.count != 1 %}s{% endif %} | {{ health.revenue_health.square.today.cad_total_display }}

+

This Month: {{ health.revenue_health.square.month.count }} payment{% if health.revenue_health.square.month.count != 1 %}s{% endif %} | {{ health.revenue_health.square.month.cad_total_display }}

+

This Year: {{ health.revenue_health.square.year.count }} payment{% if health.revenue_health.square.year.count != 1 %}s{% endif %} | {{ health.revenue_health.square.year.cad_total_display }}

+ +
+ +

All confirmed payments

+

Today: {{ health.revenue_health.all_confirmed.today.count }} payment{% if health.revenue_health.all_confirmed.today.count != 1 %}s{% endif %} | {{ health.revenue_health.all_confirmed.today.cad_total_display }}

+

This Month: {{ health.revenue_health.all_confirmed.month.count }} payment{% if health.revenue_health.all_confirmed.month.count != 1 %}s{% endif %} | {{ health.revenue_health.all_confirmed.month.cad_total_display }}

+

This Year: {{ health.revenue_health.all_confirmed.year.count }} payment{% if health.revenue_health.all_confirmed.year.count != 1 %}s{% endif %} | {{ health.revenue_health.all_confirmed.year.cad_total_display }}

+ + {% if health.revenue_health.last_payment %} +
+

Last Payment: {{ health.revenue_health.last_payment.cad_total_display }} {{ health.revenue_health.last_payment.currency }}

+

When: {{ health.revenue_health.last_payment.payment_time }}

+ {% endif %} + {% elif health.revenue_health %} +

Unavailable

+

Error: {{ health.revenue_health.error }}

+ {% else %} +

Unavailable

+ {% endif %} +
+ +
+

Receivables Aging

+ {% if health.receivables_aging and health.receivables_aging.available %} +

Total Unpaid: {{ health.receivables_aging.total_unpaid_display }}

+

Open Invoices: {{ health.receivables_aging.invoice_count }}

+
+

Current: {{ health.receivables_aging.current.count }} invoice{% if health.receivables_aging.current.count != 1 %}s{% endif %} | {{ health.receivables_aging.current.cad_total_display }}

+

1-30 Days: {{ health.receivables_aging.d1_30.count }} invoice{% if health.receivables_aging.d1_30.count != 1 %}s{% endif %} | {{ health.receivables_aging.d1_30.cad_total_display }}

+

31-60 Days: {{ health.receivables_aging.d31_60.count }} invoice{% if health.receivables_aging.d31_60.count != 1 %}s{% endif %} | {{ health.receivables_aging.d31_60.cad_total_display }}

+

61-90 Days: {{ health.receivables_aging.d61_90.count }} invoice{% if health.receivables_aging.d61_90.count != 1 %}s{% endif %} | {{ health.receivables_aging.d61_90.cad_total_display }}

+

90+ Days: {{ health.receivables_aging.d90_plus.count }} invoice{% if health.receivables_aging.d90_plus.count != 1 %}s{% endif %} | {{ health.receivables_aging.d90_plus.cad_total_display }}

+ {% elif health.receivables_aging %} +

Unavailable

+

Error: {{ health.receivables_aging.error }}

+ {% else %} +

Unavailable

+ {% endif %} +
+
-

Operations Bal

+

Operations Balance

{% if health.operations_balances %} {% for asset in health.operations_balances.assets %}

@@ -227,7 +276,7 @@

-

Treasury Bal

+

Treasury Balance

{% if health.treasury_balances %} {% for asset in health.treasury_balances.assets %}