Skip to content

Commit b0a73b6

Browse files
authored
Freebucks: stop metered sessions eating bounty grants, and stop calling it a cash-out (#2942)
## What a user reported > It says cashed out but I didn't even do it, there's no mean to do it in my end. Also the wallet thing is not here anymore. They were right on both counts. Their Earn page said **Cashed out 30**, they had never pressed anything, and their wallet was empty. ## What was actually happening Two defects, and the second was hiding the first. **1. A metered session was charged twice, in two currencies.** A Freebucks-metered session is already paid for — the meter takes its price off the daily pool and wallet before the admit row is written. But `consumeRewardGrantForAdmission` ran after every fresh admission without looking at which pool granted it, so a metered account opening GLM 5.3 Flash paid **5 Freebucks to the meter and a bounty unit worth 15 to the grant debit**. The unit was **destroyed, not converted**. Only `convertEarnedSessionsToFreebucks` writes a `grant_convert` credit, and the admission path never calls it — so the earnings left `freebuff_manual_session_grant` and arrived nowhere, with nothing in the ledger to show for it. The reporting user's two bounties, correlated to the millisecond: | time (UTC) | admit | meter charge | grant debit | |---|---|---|---| | 09-04 09:02:46.9 | `z-ai/glm-5.3-flash`, pool `freebucks` | 5 @ :47.08 | 1.0 @ :47.12 | | 09-04 22:02:13.7 | `z-ai/glm-5.3-flash`, pool `freebucks` | 5 @ :14.00 | 1.0 @ :14.12 | **2. The Earn page reported the destruction as income.** `getBountyBalance` derives `spent` from `consumed_units`, which a GLM session decrements exactly as a cash-out does, and the tile rendered that on the meter as *"Cashed out — already in your wallet"*. For money nobody had credited. That mislabel is also why the first defect went unnoticed: the page was reassuring people their earnings were safe. For the record, at the time of the audit there were **zero `grant_convert` credits in the ledger, fleet-wide, ever**. `monthly_bonus` (215 rows) was the only credit reason that had ever been written. ## Changes - **Guard the grant debit** on `grantedPool === FREEBUCKS_ADMIT_POOL`. The grant pools and the meter are alternatives, never both — a metered account does not consult the pools for its entitlement and must not consult them for a debit. Regression test asserts the meter still charges **and** the grant does not; it fails without the guard. - **The "Cashed out" tile now reads the ledger** (`cashedOutFreebucks`), never `spent`. Sessions genuinely spent on GLM get their own line under the tiles instead of being folded into the wallet figure. - **Log the cash-out route's previews and failures.** For its first two days the only line it could emit was the success line on POST, and there were zero of those — a reading that means either "nobody pressed the button" or "every press failed", with no way to tell them apart on a route that moves users' earnings. - **Remediation script**, idempotent and self-verifying: it re-checks each event's admit row and meter charge in the database before paying, so a mistranscribed row is skipped rather than credited. - **`docs/freebuff-freebucks.md`** records the rule and why the attribution has a retention-bounded horizon. ## Remediation (already applied) **9 sessions, 6 accounts, all 2026-09-04, repaid at 15 Freebucks each = 135 total.** Written as `grant_convert` credits — precisely what should have happened to those units — so the tiles now reconcile instead of claiming a cash-out with no credit behind it. The reporting user's wallet holds 30. Attribution is bounded by log retention, not by choice: `consumed_units` carries no timestamp, so the only way to tell a double charge from a legitimate pre-meter GLM session is to correlate the `[FreeSession] session debited against a reward grant` lines against the pool on the admit row at that instant. Over the window covering every metered GLM admission since the meter went live (first one 09-03 16:49 UTC): 38 landed on pool `glm` (correct), 4 pre-meter, 9 on `freebucks`. ## Still open, not in this PR **1,081 users hold 1,834.3 spendable bounty units — 27,514 Freebucks — and not one cash-out has ever completed.** This PR stops the units being eaten and makes the page honest about it, but it does not explain the zero. The new preview logging is what will distinguish "nobody presses it" from "every press fails" within a day of deploy. ## Testing - `web`: 463 pass in `src/server/free-session` (incl. the new regression test, verified to fail without the fix) - `freebuff/web`: 7674 pass - typecheck clean on both
1 parent b0ef46c commit b0a73b6

8 files changed

Lines changed: 716 additions & 9 deletions

File tree

docs/freebuff-freebucks.md

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -263,6 +263,87 @@ multi-session admissions free). Keyed on `active_instance_id`; the reclaim
263263
retry mints a new one, so a session that loses the pre-read race can charge
264264
twice — rare, and closing it needs `admitOrTakeOver` to return the admit id.
265265

