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
|
## 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-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-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.
|
- [ ] `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
|
// Remaining shared usage credits — the monthly `usage_credits` balance plus the
|
||||||
// rolled-over `topup_credits` balance. Both DataForSEO and LLM spend draw from
|
// 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).
|
// 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;
|
monthlyRemaining: number;
|
||||||
topupRemaining: 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 {
|
return {
|
||||||
monthlyRemaining: monthlyCheck.balance?.remaining ?? 0,
|
monthlyRemaining: monthlyCheck.balance.remaining,
|
||||||
topupRemaining: topupCheck.balance?.remaining ?? 0,
|
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.
|
* Throws INSUFFICIENT_CREDITS when the org has no usage/topup credits left.
|
||||||
* Returns the monthly remaining so a caller can split spend monthly-first.
|
* 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;
|
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 topupDeduct = totalCostCredits - monthlyDeduct;
|
||||||
|
|
||||||
const properties = {
|
const properties = {
|
||||||
|
|||||||
@ -17,7 +17,7 @@ import {
|
|||||||
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
import { isHostedServerAuthMode } from "@/server/lib/runtime-env";
|
||||||
import {
|
import {
|
||||||
customerHasManagedAccess,
|
customerHasManagedAccess,
|
||||||
getUsageCreditsRemaining,
|
checkUsageCreditsDepleted,
|
||||||
trackUsageCreditSpend,
|
trackUsageCreditSpend,
|
||||||
} from "@/server/billing/subscription";
|
} from "@/server/billing/subscription";
|
||||||
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
import { FREE_ONBOARDING_QUESTION_LIMIT } from "@/shared/onboardingChat";
|
||||||
@ -139,9 +139,9 @@ export class OnboardingChatAgent extends AIChatAgent {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { monthlyRemaining, topupRemaining } =
|
const { depleted, monthlyRemaining } =
|
||||||
await getUsageCreditsRemaining(organizationId);
|
await checkUsageCreditsDepleted(billingCustomer);
|
||||||
if (monthlyRemaining + topupRemaining <= 0) {
|
if (depleted) {
|
||||||
return staticAssistantResponse(
|
return staticAssistantResponse(
|
||||||
"You've used your onboarding credits. Subscribe to continue.",
|
"You've used your onboarding credits. Subscribe to continue.",
|
||||||
);
|
);
|
||||||
|
|||||||
@ -24,7 +24,7 @@ import {
|
|||||||
isHostedServerAuthMode,
|
isHostedServerAuthMode,
|
||||||
} from "@/server/lib/runtime-env";
|
} from "@/server/lib/runtime-env";
|
||||||
import {
|
import {
|
||||||
getUsageCreditsRemaining,
|
checkUsageCreditsDepleted,
|
||||||
trackUsageCreditSpend,
|
trackUsageCreditSpend,
|
||||||
} from "@/server/billing/subscription";
|
} from "@/server/billing/subscription";
|
||||||
import { getPublicOrigin } from "@/server/mcp/public-origin";
|
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
|
// 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
|
// (including free), and LLM tokens plus DataForSEO tool calls all draw
|
||||||
// down the org's credit balance. Self-hosted brings its own provider
|
// 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;
|
const { organizationId } = ctx.project;
|
||||||
if (await isHostedServerAuthMode()) {
|
if (await isHostedServerAuthMode()) {
|
||||||
const { monthlyRemaining, topupRemaining } =
|
const { depleted, monthlyRemaining } = await checkUsageCreditsDepleted({
|
||||||
await getUsageCreditsRemaining(organizationId);
|
userId: ctx.row.userId,
|
||||||
if (monthlyRemaining + topupRemaining <= 0) {
|
userEmail: ctx.userEmail,
|
||||||
|
organizationId,
|
||||||
|
projectId: ctx.project.id,
|
||||||
|
});
|
||||||
|
if (depleted) {
|
||||||
return this.refusalTurn(
|
return this.refusalTurn(
|
||||||
"You're out of credits. Top up to keep using SAM.",
|
"You're out of credits. Top up to keep using SAM.",
|
||||||
);
|
);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user