Skip to content

feat(billing): prepaid AI token bank replaces the spending cap - #406

Open
pavlo-flamingo wants to merge 22 commits into
mainfrom
feat/ai-token-bank
Open

pavlo-flamingo wants to merge 22 commits into
mainfrom
feat/ai-token-bank

Conversation

@pavlo-flamingo

@pavlo-flamingo pavlo-flamingo commented Sep 15, 2026 •

Copy link
Copy Markdown
Contributor

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

  • Token bank. Manage AI Balance modal ($20 / $50 / $100 / custom → purchaseTokens invoice), 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 with payAsYouGoEnabled: false: main sends true, 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.
  • Paywall. AI Token Balance card; the first top-up rides on the checkout as tokenAmountUsd with 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.
  • Trial. Activate Subscription opens the paywall form in a modal (ActivateSubscriptionModal, same createCheckoutSession); Manage AI Balance and Change Plan are dropped on a trial. The form is shared with the lock screen through usePlanCheckout / PlanCheckoutCards; UpgradePlanModal is update-only.
  • Auto top-up. Drawn per the mockups — checkbox, refresh mark on the counter, popover line — and locked behind AUTO_TOP_UP: the backend has no field or mutation for it yet.
  • From Fix Proceed to Payment dead ends on the subscription lock screen #274. Proceed to Payment is disabled while there is nothing to buy; every billing mutation toasts through 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 strips error.source, which that helper and 16 other call sites read.

Caveats

  • The $10 floor and the 1M-token "low" threshold are frontend constants; the schema names a configured minimum but does not expose it.
  • The backend still carries the cap (aiSpendCapUsd, updateAiSpendCap, aiSpendUsd, aiTokensOverage). Nothing here reads it, but a stored cap can still pause AI on a non-zero balance.
  • tsc, lint:ci, format and build are green. Checked against the code and the Figma frames, not on dev4.

Related: #274 (superseded by this PR)

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.
pavlo-flamingo and others added 4 commits September 16, 2026 16:46
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.
@pavlo-flamingo pavlo-flamingo self-assigned this Sep 21, 2026
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.
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@flamingo-stack flamingo-stack deleted a comment from github-actions Bot Sep 25, 2026
@github-actions

github-actions Bot commented Sep 25, 2026 •

Copy link
Copy Markdown
Contributor

🦩 Flamingo Code Review

10 finding(s) — 2 action required · 8 recommended · 0 informational

Mode: advisory · Rules cited: OPENFRAM-001-2 · Checks: lib-reuse ×1 · 7 defect(s) outside any rule

Inline comments: 8 new

Findings without an inline anchor in this diff

  • 🔴 [error/action_required] src/app/(app)/settings/billing-usage/components/billing/use-resume-subscription.ts:24 — useResumeSubscription refetches via onSuccess callback instead of React Query / named cache key invalidation
    The new hook useResumeSubscription() relies on the caller to refetch the billing query via an onSuccess callback rather than using a shared, named query-key invalidation pattern. Sibling hooks in this codebase (e.g. useCancelSubscription) call commitLocalUpdate to invalidate the entire Relay store on success, since the mutation returns a bare Boolean with no typed payload for Relay to reconcile automatically. useResumeSubscription does the same mutation shape (returns Boolean) but does not perform any store invalidation — it only calls onSuccess?.() and leaves refetching entirely to the caller. This is inconsistent with the established pattern in this exact directory (use-cancel-subscription.ts) and risks stale PENDING_CANCELLATION state being shown after a successful resume if the caller does not explicitly force a refetch.
    export function useResumeSubscription() {
      const { toast } = useToast();
      const [commit, isInFlight] = useMutation<UseResumeSubscriptionMutationType>(resumeSubscriptionMutation);
    
      const mutate = useCallback(
        (options?: ResumeSubscriptionOptions) => {
          const { onSuccess } = options ?? {};
          commit({
    
  • 🟠 [warn/recommended] lib-reuse Tool-checked src/lib/format-currency.ts:2 — formatCurrency already exists in the design system
    @flamingo-stack/openframe-frontend-core exports formatCurrency. A local definition of the same name diverges from the shared one over time; import it instead.
    export function formatCurrency(value: number): string {
    

Need another pass? Commits pushed after this review are not reviewed automatically.

  • Review the new commits — the commits added since this review
  • Review the whole diff again — ignoring what was already reviewed

Prefer typing? Comment @flamingo-review, or @flamingo-review full. To review every push on this pull request, add the flamingo-review-always label.

React 👍/👎 on inline comments to teach the reviewer.

Started 2026-09-25 03:59 UTC · updated 2026-09-25 04:03 UTC · workflow run

Comment thread src/lib/routes.ts
Comment thread src/app/components/app-layout.tsx
Comment thread src/app/(app)/checkout/cancel/page.tsx
Comment thread src/app/(app)/checkout/success/page.tsx
Comment thread src/app/components/subscription-lock/unpaid-invoices-screen.tsx
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.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants