From 5ed7137fc629f5d95f977cf9e832157ecac8179d Mon Sep 17 00:00:00 2001 From: Siddharth Balyan <52913345+alt-glitch@users.noreply.github.com> Date: Tue, 21 Jul 2026 22:42:14 +0530 Subject: [PATCH] feat(billing): plan chips and rows deep-link their tier (#68666) --- .../billing/use-billing-state.test.ts | 29 +++++++++++++++++-- .../app/settings/billing/use-billing-state.ts | 21 ++++++++++---- .../__tests__/subscriptionOverlay.test.tsx | 1 + ui-tui/src/app/interfaces.ts | 7 +++-- ui-tui/src/app/slash/commands/subscription.ts | 10 +++++-- ui-tui/src/components/subscriptionOverlay.tsx | 2 +- 6 files changed, 55 insertions(+), 15 deletions(-) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts index 90569e166da..0b2d6c43e41 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.test.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.test.ts @@ -227,8 +227,8 @@ describe('deriveBillingView', () => { expect(subscription?.description).toBe('Paid models need a subscription — pick a plan to start it on the portal.') expect(subscription?.chips).toEqual([ - { disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: subscription?.action?.url }, - { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + { disabled: false, label: 'Plus · $20/mo · $1,000 credits/mo', url: `${subscription?.action?.url}&plan=plus` }, + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` } ]) }) @@ -265,7 +265,7 @@ describe('deriveBillingView', () => { expect(subscription?.chips).toEqual([ { disabled: true, label: '✓ Plus · $20/mo · $1,000 credits/mo' }, - { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: subscription?.action?.url } + { disabled: false, label: 'Ultra · $40/mo · $3,000 credits/mo', url: `${subscription?.action?.url}&plan=ultra` } ]) }) @@ -398,4 +398,27 @@ describe('buildManageSubscriptionUrl', () => { }) ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123') }) + + it('appends the tier as a plan query param when provided', () => { + expect( + buildManageSubscriptionUrl( + { + org_id: 'org_123', + portal_url: 'https://portal.nousresearch.com/billing' + }, + undefined, + 'ultra' + ) + ).toBe('https://portal.nousresearch.com/manage-subscription?org_id=org_123&plan=ultra') + }) + + it('omits the plan param when no tierId is given', () => { + expect( + buildManageSubscriptionUrl( + { org_id: null, portal_url: 'https://portal.nousresearch.com/billing' }, + undefined, + undefined + ) + ).toBe('https://portal.nousresearch.com/manage-subscription') + }) }) diff --git a/apps/desktop/src/app/settings/billing/use-billing-state.ts b/apps/desktop/src/app/settings/billing/use-billing-state.ts index 6fda6cc730f..9757cb610b6 100644 --- a/apps/desktop/src/app/settings/billing/use-billing-state.ts +++ b/apps/desktop/src/app/settings/billing/use-billing-state.ts @@ -162,7 +162,8 @@ export function deriveBillingView( export function buildManageSubscriptionUrl( subscription?: null | Pick, - fallbackPortalUrl?: null | string + fallbackPortalUrl?: null | string, + tierId?: string ): string { const portalUrls = [subscription?.portal_url, fallbackPortalUrl].filter( (url): url is string => typeof url === 'string' && url.length > 0 @@ -176,6 +177,10 @@ export function buildManageSubscriptionUrl( url.searchParams.set('org_id', subscription.org_id) } + if (tierId) { + url.searchParams.set('plan', tierId) + } + return url.toString() } catch { // Try the next candidate; malformed portal URLs should not break settings. @@ -276,11 +281,12 @@ function paymentMethodRow(billing: BillingStateResponse): BillingAccountRowView /** * Tier catalog as chips for accounts that can change plans; the current plan is - * inert, every other opens the portal where the change/start happens. + * inert, every other opens the portal where the change/start happens, deep-linked + * to that tier via `?plan=`. */ function subscriptionTierChips( subscription: null | SubscriptionStateResponse, - manageUrl: string + fallbackPortalUrl?: null | string ): BillingChipView[] | undefined { // Teams have no personal subscription to sell into. if (!subscription?.can_change_plan || subscription.context === 'team') { @@ -301,7 +307,9 @@ function subscriptionTierChips( const suffix = Number.isFinite(credits) && credits > 0 ? ` · $${credits.toLocaleString('en-US')} credits/mo` : '' const label = `${tier.name} · ${tier.dollars_per_month_display}/mo${suffix}` - return tier.is_current ? { disabled: true, label: `✓ ${label}` } : { disabled: false, label, url: manageUrl } + return tier.is_current + ? { disabled: true, label: `✓ ${label}` } + : { disabled: false, label, url: buildManageSubscriptionUrl(subscription, fallbackPortalUrl, tier.tier_id) } }) } @@ -310,13 +318,14 @@ function subscriptionRow( subscription: null | SubscriptionStateResponse, subscriptionResult?: BillingResult ): BillingAccountRowView { - const manageUrl = buildManageSubscriptionUrl(subscription, subscription?.portal_url ?? billing.portal_url) + const fallbackPortalUrl = subscription?.portal_url ?? billing.portal_url + const manageUrl = buildManageSubscriptionUrl(subscription, fallbackPortalUrl) const current = subscription?.current const fallbackPlan = billing.usage?.plan_name ?? EMPTY_BILLING_VALUE const value = current?.tier_name ?? fallbackPlan const renewal = formatBillingDate(current?.cycle_ends_at ?? billing.usage?.renews_at) const unavailable = subscriptionResult && !subscriptionResult.ok - const chips = subscriptionTierChips(subscription, manageUrl) + const chips = subscriptionTierChips(subscription, fallbackPortalUrl) return { action: { label: 'Adjust plan ↗', url: manageUrl }, diff --git a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx index 4a5a5ed21a1..397520c7edb 100644 --- a/ui-tui/src/__tests__/subscriptionOverlay.test.tsx +++ b/ui-tui/src/__tests__/subscriptionOverlay.test.tsx @@ -177,6 +177,7 @@ describe('SubscriptionOverlay — overview', () => { mounted.cleanup() expect(openManageLink).toHaveBeenCalledTimes(1) + expect(openManageLink).toHaveBeenCalledWith('plus') expect(preview).not.toHaveBeenCalled() // openManageLink narrates the handoff itself. expect(sys).not.toHaveBeenCalled() diff --git a/ui-tui/src/app/interfaces.ts b/ui-tui/src/app/interfaces.ts index dddad408c8e..d0d759aa306 100644 --- a/ui-tui/src/app/interfaces.ts +++ b/ui-tui/src/app/interfaces.ts @@ -198,8 +198,11 @@ export interface SubscriptionOverlayCtx { * the server doesn't say (older NAS): the confirm keeps its generic line. */ fetchCard: () => Promise - /** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */ - openManageLink: () => Promise + /** + * Build {portal}/manage-subscription?org_id=… locally and open it. Resolves + * ok/false. Pass `tierId` to deep-link a specific plan via `?plan=`. + */ + openManageLink: (tierId?: string) => Promise /** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */ openPortal: (url: string) => void /** Re-fetch subscription.state. */ diff --git a/ui-tui/src/app/slash/commands/subscription.ts b/ui-tui/src/app/slash/commands/subscription.ts index d9c12fe20f6..03b08152319 100644 --- a/ui-tui/src/app/slash/commands/subscription.ts +++ b/ui-tui/src/app/slash/commands/subscription.ts @@ -20,7 +20,7 @@ type Sys = (text: string) => void * `org_id` pins the page to the correct account in multi-org situations. * Falls back to bare `/manage-subscription` if org_id is absent. */ -function buildManageUrl(s: SubscriptionStateResponse): string | null { +function buildManageUrl(s: SubscriptionStateResponse, tierId?: string): string | null { // portal_url is already an absolute URL resolved by resolve_portal_base_url() // on the Python side (e.g. https://portal.nousresearch.com/billing). Strip any // path so we can attach /manage-subscription cleanly. @@ -46,6 +46,10 @@ function buildManageUrl(s: SubscriptionStateResponse): string | null { url.searchParams.set('org_id', s.org_id) } + if (tierId) { + url.searchParams.set('plan', tierId) + } + return url.toString() } @@ -64,8 +68,8 @@ const buildSubscriptionCtx = ( .rpc('billing.state', {}) .then(r => (r?.ok ? (r.card ?? null) : null)) .catch(() => null), - openManageLink: () => { - const url = buildManageUrl(initialState) + openManageLink: (tierId?: string) => { + const url = buildManageUrl(initialState, tierId) if (!url) { sys('Could not build manage URL — is your portal configured?') diff --git a/ui-tui/src/components/subscriptionOverlay.tsx b/ui-tui/src/components/subscriptionOverlay.tsx index 60c2b44dc11..229b5082d9a 100644 --- a/ui-tui/src/components/subscriptionOverlay.tsx +++ b/ui-tui/src/components/subscriptionOverlay.tsx @@ -441,7 +441,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) { } busyRef.current = true - void ctx.openManageLink() + void ctx.openManageLink(tier.tier_id) onClose() } })