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/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..2a01ff3a --- /dev/null +++ b/src/app/(app)/settings/billing-usage/components/ai-top-up-fields.tsx @@ -0,0 +1,132 @@ +'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)} + /> + ); + })} + +
+ {/* 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 && ( +
+ 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/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 1f5fa3b5..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 @@ -3,34 +3,35 @@ import { AlertTriangleIcon, ExternalLinkIcon, - InfoCircleIcon, - PlusCircleIcon, - Settings02Icon, + Refresh02VrIcon, 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'; 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 { 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 { AUTO_TOP_UP } from '../lib/auto-top-up'; import { formatCompactCount, formatCount, formatCurrency, formatDateOrDash } from '../lib/format'; import { openExternalTab } from '../lib/stripe-window'; -import { AiTokensLimitModal } from './ai-tokens-limit-modal'; +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'; 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 +115,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 +133,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 +167,16 @@ export function BillingUsageContent() { const resumeSubscription = useResumeSubscription(); const billingPortal = useBillingPortalSession(); const [planModalOpen, setPlanModalOpen] = useState(false); - const [aiLimitModalOpen, setAiLimitModalOpen] = 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 + * 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 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(''); @@ -170,56 +195,44 @@ 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". 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. + * 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 planInHeader = planOffered && !aiLimitInHeader && !plan.isAnnual; + const planOffered = !flags.isPendingCancellation && !needsCheckout; 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', @@ -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 as pay-as-you-go: 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,31 +306,24 @@ export function BillingUsageContent() { : flags.isTrial ? { label: 'Activate Subscription', - onClick: () => setPlanModalOpen(true), + onClick: () => setActivateModalOpen(true), variant: 'accent' as const, } : 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 — and on a trial, where Activate Subscription stands alone. + */ + const secondaryAction = aiBalanceOffered ? { - 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 +385,71 @@ 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)}} + {/* 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.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,121 +494,71 @@ 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 && ( - - )} - {/* 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. A trial has no `currentPeriodEnd`, so its row simply never - appears beside "Trial ends on". */} - {billing.nextBillingDate && ( - - )} - {billing.cancellationEffectiveAt && ( - } /> + 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 && ( +
} /> - )} - {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. */} + setPlanModalOpen(false)} onUpdated={() => { setPlanModalOpen(false); @@ -610,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)} + /> + - {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..d84f3a3b --- /dev/null +++ b/src/app/(app)/settings/billing-usage/components/manage-ai-balance-modal.tsx @@ -0,0 +1,87 @@ +'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 { AUTO_TOP_UP } from '../lib/auto-top-up'; +import { AI_BALANCE_EXPLANATION, AiTopUpFields } from './ai-top-up-fields'; +import { AutoTopUpCheckbox } from './auto-top-up-checkbox'; + +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. + * + * 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 + * saved here, and the tokens land once the invoice is paid. + * + * The button is never locked over the amount. Pressed with nothing chosen or a + * figure under the floor, it says so under the fields (`AiTopUp.validate`) — a + * disabled button explains nothing, and the user is left guessing what to fix. + */ +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.validate() != null || 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..bde4c40f 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 @@ -1,13 +1,12 @@ 'use client'; -import { Suspense, useCallback, useMemo, useState } from 'react'; +import { Suspense, useState } from 'react'; import { graphql, useLazyLoadQuery } from 'react-relay'; import type { upgradePlanModalQuery as UpgradePlanModalQueryType } from '@/__generated__/upgradePlanModalQuery.graphql'; import { SimpleModal } from '@/app/components/shared/simple-modal'; import { OpenframeProduct } from '@/generated/schema-enums'; import { DevicePlanPicker } from '../subscription/components/device-plan-picker'; import { SubscriptionSubmitButton } from '../subscription/components/subscription-submit-button'; -import type { ProductCheckoutInput } from '../subscription/hooks/use-create-checkout-session'; import type { ProductUpdates } from '../subscription/types/subscription.types'; const upgradePlanModalQuery = graphql` @@ -30,32 +29,11 @@ const upgradePlanModalQuery = graphql` } `; -/** What the picker reports up, plus what has to ride along into a checkout session. */ -interface PlanSelection { - updates: ProductUpdates; - /** - * Every non-device product, as pay-as-you-go. 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. - */ - otherProducts: ProductCheckoutInput[]; -} - /** The loading frame reports nothing: there is no selection to submit yet. */ const NOOP_UPDATES = () => {}; 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 @@ -68,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 ( @@ -114,45 +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, payAsYouGoEnabled: true })), - [products], - ); - - const handleUpdatesChange = useCallback( - (updates: ProductUpdates) => onSelectionChange({ updates, otherProducts }), - [onSelectionChange, otherProducts], - ); - return ( ); } 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/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-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..d6bfea84 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/hooks/use-ai-top-up.ts @@ -0,0 +1,128 @@ +'use client'; + +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 + * `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; + +/** + * 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; + /** 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 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; +} + +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; +} + +/** + * 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"), and the field cannot produce them in the first place — + * so the checks left are an empty field and the floor. + */ +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 }; +} + +/** + * 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: '' }); + // 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, problem } = resolveChoice(choice); + const forUsd = (usd: number): number | null => tokensForUsd(usd, tokenPrice); + + return { + ...choice, + amountUsd, + tokens: amountUsd == null ? null : forUsd(amountUsd), + 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, '') })), + validate: () => { + setAttempted(true); + return problem; + }, + reset: () => { + setChoice({ selection: initial, customUsd: '' }); + setAttempted(false); + }, + }; +} 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-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-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-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-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/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/auto-top-up.ts b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts new file mode 100644 index 00000000..acad6f9e --- /dev/null +++ b/src/app/(app)/settings/billing-usage/lib/auto-top-up.ts @@ -0,0 +1,20 @@ +/** + * 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 (`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. + */ +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 }; 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/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-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..a2ba9fb7 --- /dev/null +++ b/src/app/(app)/settings/billing-usage/subscription/components/ai-token-balance-card.tsx @@ -0,0 +1,79 @@ +'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 { 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'; +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. + * + * "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. + 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..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 @@ -6,11 +6,21 @@ import { AnthropicLogoIcon, GeminiLogoIcon, OpenaiLogoGreyIcon, + QuestionCircleIcon, + Refresh02VrIcon, + XmarkCircleIcon, } 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'; +import type { AutoTopUpStatus } from '../../lib/auto-top-up'; const PROVIDER_ICON: Record> = { ANTHROPIC: AnthropicLogoIcon, @@ -38,9 +48,42 @@ 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; +} + /** - * Two self-contained boundaries, because this is a TOOLTIP: nothing it does may - * reach the page it is opened from. + * 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({ autoTopUp, className }: ModelTokenRatesProps & { className?: string }) { + return ( + + + + + + + + + ); +} + +/** + * 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 @@ -58,20 +101,46 @@ function formatRate(value: number): 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 ( -
+
@@ -88,7 +157,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 } }, @@ -99,7 +168,7 @@ function ModelTokenRatesContent() { if (rates.length === 0) return null; return ( -
+ <>
Model OpenFrame Token @@ -125,13 +194,13 @@ function ModelTokenRatesContent() { ); })}
-
+ ); } function ModelTokenRatesSkeleton() { return ( -
+ <>
@@ -145,6 +214,6 @@ function ModelTokenRatesSkeleton() {
))} -
+ ); } 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/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..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,76 +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 { useAiSpendLimit } from '../../hooks/use-ai-spend-limit'; -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 { 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. 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. - unitSize - payAsYouGoOption { - id - price - } - ...devicePlanPickerProductFragment - } - } - 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. - usage { - activeDevices - } - products { - name - ...devicePlanPickerSubscriptionFragment - } - } - } -`; - /** * The paywall. * @@ -82,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 @@ -124,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) { @@ -150,7 +84,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 = @@ -158,116 +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 is metered — there is no - // package to choose (see `AiTokensUsageCard`). - 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. - */ - const aiLimit = useAiSpendLimit({ - capUsd: data?.subscription?.aiSpendCapUsd ?? null, - tokenPrice: aiTokenPrice(aiProduct?.payAsYouGoOption?.price, aiProduct?.unitSize), - }); - - /** - * Every non-device product, entered as pay-as-you-go. 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. - */ - const otherProducts = useMemo( - () => - products - .filter(p => p.name !== OpenframeProduct.MANAGED_DEVICES) - .map(p => ({ productName: p.name, payAsYouGoEnabled: true })), - [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; - /** - * `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; - 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, @@ -279,17 +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 d0657372..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 @@ -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,20 @@ 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; + /** + * 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. @@ -41,33 +44,38 @@ 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. 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 * 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. + * 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, packageUpdates, checkoutProducts, hasInvalidCustom, - aiSpendCapUsd, + tokenAmountUsd = null, + validateTopUp, 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,40 +85,34 @@ 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 = (problem: string) => { + toast({ title: 'Check the AI top-up', description: problem, variant: 'destructive' }); }; 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 ( @@ -123,7 +125,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/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/(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', }); }, 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/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.', +}; 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 }),