266+
## A metered session must NOT also debit a reward grant (2026-09-05)
267+
268+
`consumeRewardGrantForAdmission` runs after every fresh admission and, until
269+
this date, did so without looking at which pool granted it. On the meter that
270+
was a second charge for one session, in a second currency: GLM 5.3 Flash costs
271+
**5 Freebucks**, and the bounty unit it also took is worth **15**
272+
(`FREEBUCKS_PER_EARNED_SESSION`). The user paid 20 for a 5-Freebuck session.
273+
274+
The unit was **destroyed, not converted**. Only
275+
`convertEarnedSessionsToFreebucks` writes a `grant_convert` credit, and the
276+
admission path does not call it — so the earnings left
277+
`freebuff_manual_session_grant` and arrived nowhere. It was invisible in the
278+
ledger by construction, because the ledger only ever sees the session debit.
279+
280+
Then it was actively misreported. `getBountyBalance` derives `spent` from
281+
`consumed_units`, which a GLM session decrements exactly as a cash-out does,
282+
and the Earn page rendered that on the meter as **"Cashed out — already in
283+
your wallet"**. A user wrote in saying it claimed they had cashed out when
284+
they never had, which is how any of this was found. The tile now reads the
285+
LEDGER (`cashedOutFreebucks`), and sessions genuinely spent on GLM are named
286+
in a line under the tiles instead of being folded into the wallet figure.
287+
288+
**Rule: the grant pools and the meter are alternatives, never both.** A
289+
metered account does not consult the free session pools for its ENTITLEMENT,
290+
and it must not consult them for a DEBIT either. `grantedPool ===
291+
FREEBUCKS_ADMIT_POOL` is the guard, with a regression test in
292+
`public-api.test.ts` that asserts the meter's charge still happens and the
293+
grant debit does not.
294+
295+
Scope, measured: 9 sessions, 6 accounts, all on 2026-09-04; repaid at 15 each
296+
by `scripts/freebuff-freebucks-double-charge-remediation.ts` (idempotent, and
297+
it re-verifies each event against the admit row before paying). It could only
298+
ever be six because `consumed_units` carries no timestamp — the attribution
299+
comes from correlating the `[FreeSession] session debited against a reward
300+
grant` log lines against the pool on the admit row at that instant, and Axiom
301+
retention is what bounds how far back that can be done. **That is the reason
302+
the cash-out route now logs its previews too**: for its first two days the
303+
only line it could emit was the success line on POST, and there were zero of
304+
those — a reading that means either "nobody pressed the button" or "every
305+
press failed", with no way at all to tell which.
306+
307+
## Earned sessions CLAIM THEMSELVES; the button is a shortcut (2026-09-05)
308+
309+
Cashing out shipped as a control on the Bounties page. Measured across the
310+
first two days of its life it was pressed **zero times** — while **107 metered
311+
accounts held 199 earned units worth 2,985 Freebucks**, and six of them were
312+
shown *"You're out of Freebucks — back in 3h 58m"* beside an $8/mo upgrade in
313+
a single 24 hours, holding money they had already worked for. The endpoint was
314+
fine the whole time (probed in prod: 200, one account previewing 165). A
315+
control nobody finds is not a way to get paid, and upselling somebody their
316+
own unclaimed earnings is the worst version of not finding it.
317+
318+
So the claim now fires **where it changes the outcome**, in two places, both
319+
gated on the account being unable to afford any row it is offered:
320+
321+
- **The admission gate**, immediately before refusing for balance. Claim, then
322+
re-read, then re-test the price.
323+
- **The display block**, which is what draws the picker's wall. A poll never
324+
reaches the admission gate, so without this the panel keeps rendering the
325+
countdown and the upsell to someone who is one claim away from a session.
326+
327+
**Not on the happy path.** Both sites check the balance first — free, already
328+
in hand — so the extra grant read is paid only by accounts that are stuck. An
329+
account that can afford a row is never touched, which is a cost argument and
330+
also a consent one: moving somebody's earnings for them at a moment they had
331+
no reason to want it is not a favour.
332+
333+
**GRANTS ONLY. Referrals still need the button.** A grant is a stock consumed
334+
transactionally, so claiming one spends it everywhere at once. A referral is
335+
not: one cashed out here still feeds the daily GLM entitlement on the CLI
336+
(`getGlmReferralEntitlement` reads the same count and knows nothing about the
337+
ledger). That bounded overpayment is fine when a user opts into it by pressing
338+
a button, and not something to widen silently for everybody from an admission
339+
gate.
340+
341+
`claimEarnedFreebucks` drives `convertEarnedSessionsToFreebucks` off the
342+
session deps rather than reimplementing it — one owner for the consume-then-
343+
credit order and the once-ever keying, because two of those would be two
344+
chances to consume without crediting. It is TOTAL: every failure returns 0 and
345+
the caller refuses on the balance it already read.
346+
266347
## The wire block
267348

