feat(billing): prepaid AI token bank replaces the spending cap - #406
Open
pavlo-flamingo wants to merge 22 commits into
Open
pavlo-flamingo wants to merge 22 commits into
pavlo-flamingo wants to merge 22 commits into
Conversation
The product drops the per-period AI spend cap and pay-as-you-go token billing: AI runs on the period's free grant, then on a balance the tenant tops up. The backend exposes it as purchaseTokens(amountUsd) -> invoice, usage.purchasedTokensRemaining(+Usd) and CheckoutInput.tokenAmountUsd. - Billing & Usage: "Manage AI Balance" header action and modal with $20 / $50 / $100 / Custom one-time top-ups. The Paid AI Tokens card shows the balance (tokens + USD) with the per-model rates popover. Low / empty / trial-exhausted alerts under the cards. No AI rate row, no "AI Usage Beyond Free Tokens" block, no plan block on a trial. - Paywall: "AI Token Balance" card picks the first top-up ($50 by default), sent as tokenAmountUsd on checkout and added to the total due today. - App-wide bar: AiBalanceBar (low / empty, not dismissible) replaces AiSpendLimitBar; its CTA deep-links to the modal (?action=manageAiBalance). - Checkout inputs no longer force payAsYouGoEnabled on committed packages or on the AI product: the backend decides per product now. - Removed: use-ai-spend-limit, ai-spend-limit-fields, ai-tokens-limit-modal, use-update-ai-spend-cap, ai-tokens-usage-card, lib/ai-spend-tone. Not built, pending backend support: auto top-up (no API for it), tiered token pricing in the tile estimates (entry rate only), the purchase minimum. schema.graphql refreshed from bnk-tkn.dev4.
…ists
The mockups show auto top-up in three places: an "Enable Auto Top-up" checkbox in
Manage AI Balance, a refresh mark beside the Paid AI Tokens figure, and an
enabled/disabled line at the top of the model rates popover. The backend has no
field to store the choice and no mutation to set it, so none of it was drawn.
Draw all three, driven by one AUTO_TOP_UP status ({ available: false,
enabled: false }) in lib/auto-top-up.ts. The checkbox renders unchecked and
disabled with a "Coming soon" tag; the card mark renders only when enabled; the
popover line reads "Auto Top Up Disabled" on the billing page and is left out
on the paywall, where there is no balance yet. When the subscription grows the
field, the constant becomes a read of it and the surfaces stay as they are.
…op-up at $10 On a trial the billing page offered Manage AI Balance beside Activate Subscription, and Activate opened the Upgrade Plan modal — a device-only checkout that sends no tokenAmountUsd, which a checkout requiring a first top-up refuses. A trial has no balance to manage either. Activate Subscription now opens the paywall's own form in a modal (ActivateSubscriptionModal): device plan, AI Token Balance card with the first top-up, one Proceed to Payment on createCheckoutSession — the same purchase the lock screen makes, made early. The form (query, choices, what the button sends) moves into usePlanCheckout and PlanCheckoutCards, shared by the lock screen; UpgradePlanModal becomes update-only and Manage AI Balance and Change Plan are dropped on a trial. The top-up gets a $10 floor (MIN_TOP_UP_USD — the schema names a configured minimum but does not expose it). Neither surface locks its button over a bad amount any more: the press reveals the problem under the fields and in a toast, and the button stays pressable. The mockup's auto top-up checkbox joins the paywall card, locked like everywhere else (AutoTopUpCheckbox).
…verywhere Ports #274 as reviewed. Proceed to Payment looked live before the plan picker reported anything and did nothing on click; it is disabled until there is something to buy. The billing mutations (update, cancel, resume, customer portal, test clock, seed) showed Relay's raw wrapper on failure; they now go through getRelayErrorMessage like checkout and purchaseTokens already do, and the weaker local extractGraphqlErrorMessage goes. The Relay network-layer throw from #274 is deliberately not taken: it drops error.source, which the shared helper and 16 other call sites read.
2 tasks done
A trial could not activate: createCheckoutSession answered "Sold in advance, so pay-as-you-go cannot be enabled: [AI_ASSISTANCE]". main sends payAsYouGoEnabled: true for every non-device product, and AI is now sold in advance (the token bank), where asking for the meter is refused. This branch had already stopped sending true by leaving the flag out. Send an explicit false for AI_ASSISTANCE instead, so the result does not depend on how the backend reads an absent flag. Devices are unchanged: true on pay-as-you-go, left out on a committed package. updateSubscription is untouched: UpdateSubscriptionInput carries only packageUpdates and discountCode, PackageUpdateInput has no such flag, and the frontend never sends an AI entry there.
Conflicts were with #428 (read-only billing on the desktop build): - routes.ts: keeps the billingUsage builder, takes the tenant management routes. - app-layout.tsx: the billing bars also hide on the read-only build; neither the top-up nor the checkout they lead to exists there. - billing-usage-content.tsx: takes isBillingReadOnly/openBillingInBrowser, drops the import of the deleted use-ai-spend-limit. Not flagged by git or tsc: openBillingInBrowser interpolated routes.settings.billingUsage into a URL, and on this branch that is a builder, so the link would have carried the function's source. It calls the builder now. Manage AI Balance is also closed to ?action= on the read-only build.
Conflicts were with #361 (software inventory), which moved display formatting into the shared modules: formatDate (empty-safe) in lib/format-date, formatCount/formatCompactCount in lib/format-number, EMPTY_VALUE, pluralize. The billing files on this branch follow it; billing-usage/lib/format.ts keeps formatCurrency and this branch's formatWholeCurrency. The spend-cap files main still edits stay deleted. schema.graphql: main's copy was refreshed from test-env.qa on 2026-09-22 and that environment has no token bank API, so it is main's schema with the token bank delta from 0e62a70 re-applied (purchaseTokens, TokenPurchase, purchasedTokensRemaining(+Usd), CheckoutInput.tokenAmountUsd, the Long tiers, BillingPeriod.UNLIMITED). Refresh it from an environment that has both once one is reachable. eslint.config.mjs: the colocation exception lists both use-plan-checkout and the software picker lists.
…the update mutation. CU-86akm01mt Confirming Monthly → Annual crashed Billing & Usage into the error boundary (TypeError: Cannot read properties of undefined (reading 'toLocaleString')) right after the success toast. updateSubscription returned pendingInvoices with id, hostedInvoiceUrl and createdAt only; Relay replaced the store's list with that, the upgrade's new invoice had no amountDue, the page re-rendered from the store before its refetch, and formatCurrency threw in the invoices table. pendingInvoiceFields_invoice (@inline, src/graphql/billing) is now what the billing page, the suspended-workspace lock screen and the mutation all select, read through toPendingInvoice — so a field the page adds is returned by the mutation by construction. The mutation document moves next to it; the hook keeps its API. InvoicesHistory takes the generated row type instead of a hand-written InvoiceItem that promised an amountDue the store did not have.
…olocated Relay Auto top-up (openframe-saas-tenant #3223): the page reads autoTopUpSettings on its own island so a refused read never takes the page down; the Manage AI Balance modal saves, updates or switches the arrangement off through updateAutoTopUp and relinks the root record, so the card's mark updates in place; the paywall checkout sends autoTopUpEnabled beside the first top-up. The billing module is laid out like software: one component per file, each owning its fragment, non-component readers as @inline fragments, mutations in the hook that owns the action. The paywall body reads a fragment on Query instead of the parent's response type, so the lock screen and the Activate modal feed the same form from their own queries. The invoice row is one @inline fragment for the table and the suspended-workspace screen. src/graphql/billing, the hand-written read models and the react-query wrapper over /api/graphql in the cancellation dialog are gone; the lint exception for use-plan-checkout is no longer needed. Resume and cancel still answer with a bare Boolean, so the page refetches with a fetchKey bump after them.
…t never ends The $20 / $50 / $100 tiles showed grey bars forever on a tenant whose plan catalog carries no price for the AI product (the new backend sells it as an UNLIMITED package; the old read looked at payAsYouGoOption.price only), so "no rate" was indistinguishable from "still loading". Loading and unknown are now separate: the tile draws a skeleton only while the query is in flight, a dash once it settled without a rate, and an error line under the grid says the catalog has no price for the AI product.
The Mingo drawer kept accepting messages after the free grant ran out and
the balance hit zero; every send failed on the backend.
The balance hydrator now derives a paused reason (trial with the grant
spent, or an empty balance) beside the bar tone, and the layout hands it to
EmbeddableChat as composerLock (core lib 0.0.666): the editor is disabled
with the reason as placeholder, Send and attachments are off, welcome chips
are hidden and a queued prompt is dropped. Copy differs for trial ("Activate
your subscription"), balance ("Top up your balance") and the mobile build,
which cannot name a purchase.
Contributor
🦩 Flamingo Code Review10 finding(s) — 2 action required · 8 recommended · 0 informational Mode: advisory · Rules cited: Inline comments: 8 new Findings without an inline anchor in this diff
Need another pass? Commits pushed after this review are not reviewed automatically.
Prefer typing? Comment React 👍/👎 on inline comments to teach the reviewer. Started 2026-09-25 03:59 UTC · updated 2026-09-25 04:03 UTC · workflow run |
schema.graphql: main's test-env snapshot plus what the token-bank backend adds (AutoTopUpSettings/Input/DisabledReason, TokenPurchase, BillingPeriod UNLIMITED, CheckoutInput.tokenAmountUsd/autoTopUpEnabled, purchaseTokens, updateAutoTopUp, autoTopUpSettings, SubscriptionUsage.purchasedTokens*). Core lib pin 0.0.675 (main's, carries composerLock from 0.0.666).
resumeSubscription returns a bare Boolean, so nothing in the payload moves the status from PENDING_CANCELLATION back to ACTIVE. Only the billing page refetched; the lock guard and the balance bars kept the cancelled state until their next fetch. The hook now invalidates the store on success, the same step useCancelSubscription takes in the other direction.
`pendingInvoices` is nullable now and an invoice can arrive without its `hostedInvoiceUrl` (openframe-saas-tenant 0baa8a59). The invoices section and the header's Pay Overage crashed on the null list; the lock screen and the table handed a null href to their buttons. The history section now says the invoices could not be loaded, the lock screen shows its unavailable copy, Pay Overage opens the newest invoice that has a link, and a row without one keeps its control disabled. `schema.graphql` is a fresh pull from the PR environment.
The auto top-up line sits on the success tint when enabled and stays on the panel when not, every provider logo is the grey cut, and the rows take the mockup's spacing tokens.
Conflicts: app-layout.tsx (main dropped the onboarding top bars; kept the branch's `showBillingBars` with main's comments) and routes.ts (kept the `billingUsage` builder beside main's `cloudTenantManagement` block). schema.graphql is main's snapshot plus what the PR environment adds, with `pendingInvoices` and `hostedInvoiceUrl` nullable. The PR environment alone lags main (`deviceLogs(machineId:)` and others), so a plain pull from it does not compile main's operations.
Reverts 5bf4b72. The drawer accepts messages whatever the balance says, and the balance hydrator is back to mounting only where the bars can act. The unit tests for aiBalanceTone stay; only the aiPausedReason ones go.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Problem
AI was sold as a spending cap over pay-as-you-go tokens (
updateAiSpendCap,aiSpendUsd,aiTokensOverage). Product moved to a prepaid token bank —purchaseTokens,purchasedTokensRemaining,CheckoutInput.tokenAmountUsd— and the billing screens still drew the old model. On a trial, Activate Subscription opened the device-only Upgrade Plan modal, whose checkout sends no top-up and is refused once one is required.Changes
purchaseTokensinvoice), Paid AI Tokens counter with the model-rates popover, low / empty / trial-exhausted alerts, an app-wide AI balance bar that is not dismissible and deep-links to the modal (?action=manageAiBalance). Spend-cap UI and hooks removed. The checkout sends AI_ASSISTANCE withpayAsYouGoEnabled: false:mainsendstrue, which a product sold in advance refuses ("Sold in advance, so pay-as-you-go cannot be enabled") — the reason a trial cannot activate on DEV today.tokenAmountUsdwith a $10 floor (MIN_TOP_UP_USD). The button is never locked over a bad amount: the press shows the problem under the field and in a toast.ActivateSubscriptionModal, samecreateCheckoutSession); Manage AI Balance and Change Plan are dropped on a trial. The form is shared with the lock screen throughusePlanCheckout/PlanCheckoutCards;UpgradePlanModalis update-only.AUTO_TOP_UP: the backend has no field or mutation for it yet.getRelayErrorMessage, so the server's message shows instead of Relay's wrapper. The Relay-layer throw from Fix Proceed to Payment dead ends on the subscription lock screen #274 is not taken — it stripserror.source, which that helper and 16 other call sites read.Caveats
aiSpendCapUsd,updateAiSpendCap,aiSpendUsd,aiTokensOverage). Nothing here reads it, but a stored cap can still pause AI on a non-zero balance.tsc,lint:ci,formatandbuildare green. Checked against the code and the Figma frames, not on dev4.Related: #274 (superseded by this PR)