From 0e62a70d0b05226059b255b97e788daa4952afd8 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Mon, 14 Sep 2026 21:44:34 +0100 Subject: [PATCH 1/4] feat(billing): replace the AI spending cap with a prepaid token bank 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. --- schema.graphql | 73 +++- src/app/(app)/checkout/cancel/page.tsx | 2 +- src/app/(app)/checkout/success/page.tsx | 2 +- .../components/ai-spend-limit-fields.tsx | 176 --------- .../components/ai-tokens-limit-modal.tsx | 80 ---- .../components/ai-top-up-fields.tsx | 126 +++++++ .../components/billing-usage-content.tsx | 342 +++++++----------- .../components/billing-usage-skeleton.tsx | 1 - .../components/manage-ai-balance-modal.tsx | 76 ++++ .../components/upgrade-plan-modal.tsx | 15 +- .../components/usage-stat-card.tsx | 24 +- .../billing-usage/hooks/use-ai-spend-limit.ts | 136 ------- .../billing-usage/hooks/use-ai-top-up.ts | 85 +++++ .../hooks/use-billing-summary.ts | 71 ++-- .../hooks/use-purchase-tokens.ts | 83 +++++ .../hooks/use-update-ai-spend-cap.ts | 73 ---- .../billing-usage/lib/ai-token-price.ts | 14 + .../settings/billing-usage/lib/format.ts | 5 + .../ai-assistants-included-note.tsx | 9 +- .../components/ai-token-balance-card.tsx | 76 ++++ .../components/ai-tokens-usage-card.tsx | 106 ------ .../components/model-token-rates.tsx | 35 +- .../components/plan-total-summary.tsx | 34 +- .../components/subscription-settings-view.tsx | 74 ++-- .../components/subscription-submit-button.tsx | 63 ++-- .../hooks/use-create-checkout-session.ts | 31 +- .../subscription/utils/subscription.utils.ts | 4 +- .../settings/components/settings-hub.tsx | 6 +- src/app/components/app-layout.tsx | 26 +- src/app/components/billing-bars.tsx | 86 ++--- src/lib/ai-balance-tone.ts | 43 +++ src/lib/ai-spend-tone.ts | 39 -- src/lib/routes.ts | 10 +- 33 files changed, 985 insertions(+), 1041 deletions(-) delete mode 100644 src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx delete mode 100644 src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx create mode 100644 src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx create mode 100644 src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx delete mode 100644 src/app/(app)/settings/billing-usage/hooks/use-ai-spend-limit.ts create mode 100644 src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts create mode 100644 src/app/(app)/settings/billing-usage/hooks/use-purchase-tokens.ts delete mode 100644 src/app/(app)/settings/billing-usage/hooks/use-update-ai-spend-cap.ts create mode 100644 src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx delete mode 100644 src/app/(app)/settings/billing-usage/subscription/components/ai-tokens-usage-card.tsx create mode 100644 src/lib/ai-balance-tone.ts delete mode 100644 src/lib/ai-spend-tone.ts diff --git a/schema.graphql b/schema.graphql index 5ffda165..97441335 100644 --- a/schema.graphql +++ b/schema.graphql @@ -1,6 +1,6 @@ # Auto-generated by scripts/fetch-schema.mjs via introspection -# Source: https://tst-notifications.dev.openframe.build/api/graphql -# Generated: 2026-09-14T16:34:07.687Z +# Source: https://bnk-tkn.dev4.openframe.build/api/graphql +# Generated: 2026-09-14T19:34:36.356Z # # Do not edit manually. Re-run: npm run fetch-schema directive @extends on OBJECT | INTERFACE @@ -137,6 +137,12 @@ enum BillingMetricType { enum BillingPeriod { MONTHLY YEARLY + + """ + Bought outright rather than on a cycle: the period of a prepaid package, which is paid for once and + lasts until it is spent. A plan that holds one cannot be serialized at all without this value. + """ + UNLIMITED } """ --- Billing Plan (detailed) ---""" @@ -235,6 +241,14 @@ input CancelSubscriptionInput { input CheckoutInput { products: [ProductCheckoutInput!]! discountCode: String + + """ + Money to put towards AI tokens as part of this checkout. Required, and at least the configured + minimum, unless the requirement is switched off by configuration: a subscription that starts with an + empty bank can use AI only until its free grant runs out. The amount is charged on the checkout's own + invoice, and the tokens land when it is paid. + """ + tokenAmountUsd: Float } """ --- Subscription / Checkout ---""" @@ -1277,6 +1291,19 @@ type Mutation { """ updateAiSpendCap(amountUsd: Float): SubscriptionDetail! + """ + Buy AI tokens for the given amount in USD. The amount must be a whole number of dollars and at + least the configured minimum; the backend converts it to a quantity at the current tiered price + and raises an invoice for it. Tokens are credited only once that invoice is paid, so the value + returned is where the customer goes to pay it. + + Available to a tenant that has run out of tokens: hitting the AI budget stops AI, not the purchase + that lifts it. Not available while the subscription itself is blocked — past due, suspended, + cancelled, or an expired trial — because we do not sell more to a tenant that already owes: settle + first, then buy. + """ + purchaseTokens(amountUsd: Float!): TokenPurchase! + """ Update the current authenticated tenant's name and/or website. saas-api only. """ @@ -1878,8 +1905,12 @@ enum PriceModel { } type PriceTier { - from: Int! - upTo: Int + """ + First quantity this rate applies to. A Long because a token tier is counted in tokens: at ten dollars a + million, a ten-thousand-dollar tier already stands at a billion, well past what an Int can hold. + """ + from: Long! + upTo: Long unitPrice: Float! } @@ -1901,7 +1932,14 @@ input ProductCheckoutInput { productName: OpenframeProduct! packageOptionId: String quantity: Int - payAsYouGoEnabled: Boolean = true + + """ + Whether the product is billed by the meter beyond its allowance. Leave it out and the product decides: + on for a metered product, off for one sold in advance. There is no default here because a single one + could only ever be right for one kind, and asking for the meter on a product sold in advance is + refused rather than quietly ignored. + """ + payAsYouGoEnabled: Boolean enabled: Boolean = true } @@ -2982,6 +3020,19 @@ type SubscriptionUsage { What the AI overage accrued so far costs, in USD. This is what aiSpendCapUsd is measured against. """ aiSpendUsd: Float! + + """ + Prepaid tokens still available, net of anything owed, and never below zero: a tenant that kept working + through an automatic top-up has nothing left to spend rather than a negative amount of it. What it owes + is settled by the next purchase and is not reported here. + """ + purchasedTokensRemaining: Long! + + """ + What those tokens are worth, priced at the rate each pack was bought at and excluding tax. Zero + whenever purchasedTokensRemaining is, so the two never contradict each other. + """ + purchasedTokensRemainingUsd: Float! } """ Tag key definition (shared across all entity types)""" @@ -3178,6 +3229,18 @@ enum TimerState { COMPLETED } +"""What a token purchase left the customer with: an invoice to pay.""" +type TokenPurchase { + """Tokens the amount bought at the price in force when it was quoted.""" + tokens: Long! + + """Amount charged, in USD, excluding tax.""" + amountUsd: Float! + + """Where the customer pays the invoice. Tokens land once it is paid.""" + paymentUrl: String! +} + type ToolConnection implements Node { id: ID! machineId: String! diff --git a/src/app/(app)/checkout/cancel/page.tsx b/src/app/(app)/checkout/cancel/page.tsx index 525484e6..34b983a9 100644 --- a/src/app/(app)/checkout/cancel/page.tsx +++ b/src/app/(app)/checkout/cancel/page.tsx @@ -22,7 +22,7 @@ export default function CheckoutCancelPage() { iconWrapperClassName="bg-ods-error-secondary text-ods-error" title="Payment Cancelled" description="No charges were made. You can pick a plan whenever you're ready." - primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage }} + primaryCta={{ label: 'Back to Billing', href: routes.settings.billingUsage() }} secondaryCta={{ label: 'Go to Dashboard', href: routes.dashboard }} pending={gate === 'loading'} /> diff --git a/src/app/(app)/checkout/success/page.tsx b/src/app/(app)/checkout/success/page.tsx index 6cbdcf99..8810fc21 100644 --- a/src/app/(app)/checkout/success/page.tsx +++ b/src/app/(app)/checkout/success/page.tsx @@ -33,7 +33,7 @@ export default function CheckoutSuccessPage() { title="Payment Successful" description="Thanks for subscribing. Your plan is activating now — it may take a moment to show up across the app." primaryCta={{ label: 'Continue to Dashboard', href: routes.dashboard }} - secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage }} + secondaryCta={{ label: 'View Subscription', href: routes.settings.billingUsage() }} pending={gate === 'loading'} /> ); diff --git a/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx b/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx deleted file mode 100644 index a261e570..00000000 --- a/src/app/(app)/settings/billing-usage/components/ai-spend-limit-fields.tsx +++ /dev/null @@ -1,176 +0,0 @@ -'use client'; - -import { CheckCircleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; -import { CheckboxBlock, Input, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; -import { type ReactNode, useId } from 'react'; -import { type AiSpendLimit, CUSTOM_LIMIT, PRESET_TOKEN_LIMITS } from '../hooks/use-ai-spend-limit'; -import { formatCompactCount, formatCurrency } from '../lib/format'; - -/** - * What a limit does once it is reached. Stated wherever the limit is edited, but - * in different places — above the fields in the modal, below them on the paywall - * card — so it is a constant rather than part of the fields. - */ -export const AI_LIMIT_EXPLANATION = - "When the limit is reached, Fae and Mingo pause until the next cycle. You'll be notified before that happens, and can raise the limit anytime in Settings."; - -interface AiSpendLimitFieldsProps { - limit: AiSpendLimit; - /** Blocks every control — a save in flight, or a catalog that has not landed. */ - disabled?: boolean; - /** - * A choice was settled: a preset clicked, a custom amount finished, or the - * limit switched off (`null`). Surfaces that write immediately hang the - * mutation here; the modal leaves it out and saves from its own button. - */ - - onCommit?: (capUsd: number | null) => void; -} - -/** - * The AI spending limit, as controls: the switch, the four amounts, and the - * custom figure. - * - * Shared because the billing page's modal and the paywall's AI card ask the - * exact same question of the same subscription field. They differ only in when - * they write the answer, which is what `onCommit` is for — everything the user - * sees and does is here, once. - */ -export function AiSpendLimitFields({ limit, disabled = false, onCommit }: AiSpendLimitFieldsProps) { - const customInputId = useId(); - const customUsd = limit.selection === CUSTOM_LIMIT && limit.tokens != null ? limit.tokensToUsd(limit.tokens) : null; - /** - * Every amount here is priced from the metered rate, so without one there is - * nothing to choose between. The switch stays live regardless — a tenant whose - * catalog is unavailable must still be able to lift its own limit. - */ - const hasRate = limit.tokensToUsd(1) != null; - - const handleToggle = (checked: boolean) => { - limit.setEnabled(checked); - // Switching it off is a complete answer on its own; switching it on is not, - // so nothing is written until an amount is picked. - if (!checked) onCommit?.(null); - }; - - const handlePreset = (tokens: number) => { - const usd = limit.tokensToUsd(tokens); - if (usd == null) return; - limit.selectPreset(tokens); - onCommit?.(usd); - }; - - // On blur / Enter rather than per keystroke: every intermediate number a user - // types past ("1", "10", "100") would otherwise be saved as their limit. - const handleCustomCommit = () => { - if (limit.selection !== CUSTOM_LIMIT || limit.capUsd == null) return; - onCommit?.(limit.capUsd); - }; - - return ( -
- - - {limit.enabled && ( - <> -
-

Set a monthly spending limit

-
- {PRESET_TOKEN_LIMITS.map(tokens => { - const usd = limit.tokensToUsd(tokens); - return ( - : `~${formatCurrency(usd)}`} - onSelect={() => handlePreset(tokens)} - /> - ); - })} - -
-
- - {limit.selection === CUSTOM_LIMIT && ( -
- million tokens} - onChange={event => limit.setCustomMillions(event.target.value.replace(/[^\d.]/g, ''))} - onBlur={handleCustomCommit} - onKeyDown={event => { - if (event.key === 'Enter') handleCustomCommit(); - }} - /> - {/* Holds its line whether or not there is an amount yet, so typing - one does not push the rest of the form down. */} -

- {customUsd != null ? `~${formatCurrency(customUsd)}` : ''} -

-
- )} - - )} -
- ); -} - -interface LimitTileProps { - selected: boolean; - disabled: boolean; - title: ReactNode; - subtitle: ReactNode; - onSelect: () => void; -} - -/** - * One limit, as a button rather than a radio: the grid is four independent - * choices of the same shape, and the check mark on the chosen one is the whole - * selected state — there is no list semantics here for a radio group to carry. - */ -function LimitTile({ selected, disabled, title, subtitle, onSelect }: LimitTileProps) { - return ( - - ); -} diff --git a/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx b/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx deleted file mode 100644 index 41955993..00000000 --- a/src/app/(app)/settings/billing-usage/components/ai-tokens-limit-modal.tsx +++ /dev/null @@ -1,80 +0,0 @@ -'use client'; - -import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { SimpleModal } from '@/app/components/shared/simple-modal'; -import { useAiSpendLimit } from '../hooks/use-ai-spend-limit'; -import { useUpdateAiSpendCap } from '../hooks/use-update-ai-spend-cap'; -import { AI_LIMIT_EXPLANATION, AiSpendLimitFields } from './ai-spend-limit-fields'; - -interface AiTokensLimitModalProps { - isOpen: boolean; - onClose: () => void; - /** $ per token from the AI product's metered option. `null` locks the fields. */ - tokenPrice: number | null; - /** The cap stored on the subscription, in USD. `null` = uncapped. */ - capUsd: number | null; -} - -/** - * Setting the ceiling on a month's AI spend, from the billing page. - * - * Unlike the paywall's AI card — which writes every choice as it is made, - * because the user is mid-purchase and may never press anything else — this - * saves once, on its own button. The cap is a live setting on a live - * subscription here, and opening the dialog to look at it must not be able to - * change it. - * - * The mutation answers with the subscription's `id` and new `aiSpendCapUsd`, so - * Relay normalises the value into the record the page reads. Nothing refetches. - */ -export function AiTokensLimitModal({ isOpen, onClose, tokenPrice, capUsd }: AiTokensLimitModalProps) { - // Unmounted while closed, so every opening starts from what is stored rather - // than from the edits of a dialog that was dismissed. - if (!isOpen) return null; - - return ; -} - -function AiTokensLimitModalBody({ onClose, tokenPrice, capUsd }: Omit) { - const limit = useAiSpendLimit({ capUsd, tokenPrice }); - const updateCap = useUpdateAiSpendCap(); - - const handleSave = () => { - if (!limit.isComplete) return; - updateCap.mutate(limit.capUsd, { onSuccess: onClose }); - }; - - return ( - - {/* Figma splits the footer into two halves and leaves the left one - empty, so the button fills the right half rather than hugging its - label. `ModalV2Footer` is a bare `flex`, so the spacer is ours. */} -
- - - } - > -
-

{AI_LIMIT_EXPLANATION}

