import type { BillingMutationResponse, BillingStateResponse, SubscriptionPreviewResponse, SubscriptionStateResponse, SubscriptionUpgradeResponse } from '../../../gatewayTypes.js' import { openExternalUrl } from '../../../lib/openExternalUrl.js' import type { SubscriptionOverlayCtx } from '../../interfaces.js' import { patchOverlayState } from '../../overlayStore.js' import type { SlashCommand, SlashRunCtx } from '../types.js' type Sys = (text: string) => void /** * Build the manage-subscription URL locally from the loaded subscription state. * * Uses `portal_url` (the resolved portal base URL carried in the state) and * `org_id` to construct `{portal_base}/manage-subscription?org_id=`. * `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, 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. let base: string | null = null if (s.portal_url) { try { base = new URL(s.portal_url).origin } catch { // A malformed portal_url must not throw out of the Ink key handler // (it would crash the overlay) — treat it as "no manage URL". return null } } if (!base) { return null } const url = new URL('/manage-subscription', base) if (s.org_id) { url.searchParams.set('org_id', s.org_id) } if (tierId) { url.searchParams.set('plan', tierId) } return url.toString() } /** * Build the ctx the overlay uses to talk to the gateway + emit transcript * lines. Mirrors topup.ts's buildOverlayCtx — all RPC + error-mapping logic * lives here (single source of truth); the overlay only renders + routes keys. */ const buildSubscriptionCtx = ( ctx: SlashRunCtx, sys: Sys, initialState: SubscriptionStateResponse ): SubscriptionOverlayCtx => ({ fetchCard: () => ctx.gateway .rpc('billing.state', {}) .then(r => (r?.ok ? (r.card ?? null) : null)) .catch(() => null), openManageLink: (tierId?: string) => { const url = buildManageUrl(initialState, tierId) if (!url) { sys('Could not build manage URL — is your portal configured?') return Promise.resolve(false) } const opened = openExternalUrl(url) if (opened) { sys('Opening your subscription page in the browser — finish there, then re-run /subscription.') } else { sys('Could not open browser — visit your subscription page manually at ' + url) } return Promise.resolve(opened) }, openPortal: (url: string) => { if (openExternalUrl(url)) { sys('Opening the portal in your browser — finish there, then re-run /subscription.') } else { sys('Could not open browser — visit ' + url + ' to finish.') } }, preview: tierId => ctx.gateway .rpc('subscription.preview', { subscription_type_id: tierId }) .then(r => r ?? null) .catch(() => null), refreshState: () => ctx.gateway .rpc('subscription.state', {}) .then(r => r ?? null) .catch(() => null), requestRemoteSpending: () => ctx.gateway .rpc('billing.step_up', { session_id: ctx.sid ?? undefined }) // Carry the typed denial (session_revoked / remote_spending_revoked / // rate_limited / …) so the stepup screen shows the right recovery. .then(r => ({ error: r?.error, granted: !!(r && r.ok && r.granted), message: r?.message })) .catch(() => ({ granted: false, message: 'Could not reach the billing service — check your connection, then retry.' })), resume: () => ctx.gateway .rpc('subscription.resume', {}) .then(r => r ?? null) .catch(() => null), scheduleCancellation: () => ctx.gateway .rpc('subscription.change', { cancel: true }) .then(r => r ?? null) .catch(() => null), scheduleChange: tierId => ctx.gateway .rpc('subscription.change', { subscription_type_id: tierId }) .then(r => r ?? null) .catch(() => null), sys, upgrade: (tierId, idempotencyKey) => ctx.gateway .rpc('subscription.upgrade', { subscription_type_id: tierId, ...(idempotencyKey ? { idempotency_key: idempotencyKey } : {}) }) .then(r => r ?? null) .catch(() => null) }) export const subscriptionCommands: SlashCommand[] = [ { help: 'View or change your Nous subscription plan', name: 'subscription', aliases: ['upgrade'], // ZERO sub-commands: bare `/subscription` fetches state and opens the // overlay's in-terminal change flow (only /upgrade's charge_now confirm // moves money, via the V3 upgrade route). run: (_arg, ctx) => { const sys: Sys = ctx.transcript.sys ctx.gateway .rpc('subscription.state', {}) .then( ctx.guarded(s => { if (!s.logged_in) { sys('Not logged into Nous Portal — run /portal to log in, then /subscription.') return } patchOverlayState({ subscription: { ctx: buildSubscriptionCtx(ctx, sys, s), screen: 'overview', state: s } }) }) ) .catch(ctx.guardedErr) } } ]