Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
39 changes: 33 additions & 6 deletions apps/api/core/credits.py
Original file line number Diff line number Diff line change
Expand Up @@ -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("""
Expand Down
24 changes: 21 additions & 3 deletions apps/api/routers/billing/account.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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
Expand Down
Loading