268349
`FreebuffFreebucksInfo` on every authenticated session response inside the

freebuff/web/src/components/earn/BountyBoard.tsx

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
'use client'
22

3-
import { FREEBUCKS_PER_EARNED_SESSION } from '@codebuff/common/constants/freebuff-freebucks'
3+
import {
4+
FREEBUCKS_LABEL,
5+
FREEBUCKS_PER_EARNED_SESSION,
6+
} from '@codebuff/common/constants/freebuff-freebucks'
7+
import { FREEBUFF_REWARD_MODEL_DISPLAY_NAME } from '@codebuff/common/constants/freebuff-models'
48
import { useCallback, useEffect, useMemo, useState } from 'react'
59

610
import { Button } from '@/vly/components/ui/button'
@@ -17,6 +21,9 @@ interface BountyBalance {
1721
earned: number
1822
spent: number
1923
remaining: number
24+
/** Freebucks a cash-out actually credited, read off the ledger. Never
25+
* derived from `spent` — see the field docs on the server's BountyBalance. */
26+
cashedOutFreebucks: number
2027
}
2128

2229
/** Sessions are fractional — ending one early charges only tenths — so a whole
@@ -126,6 +133,21 @@ export function BountyBoard() {
126133
}
127134
const openTask = tasks?.find((task) => task.id === openTaskId) ?? null
128135

136+
/** Reward sessions that went on GLM rather than into the wallet: everything
137+
* consumed, minus what the ledger says a cash-out actually paid for. Floored
138+
* at zero so a rounding difference between the two sources — units to a
139+
* tenth, Freebucks to a whole — can never render a negative sentence. */
140+
const spentOnSessions = balance
141+
? Math.max(
142+
0,
143+
Math.round(
144+
(balance.spent -
145+
balance.cashedOutFreebucks / FREEBUCKS_PER_EARNED_SESSION) *
146+
10,
147+
) / 10,
148+
)
149+
: 0
150+
129151
return (
130152
<section className="flex flex-col gap-5">
131153
{/* The pitch used to live here, in a dithered header with an eyebrow and
@@ -162,11 +184,19 @@ export function BountyBoard() {
162184
}
163185
detail="From approved bounties, all time"
164186
/>
187+
{/* On the meter this reads the LEDGER, not `spent`. They are
188+
different things and the page used to print one as the other:
189+
`spent` counts every reward session ever consumed, so an account
190+
that had simply used its bounties on GLM was told they were
191+
"already in your wallet" — money nobody had credited. A user
192+
reported exactly that on 2026-09-05. The sessions they spent are
193+
named by the line under the tiles instead, which is what actually
194+
happened to them. */}
165195
<BalanceStat
166196
label={metered ? 'Cashed out' : 'Used'}
167197
value={
168198
metered
169-
? formatSessions(balance.spent * FREEBUCKS_PER_EARNED_SESSION)
199+
? formatSessions(balance.cashedOutFreebucks)
170200
: formatSessions(balance.spent)
171201
}
172202
detail={metered ? 'Already in your wallet' : 'Spent on reward sessions'}
@@ -188,6 +218,21 @@ export function BountyBoard() {
188218
</div>
189219
)}
190220

