From db21e44f8c2270ad2c099746ee4a41354865ed3d Mon Sep 17 00:00:00 2001 From: MytelligentPRV Date: Sat, 20 Jun 2026 17:09:48 -0700 Subject: [PATCH] fix(billing): invoice credits_used reads the authoritative ledger, not drifted counters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Billing surfaces didn't reconcile on a real account: balance deducted 861 since restore (correct), but /billing/invoice showed credits_used=416 for the whole month — impossible if both count the same credits. Root cause: split-brain accounting. The credit balance is maintained atomically by check_and_deduct_credits (user_credits.credits_balance ⇄ credit_transactions) — the authoritative ledger, internally consistent (72 debits = 861 = exact balance delta, zero phantom deductions). But /billing/invoice read api_keys.monthly_calls_count (current month) / COUNT(*) of tx (past months) — neither authoritative: * monthly_calls_count is incremented by _increment_calls in a SEPARATE, non-atomic, post-call path. It drifts low: /search debits carry NULL api_key_id so they never increment it (36 cr missed), the LLM path increments by 1 instead of credit cost, and even the /execute portion read 416 vs an 825 ledger sum. * COUNT(*) is a call count, not a credit sum. Invoice now sums -amount from credit_transactions over the period (both current and past months), tying credits_used out to the balance delta exactly (June: 861). NOTE (follow-up, not in this commit): compute_calls_remaining() derives quota from the same drifted monthly_calls_count, so quota/remaining over-states by the drift (~445 cr here) and under-charges. That counter should be made atomic with the ledger (or remaining derived from user_credits.credits_balance) before real money flows. /billing/balance is already correct (reads user_credits directly). Co-Authored-By: Claude Opus 4.8 (1M context) --- apps/api/routers/billing/account.py | 39 +++++++++++++++-------------- 1 file changed, 20 insertions(+), 19 deletions(-) diff --git a/apps/api/routers/billing/account.py b/apps/api/routers/billing/account.py index 29a56c3..448ec23 100644 --- a/apps/api/routers/billing/account.py +++ b/apps/api/routers/billing/account.py @@ -1037,25 +1037,26 @@ async def get_invoice(year: int, month: int, request: Request, db=Depends(get_db now_utc = datetime.now(timezone.utc) is_current_month = (year == now_utc.year and month == now_utc.month) - if is_current_month: - # Current month: monthly_calls_count on api_keys is always authoritative - key_usage = await db.fetchrow( - "SELECT monthly_calls_count FROM api_keys WHERE id = $1", key_record["id"] - ) - calls_used = int((key_usage["monthly_calls_count"] or 0) if key_usage else 0) - if calls_used == 0: - raise HTTPException(status_code=404, detail="no_activity_in_period") - else: - # Past month: count from credit_transactions (execution + cross_rail) - row = await db.fetchrow(""" - SELECT COUNT(*) AS tx_count - FROM credit_transactions - WHERE user_id = $1 AND type IN ('execution', 'cross_rail') - AND created_at >= $2 AND created_at < $3 - """, key_record["user_id"], period_start, period_end) - calls_used = int((row["tx_count"] or 0) if row else 0) - if calls_used == 0: - raise HTTPException(status_code=404, detail="no_activity_in_period") + # Credits used = the AUTHORITATIVE ledger (user_credits ⇄ credit_transactions), + # summed over the period. Previously this read api_keys.monthly_calls_count for + # the current month and COUNT(*) of transactions for past months — two non- + # authoritative figures: monthly_calls_count is a denormalized per-key counter + # maintained by a SEPARATE non-atomic path (_increment_calls) that drifts low + # (it never counts /search debits — those carry NULL api_key_id — and the LLM + # path under-increments), and COUNT(*) is a call count, not credits. Both + # under-reported vs the real spend (e.g. June: 416/72 shown vs 861 actually + # deducted). Summing -amount from the immutable ledger ties the invoice out to + # the user_credits balance delta exactly — the only number real money may use. + row = await db.fetchrow(""" + SELECT COALESCE(SUM(-amount), 0) AS credits_used + FROM credit_transactions + WHERE user_id = $1 AND amount < 0 + AND type IN ('execution', 'cross_rail', 'cloud_compute') + AND created_at >= $2 AND created_at < $3 + """, key_record["user_id"], period_start, period_end) + calls_used = int((row["credits_used"] or 0) if row else 0) + if calls_used == 0: + raise HTTPException(status_code=404, detail="no_activity_in_period") tier = key_record["tier"] or "free" plan_def = PLANS.get(tier, PLANS["free"])