+ )
+}
diff --git a/apps/desktop/src/app/settings/billing/api.test.ts b/apps/desktop/src/app/settings/billing/api.test.ts
index 02f359c35a58..aa5f9c384952 100644
--- a/apps/desktop/src/app/settings/billing/api.test.ts
+++ b/apps/desktop/src/app/settings/billing/api.test.ts
@@ -133,6 +133,48 @@ describe('createBillingApi', () => {
})
})
+ it('previews a subscription change with the chosen tier id', async () => {
+ requestGatewayMock.mockResolvedValueOnce({ effect: 'scheduled', ok: true, target_tier_name: 'Plus' })
+
+ const api = createBillingApi(requestGatewayMock)
+ const response = await api.previewSubscriptionChange('tier_plus')
+
+ expect(response).toEqual({ data: { effect: 'scheduled', ok: true, target_tier_name: 'Plus' }, ok: true })
+ expect(requestGatewayMock).toHaveBeenCalledWith('subscription.preview', { subscription_type_id: 'tier_plus' })
+ })
+
+ it('schedules a subscription change with the chosen tier id', async () => {
+ requestGatewayMock.mockResolvedValueOnce({ message: 'Downgrade scheduled.', ok: true })
+
+ const api = createBillingApi(requestGatewayMock)
+ const response = await api.scheduleSubscriptionChange('tier_plus')
+
+ expect(response).toEqual({ data: { message: 'Downgrade scheduled.', ok: true }, ok: true })
+ expect(requestGatewayMock).toHaveBeenCalledWith('subscription.change', { subscription_type_id: 'tier_plus' })
+ })
+
+ it('resumes (undoes) a scheduled change with no params', async () => {
+ requestGatewayMock.mockResolvedValueOnce({ message: 'Change cancelled.', ok: true })
+
+ const api = createBillingApi(requestGatewayMock)
+ const response = await api.resumeSubscription()
+
+ expect(response).toEqual({ data: { message: 'Change cancelled.', ok: true }, ok: true })
+ expect(requestGatewayMock).toHaveBeenCalledWith('subscription.resume', {})
+ })
+
+ it('surfaces an insufficient_scope refusal from a subscription preview', async () => {
+ requestGatewayMock.mockResolvedValueOnce({
+ error: { kind: 'insufficient_scope', message: 'billing:manage required' },
+ ok: false
+ })
+
+ const api = createBillingApi(requestGatewayMock)
+ const response = await api.scheduleSubscriptionChange('tier_plus')
+
+ expect(response).toMatchObject({ ok: false, refusal: { kind: 'insufficient_scope' } })
+ })
+
it('sends a step-up session id when provided', async () => {
requestGatewayMock.mockResolvedValueOnce({ granted: true, ok: true })
diff --git a/apps/desktop/src/app/settings/billing/api.ts b/apps/desktop/src/app/settings/billing/api.ts
index 6177db8da1b6..8632ae1b2861 100644
--- a/apps/desktop/src/app/settings/billing/api.ts
+++ b/apps/desktop/src/app/settings/billing/api.ts
@@ -1,4 +1,4 @@
-import { useMemo } from 'react'
+import { createContext, useContext, useMemo } from 'react'
import { useGatewayRequest } from '@/app/gateway/hooks/use-gateway-request'
@@ -9,6 +9,7 @@ import type {
BillingMutationResponse,
BillingRefusalCode,
BillingStateResponse,
+ SubscriptionPreviewResponse,
SubscriptionStateResponse
} from './types'
@@ -48,6 +49,12 @@ export interface BillingApi {
chargeStatus: (chargeId: string) => Promise>
fetchBillingState: () => Promise>
fetchSubscriptionState: () => Promise>
+ /** Chargeless quote for a plan change (POST /subscription/preview). */
+ previewSubscriptionChange: (tierId: string) => Promise>
+ /** Clear a scheduled downgrade / cancellation — the undo (DELETE pending-change). */
+ resumeSubscription: () => Promise>
+ /** Schedule a chargeless downgrade at period end (PUT pending-change). */
+ scheduleSubscriptionChange: (tierId: string) => Promise>
stepUp: (sessionId?: string) => Promise>
updateAutoReload: (input: UpdateAutoReloadInput) => Promise>
}
@@ -149,6 +156,15 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
callBilling(requestGateway, 'billing.charge_status', { charge_id: chargeId }),
fetchBillingState: () => callBilling(requestGateway, 'billing.state'),
fetchSubscriptionState: () => callBilling(requestGateway, 'subscription.state'),
+ previewSubscriptionChange: tierId =>
+ callBilling(requestGateway, 'subscription.preview', {
+ subscription_type_id: tierId
+ }),
+ resumeSubscription: () => callBilling(requestGateway, 'subscription.resume', {}),
+ scheduleSubscriptionChange: tierId =>
+ callBilling(requestGateway, 'subscription.change', {
+ subscription_type_id: tierId
+ }),
stepUp: sessionId =>
callBilling(requestGateway, 'billing.step_up', {
...(sessionId !== undefined ? { session_id: sessionId } : {})
@@ -161,8 +177,17 @@ export const createBillingApi = (requestGateway: BillingRequestGateway): Billing
})
})
-export function useBillingApi(): BillingApi {
- const { requestGateway } = useGatewayRequest()
+// An override for the gateway-backed api — DEV fixtures provide a simulated
+// implementation here so every consumer (hooks, rows) transparently runs against it
+// with no fixture awareness of their own. `null` (the default) = the real gateway api.
+const BillingApiContext = createContext(null)
- return useMemo(() => createBillingApi(requestGateway), [requestGateway])
+export const BillingApiProvider = BillingApiContext.Provider
+
+export function useBillingApi(): BillingApi {
+ const override = useContext(BillingApiContext)
+ const { requestGateway } = useGatewayRequest()
+ const real = useMemo(() => createBillingApi(requestGateway), [requestGateway])
+
+ return override ?? real
}
diff --git a/apps/desktop/src/app/settings/billing/auto-reload-row.tsx b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx
new file mode 100644
index 000000000000..5b5b94017ef6
--- /dev/null
+++ b/apps/desktop/src/app/settings/billing/auto-reload-row.tsx
@@ -0,0 +1,290 @@
+import { useQueryClient } from '@tanstack/react-query'
+import { useState } from 'react'
+
+import { Button } from '@/components/ui/button'
+import { Input } from '@/components/ui/input'
+import { cn } from '@/lib/utils'
+
+import { ListRow, Pill } from '../primitives'
+
+import { RowValue } from './account-row-value'
+import type { BillingRefusal } from './api'
+import { useBillingApi } from './api'
+import { initialAutoReloadAmount, validateAutoReloadInputs } from './billing-amounts'
+import { BillingRefusalInline } from './inline-feedback'
+import type { BillingAutoReload, BillingStateResponse } from './types'
+import type { BillingAccountRowView } from './use-billing-state'
+
+export function AutoReloadRow({
+ autoReload,
+ bounds,
+ row
+}: {
+ autoReload: BillingAutoReload
+ bounds: Pick
+ row: BillingAccountRowView
+}) {
+ const api = useBillingApi()
+ const queryClient = useQueryClient()
+ const [confirmDisable, setConfirmDisable] = useState(false)
+ const [editing, setEditing] = useState(false)
+ // Validation errors are silent until the user edits a field or attempts a
+ // save — opening Manage on a prefilled (possibly below-min) config must not
+ // flash an error (spec §9).
+ const [showErrors, setShowErrors] = useState(false)
+ const [message, setMessage] = useState(null)
+ const [refusal, setRefusal] = useState(null)
+
+ const [reloadTo, setReloadTo] = useState(
+ initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
+ )
+
+ const [saving, setSaving] = useState(false)
+
+ const [threshold, setThreshold] = useState(
+ initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
+ )
+
+ const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
+ const busy = saving
+ const maxBound = bounds.max_usd ?? undefined
+ const minBound = bounds.min_usd ?? undefined
+
+ // Only the canonical-card enabled state edits in place (flagged in the view model).
+ // Off / divergent-card rows have no Manage affordance (or a portal link) and render
+ // read-only.
+ const editable = row.manageInApp === true
+
+ const resetFeedback = () => {
+ setConfirmDisable(false)
+ setMessage(null)
+ setRefusal(null)
+ }
+
+ const openEdit = () => {
+ resetFeedback()
+ setShowErrors(false)
+ setEditing(true)
+ }
+
+ const cancelEdit = () => {
+ resetFeedback()
+ setEditing(false)
+ }
+
+ const save = async () => {
+ if (!validation.values || busy) {
+ return
+ }
+
+ resetFeedback()
+ setSaving(true)
+
+ const result = await api.updateAutoReload({
+ enabled: true,
+ reload_to_usd: validation.values.reloadTo,
+ threshold_usd: validation.values.threshold
+ })
+
+ setSaving(false)
+
+ if (!result.ok) {
+ setRefusal(result.refusal)
+
+ return
+ }
+
+ await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
+ setMessage({ kind: 'success', text: 'Auto-refill updated.' })
+ setEditing(false)
+ }
+
+ const disable = async () => {
+ if (busy) {
+ return
+ }
+
+ resetFeedback()
+ setSaving(true)
+
+ // The gateway's billing.auto_reload handler unconditionally requires threshold
+ // + top_up_amount (invalid_request otherwise), so a disable must still carry the
+ // current amounts — mirroring the TUI, which always sends both.
+ const result = await api.updateAutoReload({
+ enabled: false,
+ reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
+ threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
+ })
+
+ setSaving(false)
+
+ if (!result.ok) {
+ setRefusal(result.refusal)
+
+ return
+ }
+
+ await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
+ setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
+ setEditing(false)
+ }
+
+ // Read-only states (off / divergent card) keep the original ListRow shape.
+ if (!editable) {
+ return (
+ }
+ below={
+ <>
+ {row.caption ? (
+
+ {row.caption}
+
+ ) : null}
+
+ {message && {message.text}}
+ >
+ }
+ description={row.description}
+ key={row.id}
+ title={row.title}
+ />
+ )
+ }
+
+ const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
+ resetFeedback()
+ setShowErrors(true)
+ setter(event.target.value)
+ }
+
+ // Zero-shift by exact reservation, not a magic min-height: the edit form is
+ // ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
+ // so the row's height always equals the tallest state at EVERY container width —
+ // no breakpoint math that under-reserves when the two inputs stack on narrow
+ // panes. The form is `invisible` + `aria-hidden` when not editing.
+ return (
+
+
+
+
+ {row.title}
+
+
+ {row.description}
+
+
+ {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
+
+
+
+
+
+ {/* Pre-allocated error line — occupies height whether or not shown. */}
+
- )
-}
-
-function AutoReloadRow({
- autoReload,
- bounds,
- row
-}: {
- autoReload: BillingAutoReload
- bounds: Pick
- row: BillingAccountRowView
-}) {
- const api = useBillingApi()
- const queryClient = useQueryClient()
- const [confirmDisable, setConfirmDisable] = useState(false)
- const [editing, setEditing] = useState(false)
- // Validation errors are silent until the user edits a field or attempts a
- // save — opening Manage on a prefilled (possibly below-min) config must not
- // flash an error (spec §9).
- const [showErrors, setShowErrors] = useState(false)
- const [message, setMessage] = useState(null)
- const [refusal, setRefusal] = useState(null)
-
- const [reloadTo, setReloadTo] = useState(
- initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display)
- )
-
- const [saving, setSaving] = useState(false)
-
- const [threshold, setThreshold] = useState(
- initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
- )
-
- const validation = validateAutoReloadInputs(threshold, reloadTo, bounds)
- const busy = saving
- const maxBound = bounds.max_usd ?? undefined
- const minBound = bounds.min_usd ?? undefined
-
- // Only the canonical-card enabled state edits in place (flagged in the view model).
- // Off / divergent-card rows have no Manage affordance (or a portal link) and render
- // read-only.
- const editable = row.manageInApp === true
-
- const resetFeedback = () => {
- setConfirmDisable(false)
- setMessage(null)
- setRefusal(null)
- }
-
- const openEdit = () => {
- resetFeedback()
- setShowErrors(false)
- setEditing(true)
- }
-
- const cancelEdit = () => {
- resetFeedback()
- setEditing(false)
- }
-
- const save = async () => {
- if (!validation.values || busy) {
- return
- }
-
- resetFeedback()
- setSaving(true)
-
- const result = await api.updateAutoReload({
- enabled: true,
- reload_to_usd: validation.values.reloadTo,
- threshold_usd: validation.values.threshold
- })
-
- setSaving(false)
-
- if (!result.ok) {
- setRefusal(result.refusal)
-
- return
- }
-
- await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
- setMessage({ kind: 'success', text: 'Auto-refill updated.' })
- setEditing(false)
- }
-
- const disable = async () => {
- if (busy) {
- return
- }
-
- resetFeedback()
- setSaving(true)
-
- // The gateway's billing.auto_reload handler unconditionally requires threshold
- // + top_up_amount (invalid_request otherwise), so a disable must still carry the
- // current amounts — mirroring the TUI, which always sends both.
- const result = await api.updateAutoReload({
- enabled: false,
- reload_to_usd: initialAutoReloadAmount(autoReload.reload_to_usd, autoReload.reload_to_display),
- threshold_usd: initialAutoReloadAmount(autoReload.threshold_usd, autoReload.threshold_display)
- })
-
- setSaving(false)
-
- if (!result.ok) {
- setRefusal(result.refusal)
-
- return
- }
-
- await queryClient.invalidateQueries({ queryKey: ['billing', 'state'] })
- setMessage({ kind: 'success', text: 'Auto-refill turned off.' })
- setEditing(false)
- }
-
- // Read-only states (off / divergent card) keep the original ListRow shape.
- if (!editable) {
- return (
- }
- below={
- <>
- {row.caption ? (
-
- {row.caption}
-
- ) : null}
-
- {message && {message.text}}
- >
- }
- description={row.description}
- key={row.id}
- title={row.title}
- />
- )
- }
-
- const onField = (setter: (value: string) => void) => (event: { target: { value: string } }) => {
- resetFeedback()
- setShowErrors(true)
- setter(event.target.value)
- }
-
- // Zero-shift by exact reservation, not a magic min-height: the edit form is
- // ALWAYS rendered and both states share a single grid cell (`[grid-area:stack]`),
- // so the row's height always equals the tallest state at EVERY container width —
- // no breakpoint math that under-reserves when the two inputs stack on narrow
- // panes. The form is `invisible` + `aria-hidden` when not editing.
- return (
-
-
-
-
- {row.title}
-
-
- {row.description}
-
-
- {/* EDIT layer — always in layout (reserves exact height); hidden until editing. */}
-
-
-
-
-
- {/* Pre-allocated error line — occupies height whether or not shown. */}
-