221+
{/* The three tiles no longer add up on the meter, and that is the honest
222+
state rather than a rounding slip: a reward session spent on GLM is
223+
earned, gone, and never became Freebucks. Saying so is the whole
224+
point — silently folding it into "cashed out" is what made a user
225+
believe we had taken their wallet. */}
226+
{metered && balance && spentOnSessions > 0 && (
227+
<p className="text-[13px] leading-relaxed text-muted-foreground">
228+
{formatSessions(spentOnSessions)} reward{' '}
229+
{spentOnSessions === 1 ? 'session' : 'sessions'} were spent on{' '}
230+
{FREEBUFF_REWARD_MODEL_DISPLAY_NAME} before your account moved to{' '}
231+
{FREEBUCKS_LABEL}, so they were used rather than cashed out. Anything
232+
still showing under &ldquo;to cash out&rdquo; is yours to convert.
233+
</p>
234+
)}
235+
191236
{/* Earned sessions are worth what they convert to on the meter. The
192237
tiles' outstanding figure is handed over as a CROSS-CHECK: it and the
193238
card read different queries, and a user was left staring at "30 to

freebuff/web/src/components/earn/FreebucksConvertCard.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,21 @@ export function FreebucksConvertCard({
161161
`Your earned ${FREEBUCKS_LABEL} are already in your wallet. If the total above still shows ${formatFreebucks(pendingHint)}, reload — and tell us if it stays.`
162162
: 'Nothing left to cash out. New referrals and bounties show up here.'}
163163
</p>
164+
{/* The press is a SHORTCUT now, not the only way through. Bounty
165+
sessions claim themselves the moment an empty balance would
166+
otherwise stop you — which is the fix for this control having
167+
been used zero times in its first two days while 107 accounts
168+
sat on unclaimed earnings. Saying so matters as much as doing
169+
it: a user who does not know their earnings are safe behaves
170+
exactly like a user whose earnings are gone, and one of them
171+
wrote in to tell us so. Referrals are the exception and say
172+
nothing about being automatic, because they are not. */}
173+
{preview.total > 0 && preview.grantUnits > 0 && (
174+
<p className="mt-1 text-[13px] leading-5 text-muted-foreground">
175+
Your bounty {FREEBUCKS_LABEL} are added automatically when you run
176+
out — this button just does it now.
177+
</p>
178+
)}
164179
{credited !== null && credited > 0 && (
165180
<p className="mt-1 text-[13px] font-medium text-brand dark:text-brand">
166181
+{formatFreebucks(credited)} {FREEBUCKS_LABEL} added to your

freebuff/web/src/server/bounties/balance.ts

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,7 @@
11
import { and, eq, sql } from 'drizzle-orm'
22

3+
import { FREEBUCKS_GRANT_CONVERT_REASON } from '@codebuff/common/constants/freebuff-freebucks'
4+
35
import { logger } from '@/util/logger'
46

57
import db from '@codebuff/internal/db'
@@ -20,13 +22,33 @@ import * as schema from '@codebuff/internal/db/schema'
2022
export interface BountyBalance {
2123
/** Sessions every approved bounty ever paid, expired ones included. */
2224
earned: number
23-
/** Of those, sessions already spent on GLM. */
25+
/** Of those, sessions no longer available — spent on GLM or cashed out. */
2426
spent: number
2527
/** Spendable right now: live grants, minus what they have funded. */
2628
remaining: number
29+
/**
30+
* FREEBUCKS actually credited by a cash-out, read off the ledger.
31+
*
32+
* This is NOT `spent * FREEBUCKS_PER_EARNED_SESSION`, and conflating the two
33+
* is the bug this field exists to close. `spent` is `consumed_units`, which
34+
* a GLM session decrements just as a cash-out does — so on the meter the
35+
* page rendered every session a user had ever spent as "Cashed out —
36+
* already in your wallet", for money that was never credited to anyone.
37+
* Reported by a user on 2026-09-05 as "it says cashed out but I didn't even
38+
* do it", which is exactly right: they never pressed anything.
39+
*
40+
* The only thing that means a cash-out happened is a `grant_convert` credit
41+
* in the Freebucks ledger, so that is what this counts.
42+
*/
43+
cashedOutFreebucks: number
2744
}
2845

29-
const ZERO: BountyBalance = { earned: 0, spent: 0, remaining: 0 }
46+
const ZERO: BountyBalance = {
47+
earned: 0,
48+
spent: 0,
49+
remaining: 0,
50+
cashedOutFreebucks: 0,
51+
}
3052

3153
/** Sessions round to a tenth everywhere else in this system; an early-ended
3254
* session charges only part of one, and a balance that renders 0.30000000004
@@ -65,10 +87,36 @@ export async function getBountyBalance(userId: string): Promise<BountyBalance> {
6587
),
6688
)
6789

90+
// Credits only (`amount < 0` is a credit — see the ledger's sign
91+
// convention). Unscoped by grant: a cash-out consumes every grant the
92+
// account holds and writes one credit for the lot, so there is nothing on
93+
// the credit row to attribute back to a bounty, and inventing a split here
94+
// would report a number the ledger cannot back.
95+
const [credited] = await db
96+
.select({
97+
freebucks: sql<
98+
string | null
99+
>`sum(-${schema.freebuffFreebucksLedger.amount})`,
100+
})
101+
.from(schema.freebuffFreebucksLedger)
102+
.where(
103+
and(
104+
eq(schema.freebuffFreebucksLedger.user_id, userId),
105+
eq(
106+
schema.freebuffFreebucksLedger.reason,
107+
FREEBUCKS_GRANT_CONVERT_REASON,
108+
),
109+
),
110+
)
111+
68112
return {
69113
earned: round(Number(row?.earned ?? 0)),
70114
spent: round(Number(row?.spent ?? 0)),
71115
remaining: Math.max(0, round(Number(row?.remaining ?? 0))),
116+
cashedOutFreebucks: Math.max(
117+
0,
118+
Math.round(Number(credited?.freebucks ?? 0)),
119+
),
72120
}
73121
} catch (error) {
74122
// The board itself must still render. A missing balance costs three tiles;

0 commit comments

Comments
 (0)