fix(billing): confirm credit depletion before refusing chat turns (#407)
This commit is contained in:
parent
278b19836d
commit
7990c4db7f
@ -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.
|
||||
|
||||
@ -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 free→paid 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 = {
|
||||
|
||||
@ -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.",
|
||||
);
|
||||
|
||||
@ -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.",
|
||||
);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user