- {/* Only the save gates the fields: the amounts disable themselves when - the metered rate is missing, so switching the limit off stays - possible even then. */} - -
- - ); -} diff --git a/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx new file mode 100644 index 00000000..0a0cbaa7 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx @@ -0,0 +1,126 @@ +'use client'; + +import { CheckCircleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { Input, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; +import { type ReactNode, useId } from 'react'; +import { type AiTopUp, CUSTOM_TOP_UP, TOP_UP_PRESETS_USD } from '../hooks/use-ai-top-up'; +import { formatCompactCount, formatWholeCurrency } from '../lib/format'; + +/** + * What the balance is. Stated wherever it is topped up — the billing page's + * modal and the paywall's card — so it is a constant rather than part of the + * fields. + */ +export const AI_BALANCE_EXPLANATION = 'One unified balance powers AI assistants across all supported models.'; + +interface AiTopUpFieldsProps { + topUp: AiTopUp; + /** The uppercase label over the amounts — what this top-up is for on this surface. */ + label: string; + /** Blocks every control — a purchase in flight, or a catalog that has not landed. */ + disabled?: boolean; +} + +/** + * The top-up amount, as controls: three presets, a custom choice, and the field + * behind it. + * + * Shared because the billing page's Manage AI Balance modal and the paywall's + * AI Token Balance card ask the exact same question of the same catalog rate. + * They differ only in what they do with the answer — everything the user sees + * and does is here, once. + */ +export function AiTopUpFields({ topUp, label, disabled = false }: AiTopUpFieldsProps) { + const customInputId = useId(); + + return ( +
+
+

{label}

+
+ {TOP_UP_PRESETS_USD.map(usd => { + const tokens = topUp.tokensForUsd(usd); + return ( + : `${formatCompactCount(tokens)} tokens`} + onSelect={() => topUp.selectPreset(usd)} + /> + ); + })} + +
+
+ + {topUp.selection === CUSTOM_TOP_UP && ( +
+ USD} + onChange={event => topUp.setCustomUsd(event.target.value)} + /> + {/* Holds its line whether or not there is an amount yet, so typing + one does not push the rest of the form down. */} +

+ {topUp.tokens != null ? `${formatCompactCount(topUp.tokens)} tokens` : ''} +

+
+ )} +
+ ); +} + +interface TopUpTileProps { + selected: boolean; + disabled: boolean; + title: ReactNode; + subtitle: ReactNode; + onSelect: () => void; +} + +/** + * One amount, as a button rather than a radio: the grid is four independent + * choices of the same shape, and the check mark on the chosen one is the whole + * selected state — there is no list semantics here for a radio group to carry. + */ +function TopUpTile({ selected, disabled, title, subtitle, onSelect }: TopUpTileProps) { + return ( + + ); +} diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx index 1f5fa3b5..2705d781 100644 --- a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx +++ b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx @@ -3,12 +3,10 @@ import { AlertTriangleIcon, ExternalLinkIcon, - InfoCircleIcon, - PlusCircleIcon, - Settings02Icon, TagPercentIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { type ActionsMenuGroup, Button, PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { useApiParams } from '@flamingo-stack/openframe-frontend-core/hooks'; import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; import { useState } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; @@ -17,20 +15,20 @@ import { LockedScreen } from '@/app/components/shared/locked-screen'; import { SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status'; import { useFeatureFlag } from '@/app/hooks/use-feature-flag'; import { useSafeBack } from '@/app/hooks/use-safe-back'; -import { routes } from '@/lib/routes'; -import { TOKENS_PER_MILLION } from '../hooks/use-ai-spend-limit'; +import { MANAGE_AI_BALANCE_ACTION, routes } from '@/lib/routes'; import { useBillingPortalSession } from '../hooks/use-billing-portal-session'; -import { useBillingSummary } from '../hooks/use-billing-summary'; +import { type AiAlert, useBillingSummary } from '../hooks/use-billing-summary'; import { useCancelSubscription } from '../hooks/use-cancel-subscription'; import { useCancellationImpact } from '../hooks/use-cancellation-impact'; import { useResumeSubscription } from '../hooks/use-resume-subscription'; import { formatCompactCount, formatCount, formatCurrency, formatDateOrDash } from '../lib/format'; import { openExternalTab } from '../lib/stripe-window'; -import { AiTokensLimitModal } from './ai-tokens-limit-modal'; +import { ModelTokenRatesPopover } from '../subscription/components/model-token-rates'; import { BillingRow, SectionBlock, TestModeBanner } from './billing-section'; import { CancelOfferModal } from './cancel-offer-modal'; import { type CancelReason, CancelSubscriptionModal } from './cancel-subscription-modal'; import { InvoicesHistory } from './invoices-history'; +import { ManageAiBalanceModal } from './manage-ai-balance-modal'; import { SubscriptionCancelledModal } from './subscription-cancelled-modal'; import { TestClockPanel } from './test-clock-panel'; import { UpgradePlanModal } from './upgrade-plan-modal'; @@ -114,17 +112,13 @@ const billingUsageContentQuery = graphql` devicesUsed activeDevices # The AI counters the top row is built from: the period's free grant and - # how much of it is gone, then the tokens billed past it and what they - # have cost so far. aiSpendUsd is what aiSpendCapUsd is measured against - # — the page compares those two and nothing else. + # how much of it is gone, then the prepaid balance AI draws from once + # the grant is spent — in tokens, and what those are worth. aiTokensFree aiTokensFreeUsed - aiTokensOverage - aiSpendUsd + purchasedTokensRemaining + purchasedTokensRemainingUsd } - # Customer-set ceiling on the AI overage one period may accrue, in USD. - # Null means uncapped; 0 is a real cap. - aiSpendCapUsd currentInvoice { estimatedOverage } @@ -136,6 +130,25 @@ const billingUsageContentQuery = graphql` } `; +/** + * The block under the AI cards, by what it is about. Titles are the mockups' + * verbatim; the reset date is appended by the page when the period has one. + */ +const AI_ALERT_COPY: Record, { title: string; description: string }> = { + 'trial-exhausted': { + title: 'AI agents are paused.', + description: 'Activate your subscription to keep Mingo and Fae running.', + }, + low: { + title: 'AI agents will pause soon.', + description: 'Your AI balance is running low. Mingo and Fae stop responding when it hits zero.', + }, + empty: { + title: 'AI agents are paused. Your AI balance is empty.', + description: 'Mingo and Fae stopped responding until you top up.', + }, +}; + export function BillingUsageContent() { const handleBack = useSafeBack(routes.settings.root()); // Bumped after a resume so the billing query refetches from the network — the @@ -151,7 +164,16 @@ export function BillingUsageContent() { const resumeSubscription = useResumeSubscription(); const billingPortal = useBillingPortalSession(); const [planModalOpen, setPlanModalOpen] = useState(false); - const [aiLimitModalOpen, setAiLimitModalOpen] = useState(false); + /** + * The Manage AI Balance modal's open state IS the URL (`?action=`): the + * app-wide balance bar deep-links to it from any page, and one owner of the + * state is what keeps that link and the header button from disagreeing. + * Closing clears the param, so a reload does not reopen a dismissed dialog. + */ + const { params: pageParams, setParam: setPageParam } = useApiParams({ action: { type: 'string', default: '' } }); + const aiBalanceModalOpen = pageParams.action === MANAGE_AI_BALANCE_ACTION; + const openAiBalanceModal = () => setPageParam('action', MANAGE_AI_BALANCE_ACTION); + const closeAiBalanceModal = () => setPageParam('action', ''); const [cancelStep, setCancelStep] = useState<'idle' | 'reason' | 'offer' | 'cancelled'>('idle'); const [cancelReason, setCancelReason] = useState(null); const [cancelComment, setCancelComment] = useState(''); @@ -182,44 +204,20 @@ export function BillingUsageContent() { // up reading "247/300" over "Pay as you go", or a bare count over "Prepaid". const devicePrepaid = !flags.isTrial && device.allocation > 0; - /** - * The header carries at most one of these two, and the menu carries the other - * — never both in both places. Repeating an action in the overflow menu that - * is already a button beside it makes the menu read as a different, second - * thing to do. - * - * The AI limit wins the header whenever one is set: it is the thing most - * likely to be in the user's way, and the only one of the two a paused - * assistant depends on. The plan gets the header only when there is something - * to move UP to — a monthly plan has the annual one; an annual plan has - * nothing above it, so its plan change (really a device-count change) belongs - * in the menu. - */ - const aiLimitInHeader = flags.hasAi && ai.capUsd != null; /** * A scheduled cancellation drops the plan offer everywhere. The subscription * is already on its way out, so a change would be bought into something that * ends anyway; renewing is the move that makes the rest meaningful again. */ const planOffered = !flags.isPendingCancellation; - const planInHeader = planOffered && !aiLimitInHeader && !plan.isAnnual; const menuActions: ActionsMenuGroup[] = [ { items: [ - // Only when the header does not already offer it — which is exactly when - // there is no limit yet, hence the label. - ...(flags.hasAi && !aiLimitInHeader - ? [ - { - id: 'ai-limit', - label: 'Set AI Limit', - icon: , - onClick: () => setAiLimitModalOpen(true), - }, - ] - : []), - ...(planOffered && !planInHeader + // The plan change lives in the menu whatever the plan: the header's + // quieter slot is the AI balance, which is the thing most likely to be + // in the user's way (see `secondaryAction`). + ...(planOffered ? [ { id: 'change-plan', @@ -264,7 +262,7 @@ export function BillingUsageContent() { * * All of them end in the same modal — there is no plan page to send anyone to * any more. Activation from a trial is the checkout branch of it, which is why - * the modal folds every other product in as pay-as-you-go: a checkout session + * the modal folds every other product in: a checkout session * describes the whole plan, not just the part the modal edits. */ const statusAction = flags.isPendingCancellation @@ -298,26 +296,19 @@ export function BillingUsageContent() { } : null; - /** The header's second, quieter action — see `aiLimitInHeader` for which one and why. */ - const secondaryAction = aiLimitInHeader + /** + * The header's second, quieter action: the balance, not the plan. It is the + * one of the two a paused assistant depends on, and it is what the app-wide + * balance bar deep-links to. Absent without the AI product — there is no + * balance to manage. + */ + const secondaryAction = flags.hasAi ? { - label: 'Expand AI Limit', - // Secondary, like the menu's icons: the label carries the action, and a - // white glyph beside a white label reads as two emphases in one button. - icon: , - onClick: () => setAiLimitModalOpen(true), + label: 'Manage AI Balance', + onClick: openAiBalanceModal, variant: 'outline' as const, } - : planInHeader - ? { - // Named for what it does: the only plan above a monthly one is the - // annual one. Changing the device count is not an upgrade and is - // offered as "Change Plan" in the menu instead. - label: 'Upgrade to Annual Plan', - onClick: () => setPlanModalOpen(true), - variant: 'outline' as const, - } - : null; + : null; /** Rightmost is the accent one: the status action, when there is something to settle. */ const actions = [...(secondaryAction ? [secondaryAction] : []), ...(statusAction ? [statusAction] : [])]; @@ -379,71 +370,58 @@ export function BillingUsageContent() { /> } /> - {/* Two counters, because AI is metered in two parts: what the period - gives away, and what is billed past it. Both are server figures — the - free grant and the tokens beyond it come from `usage`, and the paid - counter's denominator is the customer's own cap converted at the - metered rate. With no cap it has none, and none is invented. */} + {/* Two counters, because AI runs on two figures: what the period gives + away, and the balance it draws from once that is spent. Both are + server figures from `usage`; the paid card's colour is the balance's + state, the free card's is a trial's (which has no balance to colour). */} {flags.hasAi && ( <> {formatCompactCount(ai.freeUsed)} /{formatCompactCount(ai.free)} } - caption="Updated monthly" + caption={flags.isTrial ? 'Included with trial' : 'Updated monthly'} /> - {formatCompactCount(ai.paid)} - {ai.capTokens != null && /{formatCompactCount(ai.capTokens)}} - - } + tone={ai.paidTone} + value={formatCompactCount(ai.paid)} caption={ <> - {formatCurrency(ai.spendUsd)} on next invoice + {formatCurrency(ai.paidUsd)} balance } + trailing={} /> )}
- {/* The cap the user set is being reached, so AI is about to stop — or has. - Only the icon carries the colour: the card above already states the - figure in full, and this block is the sentence explaining it. - - The fix is in the header, where the primary button becomes "Expand AI - Limit" for exactly these two states. */} - {ai.tone !== 'default' && ai.capUsd != null && ( + {/* The one sentence the page owes about AI right now: the balance is + running low, it is empty, or a trial has spent its grant. Only the icon + carries the colour — the card above already states the figure in full, + and this block is the sentence explaining it. The fix is in the header: + Manage AI Balance, or Activate Subscription on a trial. */} + {ai.alert && (
-

- {ai.capReached ? 'AI agents are paused. Your AI balance is empty.' : 'AI agents will pause soon.'} -

- {/* No figures: the card above states the spend and the limit in full, - and a block that repeats them turns one fact into two to compare. - The "balance" here is the headroom left under the cap the user set - — not the token bank this product stopped selling. */} +

{AI_ALERT_COPY[ai.alert].title}

- {ai.capReached - ? // Not "top up": nothing can be bought to resume. The cap is - // self-imposed and raising it is the only way out, which is - // exactly what the header button does. - 'Mingo and Fae stopped responding until you raise the limit.' - : 'Your AI balance is running low. Mingo and Fae stop responding when it hits zero.'} + {AI_ALERT_COPY[ai.alert].description} {/* Only when the period has a known end — the reset date is that - date, not a separate fact this can guess at. */} - {billing.nextBillingDate && ` Free tokens reset on ${formatDateOrDash(billing.nextBillingDate)}.`} + date, not a separate fact this can guess at. A trial resets + nothing: activation is what refills it. */} + {ai.alert !== 'trial-exhausted' && + billing.nextBillingDate && + ` Free tokens reset on ${formatDateOrDash(billing.nextBillingDate)}.`}

@@ -488,117 +466,68 @@ export function BillingUsageContent() { )} {/* Side by side once there is a second block to read against the plan — - the plan it is changing to, or what its metered AI is costing. On its - own, Current Plan takes the full width. */} -
0) && 'md:grid-cols-2', - )} - > - - - {plan.deviceRate != null && ( - } /> - )} - {ai.tokenPrice != null && ( - } /> - )} - {/* The grant the tenant is actually on this period, served by the - backend — unlike the Updated Plan's, which has to be derived. */} - {flags.hasAi && } />} - {!flags.isTrial && nextPaymentAmount > 0 && ( - + the plan it is changing to. On its own, Current Plan takes the full + width. A trial has no plan to state: its one date is on the device + card, and the header offers activation. */} + {!flags.isTrial && ( +
- )} - {billing.cancellationEffectiveAt && ( - } /> - )} - {billing.currentPlanEndsOn && ( - } /> - )} - {flags.isTrial && billing.trialExpirationDate && ( - } /> - )} - - - {/* The plan that takes over — a scheduled package, or the metered - billing a lapsing commitment falls back to. It answers the left - column's questions in the left column's order, so the two read as one - comparison; the AI rate is the same either way and is stated on both - sides rather than left to be assumed unchanged. */} - {flags.hasPendingPlan && ( - - - {updatedPlan.deviceRate != null && ( - } /> + > + + + {plan.deviceRate != null && ( + } /> )} - {ai.tokenPrice != null && ( - } /> + {/* The grant the tenant is actually on this period, served by the + backend — unlike the Updated Plan's, which has to be derived. */} + {flags.hasAi && } />} + {nextPaymentAmount > 0 && } + {/* Independent rows, not one slot fought over by several dates: each is + present exactly when its own field is (see `useBillingSummary`). A + plan that is ending still has a billing date, and Figma shows both — + they land on the same day because the subscription runs to the end + of the paid period and stops there, which is two facts, not one + repeated. */} + {billing.nextBillingDate && ( + )} - {flags.hasAi && ( - } /> + {billing.cancellationEffectiveAt && ( + } /> )} - {updatedPlan.startsOn && ( - } /> + {billing.currentPlanEndsOn && ( + } /> )} - )} - {/* Metered AI, once any of it has actually been billed. Beside the plan - rather than under the cards: it is the plan's fine print — what the - surplus costs, what ceiling it is running into, and when the meter - resets — and the top card states only the count. */} - {ai.paid > 0 && ( - -
- -

- Extra token usage continues at pay-as-you-go rates and appears on your next invoice. -

-
- - - {/* Stated in both units, because the limit is chosen in tokens and - charged in dollars — see the AI Tokens Limit modal. */} - {ai.capUsd != null && ( - - {ai.capTokens != null && formatCompactCount(ai.capTokens)} - ({formatCurrency(ai.capUsd)}) - - } - /> - )} - {billing.nextBillingDate && ( - - )} -
- )} -
+ {/* The plan that takes over — a scheduled package, or the metered + billing a lapsing commitment falls back to. It answers the left + column's questions in the left column's order, so the two read as + one comparison. */} + {flags.hasPendingPlan && ( + + + {updatedPlan.deviceRate != null && ( + } /> + )} + {flags.hasAi && ( + } /> + )} + {updatedPlan.startsOn && ( + } /> + )} + + )} +
+ )} - {/* Writes through `updateAiSpendCap`, whose response carries the new cap - into the same subscription record this page reads — so nothing here - refetches when it saves. */} - setAiLimitModalOpen(false)} - tokenPrice={ai.tokenPrice} - capUsd={ai.capUsd} - /> + {/* Raises an invoice and opens it; the balance moves once that is paid, + which is when the page's next fetch reads it. Nothing to refetch here. */} + - {formatCurrency(amount)} - / 1M tokens - - ); -} - /** A monthly token grant: the count, with its cadence trailing. */ function MonthlyTokens({ tokens }: { tokens: number }) { return ( diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx index a573a18a..5298bc49 100644 --- a/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx +++ b/src/app/(app)/settings/billing-usage/components/billing-usage-skeleton.tsx @@ -110,7 +110,6 @@ export function BillingUsageSkeleton() { } /> } /> - } /> } /> } /> } /> diff --git a/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx new file mode 100644 index 00000000..7cf698c1 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { SimpleModal } from '@/app/components/shared/simple-modal'; +import { useAiTopUp } from '../hooks/use-ai-top-up'; +import { usePurchaseTokens } from '../hooks/use-purchase-tokens'; +import { AI_BALANCE_EXPLANATION, AiTopUpFields } from './ai-top-up-fields'; + +interface ManageAiBalanceModalProps { + isOpen: boolean; + onClose: () => void; + /** $ per token from the AI product's metered option. `null` until it loads. */ + tokenPrice: number | null; +} + +/** + * Buying AI tokens from the billing page. + * + * One-time top-ups only. The mockup also offers "Enable Auto Top-up" — refill + * the balance with the chosen amount whenever it runs out — but the API has + * nothing to store that choice in: `purchaseTokens` is a single purchase, and no + * field on the subscription says whether to repeat it. The checkbox is not drawn + * until it can do something. + * + * The purchase raises an invoice rather than charging on the spot, so the + * button reads "Proceed to Payment" rather than the mockup's "Save": nothing is + * saved here, and the tokens land once the invoice is paid. + */ +export function ManageAiBalanceModal({ isOpen, onClose, tokenPrice }: ManageAiBalanceModalProps) { + // Unmounted while closed, so every opening starts from nothing chosen rather + // than from the edits of a dialog that was dismissed. + if (!isOpen) return null; + + return ; +} + +function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit) { + const topUp = useAiTopUp({ tokenPrice }); + const purchase = usePurchaseTokens(); + + const handleSubmit = () => { + if (topUp.amountUsd == null) return; + purchase.mutate(topUp.amountUsd, { onSuccess: onClose }); + }; + + return ( + + {/* Figma splits the footer into two halves and leaves the left one + empty, so the button fills the right half rather than hugging its + label. `ModalV2Footer` is a bare `flex`, so the spacer is ours. */} +
+ + + } + > +
+

{AI_BALANCE_EXPLANATION}

