diff --git a/.agents/PAPERCUTS.md b/.agents/PAPERCUTS.md index b429243..75df0c3 100644 --- a/.agents/PAPERCUTS.md +++ b/.agents/PAPERCUTS.md @@ -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. diff --git a/src/server/billing/subscription.ts b/src/server/billing/subscription.ts index c3b1310..3adc164 100644 --- a/src/server/billing/subscription.ts +++ b/src/server/billing/subscription.ts @@ -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 = { diff --git a/src/server/features/onboarding/OnboardingChatAgent.ts b/src/server/features/onboarding/OnboardingChatAgent.ts index 89a8b67..a13092c 100644 --- a/src/server/features/onboarding/OnboardingChatAgent.ts +++ b/src/server/features/onboarding/OnboardingChatAgent.ts @@ -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.", ); diff --git a/src/server/features/sam/SamChatAgent.ts b/src/server/features/sam/SamChatAgent.ts index c2ddd4a..97908e9 100644 --- a/src/server/features/sam/SamChatAgent.ts +++ b/src/server/features/sam/SamChatAgent.ts @@ -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.", );