|
|
|
|
@ -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", |
|
|
|
|
|