diff --git a/apps/api/core/credits.py b/apps/api/core/credits.py index 442a930..b9dcd39 100644 --- a/apps/api/core/credits.py +++ b/apps/api/core/credits.py @@ -956,21 +956,48 @@ async def _monthly_topup_reset(): for _rk in reset_keys: _p = PLANS.get(_rk["tier"], PLANS["free"]) _monthly = _p["monthly_credits"] - await db.execute(""" - UPDATE user_credits - SET credits_balance = GREATEST(credits_balance, $1), + # Capture the pioneer pool BEFORE zeroing so the reset leaves a + # ledger trail — the pool must never drop to 0 without a + # credit_transactions row (previously this was a silent UPDATE). + # RETURNING fires only when the month-gate actually matches, so a + # no-op reset writes no forfeiture row and zeroes no counters. + _reset_row = await db.fetchrow(""" + WITH before AS ( + SELECT pioneer_credits_balance AS old_pioneer + FROM user_credits WHERE user_id = $2::uuid + ) + UPDATE user_credits uc + SET credits_balance = GREATEST(uc.credits_balance, $1), pioneer_credits_balance = 0, last_credited_at = NOW(), updated_at = NOW() - WHERE user_id = $2::uuid + FROM before + WHERE uc.user_id = $2::uuid AND ( - last_credited_at IS NULL - OR date_trunc('month', last_credited_at AT TIME ZONE 'UTC') + uc.last_credited_at IS NULL + OR date_trunc('month', uc.last_credited_at AT TIME ZONE 'UTC') < date_trunc('month', NOW() AT TIME ZONE 'UTC') ) + RETURNING before.old_pioneer """, _monthly, _rk["user_id"]) + if _reset_row is None: + continue # gate didn't match (already credited this month) + _forfeited = int(_reset_row["old_pioneer"] or 0) + if _forfeited > 0: + # Ledger trail for the forfeited pioneer overflow. balance_after + # is the pioneer pool after the reset (0), matching how + # pioneer_drip rows record that pool. + await db.execute(""" + INSERT INTO credit_transactions + (user_id, amount, balance_after, type, description, api_endpoint) + VALUES ($1::uuid, $2, 0, 'pioneer_reset', $3, '/account/pioneer/reset') + """, _rk["user_id"], -_forfeited, + f"pioneer cycle reset: {_forfeited} unused pioneer credits " + f"forfeited at subscription renewal") # Zero per-cycle pioneer drip counters so the dashboard shows # "earned this cycle" from day 1 of the new subscription month. + # (These columns are now display-vestigial — pioneer_status + # derives the figures from the ledger — but keep them clean.) # Lifetime enrollment days are derived at query time from # pioneer_opted_in_at and are never reset. await db.execute(""" diff --git a/apps/api/routers/billing/account.py b/apps/api/routers/billing/account.py index 448ec23..763e2d7 100644 --- a/apps/api/routers/billing/account.py +++ b/apps/api/routers/billing/account.py @@ -1335,6 +1335,7 @@ async def pioneer_status(request: Request, db=Depends(get_db)): pioneer_calls_made = 0 pioneer_calls_this_month = 0 credits_earned_this_month = 0 + drip_days_this_cycle = 0 try: pioneer_calls_made = await db.fetchval(""" SELECT COUNT(DISTINCT sa.id) @@ -1365,8 +1366,23 @@ async def pioneer_status(request: Request, db=Depends(get_db)): AND type = 'pioneer_drip' AND created_at >= date_trunc('month', NOW()) """, user_id) or 0 + + # Distinct Pacific calendar days dripped this cycle — DERIVED from the + # immutable pioneer_drip ledger, not the denormalized + # users.pioneer_drip_days_this_cycle column. The denormalized column can + # orphan (it survived a full-account reset that wiped the pool + ledger, + # leaving "15,600 / 13 days" with zero backing rows). Deriving from the + # ledger means the figure can never disagree with the credits actually + # dripped again. + drip_days_this_cycle = await db.fetchval(""" + SELECT COUNT(DISTINCT (created_at AT TIME ZONE 'America/Los_Angeles')::date) + FROM credit_transactions + WHERE user_id = $1::uuid + AND type = 'pioneer_drip' + AND created_at >= date_trunc('month', NOW()) + """, user_id) or 0 except Exception: - pass # non-critical: credits_earned_this_month display falls back to 0 + pass # non-critical: pioneer drip displays fall back to 0 now = datetime.now(timezone.utc) tier = await _resolve_pioneer_tier(db, user_id) @@ -1402,8 +1418,10 @@ async def pioneer_status(request: Request, db=Depends(get_db)): "credits_earned_this_month": int(credits_earned_this_month), # backward compat alias # Task 10 display fields "days_enrolled": int((now - user["pioneer_opted_in_at"]).days) if user["pioneer_opted_in_at"] else 0, - "drip_credits_this_cycle": int(user["pioneer_drip_credits_this_cycle"] or 0), - "drip_days_this_cycle": int(user["pioneer_drip_days_this_cycle"] or 0), + # Derived from the pioneer_drip ledger (same source as credits_earned_this_month), + # NOT the denormalized users.pioneer_drip_* columns which can orphan. + "drip_credits_this_cycle": int(credits_earned_this_month), + "drip_days_this_cycle": int(drip_days_this_cycle), "daily_drip_rate": _PIONEER_DAILY_CREDITS.get(tier, 0), "rollover_note": "Credits reset with your monthly subscription. Unused credits do not carry over.", # Searches that were in the 60% boosted bucket AND had a boosted provider