+ +
+ + ); +} diff --git a/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx b/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx index 1229df3a..cc014af9 100644 --- a/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx +++ b/src/app/(app)/settings/billing-usage/components/upgrade-plan-modal.tsx @@ -34,11 +34,13 @@ const upgradePlanModalQuery = graphql` interface PlanSelection { updates: ProductUpdates; /** - * Every non-device product, as pay-as-you-go. A checkout session describes the + * Every non-device product, with no options. A checkout session describes the * WHOLE target plan, not a diff, so leaving these out would activate a - * subscription with the AI assistants switched off. They are metered-only, so - * "pay as you go" is their entire configuration — the same entry the paywall's - * AI card contributes. + * subscription with the AI assistants switched off. How each is billed is the + * product's own decision — the same entry the paywall contributes. + * + * No `tokenAmountUsd` rides along from here: the modal has no top-up control, + * so a checkout configured to require one is refused with the server's message. */ otherProducts: ProductCheckoutInput[]; } @@ -136,10 +138,7 @@ function UpgradePlanBody({ onSelectionChange }: { onSelectionChange: (selection: data.subscription?.products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; const otherProducts = useMemo( - () => - products - .filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES) - .map(p => ({ productName: p.name, payAsYouGoEnabled: true })), + () => products.filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES).map(p => ({ productName: p.name })), [products], ); diff --git a/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx b/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx index 03841da4..e304074c 100644 --- a/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx +++ b/src/app/(app)/settings/billing-usage/components/usage-stat-card.tsx @@ -6,8 +6,9 @@ import type { ReactNode } from 'react'; /** * How the counter is doing against whatever bounds it. * - * - `warning` — heading for the limit: devices past the package, AI spend - * approaching its cap. It costs more, or it is about to stop. + * - `warning` — heading for the limit: devices past the package, an AI balance + * running low, a trial that has spent its grant. It costs more, or it is + * about to stop. * - `error` — the limit is reached and something has actually stopped. */ export type UsageStatTone = 'default' | 'warning' | 'error'; @@ -24,6 +25,8 @@ interface UsageStatCardProps { * out what it costs. */ tone?: UsageStatTone; + /** Pinned to the card's top-right corner, outside the text flow (e.g. a help popover). */ + trailing?: ReactNode; } const TONE_CARD: Record = { @@ -53,7 +56,7 @@ const TONE_VALUE: Record = { * no ring, which it has no variant for. So the composition lives here, in the * page that needs it, on the same ODS tokens the card is built from. */ -export function UsageStatCard({ title, value, caption, tone = 'default' }: UsageStatCardProps) { +export function UsageStatCard({ title, value, caption, tone = 'default', trailing }: UsageStatCardProps) { return ( // The state travels to the value/caption slots as an attribute rather than a // prop: both are nodes built by the caller, so the card cannot pass anything @@ -62,7 +65,7 @@ export function UsageStatCard({ title, value, caption, tone = 'default' }: Usage
{value}

{caption}

+ {trailing && ( +
+ {trailing} +
+ )}
); } @@ -92,9 +102,9 @@ export function StatSuffix({ children }: { children: ReactNode }) { /** * The part of a caption that carries the information, inside a line that is - * otherwise a label ("Trial Period ends **12/15/26**", "**$14.00** on next - * invoice"). Lifted to the primary text colour — the caption's own grey is the - * label's, not the value's. + * otherwise a label ("Trial Period ends **12/15/26**", "**$50.00** balance"). + * Lifted to the primary text colour — the caption's own grey is the label's, + * not the value's. */ export function StatEmphasis({ children }: { children: ReactNode }) { return ( diff --git a/src/app/(app)/settings/billing-usage/hooks/use-ai-spend-limit.ts b/src/app/(app)/settings/billing-usage/hooks/use-ai-spend-limit.ts deleted file mode 100644 index bb4309d8..00000000 --- a/src/app/(app)/settings/billing-usage/hooks/use-ai-spend-limit.ts +++ /dev/null @@ -1,136 +0,0 @@ -'use client'; - -import { useState } from 'react'; - -/** The unit the custom limit is entered in — "50" means 50 million tokens. */ -export const TOKENS_PER_MILLION = 1_000_000; - -/** - * The limits offered as one click each. Token counts rather than dollars because - * that is the unit the product is metered in and the one the free grant is - * stated in — the money under each is derived from the catalog rate. The cap the - * backend stores is USD (`aiSpendCapUsd`), so every choice is converted on the - * way out and back. - */ -export const PRESET_TOKEN_LIMITS = [2_000_000, 5_000_000, 10_000_000] as const; - -export const CUSTOM_LIMIT = 'custom'; - -export type AiLimitSelection = number | typeof CUSTOM_LIMIT | null; - -/** What the user has picked, before it means anything in dollars. */ -interface LimitChoice { - enabled: boolean; - selection: AiLimitSelection; - /** Free text, in millions — kept as typed so a half-entered "1." survives. */ - customMillions: string; -} - -export interface AiSpendLimit extends LimitChoice { - /** Tokens the current choice means; `null` when nothing valid is chosen yet. */ - tokens: number | null; - /** - * What `updateAiSpendCap` would be sent for this choice: `null` removes the - * cap. Also `null` while an enabled limit has no figure behind it — read it - * together with `isComplete`, which tells the two apart. - */ - capUsd: number | null; - /** The choice is finished: either no limit, or a limit with a figure. */ - isComplete: boolean; - /** - * The finished choice differs from what the subscription already stores — so - * there is something to send. Surfaces that save on a button read this to - * decide whether to issue the mutation at all. - */ - changed: boolean; - /** What a token count costs, or `null` until the metered rate is known. */ - tokensToUsd: (tokens: number) => number | null; - setEnabled: (next: boolean) => void; - selectPreset: (tokens: number) => void; - selectCustom: () => void; - setCustomMillions: (next: string) => void; - /** Drop local edits and show what the server holds (e.g. a refused save). */ - reset: () => void; -} - -interface UseAiSpendLimitOptions { - /** The cap stored on the subscription, in USD. `null` = uncapped. */ - capUsd: number | null; - /** $ per token from the AI product's metered option. `null` until it loads. */ - tokenPrice: number | null; -} - -/** Tokens from the millions field: digits with at most one decimal point. */ -function parseMillions(value: string): number | null { - const millions = Number.parseFloat(value); - if (!Number.isFinite(millions) || millions <= 0) return null; - return Math.round(millions * TOKENS_PER_MILLION); -} - -/** - * The stored cap, told back in the unit this picker speaks. Without a rate a USD - * cap cannot be converted, so the limit shows as on with no tile selected — the - * reseed below picks one the moment the catalog lands. - */ -function seedFromServer(capUsd: number | null, tokenPrice: number | null): LimitChoice { - if (capUsd == null) return { enabled: false, selection: null, customMillions: '' }; - if (!tokenPrice) return { enabled: true, selection: null, customMillions: '' }; - - const tokens = Math.round(capUsd / tokenPrice); - const preset = PRESET_TOKEN_LIMITS.find(option => option === tokens); - if (preset != null) return { enabled: true, selection: preset, customMillions: '' }; - - return { enabled: true, selection: CUSTOM_LIMIT, customMillions: String(tokens / TOKENS_PER_MILLION) }; -} - -/** - * The AI spending limit as the user is editing it, seeded from what the - * subscription already holds. - * - * Two surfaces edit the same setting — the billing page's modal and the - * paywall's AI card — and they differ only in WHEN they write it (a Save button - * vs. immediately). So the state and the token↔dollar arithmetic live here and - * neither owns them, while committing stays with the caller. - * - * Local edits are dropped whenever the server's answer changes: after a - * successful save the two agree, so nothing moves; after a refused one `reset()` - * puts back what is actually stored. - */ -export function useAiSpendLimit({ capUsd, tokenPrice }: UseAiSpendLimitOptions): AiSpendLimit { - const [choice, setChoice] = useState(() => seedFromServer(capUsd, tokenPrice)); - - // Reseed during render rather than in an effect: the values arrive from a - // query, and an effect would paint one frame of the stale choice first. - const serverKey = `${capUsd ?? ''}|${tokenPrice ?? ''}`; - const [seededFrom, setSeededFrom] = useState(serverKey); - if (serverKey !== seededFrom) { - setSeededFrom(serverKey); - setChoice(seedFromServer(capUsd, tokenPrice)); - } - - // Rounded to cents, which is the granularity the cap is billed and stored at. - // Without it a preset read back from the server (`capUsd / price` → tokens → - // `tokens * price`) lands a float epsilon away from the value it came from, - // and `changed` below would report every untouched limit as edited. - const tokensToUsd = (tokens: number): number | null => - tokenPrice == null ? null : Math.round(tokens * tokenPrice * 100) / 100; - - const tokens = choice.selection === CUSTOM_LIMIT ? parseMillions(choice.customMillions) : choice.selection; - const resolvedCapUsd = choice.enabled && tokens != null ? tokensToUsd(tokens) : null; - const isComplete = !choice.enabled || resolvedCapUsd != null; - - return { - ...choice, - tokens, - capUsd: resolvedCapUsd, - isComplete, - changed: isComplete && resolvedCapUsd !== capUsd, - tokensToUsd, - // Turning the limit on chooses nothing by itself — the tiles are the choice. - setEnabled: next => setChoice({ enabled: next, selection: null, customMillions: '' }), - selectPreset: preset => setChoice(current => ({ ...current, selection: preset })), - selectCustom: () => setChoice(current => ({ ...current, selection: CUSTOM_LIMIT })), - setCustomMillions: next => setChoice(current => ({ ...current, customMillions: next })), - reset: () => setChoice(seedFromServer(capUsd, tokenPrice)), - }; -} diff --git a/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts b/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts new file mode 100644 index 00000000..289aa015 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts @@ -0,0 +1,85 @@ +'use client'; + +import { useState } from 'react'; +import { tokensForUsd } from '../lib/ai-token-price'; + +/** + * The amounts offered as one click each, in whole dollars — the unit + * `purchaseTokens` and `CheckoutInput.tokenAmountUsd` take. The tokens under + * each are derived from the catalog rate, never the other way round. + */ +export const TOP_UP_PRESETS_USD = [20, 50, 100] as const; + +export type TopUpPresetUsd = (typeof TOP_UP_PRESETS_USD)[number]; + +export const CUSTOM_TOP_UP = 'custom'; + +export type TopUpSelection = number | typeof CUSTOM_TOP_UP | null; + +/** What the user has picked, before it means anything in tokens. */ +interface TopUpChoice { + selection: TopUpSelection; + /** Free text, whole dollars — kept as typed so the field never fights the user. */ + customUsd: string; +} + +export interface AiTopUp extends TopUpChoice { + /** Dollars the current choice means; `null` while nothing valid is chosen. */ + amountUsd: number | null; + /** What that buys at the catalog rate; `null` without a rate or an amount. */ + tokens: number | null; + /** A preset, or a custom figure that parses — there is an amount to send. */ + isComplete: boolean; + tokensForUsd: (usd: number) => number | null; + selectPreset: (usd: TopUpPresetUsd) => void; + selectCustom: () => void; + setCustomUsd: (next: string) => void; + /** Back to the opening choice (e.g. after a refused purchase). */ + reset: () => void; +} + +interface UseAiTopUpOptions { + /** $ per token from the AI product's metered option. `null` until it loads. */ + tokenPrice: number | null; + /** What is picked before the user touches anything; nothing by default. */ + initial?: TopUpPresetUsd | null; +} + +/** + * Whole dollars only: the backend refuses cents ("the amount must be a whole + * number of dollars"), so the field cannot produce them in the first place. + */ +function parseWholeUsd(value: string): number | null { + if (!/^\d+$/.test(value)) return null; + const usd = Number.parseInt(value, 10); + return usd > 0 ? usd : null; +} + +/** + * The AI top-up as the user is choosing it. + * + * Two surfaces ask the same question — the billing page's Manage AI Balance + * modal and the paywall's AI Token Balance card — and they differ only in what + * they do with the answer: an invoice now (`purchaseTokens`), or a line on the + * checkout (`tokenAmountUsd`). So the state and the dollar↔token arithmetic + * live here, and neither surface owns them. + */ +export function useAiTopUp({ tokenPrice, initial = null }: UseAiTopUpOptions): AiTopUp { + const [choice, setChoice] = useState({ selection: initial, customUsd: '' }); + + const amountUsd = choice.selection === CUSTOM_TOP_UP ? parseWholeUsd(choice.customUsd) : choice.selection; + const forUsd = (usd: number): number | null => tokensForUsd(usd, tokenPrice); + + return { + ...choice, + amountUsd, + tokens: amountUsd == null ? null : forUsd(amountUsd), + isComplete: amountUsd != null, + tokensForUsd: forUsd, + selectPreset: usd => setChoice(current => ({ ...current, selection: usd })), + selectCustom: () => setChoice(current => ({ ...current, selection: CUSTOM_TOP_UP })), + // Digits only, so a pasted "$50" or "50.00" lands as the whole dollars it meant. + setCustomUsd: next => setChoice(current => ({ ...current, customUsd: next.replace(/\D/g, '') })), + reset: () => setChoice({ selection: initial, customUsd: '' }), + }; +} diff --git a/src/app/(app)/settings/billing-usage/hooks/use-billing-summary.ts b/src/app/(app)/settings/billing-usage/hooks/use-billing-summary.ts index a2cc5e34..77f28c99 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-billing-summary.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-billing-summary.ts @@ -1,11 +1,14 @@ import type { billingUsageContentQuery$data } from '@/__generated__/billingUsageContentQuery.graphql'; import { SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status'; import { BillingPeriod, OpenframeProduct, SubscriptionProductStatus } from '@/generated/schema-enums'; -import { aiSpendTone } from '@/lib/ai-spend-tone'; +import { aiBalanceTone, aiFreeTokensExhausted } from '@/lib/ai-balance-tone'; import type { UsageStatTone } from '../components/usage-stat-card'; import { freeTokensForPlan } from '../lib/ai-free-tokens'; import { aiTokenPrice as tokenPriceFromUnit } from '../lib/ai-token-price'; +/** The one sentence the page owes about AI right now, if any. */ +export type AiAlert = 'trial-exhausted' | 'low' | 'empty' | null; + type SubscriptionData = billingUsageContentQuery$data['subscription']; type BillingPlanData = billingUsageContentQuery$data['billingPlan']; @@ -101,52 +104,54 @@ export function useBillingSummary(subscription: SubscriptionData, billingPlan: B const deviceOverLimit = !deviceIsPayg && deviceAllocation > 0 && deviceOverage > 0; /** - * AI is consumption against two figures the backend serves itself: the free - * grant for the period, and the ceiling the customer put on what may be billed - * beyond it. Nothing here is derived from an AI package — there is none to buy. - * - * The cap is stored in USD, and the cards count tokens, so the metered rate - * converts between them. Without a rate the paid counter simply has no - * denominator; it never invents one. - */ - /** - * The tenant's own metered rate, per token. + * AI runs on two figures the backend serves itself: the period's free grant, + * and the prepaid balance it draws from once the grant is spent. Nothing here + * is derived from an AI package or a spending cap — there is neither. * - * Two records for one figure, on purpose: the price is the SUBSCRIPTION's (a - * negotiated rate is what this tenant is actually billed), while `unitSize` — - * the block that price is quoted per — exists only on the catalog product. - * Either half missing leaves the rate unknown, and no AI price is printed. + * The metered rate is still read, for one purpose: pricing the top-up tiles + * (what $20 buys). Two records for one figure, on purpose: the price is the + * SUBSCRIPTION's (a negotiated rate is what this tenant is actually billed), + * while `unitSize` — the block that price is quoted per — exists only on the + * catalog product. Either half missing leaves the rate unknown, and no token + * count is printed under an amount. */ const aiCatalogProduct = billingPlan?.products.find(p => p.name === OpenframeProduct.AI_ASSISTANCE) ?? null; const aiTokenPrice = tokenPriceFromUnit(aiProduct?.payAsYouGoOption?.price, aiCatalogProduct?.unitSize); + // GraphQL `Long` arrives as a string or a number depending on its size. const aiTokensFree = Number(subscription?.usage?.aiTokensFree ?? 0); const aiTokensFreeUsed = Number(subscription?.usage?.aiTokensFreeUsed ?? 0); - const aiTokensPaid = Number(subscription?.usage?.aiTokensOverage ?? 0); - const aiSpendUsd = subscription?.usage?.aiSpendUsd ?? 0; - const aiCapUsd = subscription?.aiSpendCapUsd ?? null; + const purchasedTokens = Number(subscription?.usage?.purchasedTokensRemaining ?? 0); + const purchasedTokensUsd = subscription?.usage?.purchasedTokensRemainingUsd ?? 0; + const freeExhausted = aiFreeTokensExhausted({ freeTokens: aiTokensFree, freeUsed: aiTokensFreeUsed }); /** - * Shared with the app-wide limit bar, so the two cannot disagree about when - * AI is close to stopping (see `lib/ai-spend-tone.ts`). A cap of 0 is a real - * cap — nothing beyond the free tokens — so every check there is against - * `null`, never falsy. + * Shared with the app-wide balance bar, so the two cannot disagree about when + * AI is close to stopping (see `lib/ai-balance-tone.ts`). + * + * A trial has no balance: it runs on its grant alone, and spending that is + * the one thing the trial's own card warns about — the paid card stays quiet, + * and the fix is activation, not a top-up. */ - const aiCapped = aiCapUsd != null; - const aiTone = aiSpendTone(aiSpendUsd, aiCapUsd); + const balanceTone = isTrial + ? 'default' + : aiBalanceTone({ freeTokens: aiTokensFree, freeUsed: aiTokensFreeUsed, purchasedRemaining: purchasedTokens }); + const trialExhausted = isTrial && freeExhausted; + + let alert: AiAlert = null; + if (trialExhausted) alert = 'trial-exhausted'; + else if (balanceTone === 'error') alert = 'empty'; + else if (balanceTone === 'warning') alert = 'low'; const ai = { tokenPrice: aiTokenPrice, free: aiTokensFree, freeUsed: aiTokensFreeUsed, - /** Tokens spent past the free grant — the ones that get billed. */ - paid: aiTokensPaid, - spendUsd: aiSpendUsd, - capUsd: aiCapUsd, - /** The cap told back in tokens, which is the unit the counter is in. */ - capTokens: aiCapped && aiTokenPrice ? Math.round(aiCapUsd / aiTokenPrice) : null, - capReached: aiTone === 'error', - capNear: aiTone === 'warning', - tone: aiTone as UsageStatTone, + /** Prepaid tokens still in the bank, and what they are worth. */ + paid: purchasedTokens, + paidUsd: purchasedTokensUsd, + freeTone: (trialExhausted ? 'warning' : 'default') as UsageStatTone, + paidTone: balanceTone as UsageStatTone, + alert, }; /** diff --git a/src/app/(app)/settings/billing-usage/hooks/use-purchase-tokens.ts b/src/app/(app)/settings/billing-usage/hooks/use-purchase-tokens.ts new file mode 100644 index 00000000..49e95930 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/hooks/use-purchase-tokens.ts @@ -0,0 +1,83 @@ +'use client'; + +import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; +import { useCallback } from 'react'; +import { graphql, useMutation } from 'react-relay'; +import type { usePurchaseTokensMutation as UsePurchaseTokensMutationType } from '@/__generated__/usePurchaseTokensMutation.graphql'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; +import { openDeferredTab } from '../lib/stripe-window'; + +/** + * Tokens are credited only once the invoice this raises is paid, so the answer + * that matters is `paymentUrl` — where the customer goes to pay it. Nothing in + * the store changes on completion: the balance moves when the payment lands, + * and the page reads it on its next fetch. + */ +const purchaseTokensMutation = graphql` + mutation usePurchaseTokensMutation($amountUsd: Float!) { + purchaseTokens(amountUsd: $amountUsd) { + paymentUrl + } + } +`; + +interface PurchaseTokensOptions { + /** The invoice exists and its page is open. Callers in a modal close on this. */ + onSuccess?: () => void; +} + +/** + * Buys AI tokens for a whole number of dollars and sends the customer to the + * invoice for them. + * + * The invoice tab is opened from the click, before the mutation answers, for + * the same reason checkout does it (see `openDeferredTab`): a tab opened once + * the response is in has lost the user gesture that lets it through the popup + * blocker. It is closed again if the purchase is refused — which the backend + * does below the configured minimum, and while the subscription itself is + * blocked (past due, suspended, cancelled, an expired trial). + */ +export function usePurchaseTokens() { + const { toast } = useToast(); + const [commit, isInFlight] = useMutation(purchaseTokensMutation); + + const mutate = useCallback( + (amountUsd: number, options?: PurchaseTokensOptions) => { + const tab = openDeferredTab(); + + commit({ + variables: { amountUsd }, + onCompleted: response => { + const url = response.purchaseTokens?.paymentUrl; + if (!url) { + tab.cancel(); + toast({ + title: 'Top-up Failed', + description: 'No invoice was returned. Please try again later.', + variant: 'destructive', + }); + return; + } + toast({ + title: 'Invoice Opened', + description: 'Pay the invoice in the new tab. Tokens are credited once it is paid.', + variant: 'success', + }); + tab.navigate(url); + options?.onSuccess?.(); + }, + onError: err => { + tab.cancel(); + toast({ + title: 'Top-up Failed', + description: getRelayErrorMessage(err, 'Failed to buy AI tokens'), + variant: 'destructive', + }); + }, + }); + }, + [commit, toast], + ); + + return { mutate, isPending: isInFlight }; +} diff --git a/src/app/(app)/settings/billing-usage/hooks/use-update-ai-spend-cap.ts b/src/app/(app)/settings/billing-usage/hooks/use-update-ai-spend-cap.ts deleted file mode 100644 index c6fc6b2b..00000000 --- a/src/app/(app)/settings/billing-usage/hooks/use-update-ai-spend-cap.ts +++ /dev/null @@ -1,73 +0,0 @@ -'use client'; - -import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; -import { useCallback } from 'react'; -import { graphql, useMutation } from 'react-relay'; -import type { useUpdateAiSpendCapMutation as UseUpdateAiSpendCapMutationType } from '@/__generated__/useUpdateAiSpendCapMutation.graphql'; - -/** - * The response is a `SubscriptionDetail` with its `id`, so Relay normalises the - * new cap straight into the record every other surface reads. Nothing here has - * to refetch, and no caller has to thread the value back up. - */ -const updateAiSpendCapMutation = graphql` - mutation useUpdateAiSpendCapMutation($amountUsd: Float) { - updateAiSpendCap(amountUsd: $amountUsd) { - id - aiSpendCapUsd - } - } -`; - -interface UpdateAiSpendCapOptions { - /** The cap is stored. Callers that edit it in a modal close on this. */ - onSuccess?: () => void; - /** The server refused the change — the caller restores what it was showing. */ - onError?: () => void; -} - -/** - * Sets the ceiling, in USD, on the AI overage one billing period may accrue. - * - * `null` is not "no change": it is how the schema expresses "no cap at all", so - * it is what clearing the limit sends. `0` is a real cap — nothing beyond the - * free tokens — which is why the argument is `number | null` and never a falsy - * check. - * - * Usable while AI is blocked, by backend contract: a tenant that hit its own - * ceiling has to be able to raise it. - */ -export function useUpdateAiSpendCap() { - const { toast } = useToast(); - const [commit, isInFlight] = useMutation(updateAiSpendCapMutation); - - const mutate = useCallback( - (amountUsd: number | null, options?: UpdateAiSpendCapOptions) => { - commit({ - variables: { amountUsd }, - onCompleted: () => { - toast({ - title: amountUsd == null ? 'Spending Limit Removed' : 'Spending Limit Set', - description: - amountUsd == null - ? 'AI usage is no longer capped for this workspace.' - : 'AI pauses for the rest of the cycle once this is reached.', - variant: 'success', - }); - options?.onSuccess?.(); - }, - onError: err => { - toast({ - title: 'Limit Not Saved', - description: err instanceof Error ? err.message : 'Failed to update the AI spending limit', - variant: 'destructive', - }); - options?.onError?.(); - }, - }); - }, - [commit, toast], - ); - - return { mutate, isPending: isInFlight }; -} diff --git a/src/app/(app)/settings/billing-usage/lib/ai-token-price.ts b/src/app/(app)/settings/billing-usage/lib/ai-token-price.ts index 2393a0d1..22b9ca38 100644 --- a/src/app/(app)/settings/billing-usage/lib/ai-token-price.ts +++ b/src/app/(app)/settings/billing-usage/lib/ai-token-price.ts @@ -18,3 +18,17 @@ export function aiTokenPrice(unitPrice: number | null | undefined, unitSize: unk if (!Number.isFinite(size) || size <= 0) return null; return unitPrice / size; } + +/** + * How many tokens `amountUsd` buys at `tokenPrice` ($ per token, from + * {@link aiTokenPrice}); `null` when either is unknown. + * + * An estimate for the tiles ("$20 → 2M tokens"), priced at the entry rate: the + * catalog can state price bands, and the backend quotes the exact quantity at + * purchase, so the figure here is not what is charged — the invoice is. + */ +export function tokensForUsd(amountUsd: number, tokenPrice: number | null): number | null { + if (tokenPrice == null || tokenPrice <= 0) return null; + if (!Number.isFinite(amountUsd) || amountUsd <= 0) return null; + return Math.floor(amountUsd / tokenPrice); +} diff --git a/src/app/(app)/settings/billing-usage/lib/format.ts b/src/app/(app)/settings/billing-usage/lib/format.ts index 0b58424c..120fb98c 100644 --- a/src/app/(app)/settings/billing-usage/lib/format.ts +++ b/src/app/(app)/settings/billing-usage/lib/format.ts @@ -26,3 +26,8 @@ export function formatDateOrDash(iso: string | null | undefined): string { return iso; } } + +/** Whole-dollar amounts, the way the top-up tiles state them: 20 → "$20". */ +export function formatWholeCurrency(value: number): string { + return value.toLocaleString('en-US', { style: 'currency', currency: 'USD', maximumFractionDigits: 0 }); +} diff --git a/src/app/(app)/settings/billing-usage/subscription/components/ai-assistants-included-note.tsx b/src/app/(app)/settings/billing-usage/subscription/components/ai-assistants-included-note.tsx index b4f312b9..413935cd 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/ai-assistants-included-note.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/ai-assistants-included-note.tsx @@ -6,8 +6,9 @@ import { MagicWandIcon } from '@flamingo-stack/openframe-frontend-core/component * States what the plan picker's prices do NOT need to say: the assistants come * with the product, so nothing on this page is the thing that buys them. * - * The wording follows the pay-as-you-go AI model this app ships — usage is - * metered and billed after the fact, not bought up front as a token balance. + * The wording follows the token-bank model this app ships — every paid plan + * grants free tokens each month, and use beyond them draws from a balance the + * tenant tops up. */ export function AiAssistantsIncludedNote() { return ( @@ -18,8 +19,8 @@ export function AiAssistantsIncludedNote() {

AI Assistants are Included

- Fae and Mingo are already built in. Running them on any supported model is billed pay as you go, on top of - your device plan. + Fae and Mingo are already built in. Each paid plan includes a monthly limit of free tokens to run them on all + supported models.

diff --git a/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx new file mode 100644 index 00000000..9ea818f9 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx @@ -0,0 +1,76 @@ +'use client'; + +import { GiftIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; +import { Card, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { AI_BALANCE_EXPLANATION, AiTopUpFields } from '../../components/ai-top-up-fields'; +import type { AiTopUp } from '../../hooks/use-ai-top-up'; +import { freeTokensForPlan } from '../../lib/ai-free-tokens'; +import { formatCompactCount } from '../../lib/format'; +import type { DevicePlanMode } from '../types/subscription.types'; +import { ModelTokenRatesPopover } from './model-token-rates'; + +interface AiTokenBalanceCardProps { + /** The catalog has not answered yet — see the component docblock. */ + loading: boolean; + /** + * How the devices beside this card will be paid for. The grant follows it, so + * switching Monthly/Annual on the left restates the free tokens here. + * `null` until the picker has reported a selection. + */ + deviceMode: DevicePlanMode | null; + /** + * The first top-up as the user is choosing it, owned by the paywall. Held + * there because the page's one button is what sends it — see below. + */ + topUp: AiTopUp; +} + +/** + * AI on the paywall: the balance the assistants run on, what the plan gives + * away, and the first top-up. + * + * It is NOT a plan picker. AI has no package to prepay; what is chosen here is + * a balance, and it rides on the checkout (`CheckoutInput.tokenAmountUsd`) to be + * charged on the same invoice as the devices. So the card writes nothing of its + * own — the page's "Proceed to Payment" submits the whole form at once. + * + * The mockup's "Enable Auto Top-up" is not drawn here either — see + * `ManageAiBalanceModal` for why. + */ +export function AiTokenBalanceCard({ loading, deviceMode, topUp }: AiTokenBalanceCardProps) { + // A prepaid year is a commitment; pay-as-you-go is not, and it grants less. + const freeTokens = deviceMode == null ? null : freeTokensForPlan(deviceMode === 'ANNUAL'); + + return ( + +
+
+

AI Token Balance

+

{AI_BALANCE_EXPLANATION}

+
+ {/* Per-model token rates are what the figures below are counted in. */} + +
+ + {/* The grant depends on the plan next door, so it holds its line until + that choice exists rather than letting a sentence appear under the + user's cursor. */} +
+ + {freeTokens == null ? ( + + ) : ( +

+ {formatCompactCount(freeTokens)} free tokens every month. + Anything beyond that draws from your balance. +

+ )} +
+ + +
+ ); +} diff --git a/src/app/(app)/settings/billing-usage/subscription/components/ai-tokens-usage-card.tsx b/src/app/(app)/settings/billing-usage/subscription/components/ai-tokens-usage-card.tsx deleted file mode 100644 index 75dfc18c..00000000 --- a/src/app/(app)/settings/billing-usage/subscription/components/ai-tokens-usage-card.tsx +++ /dev/null @@ -1,106 +0,0 @@ -'use client'; - -import { GiftIcon, QuestionCircleIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; -import { - Card, - DropdownMenu, - DropdownMenuContent, - DropdownMenuTrigger, - Skeleton, -} from '@flamingo-stack/openframe-frontend-core/components/ui'; -import { AI_LIMIT_EXPLANATION, AiSpendLimitFields } from '../../components/ai-spend-limit-fields'; -import type { AiSpendLimit } from '../../hooks/use-ai-spend-limit'; -import { freeTokensForPlan } from '../../lib/ai-free-tokens'; -import { formatCompactCount } from '../../lib/format'; -import type { DevicePlanMode } from '../types/subscription.types'; -import { ModelTokenRates } from './model-token-rates'; - -interface AiTokensUsageCardProps { - /** The catalog has not answered yet — see the component docblock. */ - loading: boolean; - /** - * How the devices beside this card will be paid for. The grant follows it, so - * switching Monthly/Annual on the left restates the free tokens here. - * `null` until the picker has reported a selection. - */ - deviceMode: DevicePlanMode | null; - /** - * The spending limit as the user is editing it, owned by the paywall. Held - * there because the page's one button is what saves it — see below. - */ - limit: AiSpendLimit; -} - -/** - * AI on the paywall: what it costs, what it gives away, and the ceiling the user - * may put on it. - * - * It is NOT a plan picker. AI is metered only — there is no package to prepay - * and nothing here contributes to the checkout total (the paywall enters AI as - * pay-as-you-go on its own). - * - * Nor does this card SAVE anything. The limit is part of the form the page's - * "Proceed to Payment" submits, so it is written there, once, with the rest of - * the choice (see `SubscriptionSubmitButton`). Writing it per click — which this - * did — meant that merely unticking the box to look at the options changed the - * subscription, with no way back but re-entering the old figure. - * - * The controls are the same ones the billing page's AI Tokens Limit modal shows - * (`AiSpendLimitFields`); only the moment of writing differs. - */ -export function AiTokensUsageCard({ loading, deviceMode, limit }: AiTokensUsageCardProps) { - // A prepaid year is a commitment; pay-as-you-go is not, and it grants less. - const freeTokens = deviceMode == null ? null : freeTokensForPlan(deviceMode === 'ANNUAL'); - - return ( - -
-
-

AI Tokens Usage

-

- Fae and Mingo are billed for what they actually use. No prepayment needed. -

-
- {/* Same popover the plan picker used to carry, kept on the card the rates - belong to: per-model token rates are what the figures below are in. */} - - - - - - - - -
- - {/* The grant depends on the plan next door, so it holds its line until - that choice exists rather than letting a sentence appear under the - user's cursor. */} -
- - {freeTokens == null ? ( - - ) : ( -

- {formatCompactCount(freeTokens)} free tokens every month. - Usage beyond that is billed at the end of each cycle. -

- )} -
- - {/* No `onCommit`: nothing is written until the page is submitted. */} - - - {limit.enabled &&

{AI_LIMIT_EXPLANATION}

} -
- ); -} diff --git a/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx b/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx index 9c1cb054..95431207 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx @@ -6,8 +6,15 @@ import { AnthropicLogoIcon, GeminiLogoIcon, OpenaiLogoGreyIcon, + QuestionCircleIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; -import { Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuTrigger, + Skeleton, +} from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; import { type ComponentType, Suspense } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; import type { modelTokenRatesQuery as ModelTokenRatesQueryType } from '@/__generated__/modelTokenRatesQuery.graphql'; @@ -38,6 +45,30 @@ function formatRate(value: number): string { return `1:${Math.round(1 / value)}`; } +/** + * The question-mark button that opens the rates, for every card that counts in + * tokens: the paywall's AI Token Balance card and the billing page's Paid AI + * Tokens counter. One trigger, so the two cannot open two different panels. + */ +export function ModelTokenRatesPopover({ className }: { className?: string }) { + return ( + + + + + + + + + ); +} + /** * Two self-contained boundaries, because this is a TOOLTIP: nothing it does may * reach the page it is opened from. @@ -88,7 +119,7 @@ function ModelTokenRatesContent() { modelTokenRatesQuery, {}, { - // Opened from the plan picker, which the lock screen shows — so it has to + // Opened from the paywall, which the lock screen shows — so it has to // load on a locked workspace too (see `subscription-gate.ts`). fetchPolicy: 'store-and-network', networkCacheConfig: { metadata: { skipSubscriptionGate: true } }, diff --git a/src/app/(app)/settings/billing-usage/subscription/components/plan-total-summary.tsx b/src/app/(app)/settings/billing-usage/subscription/components/plan-total-summary.tsx index 9531fc56..b7828fa2 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/plan-total-summary.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/plan-total-summary.tsx @@ -8,6 +8,12 @@ import type { SelectionTotal } from '../types/subscription.types'; interface PlanTotalSummaryProps { /** Priced device selection; null while nothing priceable is selected. */ total: SelectionTotal | null; + /** + * The AI top-up riding on this checkout, in whole dollars — charged on the + * checkout's own invoice, so it is due today whatever the devices are. + * `null` when the plan has no AI product or nothing is chosen yet. + */ + topUpUsd: number | null; /** Whether the AI add-on is part of this plan, and therefore worth explaining. */ showAiNote: boolean; /** Catalog still loading: hold the total's line instead of letting the row appear late. */ @@ -20,22 +26,36 @@ interface PlanTotalSummaryProps { * * The label is deliberately not fixed: a prepaid year IS charged at checkout, a * pay-as-you-go month is metered and invoiced afterwards, and calling the latter - * "due today" would be a bill the user never gets. + * "due today" would be a bill the user never gets. The top-up is the exception + * that IS due today on either plan — so on a prepaid year it joins the total, + * and on a metered month it gets a line of its own rather than being folded + * into an estimate it is not part of. */ -export function PlanTotalSummary({ total, showAiNote, loading = false, className }: PlanTotalSummaryProps) { +export function PlanTotalSummary({ total, topUpUsd, showAiNote, loading = false, className }: PlanTotalSummaryProps) { + const topUp = topUpUsd != null && topUpUsd > 0 ? topUpUsd : 0; + return (
- {showAiNote &&

Devices and AI are billed together at checkout.

} + {showAiNote &&

Devices and AI balance are billed together at checkout.

} {loading && } - {total && ( + {total && total.prepaid && ( +

+ Total due today: {formatCurrency(total.amount + topUp)} +

+ )} + {total && !total.prepaid && (

- {total.prepaid ? 'Total due today: ' : 'Estimated total: '} + Estimated total:{' '} - {formatCurrency(total.amount)} - {!total.prepaid && ` / ${total.period}`} + {formatCurrency(total.amount)} / {total.period}

)} + {total && !total.prepaid && topUp > 0 && ( +

+ AI balance due today: {formatCurrency(topUp)} +

+ )}
); } diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx index 160d87d3..4f546743 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx @@ -13,12 +13,12 @@ import { SubscriptionStatus } from '@/app/components/subscription-lock/subscript import { WorkspaceInactiveScreen } from '@/app/components/subscription-lock/workspace-inactive-screen'; import { OpenframeProduct } from '@/generated/schema-enums'; import { routes } from '@/lib/routes'; -import { useAiSpendLimit } from '../../hooks/use-ai-spend-limit'; +import { useAiTopUp } from '../../hooks/use-ai-top-up'; import { aiTokenPrice } from '../../lib/ai-token-price'; import type { ProductCheckoutInput } from '../hooks/use-create-checkout-session'; import type { ProductUpdates } from '../types/subscription.types'; import { AiAssistantsIncludedNote } from './ai-assistants-included-note'; -import { AiTokensUsageCard } from './ai-tokens-usage-card'; +import { AiTokenBalanceCard } from './ai-token-balance-card'; import { DeviceManagementCard } from './device-management-card'; import { PlanTotalSummary } from './plan-total-summary'; import { SubscriptionSubmitButton } from './subscription-submit-button'; @@ -43,10 +43,9 @@ const subscriptionSettingsViewQuery = graphql` packageOptions { billingPeriod } - # AI's metered rate. Read here rather than inside the AI card because the - # spending limit it prices is owned by this page now — the card no longer - # saves anything of its own. unitSize is what price is quoted per (AI: - # a block of tokens), so both are needed to price one token. + # AI's metered rate, which prices the top-up amounts (what $20 buys). + # unitSize is what price is quoted per (AI: a block of tokens), so both + # are needed to price one token. unitSize payAsYouGoOption { id @@ -57,11 +56,10 @@ const subscriptionSettingsViewQuery = graphql` } subscription { id - aiSpendCapUsd # NOT aiTokensFree: that is the grant for the period the tenant is in # (5M on a trial), and this page previews the plan they are about to buy. - # See FREE_TOKENS_BY_PLAN in the AI card for what stands in until a - # prospective figure exists. + # See freeTokensForPlan (lib/ai-free-tokens.ts) for what stands in until + # a prospective figure exists. usage { activeDevices } @@ -150,7 +148,7 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { // Paid from the lock screen: the mutation's response carries the subscription's // new status into the Relay store, which is what unlocks the app — and Billing // & Usage is where the plan just bought is worth looking at. - const handleUpdated = useCallback(() => router.push(routes.settings.billingUsage), [router]); + const handleUpdated = useCallback(() => router.push(routes.settings.billingUsage()), [router]); // No active paid subscription → create a new one via Stripe Checkout instead // of an update (no diff/validation gating in that flow). const needsCheckout = @@ -185,32 +183,31 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { */ const deviceCount = data?.subscription?.usage?.activeDevices ?? null; - // Only the device card takes a plan selection. AI is metered — there is no - // package to choose (see `AiTokensUsageCard`). + // Only the device card takes a plan selection. AI has no package to choose — + // its card picks a balance (see `AiTokenBalanceCard`). const [deviceUpdates, setDeviceUpdates] = useState(null); /** - * The AI spending limit, held HERE rather than in the card that draws it: it - * is part of the same form as the plan, and the page's one button is what - * stores it. The card used to write every click straight through, so simply - * unticking the box to see the options changed the subscription. + * The first top-up, held HERE rather than in the card that draws it: it is + * part of the same form as the plan, and the page's one button is what sends + * it (`CheckoutInput.tokenAmountUsd`). $50 is picked up front, as the mockup + * has it — a checkout may require an amount, and a form that starts with + * nothing chosen would refuse its own default. */ - const aiLimit = useAiSpendLimit({ - capUsd: data?.subscription?.aiSpendCapUsd ?? null, + const topUp = useAiTopUp({ tokenPrice: aiTokenPrice(aiProduct?.payAsYouGoOption?.price, aiProduct?.unitSize), + initial: 50, }); /** - * Every non-device product, entered as pay-as-you-go. A checkout session + * Every non-device product, entered with no options. A checkout session * describes the WHOLE target plan rather than a diff, so leaving these out - * would activate a subscription with the AI assistants switched off — the same - * entry the AI card used to contribute before it stopped selling packages. + * would activate a subscription with the AI assistants switched off. How each + * is billed is the product's own decision — `payAsYouGoEnabled` is left out on + * purpose, since asking for the meter on a product sold in advance is refused. */ const otherProducts = useMemo( - () => - products - .filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES) - .map(p => ({ productName: p.name, payAsYouGoEnabled: true })), + () => products.filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES).map(p => ({ productName: p.name })), [products], ); @@ -218,12 +215,10 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { const checkoutProducts = deviceUpdates?.checkout ? [deviceUpdates.checkout, ...otherProducts] : []; const hasInvalidCustom = deviceUpdates != null && !deviceUpdates.valid; const selectionTotal = deviceUpdates?.total ?? null; - /** - * `undefined` when the limit was left as the subscription already has it, so - * the submit issues no cap mutation at all. `null` is a real value there — it - * is how "no limit" is expressed — which is why this is not a falsy check. - */ - const aiSpendCapUsd = aiLimit.changed ? aiLimit.capUsd : undefined; + // Only when the AI product is for sale here: a catalog without it has no + // balance to open, and the checkout must not carry an amount for it. + const tokenAmountUsd = showAiCard ? topUp.amountUsd : null; + const hasInvalidTopUp = showAiCard && !topUp.isComplete; return ( <> @@ -244,16 +239,23 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { onUpdatesChange={setDeviceUpdates} /> )} - {showAiCard && } + {showAiCard && } {/* The mobile submit bar is fixed to the viewport, so the total it applies to rides in the page flow above it rather than inside it. */} - +
@@ -285,7 +288,8 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { packageUpdates={packageUpdates} checkoutProducts={checkoutProducts} hasInvalidCustom={hasInvalidCustom} - aiSpendCapUsd={aiSpendCapUsd} + tokenAmountUsd={tokenAmountUsd} + hasInvalidTopUp={hasInvalidTopUp} onUpdated={handleUpdated} className="w-full" /> diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx index d0657372..c5b850a5 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx @@ -2,8 +2,6 @@ import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; -import { useUpdateAiSpendCap } from '../../hooks/use-update-ai-spend-cap'; -import { openDeferredTab } from '../../lib/stripe-window'; import { type ProductCheckoutInput, useCreateCheckoutSession } from '../hooks/use-create-checkout-session'; import { type PackageUpdateInput, useUpdateSubscription } from '../hooks/use-update-subscription'; @@ -17,15 +15,17 @@ interface SubscriptionSubmitButtonProps { packageUpdates: PackageUpdateInput[]; /** Desired end-state for the checkout flow. */ checkoutProducts: ProductCheckoutInput[]; - /** True when a Custom Amount has an empty/invalid quantity (update flow only). */ + /** True when a Custom Amount has an empty/invalid quantity. */ hasInvalidCustom: boolean; /** - * The AI spending cap to store, when the user changed it: a USD figure, or - * `null` to remove the cap entirely. `undefined` means it was left alone and - * no cap mutation is issued — the distinction matters because `null` is itself - * a value the schema accepts (see `useUpdateAiSpendCap`). + * The AI top-up to charge on the checkout, in whole dollars + * (`CheckoutInput.tokenAmountUsd`). Checkout flow only — an existing + * subscription tops up from the billing page instead. `null` sends none, + * which the backend accepts only when it is configured not to require one. */ - aiSpendCapUsd?: number | null; + tokenAmountUsd?: number | null; + /** True when the Custom top-up is selected with an empty/invalid amount. */ + hasInvalidTopUp?: boolean; /** * The update landed. Only the update flow can call this — the checkout flow * leaves for Stripe and never comes back to this component. @@ -41,33 +41,28 @@ interface SubscriptionSubmitButtonProps { * * The ACTION still splits on the subscription state: * - no active paid subscription → `createCheckoutSession`, which redirects to - * Stripe. No diff gating: there is nothing to compare against. + * Stripe. No diff gating: there is nothing to compare against. The AI top-up + * rides along on the same input and lands on the same invoice. * - active paid subscription → `updateSubscription`, a mutation that applies the * plan change in place and does NOT redirect to a payment page (an upgrade may * raise an invoice afterwards). Disabled when the selection equals the current * plan, validated on click. - * - * The AI spending cap rides along, because it is part of the same form: it is - * stored FIRST, and only a stored cap lets the payment proceed. Checkout leaves - * the app for Stripe, so there is no "afterwards" to save it in — and a payment - * that went through while the limit beside it silently did not is the one - * outcome worth refusing. */ export function SubscriptionSubmitButton({ needsCheckout, packageUpdates, checkoutProducts, hasInvalidCustom, - aiSpendCapUsd, + tokenAmountUsd = null, + hasInvalidTopUp = false, onUpdated, className, }: SubscriptionSubmitButtonProps) { const updateSubscription = useUpdateSubscription(); const createCheckout = useCreateCheckoutSession(); - const updateAiSpendCap = useUpdateAiSpendCap(); const { toast } = useToast(); - const isPending = updateSubscription.isPending || createCheckout.isPending || updateAiSpendCap.isPending; + const isPending = updateSubscription.isPending || createCheckout.isPending; const rejectInvalidAmount = () => { toast({ @@ -77,13 +72,12 @@ export function SubscriptionSubmitButton({ }); }; - /** Runs `action` behind the cap, when there is a cap change to store. */ - const withAiSpendCap = (action: () => void, onRefused?: () => void) => { - if (aiSpendCapUsd === undefined) { - action(); - return; - } - updateAiSpendCap.mutate(aiSpendCapUsd, { onSuccess: action, onError: onRefused }); + const rejectInvalidTopUp = () => { + toast({ + title: 'Invalid top-up amount', + description: 'Enter a whole number of dollars for the AI balance.', + variant: 'destructive', + }); }; if (needsCheckout) { @@ -93,21 +87,18 @@ export function SubscriptionSubmitButton({ className={className} onClick={() => { // Checkout has no diff to gate on, but an out-of-range quantity is still - // one: it would be sent as a plan nobody can be billed for. + // one: it would be sent as a plan nobody can be billed for. The same + // goes for a top-up with no figure behind it. if (hasInvalidCustom) { rejectInvalidAmount(); return; } + if (hasInvalidTopUp) { + rejectInvalidTopUp(); + return; + } if (!checkoutProducts.length) return; - // Opened from the click itself, and carried through both mutations: - // Stripe's URL only exists once the second answers, and a tab opened - // then has lost the user gesture that lets it through (see - // `openDeferredTab`). It is closed again if either step fails. - const tab = openDeferredTab(); - withAiSpendCap( - () => createCheckout.mutate({ products: checkoutProducts }, { target: tab }), - () => tab.cancel(), - ); + createCheckout.mutate({ products: checkoutProducts, tokenAmountUsd: tokenAmountUsd ?? undefined }); }} loading={isPending} disabled={isPending} @@ -123,7 +114,7 @@ export function SubscriptionSubmitButton({ return; } if (!packageUpdates.length) return; - withAiSpendCap(() => updateSubscription.mutate({ packageUpdates }, { onSuccess: onUpdated })); + updateSubscription.mutate({ packageUpdates }, { onSuccess: onUpdated }); }; return ( diff --git a/src/app/(app)/settings/billing-usage/subscription/hooks/use-create-checkout-session.ts b/src/app/(app)/settings/billing-usage/subscription/hooks/use-create-checkout-session.ts index e5300faf..8b0a047e 100644 --- a/src/app/(app)/settings/billing-usage/subscription/hooks/use-create-checkout-session.ts +++ b/src/app/(app)/settings/billing-usage/subscription/hooks/use-create-checkout-session.ts @@ -8,7 +8,8 @@ import type { ProductCheckoutInput, useCreateCheckoutSessionMutation as UseCreateCheckoutSessionMutationType, } from '@/__generated__/useCreateCheckoutSessionMutation.graphql'; -import { type DeferredTab, openDeferredTab } from '../../lib/stripe-window'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; +import { openDeferredTab } from '../../lib/stripe-window'; export type { CheckoutInput, ProductCheckoutInput }; @@ -20,26 +21,22 @@ const createCheckoutSessionMutation = graphql` } `; -interface CreateCheckoutSessionOptions { - /** - * A tab the caller already opened, for Stripe to be shown in. - * - * Passed in rather than opened here because this mutation does not always run - * from the click that started it — the paywall stores the AI spending cap - * first and calls this from its callback, by which point the user gesture is - * gone and any tab opened would be a blocked popup. Omit it when `mutate` IS - * called straight from a handler and one is opened here instead. - */ - target?: DeferredTab; -} - +/** + * Starts a Stripe Checkout for the whole target plan — devices, the AI product, + * and the first AI top-up (`tokenAmountUsd`), which is charged on the checkout's + * own invoice. + * + * The Stripe tab is opened from the click, before the mutation answers: a tab + * opened once the URL is in has lost the user gesture that lets it through the + * popup blocker (see `openDeferredTab`). It is closed again if checkout fails. + */ export function useCreateCheckoutSession() { const { toast } = useToast(); const [commit, isInFlight] = useMutation(createCheckoutSessionMutation); const mutate = useCallback( - (input: CheckoutInput, options?: CreateCheckoutSessionOptions) => { - const tab = options?.target ?? openDeferredTab(); + (input: CheckoutInput) => { + const tab = openDeferredTab(); commit({ variables: { input }, @@ -65,7 +62,7 @@ export function useCreateCheckoutSession() { tab.cancel(); toast({ title: 'Checkout Failed', - description: err instanceof Error ? err.message : 'Failed to start checkout', + description: getRelayErrorMessage(err, 'Failed to start checkout'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/subscription/utils/subscription.utils.ts b/src/app/(app)/settings/billing-usage/subscription/utils/subscription.utils.ts index 7336ffea..28b352fd 100644 --- a/src/app/(app)/settings/billing-usage/subscription/utils/subscription.utils.ts +++ b/src/app/(app)/settings/billing-usage/subscription/utils/subscription.utils.ts @@ -153,10 +153,12 @@ export function buildCheckoutProduct( quantity = Number.isFinite(parsed) ? parsed : null; } + // No `payAsYouGoEnabled` on a committed package: the backend decides per + // product whether the meter runs beyond the allowance, and asking for it on + // a product sold in advance is refused rather than ignored. return { productName: inputProductName(product), packageOptionId: committedOptionId(product, currentSelection.billingPeriod), quantity, - payAsYouGoEnabled: true, }; } diff --git a/src/app/(app)/settings/components/settings-hub.tsx b/src/app/(app)/settings/components/settings-hub.tsx index d40498ea..9c06b5cb 100644 --- a/src/app/(app)/settings/components/settings-hub.tsx +++ b/src/app/(app)/settings/components/settings-hub.tsx @@ -35,7 +35,7 @@ import { SettingMenuItem, SettingMenuItemSkeleton } from './setting-menu-item'; const SETTINGS_NAV_ITEMS = [ { - href: routes.settings.billingUsage, + href: routes.settings.billingUsage(), icon: PiggyBankIcon, title: 'Billing & Usage', description: 'Subscription details, usage data, and payment settings', @@ -117,7 +117,7 @@ export function SettingsHub() { }); const gatesResolved = billingsGate !== 'loading' && billingAccessGate !== 'loading' && downloadAppsGate !== 'loading'; const visibleItems = defaultItems.filter(item => { - if (item.href === routes.settings.billingUsage) { + if (item.href === routes.settings.billingUsage()) { return billingsGate === 'on' && billingAccessGate === 'allowed'; } if (item.href === routes.settings.downloadApps) { @@ -230,7 +230,7 @@ export function SettingsHub() { icon: Icon, title, description, - } = item.href === routes.settings.billingUsage && billingHidden ? USAGE_MENU_ITEM : item; + } = item.href === routes.settings.billingUsage() && billingHidden ? USAGE_MENU_ITEM : item; return ( router.push(routes.settings.billingUsage)} + onManage={() => router.push(routes.settings.billingUsage({ action: MANAGE_AI_BALANCE_ACTION }))} /> ); - } else if (showAiSpendBar && billingBars.trial && !trialDismissed) { + } else if (showBillingBars && billingBars.trial && !trialDismissed) { // Below the AI bars and above onboarding: a trial past its halfway point is // a deadline, not a failure — but it still outranks a setup tour, because // missing it locks the workspace and the tour can be finished afterwards. topBar = ( router.push(routes.settings.billingUsage)} + onActivate={() => router.push(routes.settings.billingUsage())} onDismiss={() => { if (!trialToken) return; dismissTrialBar(trialToken); @@ -771,7 +771,7 @@ function AppShell({ children, mainClassName }: { children: React.ReactNode; main {/* Reports what the billing banners above need. Suspends, so it sits in its own boundary and renders nothing either way — a shell that waited on it would hold the whole app for a banner. */} - {showAiSpendBar && ( + {showBillingBars && ( diff --git a/src/app/components/billing-bars.tsx b/src/app/components/billing-bars.tsx index 3c60a37c..08959ea9 100644 --- a/src/app/components/billing-bars.tsx +++ b/src/app/components/billing-bars.tsx @@ -3,7 +3,6 @@ import { AlertTriangleIcon, CalendarDaysIcon, - PlusCircleIcon, XmarkIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { AnnouncementBarView, Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; @@ -11,7 +10,7 @@ import { useEffect } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; import type { billingBarsQuery as BillingBarsQueryType } from '@/__generated__/billingBarsQuery.graphql'; import { SubscriptionStatus } from '@/generated/schema-enums'; -import { type AiSpendTone, aiSpendPercent, aiSpendTone } from '@/lib/ai-spend-tone'; +import { type AiBalanceTone, aiBalanceTone } from '@/lib/ai-balance-tone'; /** * Everything the app-wide billing banners are decided from, in ONE query. @@ -29,69 +28,61 @@ const billingBarsQuery = graphql` status startDate trialExpirationDate - aiSpendCapUsd usage { - aiSpendUsd + aiTokensFree + aiTokensFreeUsed + purchasedTokensRemaining } } } `; -/** - * Stated only when the percentage cannot be computed — a cap of 0, where every - * spend is already 100% of it and no ratio exists to round. - */ -const AI_CAP_WARNING_FALLBACK_PERCENT = 100; - const DAY_MS = 24 * 60 * 60 * 1000; -interface AiSpendLimitBarProps { - /** `warning` = close to the cap, `error` = reached it. Never `default` here. */ - tone: Exclude; - /** How far into the cap the spend is, when it can be stated. */ - percent: number | null; - onExpand: () => void; +interface AiBalanceBarProps { + /** `warning` = running low, `error` = empty. Never `default` here. */ + tone: Exclude; + onManage: () => void; } /** - * App-wide bar for a subscription running into its own AI spending cap. + * App-wide bar for a subscription running out of AI tokens. * * Takes the layout's single `topBar` slot ahead of every other bar (see * `AppLayout`): agents that are about to stop answering outrank a trial that * still has days on it and a setup tour that can wait, and the state is * invisible from every page except Billing & Usage — which is where the bar - * sends you. + * sends you, with the top-up dialog already open. + * + * Not dismissible, by product decision: the agents are stopping (or have + * stopped), and hiding that would only make the silence unexplained. It goes + * away when the balance does — a top-up is the one thing that clears it. * * Colour follows the Paid AI Tokens card, from the same rule - * (`lib/ai-spend-tone.ts`), so the bar and the card can never disagree about - * whether AI is approaching its limit or past it. + * (`lib/ai-balance-tone.ts`), so the bar and the card can never disagree about + * whether AI is running low or has run out. * * Height, type scale and CTA size are `AnnouncementBarView`'s and are NOT * negotiable from a mockup — that component carries an explicit freeze notice * saying so. Only the surface colours and the slots below are ours. */ -export function AiSpendLimitBar({ tone, percent, onExpand }: AiSpendLimitBarProps) { - const reached = tone === 'error'; +export function AiBalanceBar({ tone, onManage }: AiBalanceBarProps) { + const empty = tone === 'error'; return ( } title={ - reached - ? 'AI spending limit reached. Agents are paused until the next cycle. Raise the limit in Billing & Usage to resume now.' - : // The figure is computed, not the mockup's fixed "80%": the threshold - // this bar appears at is the card's, and quoting a number the tenant - // is not actually at would be the one wrong thing a warning can say. - `AI usage is at ${percent ?? AI_CAP_WARNING_FALLBACK_PERCENT}% of your monthly limit. Agents pause at 100%. Adjust the limit in Billing & Usage.` + empty + ? 'Your AI balance is empty. AI agents are paused. Top up in Billing & Usage to continue.' + : 'Your AI balance is running low. AI agents pause at zero. Top up in Billing & Usage to continue.' } actionBlock={ - } /> @@ -110,7 +101,7 @@ interface TrialEndingBarProps { * * The one bar here that can be dismissed, because it is the one that is not * about something breaking: the trial still works, and the tenant has days to - * act. The AI bars above have no dismiss for the opposite reason — the agents + * act. The AI bar above has no dismiss for the opposite reason — the agents * are already stopping, and hiding that would only make the silence * unexplained. */ @@ -143,7 +134,7 @@ export function TrialEndingBar({ daysLeft, onActivate, onDismiss }: TrialEndingB /** What the bars need to know, once the query has answered. */ export interface BillingBarsState { - ai: { tone: AiSpendTone; percent: number | null }; + ai: { tone: AiBalanceTone }; /** * The trial, once it is past halfway. `null` at every other moment — not on a * trial, no dates to place the midpoint with, or still in the first half. @@ -158,7 +149,7 @@ export interface BillingBarsState { } | null; } -const NO_BARS: BillingBarsState = { ai: { tone: 'default', percent: null }, trial: null }; +const NO_BARS: BillingBarsState = { ai: { tone: 'default' }, trial: null }; /** * The trial, if it is past its midpoint. @@ -200,10 +191,19 @@ export function BillingBarsHydrator({ onResolved }: { onResolved: (state: Billin const data = useLazyLoadQuery(billingBarsQuery, {}, { fetchPolicy: 'store-and-network' }); const subscription = data.subscription; - const capUsd = subscription?.aiSpendCapUsd ?? null; - const spendUsd = subscription?.usage?.aiSpendUsd ?? 0; - const tone = aiSpendTone(spendUsd, capUsd); - const percent = aiSpendPercent(spendUsd, capUsd); + const usage = subscription?.usage; + // A trial runs on its free grant alone and has no balance to top up, so the + // balance bar has nothing to say about it: its own bar below is the one that + // speaks for a trial, and the billing page states a spent grant on the card. + // GraphQL `Long` arrives as a string or a number depending on its size. + const tone: AiBalanceTone = + subscription?.status === SubscriptionStatus.TRIAL + ? 'default' + : aiBalanceTone({ + freeTokens: Number(usage?.aiTokensFree ?? 0), + freeUsed: Number(usage?.aiTokensFreeUsed ?? 0), + purchasedRemaining: Number(usage?.purchasedTokensRemaining ?? 0), + }); const trial = resolveTrial( subscription?.status, subscription?.startDate, @@ -217,10 +217,10 @@ export function BillingBarsHydrator({ onResolved }: { onResolved: (state: Billin const trialToken = trial?.token ?? null; useEffect(() => { onResolved({ - ai: { tone, percent }, + ai: { tone }, trial: trialDaysLeft != null && trialToken != null ? { daysLeft: trialDaysLeft, token: trialToken } : null, }); - }, [tone, percent, trialDaysLeft, trialToken, onResolved]); + }, [tone, trialDaysLeft, trialToken, onResolved]); return null; } diff --git a/src/lib/ai-balance-tone.ts b/src/lib/ai-balance-tone.ts new file mode 100644 index 00000000..e639380e --- /dev/null +++ b/src/lib/ai-balance-tone.ts @@ -0,0 +1,43 @@ +/** + * How the AI token balance is doing, in the three states every surface that + * shows it agrees on: the Paid AI Tokens card, the block under it, and the + * app-wide balance bar. + * + * One module because the three are read together — a red card over a yellow bar + * would be two answers to one question — and because the bar lives in the app + * shell, which must not import the billing page to find out. + * + * AI runs on the period's free grant first and on the purchased balance after + * that, so the balance is only in play once the grant is spent: a bank of zero + * beside an untouched grant is nothing to warn about. + */ +export type AiBalanceTone = 'default' | 'warning' | 'error'; + +/** + * Where "running low" starts, in tokens. The mockups draw the warning at 1M — a + * few conversations on a premium model — and there is no cap to state it as a + * share of, so it is a fixed figure rather than a ratio. + */ +export const AI_BALANCE_LOW_TOKENS = 1_000_000; + +export interface AiBalanceInput { + /** The period's free grant and how much of it is spent (`usage.aiTokensFree*`). */ + freeTokens: number; + freeUsed: number; + /** Prepaid tokens still available (`usage.purchasedTokensRemaining`). */ + purchasedRemaining: number; +} + +/** The grant is spent: from here on, AI draws from the balance or stops. */ +export function aiFreeTokensExhausted({ + freeTokens, + freeUsed, +}: Pick): boolean { + return freeUsed >= freeTokens; +} + +export function aiBalanceTone(input: AiBalanceInput): AiBalanceTone { + if (!aiFreeTokensExhausted(input)) return 'default'; + if (input.purchasedRemaining <= 0) return 'error'; + return input.purchasedRemaining <= AI_BALANCE_LOW_TOKENS ? 'warning' : 'default'; +} diff --git a/src/lib/ai-spend-tone.ts b/src/lib/ai-spend-tone.ts deleted file mode 100644 index 194d5ed9..00000000 --- a/src/lib/ai-spend-tone.ts +++ /dev/null @@ -1,39 +0,0 @@ -/** - * How an AI spending cap is doing, in the three states every surface that shows - * it agrees on: the Paid AI Tokens card, the block under it, and the app-wide - * limit bar. - * - * One module because the three are read together — a red card over a yellow bar - * would be two answers to one question — and because the bar lives in the app - * shell, which must not import the billing page to find out. - */ -export type AiSpendTone = 'default' | 'warning' | 'error'; - -/** - * How much of the cap has to be spent before the app says so. - * - * The warning exists to be actionable — early enough to raise the limit before - * Fae and Mingo stop, late enough that it is not noise on a limit barely - * touched. - */ -export const AI_CAP_WARNING_RATIO = 0.9; - -/** - * `spendUsd` is the AI overage accrued this period; `capUsd` is the ceiling the - * customer set, `null` when there is none. - * - * No cap means no tone: nothing is being approached, and AI never pauses. A cap - * of 0 is a real cap — nothing beyond the free tokens — which is why every check - * here is against `null` and never falsy. - */ -export function aiSpendTone(spendUsd: number, capUsd: number | null): AiSpendTone { - if (capUsd == null) return 'default'; - if (spendUsd >= capUsd) return 'error'; - return spendUsd >= capUsd * AI_CAP_WARNING_RATIO ? 'warning' : 'default'; -} - -/** How far into the cap the spend is, 0–100, for copy that states it. */ -export function aiSpendPercent(spendUsd: number, capUsd: number | null): number | null { - if (capUsd == null || capUsd <= 0) return null; - return Math.min(100, Math.round((spendUsd / capUsd) * 100)); -} diff --git a/src/lib/routes.ts b/src/lib/routes.ts index 747f59ff..fd15bca1 100644 --- a/src/lib/routes.ts +++ b/src/lib/routes.ts @@ -69,6 +69,14 @@ export type MonitoringTab = (typeof TAB_IDS.monitoring)[number]; export type QueryDetailTab = (typeof TAB_IDS.queryDetails)[number]; export type SettingsTab = (typeof TAB_IDS.settings)[number]; export type AiSettingsTab = (typeof TAB_IDS.aiSettings)[number]; + +/** + * What `/settings/billing-usage` can be asked to open on arrival. The app-wide + * AI balance bar deep-links to the Manage AI Balance modal with it; the page + * reads the param as the modal's open state and clears it on close. + */ +export const MANAGE_AI_BALANCE_ACTION = 'manageAiBalance'; +export type BillingUsageAction = typeof MANAGE_AI_BALANCE_ACTION; export type NotificationsTab = (typeof TAB_IDS.notifications)[number]; /** Legal documents the Help Center `[docType]` route prerenders. */ @@ -300,7 +308,7 @@ export const routes = { sso: '/settings/sso', architecture: '/settings/architecture', downloadApps: '/settings/download-apps', - billingUsage: '/settings/billing-usage', + billingUsage: (o?: { action?: BillingUsageAction }) => withQuery('/settings/billing-usage', { action: o?.action }), }, notifications: (o?: { tab?: NotificationsTab }) => withQuery('/notifications', { tab: o?.tab }), From 0499b6fd6952d060a0c659af63fb9bf9d4d52789 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Mon, 14 Sep 2026 22:03:33 +0100 Subject: [PATCH 2/4] feat(billing): draw the auto top-up controls, locked until the API exists 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. --- .../components/billing-usage-content.tsx | 19 +++++- .../components/manage-ai-balance-modal.tsx | 32 +++++++-- .../settings/billing-usage/lib/auto-top-up.ts | 24 +++++++ .../components/ai-token-balance-card.tsx | 5 +- .../components/model-token-rates.tsx | 68 +++++++++++++++---- 5 files changed, 122 insertions(+), 26 deletions(-) create mode 100644 src/app/(app)/settings/billing-usage/lib/auto-top-up.ts diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx index 2705d781..00bed9c1 100644 --- a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx +++ b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx @@ -3,6 +3,7 @@ import { AlertTriangleIcon, ExternalLinkIcon, + Refresh02VrIcon, TagPercentIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { type ActionsMenuGroup, Button, PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui'; @@ -21,6 +22,7 @@ import { type AiAlert, useBillingSummary } from '../hooks/use-billing-summary'; import { useCancelSubscription } from '../hooks/use-cancel-subscription'; import { useCancellationImpact } from '../hooks/use-cancellation-impact'; import { useResumeSubscription } from '../hooks/use-resume-subscription'; +import { AUTO_TOP_UP } from '../lib/auto-top-up'; import { formatCompactCount, formatCount, formatCurrency, formatDateOrDash } from '../lib/format'; import { openExternalTab } from '../lib/stripe-window'; import { ModelTokenRatesPopover } from '../subscription/components/model-token-rates'; @@ -390,13 +392,26 @@ export function BillingUsageContent() { + {formatCompactCount(ai.paid)} + {/* The mockup's mark for a balance that refills itself; the + popover beside it spells the same state out in words. */} + {AUTO_TOP_UP.enabled && ( + + )} + + } caption={ <> {formatCurrency(ai.paidUsd)} balance } - trailing={} + trailing={} /> )} diff --git a/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx index 7cf698c1..9425c9f3 100644 --- a/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx +++ b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx @@ -1,9 +1,11 @@ 'use client'; -import { Button } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { Button, CheckboxBlock, Tag } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { useId } from 'react'; import { SimpleModal } from '@/app/components/shared/simple-modal'; import { useAiTopUp } from '../hooks/use-ai-top-up'; import { usePurchaseTokens } from '../hooks/use-purchase-tokens'; +import { AUTO_TOP_UP, AUTO_TOP_UP_DESCRIPTION } from '../lib/auto-top-up'; import { AI_BALANCE_EXPLANATION, AiTopUpFields } from './ai-top-up-fields'; interface ManageAiBalanceModalProps { @@ -16,11 +18,11 @@ interface ManageAiBalanceModalProps { /** * Buying AI tokens from the billing page. * - * One-time top-ups only. The mockup also offers "Enable Auto Top-up" — refill - * the balance with the chosen amount whenever it runs out — but the API has - * nothing to store that choice in: `purchaseTokens` is a single purchase, and no - * field on the subscription says whether to repeat it. The checkbox is not drawn - * until it can do something. + * The mockup's "Enable Auto Top-up" — refill the balance with the chosen amount + * whenever it runs out — is drawn but locked: the API has nothing to store that + * choice in yet (see `auto-top-up.ts`), so the checkbox stays off and says so, + * and the amounts below it are a one-time purchase. Once it can be switched on, + * the same amounts become the refill amount, as the mockup relabels them. * * The purchase raises an invoice rather than charging on the spot, so the * button reads "Proceed to Payment" rather than the mockup's "Save": nothing is @@ -37,6 +39,7 @@ export function ManageAiBalanceModal({ isOpen, onClose, tokenPrice }: ManageAiBa function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit) { const topUp = useAiTopUp({ tokenPrice }); const purchase = usePurchaseTokens(); + const autoTopUpId = useId(); const handleSubmit = () => { if (topUp.amountUsd == null) return; @@ -69,7 +72,22 @@ function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit

{AI_BALANCE_EXPLANATION}

- + {/* Controlled and never toggled: the value is the backend's answer, and + today the backend has none. The tag says why the box will not tick, + so a locked control does not read as a broken one. */} + } + /> +
); diff --git a/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts new file mode 100644 index 00000000..a55de467 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts @@ -0,0 +1,24 @@ +/** + * Whether the AI balance refills itself. + * + * The backend has nowhere to keep that choice yet: `purchaseTokens` is a single + * purchase, and no field on `SubscriptionDetail` records a standing order to + * repeat it. So every surface reads one answer — off, and not switchable — and + * the UI the mockups draw for the feature is in place, waiting on the API rather + * than on a second pass of design work: the checkbox in Manage AI Balance, the + * refresh mark on the Paid AI Tokens counter, the first line of the model rates + * popover. When the subscription grows the field, read it here and drop the + * constant; the surfaces need no change. + */ +export interface AutoTopUpStatus { + /** The backend can store the choice, so the checkbox is live. */ + available: boolean; + /** The balance refills itself today. Never `true` while unavailable. */ + enabled: boolean; +} + +export const AUTO_TOP_UP: AutoTopUpStatus = { available: false, enabled: false }; + +/** The checkbox's second line, from the mockup. */ +export const AUTO_TOP_UP_DESCRIPTION = + 'Automatically refill your balance with the selected amount whenever it runs out.'; diff --git a/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx index 9ea818f9..6500207c 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx @@ -34,8 +34,9 @@ interface AiTokenBalanceCardProps { * charged on the same invoice as the devices. So the card writes nothing of its * own — the page's "Proceed to Payment" submits the whole form at once. * - * The mockup's "Enable Auto Top-up" is not drawn here either — see - * `ManageAiBalanceModal` for why. + * No "Enable Auto Top-up" here: the mockup keeps that in Manage AI Balance, and + * there is no balance yet on the paywall to have that state (see + * `auto-top-up.ts`). */ export function AiTokenBalanceCard({ loading, deviceMode, topUp }: AiTokenBalanceCardProps) { // A prepaid year is a commitment; pay-as-you-go is not, and it grants less. diff --git a/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx b/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx index 95431207..e10ba81f 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/model-token-rates.tsx @@ -7,6 +7,8 @@ import { GeminiLogoIcon, OpenaiLogoGreyIcon, QuestionCircleIcon, + Refresh02VrIcon, + XmarkCircleIcon, } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { DropdownMenu, @@ -18,6 +20,7 @@ import { cn } from '@flamingo-stack/openframe-frontend-core/utils'; import { type ComponentType, Suspense } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; import type { modelTokenRatesQuery as ModelTokenRatesQueryType } from '@/__generated__/modelTokenRatesQuery.graphql'; +import type { AutoTopUpStatus } from '../../lib/auto-top-up'; const PROVIDER_ICON: Record> = { ANTHROPIC: AnthropicLogoIcon, @@ -45,12 +48,21 @@ function formatRate(value: number): string { return `1:${Math.round(1 / value)}`; } +interface ModelTokenRatesProps { + /** + * Whether the balance these rates draw on refills itself — the panel's first + * line on the billing page, where the balance is a standing figure. Left out + * on the paywall, where there is no balance yet to have that state. + */ + autoTopUp?: AutoTopUpStatus; +} + /** * The question-mark button that opens the rates, for every card that counts in * tokens: the paywall's AI Token Balance card and the billing page's Paid AI * Tokens counter. One trigger, so the two cannot open two different panels. */ -export function ModelTokenRatesPopover({ className }: { className?: string }) { +export function ModelTokenRatesPopover({ autoTopUp, className }: ModelTokenRatesProps & { className?: string }) { return ( @@ -63,15 +75,15 @@ export function ModelTokenRatesPopover({ className }: { className?: string }) { - + ); } /** - * Two self-contained boundaries, because this is a TOOLTIP: nothing it does may - * reach the page it is opened from. + * The panel's frame, and inside it two self-contained boundaries, because this + * is a TOOLTIP: nothing it does may reach the page it is opened from. * * Suspense — the rates query is fetched lazily on open, so it must not suspend * the page-level boundary (that would flash the full-page skeleton). It falls @@ -89,20 +101,46 @@ export function ModelTokenRatesPopover({ className }: { className?: string }) { * rates are a reference, and not knowing them changes nothing about the plan the * user is here to choose. */ -export function ModelTokenRates() { +export function ModelTokenRates({ autoTopUp }: ModelTokenRatesProps) { + return ( + // The frame is outside both boundaries so the auto top-up line — a fact + // about the subscription, not about the rates — stays put while the rates + // load, or fail to. +
+ {autoTopUp && } + }> + }> + + + +
+ ); +} + +/** + * On or off, in the mockup's two treatments: the refresh mark in the success + * colour when the balance refills itself, a crossed circle in the secondary + * grey when it does not. + */ +function AutoTopUpLine({ status }: { status: AutoTopUpStatus }) { + const Icon = status.enabled ? Refresh02VrIcon : XmarkCircleIcon; return ( - }> - }> - - - +
+ + {status.enabled ? 'Auto Top Up Enabled' : 'Auto Top Up Disabled'} +
); } /** Same panel, same chrome — only the rows are replaced by why they are missing. */ function ModelTokenRatesUnavailable() { return ( -
+
@@ -130,7 +168,7 @@ function ModelTokenRatesContent() { if (rates.length === 0) return null; return ( -
+ <>
Model OpenFrame Token @@ -156,13 +194,13 @@ function ModelTokenRatesContent() { ); })}
-
+ ); } function ModelTokenRatesSkeleton() { return ( -
+ <>
@@ -176,6 +214,6 @@ function ModelTokenRatesSkeleton() {
))} -
+ ); } From 696c7e71c40e905ba2786e6010bac3b8a98ea2f1 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Mon, 14 Sep 2026 22:17:47 +0100 Subject: [PATCH 3/4] feat(billing): activate a trial from the billing page, floor the AI top-up at $10 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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). --- eslint.config.mjs | 15 +- .../components/ai-top-up-fields.tsx | 6 + .../components/auto-top-up-checkbox.tsx | 34 +++ .../components/billing-usage-content.tsx | 48 +++- .../components/manage-ai-balance-modal.tsx | 27 +-- .../components/upgrade-plan-modal.tsx | 71 ++---- .../billing-usage/hooks/use-ai-top-up.ts | 61 ++++- .../settings/billing-usage/lib/auto-top-up.ts | 6 +- .../activate-subscription-modal.tsx | 114 +++++++++ .../components/ai-token-balance-card.tsx | 8 +- .../components/plan-checkout-cards.tsx | 40 +++ .../components/subscription-settings-view.tsx | 228 +++--------------- .../components/subscription-submit-button.tsx | 28 ++- .../subscription/hooks/use-plan-checkout.ts | 189 +++++++++++++++ .../subscription-lock/paywall-header.tsx | 8 +- .../subscription-lock-copy.ts | 17 ++ 16 files changed, 586 insertions(+), 314 deletions(-) create mode 100644 src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx create mode 100644 src/app/(app)/settings/billing-usage/subscription/components/activate-subscription-modal.tsx create mode 100644 src/app/(app)/settings/billing-usage/subscription/components/plan-checkout-cards.tsx create mode 100644 src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts diff --git a/eslint.config.mjs b/eslint.config.mjs index 88eeea84..df4b1203 100644 --- a/eslint.config.mjs +++ b/eslint.config.mjs @@ -182,19 +182,18 @@ export default defineConfig([ // that needs it, and read by a plain mapper function via `readInlineData` // (`device-{row,selector,}-fields.ts` compose into a ladder this way). The // consumer is a function in another file by construction. - // - `subscription-settings-view` spreads the device-plan-picker's fragments - // and hands the refs down through PaywallBody and DeviceManagementCard to - // the picker that reads them. Passing a fragment ref through an intermediate - // component is ordinary Relay; the rule only recognises a direct render. + // - `use-plan-checkout` (the paywall form's query, shared by the lock screen + // and the Activate Subscription modal) spreads the device-plan-picker's + // fragments and hands the refs down through PlanCheckoutCards and + // DeviceManagementCard to the picker that reads them. Passing a fragment + // ref through an intermediate component is ordinary Relay; the rule only + // recognises a direct render. // // Everywhere else the rule stays on, which is where it earns its place: a // component-owned fragment spread far from the component that reads it is how // Relay codebases rot. name: 'openframe-frontend/fragment-definitions-render-nothing', - files: [ - 'src/graphql/**/*.ts', - 'src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx', - ], + files: ['src/graphql/**/*.ts', 'src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts'], rules: { 'relay/must-colocate-fragment-spreads': 'off' }, }, diff --git a/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx index 0a0cbaa7..2a01ff3a 100644 --- a/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx +++ b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx @@ -62,6 +62,11 @@ export function AiTopUpFields({ topUp, label, disabled = false }: AiTopUpFieldsP onSelect={topUp.selectCustom} />
+ {/* Nothing picked yet, said once a submit has asked for it. A custom + figure's problems go under its own field instead. */} + {topUp.error != null && topUp.selection !== CUSTOM_TOP_UP && ( +

{topUp.error}

+ )}
{topUp.selection === CUSTOM_TOP_UP && ( @@ -74,6 +79,7 @@ export function AiTopUpFields({ topUp, label, disabled = false }: AiTopUpFieldsP placeholder="0" value={topUp.customUsd} disabled={disabled} + error={topUp.error ?? undefined} endAdornment={USD} onChange={event => topUp.setCustomUsd(event.target.value)} /> diff --git a/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx b/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx new file mode 100644 index 00000000..b21c278e --- /dev/null +++ b/src/app/(app)/settings/billing-usage/components/auto-top-up-checkbox.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { CheckboxBlock, Tag } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { useId } from 'react'; +import { AUTO_TOP_UP } from '../lib/auto-top-up'; + +interface AutoTopUpCheckboxProps { + /** Locks the control on top of its own lock — a purchase in flight, a catalog still loading. */ + disabled?: boolean; +} + +/** + * "Enable Auto Top-up", wherever the mockups put it: under the amounts in the + * Manage AI Balance modal, and under them again on the paywall's AI Token + * Balance card. + * + * Controlled and never toggled: the value is the backend's answer, and today + * the backend has none (see `auto-top-up.ts`). The tag says why the box will + * not tick, so a locked control does not read as a broken one. + */ +export function AutoTopUpCheckbox({ disabled = false }: AutoTopUpCheckboxProps) { + const id = useId(); + + return ( + } + /> + ); +} diff --git a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx index 00bed9c1..56958ea5 100644 --- a/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx +++ b/src/app/(app)/settings/billing-usage/components/billing-usage-content.tsx @@ -13,7 +13,7 @@ import { useState } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; import type { billingUsageContentQuery as BillingUsageContentQueryType } from '@/__generated__/billingUsageContentQuery.graphql'; import { LockedScreen } from '@/app/components/shared/locked-screen'; -import { SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status'; +import { resolveSubscriptionStatus, SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status'; import { useFeatureFlag } from '@/app/hooks/use-feature-flag'; import { useSafeBack } from '@/app/hooks/use-safe-back'; import { MANAGE_AI_BALANCE_ACTION, routes } from '@/lib/routes'; @@ -25,6 +25,7 @@ import { useResumeSubscription } from '../hooks/use-resume-subscription'; import { AUTO_TOP_UP } from '../lib/auto-top-up'; import { formatCompactCount, formatCount, formatCurrency, formatDateOrDash } from '../lib/format'; import { openExternalTab } from '../lib/stripe-window'; +import { ActivateSubscriptionModal } from '../subscription/components/activate-subscription-modal'; import { ModelTokenRatesPopover } from '../subscription/components/model-token-rates'; import { BillingRow, SectionBlock, TestModeBanner } from './billing-section'; import { CancelOfferModal } from './cancel-offer-modal'; @@ -166,6 +167,7 @@ export function BillingUsageContent() { const resumeSubscription = useResumeSubscription(); const billingPortal = useBillingPortalSession(); const [planModalOpen, setPlanModalOpen] = useState(false); + const [activateModalOpen, setActivateModalOpen] = useState(false); /** * The Manage AI Balance modal's open state IS the URL (`?action=`): the * app-wide balance bar deep-links to it from any page, and one owner of the @@ -173,7 +175,6 @@ export function BillingUsageContent() { * Closing clears the param, so a reload does not reopen a dismissed dialog. */ const { params: pageParams, setParam: setPageParam } = useApiParams({ action: { type: 'string', default: '' } }); - const aiBalanceModalOpen = pageParams.action === MANAGE_AI_BALANCE_ACTION; const openAiBalanceModal = () => setPageParam('action', MANAGE_AI_BALANCE_ACTION); const closeAiBalanceModal = () => setPageParam('action', ''); const [cancelStep, setCancelStep] = useState<'idle' | 'reason' | 'offer' | 'cancelled'>('idle'); @@ -194,13 +195,23 @@ export function BillingUsageContent() { const cancelSubscriptionEnabled = useFeatureFlag('cancel-subscription'); // Nothing to update in place: these three states have no live paid - // subscription, so a plan change has to go through Stripe Checkout. PAST_DUE - // and SUSPENDED are deliberately NOT here — those subscriptions still exist. + // subscription, so the plan is STARTED, through Stripe Checkout — the + // Activate Subscription modal, not the plan change. PAST_DUE and SUSPENDED + // are deliberately NOT here — those subscriptions still exist. const needsCheckout = status === SubscriptionStatus.TRIAL || status === SubscriptionStatus.TRIAL_EXPIRED || status === SubscriptionStatus.CANCELED; + /** + * A trial has no balance to manage: its AI runs on the grant, and what a + * paused assistant needs is the subscription, not a top-up. So the modal is + * not offered — and not reachable through the URL either, which the app-wide + * bar never writes on a trial. + */ + const aiBalanceOffered = flags.hasAi && !flags.isTrial; + const aiBalanceModalOpen = aiBalanceOffered && pageParams.action === MANAGE_AI_BALANCE_ACTION; + // A committed package is the only thing that gives the device counter a // denominator, so the same condition decides the caption — the card cannot end // up reading "247/300" over "Pay as you go", or a bare count over "Prepaid". @@ -210,8 +221,10 @@ export function BillingUsageContent() { * A scheduled cancellation drops the plan offer everywhere. The subscription * is already on its way out, so a change would be bought into something that * ends anyway; renewing is the move that makes the rest meaningful again. + * Nor is there a plan to change before one has been bought: on a trial the + * header's Activate Subscription is the whole offer. */ - const planOffered = !flags.isPendingCancellation; + const planOffered = !flags.isPendingCancellation && !needsCheckout; const menuActions: ActionsMenuGroup[] = [ { @@ -262,10 +275,10 @@ export function BillingUsageContent() { * into a subscription. Rendered alongside the plan change rather than instead * of it — a trial can both be activated and have its device plan chosen. * - * All of them end in the same modal — there is no plan page to send anyone to - * any more. Activation from a trial is the checkout branch of it, which is why - * the modal folds every other product in: a checkout session - * describes the whole plan, not just the part the modal edits. + * There is no plan page to send anyone to any more: a live plan is changed in + * the Upgrade Plan modal, and a trial is turned into a subscription in the + * Activate Subscription modal — the paywall's form, which buys the whole plan + * (devices, the AI product and the first top-up) on one checkout. */ const statusAction = flags.isPendingCancellation ? { @@ -293,7 +306,7 @@ export function BillingUsageContent() { : flags.isTrial ? { label: 'Activate Subscription', - onClick: () => setPlanModalOpen(true), + onClick: () => setActivateModalOpen(true), variant: 'accent' as const, } : null; @@ -302,9 +315,9 @@ export function BillingUsageContent() { * The header's second, quieter action: the balance, not the plan. It is the * one of the two a paused assistant depends on, and it is what the app-wide * balance bar deep-links to. Absent without the AI product — there is no - * balance to manage. + * balance to manage — and on a trial, where Activate Subscription stands alone. */ - const secondaryAction = flags.hasAi + const secondaryAction = aiBalanceOffered ? { label: 'Manage AI Balance', onClick: openAiBalanceModal, @@ -546,7 +559,6 @@ export function BillingUsageContent() { setPlanModalOpen(false)} onUpdated={() => { setPlanModalOpen(false); @@ -554,6 +566,16 @@ export function BillingUsageContent() { }} /> + {/* Leaves for Stripe in a new tab; the subscription it activates lands on + the page's next fetch, not through this modal. */} + setActivateModalOpen(false)} + /> + ) { const topUp = useAiTopUp({ tokenPrice }); const purchase = usePurchaseTokens(); - const autoTopUpId = useId(); const handleSubmit = () => { - if (topUp.amountUsd == null) return; + if (topUp.validate() != null || topUp.amountUsd == null) return; purchase.mutate(topUp.amountUsd, { onSuccess: onClose }); }; @@ -63,7 +66,7 @@ function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit Proceed to Payment @@ -72,17 +75,7 @@ function ManageAiBalanceModalBody({ onClose, tokenPrice }: Omit

{AI_BALANCE_EXPLANATION}

- {/* Controlled and never toggled: the value is the backend's answer, and - today the backend has none. The tag says why the box will not tick, - so a locked control does not read as a broken one. */} - } - /> + {}; interface UpgradePlanModalProps { isOpen: boolean; - /** - * No active paid subscription (trial, expired trial, canceled) → the submit - * creates one through Stripe Checkout instead of updating in place. A boolean - * rather than the status itself: the caller already reads the status, and the - * two enums in play here (Relay's, with its `%future added value`, and the - * generated one) do not agree on a type. - */ - needsCheckout: boolean; onClose: () => void; /** * The plan change was applied. The page has to refetch: the mutation answers @@ -70,21 +46,22 @@ interface UpgradePlanModalProps { /** * Changing the device plan, in place on the billing page. * - * The only place a plan is changed. `/settings/billing-usage/subscription` is - * gone, so this is not a shortcut to a page that also exists — the same + * The only place a LIVE plan is changed. `/settings/billing-usage/subscription` + * is gone, so this is not a shortcut to a page that also exists — the same * `DevicePlanPicker` the subscription lock screen shows, in a modal, over its * own query. The query lives here rather than on the billing page because the - * catalog, its prices and the device count are only worth fetching once someone - * opens this. + * catalog and its prices are only worth fetching once someone opens this. + * + * Update flow only. A workspace with no paid subscription yet (a trial) does + * not change a plan, it starts one — with the AI top-up the checkout requires, + * which this picker has no control for. That is `ActivateSubscriptionModal`. */ -export function UpgradePlanModal({ isOpen, needsCheckout, onClose, onUpdated }: UpgradePlanModalProps) { - const [selection, setSelection] = useState(null); +export function UpgradePlanModal({ isOpen, onClose, onUpdated }: UpgradePlanModalProps) { + const [updates, setUpdates] = useState(null); // Nothing mounts — and no query runs — until the modal is actually opened. if (!isOpen) return null; - const checkoutProducts = selection ? [selection.updates.checkout, ...selection.otherProducts] : []; - return ( @@ -116,42 +93,30 @@ export function UpgradePlanModal({ isOpen, needsCheckout, onClose, onUpdated }: } > - +
); } -function UpgradePlanBody({ onSelectionChange }: { onSelectionChange: (selection: PlanSelection) => void }) { +function UpgradePlanBody({ onUpdatesChange }: { onUpdatesChange: (updates: ProductUpdates) => void }) { const data = useLazyLoadQuery( upgradePlanModalQuery, {}, { fetchPolicy: 'store-and-network' }, ); - // Memoized: the `?? []` fallback is a new array on every render, and - // `otherProducts` below depends on it. - const products = useMemo(() => data.billingPlan?.products ?? [], [data.billingPlan]); + const products = data.billingPlan?.products ?? []; const deviceProduct = products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; const deviceSubscriptionProduct = data.subscription?.products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; - const otherProducts = useMemo( - () => products.filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES).map(p => ({ productName: p.name })), - [products], - ); - - const handleUpdatesChange = useCallback( - (updates: ProductUpdates) => onSelectionChange({ updates, otherProducts }), - [onSelectionChange, otherProducts], - ); - return ( ); } diff --git a/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts b/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts index 289aa015..d6bfea84 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts @@ -2,6 +2,7 @@ import { useState } from 'react'; import { tokensForUsd } from '../lib/ai-token-price'; +import { formatWholeCurrency } from '../lib/format'; /** * The amounts offered as one click each, in whole dollars — the unit @@ -16,6 +17,18 @@ export const CUSTOM_TOP_UP = 'custom'; export type TopUpSelection = number | typeof CUSTOM_TOP_UP | null; +/** + * The smallest top-up that can be sent, in whole dollars. + * + * A product rule stated here because the schema does not state it: both + * `purchaseTokens` and `CheckoutInput.tokenAmountUsd` speak of "a configured + * minimum" and expose no field carrying it, so without this the form would + * learn the floor from the server's refusal. Every preset clears it; only a + * custom figure can fall under it. If the backend ever exposes its minimum, + * read that instead of this. + */ +export const MIN_TOP_UP_USD = 10; + /** What the user has picked, before it means anything in tokens. */ interface TopUpChoice { selection: TopUpSelection; @@ -28,12 +41,23 @@ export interface AiTopUp extends TopUpChoice { amountUsd: number | null; /** What that buys at the catalog rate; `null` without a rate or an amount. */ tokens: number | null; - /** A preset, or a custom figure that parses — there is an amount to send. */ + /** A preset, or a custom figure in range — there is an amount to send. */ isComplete: boolean; + /** + * Why there is no amount to send, in the user's words — shown only once a + * submit has been tried (`validate`), so the form stays quiet while it is + * still being filled in. `null` when there is nothing to say, or not yet. + */ + error: string | null; tokensForUsd: (usd: number) => number | null; selectPreset: (usd: TopUpPresetUsd) => void; selectCustom: () => void; setCustomUsd: (next: string) => void; + /** + * The submit's check: reveals the problem in the fields, if there is one, and + * returns it so the caller can say it too. `null` means the amount can go. + */ + validate: () => string | null; /** Back to the opening choice (e.g. after a refused purchase). */ reset: () => void; } @@ -46,13 +70,21 @@ interface UseAiTopUpOptions { } /** + * What the choice comes to, or why it comes to nothing. + * * Whole dollars only: the backend refuses cents ("the amount must be a whole - * number of dollars"), so the field cannot produce them in the first place. + * number of dollars"), and the field cannot produce them in the first place — + * so the checks left are an empty field and the floor. */ -function parseWholeUsd(value: string): number | null { - if (!/^\d+$/.test(value)) return null; - const usd = Number.parseInt(value, 10); - return usd > 0 ? usd : null; +function resolveChoice(choice: TopUpChoice): { amountUsd: number | null; problem: string | null } { + if (choice.selection == null) return { amountUsd: null, problem: 'Choose a top-up amount.' }; + + const usd = choice.selection === CUSTOM_TOP_UP ? Number.parseInt(choice.customUsd, 10) : choice.selection; + if (!Number.isFinite(usd)) return { amountUsd: null, problem: 'Enter a top-up amount.' }; + if (usd < MIN_TOP_UP_USD) { + return { amountUsd: null, problem: `The minimum top-up is ${formatWholeCurrency(MIN_TOP_UP_USD)}.` }; + } + return { amountUsd: usd, problem: null }; } /** @@ -66,20 +98,31 @@ function parseWholeUsd(value: string): number | null { */ export function useAiTopUp({ tokenPrice, initial = null }: UseAiTopUpOptions): AiTopUp { const [choice, setChoice] = useState({ selection: initial, customUsd: '' }); + // Set by the first submit and kept: from then on the fields answer every edit + // at once, which is the moment the user is looking at them. + const [attempted, setAttempted] = useState(false); - const amountUsd = choice.selection === CUSTOM_TOP_UP ? parseWholeUsd(choice.customUsd) : choice.selection; + const { amountUsd, problem } = resolveChoice(choice); const forUsd = (usd: number): number | null => tokensForUsd(usd, tokenPrice); return { ...choice, amountUsd, tokens: amountUsd == null ? null : forUsd(amountUsd), - isComplete: amountUsd != null, + isComplete: problem == null, + error: attempted ? problem : null, tokensForUsd: forUsd, selectPreset: usd => setChoice(current => ({ ...current, selection: usd })), selectCustom: () => setChoice(current => ({ ...current, selection: CUSTOM_TOP_UP })), // Digits only, so a pasted "$50" or "50.00" lands as the whole dollars it meant. setCustomUsd: next => setChoice(current => ({ ...current, customUsd: next.replace(/\D/g, '') })), - reset: () => setChoice({ selection: initial, customUsd: '' }), + validate: () => { + setAttempted(true); + return problem; + }, + reset: () => { + setChoice({ selection: initial, customUsd: '' }); + setAttempted(false); + }, }; } diff --git a/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts index a55de467..acad6f9e 100644 --- a/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts +++ b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts @@ -5,7 +5,7 @@ * purchase, and no field on `SubscriptionDetail` records a standing order to * repeat it. So every surface reads one answer — off, and not switchable — and * the UI the mockups draw for the feature is in place, waiting on the API rather - * than on a second pass of design work: the checkbox in Manage AI Balance, the + * than on a second pass of design work: the checkbox (`AutoTopUpCheckbox`), the * refresh mark on the Paid AI Tokens counter, the first line of the model rates * popover. When the subscription grows the field, read it here and drop the * constant; the surfaces need no change. @@ -18,7 +18,3 @@ export interface AutoTopUpStatus { } export const AUTO_TOP_UP: AutoTopUpStatus = { available: false, enabled: false }; - -/** The checkbox's second line, from the mockup. */ -export const AUTO_TOP_UP_DESCRIPTION = - 'Automatically refill your balance with the selected amount whenever it runs out.'; diff --git a/src/app/(app)/settings/billing-usage/subscription/components/activate-subscription-modal.tsx b/src/app/(app)/settings/billing-usage/subscription/components/activate-subscription-modal.tsx new file mode 100644 index 00000000..f8b70d53 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/subscription/components/activate-subscription-modal.tsx @@ -0,0 +1,114 @@ +'use client'; + +import { ErrorBoundary } from '@flamingo-stack/openframe-frontend-core/components/features'; +import { ModalV2Content, ModalV2Footer } from '@flamingo-stack/openframe-frontend-core/components/ui'; +import { Suspense } from 'react'; +import { SimpleModal } from '@/app/components/shared/simple-modal'; +import { + getPaywallCopy, + type PaywallCopy, + paywallDescription, + PLANS_UNAVAILABLE_COPY, +} from '@/app/components/subscription-lock/subscription-lock-copy'; +import type { SubscriptionStatus } from '@/generated/schema-enums'; +import { type PlanCheckoutData, usePlanCheckout, usePlanCheckoutData } from '../hooks/use-plan-checkout'; +import { PlanCheckoutCards } from './plan-checkout-cards'; +import { PlanTotalSummary } from './plan-total-summary'; +import { SubscriptionSubmitButton } from './subscription-submit-button'; + +interface ActivateSubscriptionModalProps { + isOpen: boolean; + /** Names the heading — an active trial, or a subscription that ended. */ + status: SubscriptionStatus; + onClose: () => void; +} + +/** + * Starting the subscription before the trial runs out — the paywall, in a modal. + * + * The same form as the lock screen (`SubscriptionSettingsView`): the device + * plan, the first AI top-up, and one "Proceed to Payment" that opens Stripe + * Checkout with the whole plan. Nothing here is a different purchase; it is the + * same one, made early. Over its own query, run only once the modal opens — the + * billing page has no reason to hold the catalog's prices until then. + * + * The checkout leaves for Stripe in a new tab and never reports back to this + * component, so the modal stays as it was; the user closes it, and the page's + * next fetch reads the subscription Stripe activated. + */ +export function ActivateSubscriptionModal({ isOpen, status, onClose }: ActivateSubscriptionModalProps) { + // Nothing mounts — and no query runs — until the modal is actually opened. + if (!isOpen) return null; + + const copy = getPaywallCopy(status); + + return ( + + {/* A failed catalog stays inside the modal. Unbounded, the throw would + reach the page's boundary and replace the whole of Billing & Usage + with an error over a dialog that could simply be closed. */} + }> + }> + + + + + ); +} + +function ActivateSubscriptionContent({ copy }: { copy: PaywallCopy }) { + const data = usePlanCheckoutData(); + return ; +} + +interface ActivateSubscriptionBodyProps { + copy: PaywallCopy; + /** `null` while the catalog is on its way — every slot below handles that itself. */ + data: PlanCheckoutData | null; +} + +/** + * Content and footer both, so the footer's total and button read the same + * form state the cards write — `SimpleModal`'s own footer slot sits outside + * this boundary and could not. + */ +function ActivateSubscriptionBody({ copy, data }: ActivateSubscriptionBodyProps) { + const form = usePlanCheckout(data); + + return ( + <> + +

{paywallDescription(copy, form.deviceCount)}

+ +
+ + + + + + ); +} + +/** Same dialog, same close button — only the plans are replaced by why they are missing. */ +function PlansUnavailable() { + return ( + +

{PLANS_UNAVAILABLE_COPY.title}

+

{PLANS_UNAVAILABLE_COPY.description}

+
+ ); +} diff --git a/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx index 6500207c..a2ba9fb7 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx @@ -3,6 +3,7 @@ import { GiftIcon } from '@flamingo-stack/openframe-frontend-core/components/icons-v2'; import { Card, Skeleton } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { AI_BALANCE_EXPLANATION, AiTopUpFields } from '../../components/ai-top-up-fields'; +import { AutoTopUpCheckbox } from '../../components/auto-top-up-checkbox'; import type { AiTopUp } from '../../hooks/use-ai-top-up'; import { freeTokensForPlan } from '../../lib/ai-free-tokens'; import { formatCompactCount } from '../../lib/format'; @@ -34,9 +35,8 @@ interface AiTokenBalanceCardProps { * charged on the same invoice as the devices. So the card writes nothing of its * own — the page's "Proceed to Payment" submits the whole form at once. * - * No "Enable Auto Top-up" here: the mockup keeps that in Manage AI Balance, and - * there is no balance yet on the paywall to have that state (see - * `auto-top-up.ts`). + * "Enable Auto Top-up" sits under the amounts as the mockup has it, locked for + * the same reason it is locked everywhere (see `auto-top-up.ts`). */ export function AiTokenBalanceCard({ loading, deviceMode, topUp }: AiTokenBalanceCardProps) { // A prepaid year is a commitment; pay-as-you-go is not, and it grants less. @@ -72,6 +72,8 @@ export function AiTokenBalanceCard({ loading, deviceMode, topUp }: AiTokenBalanc
+ + ); } diff --git a/src/app/(app)/settings/billing-usage/subscription/components/plan-checkout-cards.tsx b/src/app/(app)/settings/billing-usage/subscription/components/plan-checkout-cards.tsx new file mode 100644 index 00000000..1fdd9a92 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/subscription/components/plan-checkout-cards.tsx @@ -0,0 +1,40 @@ +'use client'; + +import type { PlanCheckout } from '../hooks/use-plan-checkout'; +import { AiAssistantsIncludedNote } from './ai-assistants-included-note'; +import { AiTokenBalanceCard } from './ai-token-balance-card'; +import { DeviceManagementCard } from './device-management-card'; + +/** + * The form's body: the note, then the device card beside the AI card. + * + * The lock screen and the Activate Subscription modal draw exactly this and + * differ only in the frame around it and where the total and the button go + * (see `usePlanCheckout`). A fragment, so each frame spaces it as its own + * children. + */ +export function PlanCheckoutCards({ form }: { form: PlanCheckout }) { + return ( + <> + {form.showAiCard && } + + {/* `items-stretch`, not `items-start`: side by side, two cards of + different heights read as one unfinished. Each card keeps its content + top-aligned (they are `flex-col`), so the shorter one gains empty space + at the bottom rather than stretched rows. */} +
+ {form.showDeviceCard && ( + + )} + {form.showAiCard && ( + + )} +
+ + ); +} diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx index 4f546743..53e47feb 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-settings-view.tsx @@ -3,74 +3,22 @@ import { ErrorBoundary } from '@flamingo-stack/openframe-frontend-core/components/features'; import { PageLayout } from '@flamingo-stack/openframe-frontend-core/components/ui'; import { useRouter } from 'next/navigation'; -import { Suspense, useCallback, useMemo, useState } from 'react'; -import { graphql, useLazyLoadQuery } from 'react-relay'; -import type { subscriptionSettingsViewQuery as SubscriptionSettingsViewQueryType } from '@/__generated__/subscriptionSettingsViewQuery.graphql'; +import { Suspense, useCallback } from 'react'; import { PaywallHeader } from '@/app/components/subscription-lock/paywall-header'; import { useSubscriptionLock } from '@/app/components/subscription-lock/subscription-guard'; -import { getPaywallCopy, type PaywallCopy } from '@/app/components/subscription-lock/subscription-lock-copy'; +import { + getPaywallCopy, + type PaywallCopy, + PLANS_UNAVAILABLE_COPY, +} from '@/app/components/subscription-lock/subscription-lock-copy'; import { SubscriptionStatus } from '@/app/components/subscription-lock/subscription-status'; import { WorkspaceInactiveScreen } from '@/app/components/subscription-lock/workspace-inactive-screen'; -import { OpenframeProduct } from '@/generated/schema-enums'; import { routes } from '@/lib/routes'; -import { useAiTopUp } from '../../hooks/use-ai-top-up'; -import { aiTokenPrice } from '../../lib/ai-token-price'; -import type { ProductCheckoutInput } from '../hooks/use-create-checkout-session'; -import type { ProductUpdates } from '../types/subscription.types'; -import { AiAssistantsIncludedNote } from './ai-assistants-included-note'; -import { AiTokenBalanceCard } from './ai-token-balance-card'; -import { DeviceManagementCard } from './device-management-card'; +import { type PlanCheckoutData, usePlanCheckout, usePlanCheckoutData } from '../hooks/use-plan-checkout'; +import { PlanCheckoutCards } from './plan-checkout-cards'; import { PlanTotalSummary } from './plan-total-summary'; import { SubscriptionSubmitButton } from './subscription-submit-button'; -/** - * Billing data ONLY. - * - * The fleet size is back in the header and in the device panel, and it comes - * from `subscription.usage` — NOT from the `devices()` query it used to be - * spread from. That is app data: a locked workspace has it refused with - * `SUBSCRIPTION_TRIAL_EXPIRED`, and because `devices` is non-null the refusal - * nulled this whole payload and crashed the one screen a locked workspace has to - * be able to render. The same count, counted by billing, carries no such risk. - */ -const subscriptionSettingsViewQuery = graphql` - query subscriptionSettingsViewQuery { - billingPlan { - id - products { - id - name - packageOptions { - billingPeriod - } - # AI's metered rate, which prices the top-up amounts (what $20 buys). - # unitSize is what price is quoted per (AI: a block of tokens), so both - # are needed to price one token. - unitSize - payAsYouGoOption { - id - price - } - ...devicePlanPickerProductFragment - } - } - subscription { - id - # NOT aiTokensFree: that is the grant for the period the tenant is in - # (5M on a trial), and this page previews the plan they are about to buy. - # See freeTokensForPlan (lib/ai-free-tokens.ts) for what stands in until - # a prospective figure exists. - usage { - activeDevices - } - products { - name - ...devicePlanPickerSubscriptionFragment - } - } - } -`; - /** * The paywall. * @@ -80,13 +28,11 @@ const subscriptionSettingsViewQuery = graphql` * `null` refs the cards show their own pending rows (see `DeviceManagementCard`). * A parallel skeleton file is what this page used to have, and it drifted from * the real thing every time either was touched. + * + * The form itself — the query, the choices, what the button sends — is + * `usePlanCheckout`, shared with the billing page's Activate Subscription + * modal. This file is only the page around it. */ -/** Shown in place of the plans when their catalog cannot be loaded at all. */ -const PLANS_UNAVAILABLE_COPY = { - title: "We couldn't load the plans.", - description: 'Something went wrong on our side. Try again in a moment, or contact support if it keeps happening.', -}; - export function SubscriptionSettingsView() { const { status } = useSubscriptionLock(); // Resolved here rather than carried on the context, so the plan-lock wording @@ -122,24 +68,14 @@ export function SubscriptionSettingsLoading() { } function SubscriptionSettingsContent({ copy }: { copy: PaywallCopy }) { - const data = useLazyLoadQuery( - subscriptionSettingsViewQuery, - {}, - { - fetchPolicy: 'store-and-network', - // This IS the lock screen. Gating it behind the subscription gate would - // park the paywall on the very state it exists to get the user out of. - networkCacheConfig: { metadata: { skipSubscriptionGate: true } }, - }, - ); - + const data = usePlanCheckoutData(); return ; } interface PaywallBodyProps { copy: PaywallCopy; /** `null` while the catalog is on its way — every slot below handles that itself. */ - data: SubscriptionSettingsViewQueryType['response'] | null; + data: PlanCheckoutData | null; } function PaywallBody({ copy, data }: PaywallBodyProps) { @@ -156,121 +92,46 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { status === SubscriptionStatus.TRIAL_EXPIRED || status === SubscriptionStatus.CANCELED; - const loading = data == null; - // Memoized: the `?? []` fallback is a new array on every render, and the - // plan memo below depends on it. - const products = useMemo(() => data?.billingPlan?.products ?? [], [data?.billingPlan]); - const subscriptionProducts = data?.subscription?.products ?? []; - - const deviceProduct = products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; - const aiProduct = products.find(p => p.name === OpenframeProduct.AI_ASSISTANCE) ?? null; - const deviceSubscriptionProduct = subscriptionProducts.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; - - // Both cards are drawn while loading: this plan has always had the two, and - // opening on one column only to reflow into two is a worse wait than a card - // that fills in. Once the catalog answers, it decides. - const showDeviceCard = loading || deviceProduct != null; - const showAiCard = loading || aiProduct != null; - - /** - * The devices this workspace is currently running — billing's own count - * (`usage.activeDevices`), NOT the `devices()` query the paywall used to spread - * (see the query above for why that one cannot come back). - * - * One number for the whole screen: the header names it, and the pay-as-you-go - * panel prices it. A panel that counted one fleet and totalled another would be - * two answers to the same question. - */ - const deviceCount = data?.subscription?.usage?.activeDevices ?? null; - - // Only the device card takes a plan selection. AI has no package to choose — - // its card picks a balance (see `AiTokenBalanceCard`). - const [deviceUpdates, setDeviceUpdates] = useState(null); - - /** - * The first top-up, held HERE rather than in the card that draws it: it is - * part of the same form as the plan, and the page's one button is what sends - * it (`CheckoutInput.tokenAmountUsd`). $50 is picked up front, as the mockup - * has it — a checkout may require an amount, and a form that starts with - * nothing chosen would refuse its own default. - */ - const topUp = useAiTopUp({ - tokenPrice: aiTokenPrice(aiProduct?.payAsYouGoOption?.price, aiProduct?.unitSize), - initial: 50, - }); - - /** - * Every non-device product, entered with no options. A checkout session - * describes the WHOLE target plan rather than a diff, so leaving these out - * would activate a subscription with the AI assistants switched off. How each - * is billed is the product's own decision — `payAsYouGoEnabled` is left out on - * purpose, since asking for the meter on a product sold in advance is refused. - */ - const otherProducts = useMemo( - () => products.filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES).map(p => ({ productName: p.name })), - [products], + const form = usePlanCheckout(data); + + const submitButton = (className?: string) => ( + ); - const packageUpdates = deviceUpdates?.packageUpdates ?? []; - const checkoutProducts = deviceUpdates?.checkout ? [deviceUpdates.checkout, ...otherProducts] : []; - const hasInvalidCustom = deviceUpdates != null && !deviceUpdates.valid; - const selectionTotal = deviceUpdates?.total ?? null; - // Only when the AI product is for sale here: a catalog without it has no - // balance to open, and the checkout must not carry an amount for it. - const tokenAmountUsd = showAiCard ? topUp.amountUsd : null; - const hasInvalidTopUp = showAiCard && !topUp.isComplete; - return ( <> - + - {showAiCard && } - - {/* `items-stretch`, not the `items-start` this had: side by side, two cards - of different heights read as one unfinished. Each card keeps its content - top-aligned (they are `flex-col`), so the shorter one gains empty space - at the bottom rather than stretched rows. */} -
- {showDeviceCard && ( - - )} - {showAiCard && } -
+ {/* The mobile submit bar is fixed to the viewport, so the total it applies to rides in the page flow above it rather than inside it. */}
-
- -
+
{submitButton()}
{/* Fixed (not sticky) so the bar always pins to the bottom of the viewport, @@ -282,18 +143,7 @@ function PaywallBody({ copy, data }: PaywallBodyProps) { under whatever route the user was on. Its own reservation is the only one that holds on all of them. */}
-
- -
+
{submitButton('w-full')}
); diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx index c5b850a5..8a9812c5 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx @@ -24,8 +24,11 @@ interface SubscriptionSubmitButtonProps { * which the backend accepts only when it is configured not to require one. */ tokenAmountUsd?: number | null; - /** True when the Custom top-up is selected with an empty/invalid amount. */ - hasInvalidTopUp?: boolean; + /** + * The top-up's own check (`AiTopUp.validate`): reveals the problem under the + * fields and returns it, or `null` when the amount can go. Checkout flow only. + */ + validateTopUp?: () => string | null; /** * The update landed. Only the update flow can call this — the checkout flow * leaves for Stripe and never comes back to this component. @@ -47,6 +50,12 @@ interface SubscriptionSubmitButtonProps { * plan change in place and does NOT redirect to a payment page (an upgrade may * raise an invoice afterwards). Disabled when the selection equals the current * plan, validated on click. + * + * A bad amount — a device count under the floor, a top-up under its minimum — + * never disables the button. It is pressed, and the press says what is wrong: + * in the form, next to the field, and in a toast for a form scrolled out of + * view. A locked button would leave the user to guess which of the two cards + * is refusing. */ export function SubscriptionSubmitButton({ needsCheckout, @@ -54,7 +63,7 @@ export function SubscriptionSubmitButton({ checkoutProducts, hasInvalidCustom, tokenAmountUsd = null, - hasInvalidTopUp = false, + validateTopUp, onUpdated, className, }: SubscriptionSubmitButtonProps) { @@ -72,12 +81,8 @@ export function SubscriptionSubmitButton({ }); }; - const rejectInvalidTopUp = () => { - toast({ - title: 'Invalid top-up amount', - description: 'Enter a whole number of dollars for the AI balance.', - variant: 'destructive', - }); + const rejectInvalidTopUp = (problem: string) => { + toast({ title: 'Check the AI top-up', description: problem, variant: 'destructive' }); }; if (needsCheckout) { @@ -93,8 +98,9 @@ export function SubscriptionSubmitButton({ rejectInvalidAmount(); return; } - if (hasInvalidTopUp) { - rejectInvalidTopUp(); + const topUpProblem = validateTopUp?.() ?? null; + if (topUpProblem != null) { + rejectInvalidTopUp(topUpProblem); return; } if (!checkoutProducts.length) return; diff --git a/src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts b/src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts new file mode 100644 index 00000000..461c21d5 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/subscription/hooks/use-plan-checkout.ts @@ -0,0 +1,189 @@ +'use client'; + +import { useMemo, useState } from 'react'; +import { graphql, useLazyLoadQuery } from 'react-relay'; +import type { usePlanCheckoutQuery as UsePlanCheckoutQueryType } from '@/__generated__/usePlanCheckoutQuery.graphql'; +import { OpenframeProduct } from '@/generated/schema-enums'; +import { type AiTopUp, useAiTopUp } from '../../hooks/use-ai-top-up'; +import { aiTokenPrice } from '../../lib/ai-token-price'; +import type { ProductUpdates, SelectionTotal } from '../types/subscription.types'; +import type { ProductCheckoutInput } from './use-create-checkout-session'; +import type { PackageUpdateInput } from './use-update-subscription'; + +/** + * Billing data ONLY. + * + * The fleet size comes from `subscription.usage` — NOT from the `devices()` + * query it used to be spread from. That is app data: a locked workspace has it + * refused with `SUBSCRIPTION_TRIAL_EXPIRED`, and because `devices` is non-null + * the refusal nulled this whole payload and crashed the one screen a locked + * workspace has to be able to render. The same count, counted by billing, + * carries no such risk. + */ +export const usePlanCheckoutQuery = graphql` + query usePlanCheckoutQuery { + billingPlan { + id + products { + id + name + packageOptions { + billingPeriod + } + # AI's metered rate, which prices the top-up amounts (what $20 buys). + # unitSize is what price is quoted per (AI: a block of tokens), so both + # are needed to price one token. + unitSize + payAsYouGoOption { + id + price + } + ...devicePlanPickerProductFragment + } + } + subscription { + id + # NOT aiTokensFree: that is the grant for the period the tenant is in + # (5M on a trial), and this page previews the plan they are about to buy. + # See freeTokensForPlan (lib/ai-free-tokens.ts) for what stands in until + # a prospective figure exists. + usage { + activeDevices + } + products { + name + ...devicePlanPickerSubscriptionFragment + } + } + } +`; + +export type PlanCheckoutData = UsePlanCheckoutQueryType['response']; + +type CatalogProductRef = NonNullable['products'][number]; +type SubscriptionProductRef = NonNullable['products'][number]; + +/** + * The catalog and the subscription, for the form below. Suspends: mount it + * under a boundary whose fallback is the same form with `null` data, so the + * wait shows the real controls rather than a spinner where the plan will be. + */ +export function usePlanCheckoutData(): PlanCheckoutData { + return useLazyLoadQuery( + usePlanCheckoutQuery, + {}, + { + fetchPolicy: 'store-and-network', + // This IS the lock screen's data. Gating it behind the subscription gate + // would park the paywall on the very state it exists to get the user out of. + networkCacheConfig: { metadata: { skipSubscriptionGate: true } }, + }, + ); +} + +export interface PlanCheckout { + /** The catalog is on its way; every slot draws its own pending state. */ + loading: boolean; + deviceProduct: CatalogProductRef | null; + deviceSubscriptionProduct: SubscriptionProductRef | null; + /** + * Both cards are drawn while loading: this plan has always had the two, and + * opening on one column only to reflow into two is a worse wait than a card + * that fills in. Once the catalog answers, it decides. + */ + showDeviceCard: boolean; + showAiCard: boolean; + /** + * The devices this workspace is currently running — billing's own count + * (`usage.activeDevices`). One number for the whole form: the heading names + * it, and the pay-as-you-go panel prices it. + */ + deviceCount: number | null; + /** What the device card has picked; `null` until it reports. */ + deviceUpdates: ProductUpdates | null; + setDeviceUpdates: (updates: ProductUpdates) => void; + /** The first AI top-up as the user is choosing it. */ + topUp: AiTopUp; + /** ADD/CANCEL diff, for an existing subscription. */ + packageUpdates: PackageUpdateInput[]; + /** The whole target plan, for a checkout. */ + checkoutProducts: ProductCheckoutInput[]; + /** The device card holds a quantity nobody can be billed for. */ + hasInvalidCustom: boolean; + selectionTotal: SelectionTotal | null; + /** The top-up to charge on the checkout; `null` when the plan has no AI product. */ + tokenAmountUsd: number | null; +} + +/** + * The plan as a form, without a frame around it. + * + * Two surfaces fill it in — the lock screen (`SubscriptionSettingsView`) and + * the billing page's Activate Subscription modal — and they buy the same thing + * with the same button: the device plan, the AI product, and the first top-up, + * on one Stripe Checkout. So the choices and everything derived from them live + * here, and each surface only decides where the total and the button go. + */ +export function usePlanCheckout(data: PlanCheckoutData | null): PlanCheckout { + const loading = data == null; + // Memoized: the `?? []` fallback is a new array on every render, and the + // memo below depends on it. + const products = useMemo(() => data?.billingPlan?.products ?? [], [data?.billingPlan]); + const subscriptionProducts = data?.subscription?.products ?? []; + + const deviceProduct = products.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; + const aiProduct = products.find(p => p.name === OpenframeProduct.AI_ASSISTANCE) ?? null; + const deviceSubscriptionProduct = subscriptionProducts.find(p => p.name === OpenframeProduct.MANAGED_DEVICES) ?? null; + + const showDeviceCard = loading || deviceProduct != null; + const showAiCard = loading || aiProduct != null; + + const deviceCount = data?.subscription?.usage?.activeDevices ?? null; + + // Only the device card takes a plan selection. AI has no package to choose — + // its card picks a balance (see `AiTokenBalanceCard`). + const [deviceUpdates, setDeviceUpdates] = useState(null); + + /** + * Held HERE rather than in the card that draws it: it is part of the same + * form as the plan, and the form's one button is what sends it + * (`CheckoutInput.tokenAmountUsd`). $50 is picked up front, as the mockup has + * it — a checkout requires an amount, and a form that starts with nothing + * chosen would refuse its own default. + */ + const topUp = useAiTopUp({ + tokenPrice: aiTokenPrice(aiProduct?.payAsYouGoOption?.price, aiProduct?.unitSize), + initial: 50, + }); + + /** + * Every non-device product, entered with no options. A checkout session + * describes the WHOLE target plan rather than a diff, so leaving these out + * would activate a subscription with the AI assistants switched off. How each + * is billed is the product's own decision — `payAsYouGoEnabled` is left out on + * purpose, since asking for the meter on a product sold in advance is refused. + */ + const otherProducts = useMemo( + () => products.filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES).map(p => ({ productName: p.name })), + [products], + ); + + return { + loading, + deviceProduct, + deviceSubscriptionProduct, + showDeviceCard, + showAiCard, + deviceCount, + deviceUpdates, + setDeviceUpdates, + topUp, + packageUpdates: deviceUpdates?.packageUpdates ?? [], + checkoutProducts: deviceUpdates?.checkout ? [deviceUpdates.checkout, ...otherProducts] : [], + hasInvalidCustom: deviceUpdates != null && !deviceUpdates.valid, + selectionTotal: deviceUpdates?.total ?? null, + // Only when the AI product is for sale here: a catalog without it has no + // balance to open, and the checkout must not carry an amount for it. + tokenAmountUsd: showAiCard ? topUp.amountUsd : null, + }; +} diff --git a/src/app/components/subscription-lock/paywall-header.tsx b/src/app/components/subscription-lock/paywall-header.tsx index 408a85fb..bd192215 100644 --- a/src/app/components/subscription-lock/paywall-header.tsx +++ b/src/app/components/subscription-lock/paywall-header.tsx @@ -1,7 +1,7 @@ 'use client'; import { LockScreenActionsMenu } from './lock-screen-actions'; -import type { PaywallCopy } from './subscription-lock-copy'; +import { type PaywallCopy, paywallDescription } from './subscription-lock-copy'; interface PaywallHeaderProps { copy: PaywallCopy; @@ -35,11 +35,7 @@ export function PaywallHeader({ copy, deviceCount = null }: PaywallHeaderProps)

{copy.title}

-

- {deviceCount == null - ? copy.description - : `We've detected ${deviceCount.toLocaleString('en-US')} active device${deviceCount === 1 ? '' : 's'} in your OpenFrame instance that ${deviceCount === 1 ? 'requires' : 'require'} a subscription to continue management.`} -

+

{paywallDescription(copy, deviceCount)}

); } diff --git a/src/app/components/subscription-lock/subscription-lock-copy.ts b/src/app/components/subscription-lock/subscription-lock-copy.ts index b44d4248..4a144b1d 100644 --- a/src/app/components/subscription-lock/subscription-lock-copy.ts +++ b/src/app/components/subscription-lock/subscription-lock-copy.ts @@ -52,3 +52,20 @@ const DEFAULT_COPY: PaywallCopy = { export function getPaywallCopy(status: SubscriptionStatus): PaywallCopy { return PAYWALL_COPY[status] ?? DEFAULT_COPY; } + +/** + * The line under the paywall's title: the fleet the plan is for once billing + * has counted it, and `copy.description` until then. One sentence either way, + * not a number appearing into a gap left for it. + */ +export function paywallDescription(copy: PaywallCopy, deviceCount: number | null): string { + if (deviceCount == null) return copy.description; + const one = deviceCount === 1; + return `We've detected ${deviceCount.toLocaleString('en-US')} active device${one ? '' : 's'} in your OpenFrame instance that ${one ? 'requires' : 'require'} a subscription to continue management.`; +} + +/** Shown in place of the plans when their catalog cannot be loaded at all. */ +export const PLANS_UNAVAILABLE_COPY = { + title: "We couldn't load the plans.", + description: 'Something went wrong on our side. Try again in a moment, or contact support if it keeps happening.', +}; From c1aaec2aeaf2ef29ed5fe3e0c11a1124c8dded33 Mon Sep 17 00:00:00 2001 From: Pavlo Shylo Date: Tue, 15 Sep 2026 12:22:43 +0100 Subject: [PATCH 4/4] fix(billing): disable an empty checkout, toast the server's message everywhere 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. --- .../hooks/extract-graphql-error-message.ts | 10 ----- .../hooks/use-billing-portal-session.ts | 3 +- .../hooks/use-cancel-subscription.ts | 3 +- .../hooks/use-resume-subscription.ts | 3 +- .../hooks/use-seed-test-usage.ts | 4 +- .../billing-usage/hooks/use-test-clock.ts | 6 +-- .../components/subscription-submit-button.tsx | 41 +++++++++++-------- .../hooks/use-update-subscription.ts | 3 +- 8 files changed, 36 insertions(+), 37 deletions(-) delete mode 100644 src/app/(app)/settings/billing-usage/hooks/extract-graphql-error-message.ts diff --git a/src/app/(app)/settings/billing-usage/hooks/extract-graphql-error-message.ts b/src/app/(app)/settings/billing-usage/hooks/extract-graphql-error-message.ts deleted file mode 100644 index e0d0784a..00000000 --- a/src/app/(app)/settings/billing-usage/hooks/extract-graphql-error-message.ts +++ /dev/null @@ -1,10 +0,0 @@ -/** - * Relay wraps GraphQL errors as "No data returned for operation `x`, got error(s): ". - * The backend's own message is the only useful part in a toast — the wrapper eats the width and - * pushes the actual reason past the truncation point. - */ -export function extractGraphqlErrorMessage(err: unknown, fallback: string): string { - if (!(err instanceof Error)) return fallback; - const match = /got error\(s\):\s*([\s\S]+)/.exec(err.message); - return (match?.[1] ?? err.message).trim() || fallback; -} diff --git a/src/app/(app)/settings/billing-usage/hooks/use-billing-portal-session.ts b/src/app/(app)/settings/billing-usage/hooks/use-billing-portal-session.ts index c228928c..08e536d6 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-billing-portal-session.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-billing-portal-session.ts @@ -4,6 +4,7 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useCallback } from 'react'; import { graphql, useMutation } from 'react-relay'; import type { useBillingPortalSessionMutation as UseBillingPortalSessionMutationType } from '@/__generated__/useBillingPortalSessionMutation.graphql'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; import { openDeferredTab } from '../lib/stripe-window'; // Stripe hosts the portal, so the destination cannot be a link: the session is @@ -50,7 +51,7 @@ export function useBillingPortalSession() { tab.cancel(); toast({ title: 'Customer Portal Unavailable', - description: err instanceof Error ? err.message : 'Failed to open the customer portal', + description: getRelayErrorMessage(err, 'Failed to open the customer portal'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/hooks/use-cancel-subscription.ts b/src/app/(app)/settings/billing-usage/hooks/use-cancel-subscription.ts index 321914cb..2dbed7c2 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-cancel-subscription.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-cancel-subscription.ts @@ -4,6 +4,7 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useCallback } from 'react'; import { commitLocalUpdate, graphql, useMutation, useRelayEnvironment } from 'react-relay'; import type { useCancelSubscriptionMutation as UseCancelSubscriptionMutationType } from '@/__generated__/useCancelSubscriptionMutation.graphql'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; const cancelSubscriptionMutation = graphql` mutation useCancelSubscriptionMutation($input: CancelSubscriptionInput) { @@ -39,7 +40,7 @@ export function useCancelSubscription() { onError: err => { toast({ title: 'Cancel Failed', - description: err instanceof Error ? err.message : 'Failed to cancel subscription', + description: getRelayErrorMessage(err, 'Failed to cancel subscription'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/hooks/use-resume-subscription.ts b/src/app/(app)/settings/billing-usage/hooks/use-resume-subscription.ts index d91f019e..24e7f14a 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-resume-subscription.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-resume-subscription.ts @@ -4,6 +4,7 @@ import { useToast } from '@flamingo-stack/openframe-frontend-core/hooks'; import { useCallback } from 'react'; import { graphql, useMutation } from 'react-relay'; import type { useResumeSubscriptionMutation as UseResumeSubscriptionMutationType } from '@/__generated__/useResumeSubscriptionMutation.graphql'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; // Clears a scheduled cancellation (status PENDING_CANCELLATION) in Stripe so the // subscription renews again. Only valid while still inside the paid period — a @@ -48,7 +49,7 @@ export function useResumeSubscription() { onError: err => { toast({ title: 'Renew Failed', - description: err instanceof Error ? err.message : 'Failed to renew subscription', + description: getRelayErrorMessage(err, 'Failed to renew subscription'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/hooks/use-seed-test-usage.ts b/src/app/(app)/settings/billing-usage/hooks/use-seed-test-usage.ts index bf9b8cde..236de225 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-seed-test-usage.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-seed-test-usage.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { commitLocalUpdate, graphql, useMutation, useRelayEnvironment } from 'react-relay'; import type { useSeedTestUsageMutation as UseSeedTestUsageMutationType } from '@/__generated__/useSeedTestUsageMutation.graphql'; import type { BillingMetricType } from '@/generated/schema-enums'; -import { extractGraphqlErrorMessage } from './extract-graphql-error-message'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; /** * Seeds synthetic billing usage (dev/stage only — the mutation is absent from the prod @@ -58,7 +58,7 @@ export function useSeedTestUsage() { onError: err => { toast({ title: 'Seeding Failed', - description: extractGraphqlErrorMessage(err, 'Failed to seed test usage'), + description: getRelayErrorMessage(err, 'Failed to seed test usage'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/hooks/use-test-clock.ts b/src/app/(app)/settings/billing-usage/hooks/use-test-clock.ts index 8ec1ee96..f78b8e6f 100644 --- a/src/app/(app)/settings/billing-usage/hooks/use-test-clock.ts +++ b/src/app/(app)/settings/billing-usage/hooks/use-test-clock.ts @@ -5,7 +5,7 @@ import { useCallback } from 'react'; import { commitLocalUpdate, graphql, useMutation, useRelayEnvironment } from 'react-relay'; import type { useTestClockAdvanceMutation as UseTestClockAdvanceMutationType } from '@/__generated__/useTestClockAdvanceMutation.graphql'; import type { useTestClockResetMutation as UseTestClockResetMutationType } from '@/__generated__/useTestClockResetMutation.graphql'; -import { extractGraphqlErrorMessage } from './extract-graphql-error-message'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; /** * Stripe test-clock mutations (dev/stage only — gated by the `test-clock` feature flag). @@ -57,7 +57,7 @@ export function useAdvanceTestClock() { onError: err => { toast({ title: 'Advance Failed', - description: extractGraphqlErrorMessage(err, 'Failed to advance the test clock'), + description: getRelayErrorMessage(err, 'Failed to advance the test clock'), variant: 'destructive', }); }, @@ -96,7 +96,7 @@ export function useResetTestClock() { onError: err => { toast({ title: 'Reset Failed', - description: extractGraphqlErrorMessage(err, 'Failed to reset the test clock'), + description: getRelayErrorMessage(err, 'Failed to reset the test clock'), variant: 'destructive', }); }, diff --git a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx index 8a9812c5..3fababf7 100644 --- a/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx +++ b/src/app/(app)/settings/billing-usage/subscription/components/subscription-submit-button.tsx @@ -45,7 +45,11 @@ interface SubscriptionSubmitButtonProps { * The ACTION still splits on the subscription state: * - no active paid subscription → `createCheckoutSession`, which redirects to * Stripe. No diff gating: there is nothing to compare against. The AI top-up - * rides along on the same input and lands on the same invoice. + * rides along on the same input and lands on the same invoice. Disabled only + * while there is nothing to buy yet — the picker has not reported (catalog + * still loading), or the catalog has no device product — because a button + * that looks live and does nothing on click is a dead end with no spinner, + * toast or redirect to say so. * - active paid subscription → `updateSubscription`, a mutation that applies the * plan change in place and does NOT redirect to a payment page (an upgrade may * raise an invoice afterwards). Disabled when the selection equals the current @@ -86,28 +90,29 @@ export function SubscriptionSubmitButton({ }; if (needsCheckout) { + const handleCheckout = () => { + // Checkout has no diff to gate on, but an out-of-range quantity is still + // one: it would be sent as a plan nobody can be billed for. The same + // goes for a top-up with no figure behind it. + if (hasInvalidCustom) { + rejectInvalidAmount(); + return; + } + const topUpProblem = validateTopUp?.() ?? null; + if (topUpProblem != null) { + rejectInvalidTopUp(topUpProblem); + return; + } + createCheckout.mutate({ products: checkoutProducts, tokenAmountUsd: tokenAmountUsd ?? undefined }); + }; + return ( diff --git a/src/app/(app)/settings/billing-usage/subscription/hooks/use-update-subscription.ts b/src/app/(app)/settings/billing-usage/subscription/hooks/use-update-subscription.ts index 65236625..e380016d 100644 --- a/src/app/(app)/settings/billing-usage/subscription/hooks/use-update-subscription.ts +++ b/src/app/(app)/settings/billing-usage/subscription/hooks/use-update-subscription.ts @@ -8,6 +8,7 @@ import type { UpdateSubscriptionInput, useUpdateSubscriptionMutation as UseUpdateSubscriptionMutationType, } from '@/__generated__/useUpdateSubscriptionMutation.graphql'; +import { getRelayErrorMessage } from '@/lib/handle-api-error'; export type { PackageUpdateInput, UpdateSubscriptionInput }; const updateSubscriptionMutation = graphql` @@ -81,7 +82,7 @@ export function useUpdateSubscription() { onError: err => { toast({ title: 'Update Failed', - description: err instanceof Error ? err.message : 'Failed to update subscription', + description: getRelayErrorMessage(err, 'Failed to update subscription'), variant: 'destructive', }); },