Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
29 changes: 26 additions & 3 deletions apps/desktop/src/app/settings/billing/use-billing-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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` }
])
})

Expand Down Expand Up @@ -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` }
])
})

Expand Down Expand Up @@ -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')
})
})
21 changes: 15 additions & 6 deletions apps/desktop/src/app/settings/billing/use-billing-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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.
Expand Down Expand Up @@ -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') {
Expand All @@ -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) }
})
}

Expand All @@ -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 },
Expand Down
1 change: 1 addition & 0 deletions ui-tui/src/__tests__/subscriptionOverlay.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
7 changes: 5 additions & 2 deletions ui-tui/src/app/interfaces.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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. */
Expand Down
10 changes: 7 additions & 3 deletions ui-tui/src/app/slash/commands/subscription.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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()
}

Expand All @@ -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?')
Expand Down
2 changes: 1 addition & 1 deletion ui-tui/src/components/subscriptionOverlay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -441,7 +441,7 @@ function OverviewScreen({ onClose, onPatch, overlay, t }: ScreenProps) {
}

busyRef.current = true
void ctx.openManageLink()
void ctx.openManageLink(tier.tier_id)
onClose()
}
})
Expand Down
Loading