fix(billing): confirm credit depletion before refusing chat turns (#407)

This commit is contained in:
Ben Senescu 2026-07-20 18:36:02 -04:00 committed by GitHub
parent 278b19836d
commit 7990c4db7f
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
4 changed files with 97 additions and 12 deletions

View File

@ -10,6 +10,7 @@ data, or sensitive paths.
## Open
- [ ] `2026-07-20T20:08:28Z``claude` — In a fresh git worktree, `oxlint --type-aware` crashes with `Cannot find module '@oxlint/binding-darwin-arm64'` — the platform-specific optional dep is missing from the worktree's node_modules while tsc/prettier work fine, and plain `pnpm install` reports up-to-date without restoring it; `pnpm install --force` (~22s) fixes it. Worth making the worktree-setup hook (or a documented step) run the forced install so lint doesn't die on fresh worktrees.
- [ ] `2026-07-19T04:06:52Z``codex``pnpm --dir web build` fails with `vite: command not found` when `web/node_modules` is absent, despite the root toolchain being installed. Document or enforce the package-local install required before validating the `web/` subpackage.
- [ ] `2026-07-19T02:55:56Z``claude` — Adding a docs folder under `web/content/docs` whose `meta.json` lists an `[Overview](...)` link renders a duplicated, double-highlighted sidebar entry, because the folder-index strip in `web/src/lib/source.ts` (`transformPageTree.folder`) is a per-folder-name allowlist. Derive it from the meta convention (or strip the index for all folders) so new sections don't need a hidden source.ts edit.
- [ ] `2026-07-14T01:28:30Z``claude` — Regenerating the lockfile (adding or moving a dep) makes `pnpm install` re-run the `minimumReleaseAge` gate on transitive peers already pinned at that exact version (`mysql2`, `sql-escaper`, `@aws-sdk/credential-providers`), failing the install even though nothing about them changed. `pnpm install --config.minimumReleaseAge=0` — then confirm the lockfile diff stays version-neutral — unblocks it; worth documenting that regen step so the gate doesn't re-block already-pinned versions.

View File

@ -84,7 +84,7 @@ export async function customerHasManagedAccess(customerId: string) {
// Remaining shared usage credits — the monthly `usage_credits` balance plus the
// rolled-over `topup_credits` balance. Both DataForSEO and LLM spend draw from
// these (the `seo_data_usage` and `llm_usage` features both map into them).
export async function getUsageCreditsRemaining(customerId: string): Promise<{
async function getUsageCreditsRemaining(customerId: string): Promise<{
monthlyRemaining: number;
topupRemaining: number;
}> {
@ -96,12 +96,85 @@ export async function getUsageCreditsRemaining(customerId: string): Promise<{
}),
]);
// Every hosted org holds the monthly feature (the free plan is the Autumn
// default, attached at customer creation), so a check with no balance data
// is a broken read, not an empty wallet. Throwing keeps it out of the
// credit math — coercing it to 0 once locked a paying customer with ~9k
// credits out of chat (2026-07-20). The topup balance genuinely doesn't
// exist until a first top-up, so 0 is the honest reading there.
if (!monthlyCheck.balance) {
throw new AppError(
"UPSTREAM_UNAVAILABLE",
`Autumn check returned no ${AUTUMN_SEO_DATA_BALANCE_FEATURE_ID} balance for customer ${customerId}`,
);
}
return {
monthlyRemaining: monthlyCheck.balance?.remaining ?? 0,
monthlyRemaining: monthlyCheck.balance.remaining,
topupRemaining: topupCheck.balance?.remaining ?? 0,
};
}
/**
* Depletion check for the chat-agent gates. A /check reading 0 is not
* trusted on its own: Autumn has served a stale balance transiently
* (2026-07-20, minutes after a customer's freepaid upgrade), and a false
* refusal locks the customer out of chat. When the check reads depleted,
* confirm against the full customer object a separate Autumn read path
* and refuse only when both agree. A disagreement means Autumn served
* inconsistent balances: the turn proceeds on the confirmed reading and the
* inconsistency is logged at error level so it lands in Workers error
* tracking, not buried in analytics. Confirmed refusals emit a PostHog event (paywall
* analytics refusals used to be invisible everywhere).
*/
export async function checkUsageCreditsDepleted(
customer: BillingCustomerContext,
): Promise<{ depleted: boolean; monthlyRemaining: number }> {
const check = await getUsageCreditsRemaining(customer.organizationId);
if (check.monthlyRemaining + check.topupRemaining > 0) {
return { depleted: false, monthlyRemaining: check.monthlyRemaining };
}
// No try/catch: if this second read fails while the first said depleted,
// the whole gate errors rather than guessing — the turn fails generically
// and retryably instead of refusing with a possibly-false paywall.
const full = await autumn.customers.getOrCreate({
customerId: customer.organizationId,
email: customer.userEmail,
});
const confirmed = {
monthlyRemaining:
full.balances[AUTUMN_SEO_DATA_BALANCE_FEATURE_ID]?.remaining ?? 0,
topupRemaining:
full.balances[AUTUMN_SEO_DATA_TOPUP_BALANCE_FEATURE_ID]?.remaining ?? 0,
};
if (confirmed.monthlyRemaining + confirmed.topupRemaining > 0) {
console.error(
"billing.credits-gate disagreement: /check read depleted but the " +
"customer object shows credits; proceeding on the customer reading",
{
organizationId: customer.organizationId,
check,
confirmed,
},
);
return { depleted: false, monthlyRemaining: confirmed.monthlyRemaining };
}
await captureServerEvent({
distinctId: customer.userId,
event: "usage:credits_gate_refused",
organizationId: customer.organizationId,
properties: {
project_id: customer.projectId,
monthly_remaining: confirmed.monthlyRemaining,
topup_remaining: confirmed.topupRemaining,
},
});
return { depleted: true, monthlyRemaining: check.monthlyRemaining };
}
/**
* Throws INSUFFICIENT_CREDITS when the org has no usage/topup credits left.
* Returns the monthly remaining so a caller can split spend monthly-first.
@ -140,7 +213,12 @@ export async function trackUsageCreditSpend(args: {
);
if (totalCostCredits <= 0) return;
const monthlyDeduct = Math.min(args.monthlyRemaining, totalCostCredits);
// Clamp at 0: Autumn balances can read negative after an overdraft, and a
// negative monthly reading here would inflate the topup deduction.
const monthlyDeduct = Math.min(
Math.max(args.monthlyRemaining, 0),
totalCostCredits,
);
const topupDeduct = totalCostCredits - monthlyDeduct;
const properties = {

View File

@ -17,7 +17,7 @@ import {
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
import {
customerHasManagedAccess,
getUsageCreditsRemaining,
checkUsageCreditsDepleted,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
@ -139,9 +139,9 @@ export class OnboardingChatAgent extends AIChatAgent {
);
}
const { monthlyRemaining, topupRemaining } =
await getUsageCreditsRemaining(organizationId);
if (monthlyRemaining + topupRemaining <= 0) {
const { depleted, monthlyRemaining } =
await checkUsageCreditsDepleted(billingCustomer);
if (depleted) {
return staticAssistantResponse(
"You've used your onboarding credits. Subscribe to continue.",
);

View File

@ -24,7 +24,7 @@ import {
isHostedServerAuthMode,
} from "@/server/lib/runtime-env";
import {
getUsageCreditsRemaining,
checkUsageCreditsDepleted,
trackUsageCreditSpend,
} from "@/server/billing/subscription";
import { getPublicOrigin } from "@/server/mcp/public-origin";
@ -233,12 +233,18 @@ export class SamChatAgent extends Think {
// Gate every turn on credits in hosted mode: SAM is open to every plan
// (including free), and LLM tokens plus DataForSEO tool calls all draw
// down the org's credit balance. Self-hosted brings its own provider
// keys and has no Autumn balance, so it's ungated.
// keys and has no Autumn balance, so it's ungated. Depletion is
// confirmed against a second Autumn read path before refusing — a
// stale check reading here once locked a paying customer out of chat.
const { organizationId } = ctx.project;
if (await isHostedServerAuthMode()) {
const { monthlyRemaining, topupRemaining } =
await getUsageCreditsRemaining(organizationId);
if (monthlyRemaining + topupRemaining <= 0) {
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
userId: ctx.row.userId,
userEmail: ctx.userEmail,
organizationId,
projectId: ctx.project.id,
});
if (depleted) {
return this.refusalTurn(
"You're out of credits. Top up to keep using SAM.",
);