feat(billing): plan chips and rows deep-link their tier (#68666)

This commit is contained in:
Siddharth Balyan 2026-07-21 22:42:14 +05:30 committed by GitHub
parent 11ae6bf0e3
commit 5ed7137fc6
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 55 additions and 15 deletions

View file

@ -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')
})
})

View file

@ -162,7 +162,8 @@ export function deriveBillingView(
export function buildManageSubscriptionUrl(
subscription?: null | Pick<SubscriptionStateResponse, 'org_id' | 'portal_url'>,
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<SubscriptionStateResponse>
): 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 },

View file

@ -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()

View file

@ -198,8 +198,11 @@ export interface SubscriptionOverlayCtx {
* the server doesn't say (older NAS): the confirm keeps its generic line.
*/
fetchCard: () => Promise<BillingCardInfo | null>
/** Build {portal}/manage-subscription?org_id=… locally and open it. Resolves ok/false. */
openManageLink: () => Promise<boolean>
/**
* 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<boolean>
/** Open an arbitrary portal recovery URL (e.g. an upgrade's SCA handoff). */
openPortal: (url: string) => void
/** Re-fetch subscription.state. */

View file

@ -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<BillingStateResponse>('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?')

View file

@ -441,7 +441,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
}
busyRef.current = true
void ctx.openManageLink()
void ctx.openManageLink(tier.tier_id)
onClose()
}
})