From f65f7e8fc881bbd2b2a4767a72b15726492cac64 Mon Sep 17 00:00:00 2001 From: Ramnique Singh <30795890+ramnique@users.noreply.github.com> Date: Fri, 19 Jun 2026 16:42:56 +0530 Subject: [PATCH] feat(billing): consume plan variant catalog Fetch the public billing catalog during account config load and use durable plan IDs from /v1/me for display, analytics, and upgrade/manage labels. Renderer billing surfaces now resolve plan display data from the catalog and show Unknown when the backend returns an unmapped plan ID. --- apps/x/apps/main/src/oauth-handler.ts | 4 +-- .../components/settings/account-settings.tsx | 16 ++++----- .../src/components/sidebar-content.tsx | 11 +++--- .../x/packages/core/src/analytics/identify.ts | 2 +- apps/x/packages/core/src/billing/billing.ts | 9 +++-- apps/x/packages/shared/src/billing.ts | 34 +++++++++++++++++-- apps/x/packages/shared/src/rowboat-account.ts | 2 ++ 7 files changed, 52 insertions(+), 26 deletions(-) diff --git a/apps/x/apps/main/src/oauth-handler.ts b/apps/x/apps/main/src/oauth-handler.ts index 1048d9b8..dabdb0ae 100644 --- a/apps/x/apps/main/src/oauth-handler.ts +++ b/apps/x/apps/main/src/oauth-handler.ts @@ -345,11 +345,11 @@ export async function connectProvider(provider: string, credentials?: { clientId signedInUserId = billing.userId; analyticsIdentify(billing.userId, { ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); analyticsCapture('user_signed_in', { - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); } diff --git a/apps/x/apps/renderer/src/components/settings/account-settings.tsx b/apps/x/apps/renderer/src/components/settings/account-settings.tsx index 66f2b682..b24eb38a 100644 --- a/apps/x/apps/renderer/src/components/settings/account-settings.tsx +++ b/apps/x/apps/renderer/src/components/settings/account-settings.tsx @@ -17,17 +17,12 @@ import { import { Separator } from "@/components/ui/separator" import { useBilling } from "@/hooks/useBilling" import { toast } from "sonner" -import type { BillingUsageBucket } from "@x/shared/dist/billing.js" +import { getBillingPlanData, type BillingUsageBucket } from "@x/shared/dist/billing.js" interface AccountSettingsProps { dialogOpen: boolean } -function formatPlanName(plan: string | null | undefined) { - if (!plan) return 'No Plan' - return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} Plan` -} - function CreditUsageBar({ label, bucket, helper }: { label: string bucket: BillingUsageBucket @@ -62,7 +57,8 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { const [connecting, setConnecting] = useState(false) const [appUrl, setAppUrl] = useState(null) const { billing, isLoading: billingLoading } = useBilling(isRowboatConnected) - const hasPaidSubscription = billing?.subscriptionPlan === 'starter' || billing?.subscriptionPlan === 'pro' + const currentPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null + const hasPaidSubscription = currentPlan?.category === 'starter' || currentPlan?.category === 'pro' const checkConnection = useCallback(async () => { try { @@ -197,7 +193,7 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) {

- {formatPlanName(billing.subscriptionPlan)} + {currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}

{billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt ? (() => { const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) @@ -209,12 +205,12 @@ export function AccountSettings({ dialogOpen }: AccountSettingsProps) { })() : billing.subscriptionStatus ? (

{billing.subscriptionStatus}

) : null} - {!billing.subscriptionPlan && ( + {!billing.subscriptionPlanId && (

Subscribe to access AI features

)}
diff --git a/apps/x/apps/renderer/src/components/sidebar-content.tsx b/apps/x/apps/renderer/src/components/sidebar-content.tsx index 986d76cc..bd3d7888 100644 --- a/apps/x/apps/renderer/src/components/sidebar-content.tsx +++ b/apps/x/apps/renderer/src/components/sidebar-content.tsx @@ -62,6 +62,7 @@ import { SettingsDialog } from "@/components/settings-dialog" import { extractConferenceLink } from "@/lib/calendar-event" import { useBilling } from "@/hooks/useBilling" import { toast } from "@/lib/toast" +import { getBillingPlanData } from "@x/shared/dist/billing.js" import { ServiceEvent } from "@x/shared/src/service-events.js" import z from "zod" @@ -91,11 +92,6 @@ type KnowledgeActions = { onOpenInNewTab?: (path: string) => void } -function formatBillingPlanName(plan: string | null | undefined) { - if (!plan) return 'No plan' - return `${plan.charAt(0).toUpperCase()}${plan.slice(1)} plan` -} - function formatAgo(ms: number): string { const diffMs = Math.max(0, Date.now() - ms) const min = Math.floor(diffMs / 60000) @@ -437,6 +433,7 @@ export function SidebarContentPanel({ const [loggingIn, setLoggingIn] = useState(false) const [appUrl, setAppUrl] = useState(null) const { billing } = useBilling(isRowboatConnected) + const currentBillingPlan = billing ? getBillingPlanData(billing.catalog, billing.subscriptionPlanId) : null // Nav previews: unread important emails + next upcoming meetings (top 2 each). const [unreadEmailCount, setUnreadEmailCount] = useState(0) @@ -921,7 +918,7 @@ export function SidebarContentPanel({
- {formatBillingPlanName(billing.subscriptionPlan)} + {currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')} {billing.subscriptionStatus === 'trialing' && billing.trialExpiresAt && (() => { const days = Math.max(0, Math.ceil((new Date(billing.trialExpiresAt).getTime() - Date.now()) / (1000 * 60 * 60 * 24))) @@ -936,7 +933,7 @@ export function SidebarContentPanel({ onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)} className="shrink-0 rounded-md bg-sidebar-foreground/10 px-2.5 py-1 text-[11px] font-medium text-sidebar-foreground transition-colors hover:bg-sidebar-foreground/20" > - {!billing.subscriptionPlan || billing.subscriptionPlan === 'free' || billing.subscriptionPlan === 'starter' ? 'Upgrade' : 'Manage'} + {!billing.subscriptionPlanId || currentBillingPlan?.category === 'free' || currentBillingPlan?.category === 'starter' ? 'Upgrade' : 'Manage'}
diff --git a/apps/x/packages/core/src/analytics/identify.ts b/apps/x/packages/core/src/analytics/identify.ts index 3d647711..b00c8e64 100644 --- a/apps/x/packages/core/src/analytics/identify.ts +++ b/apps/x/packages/core/src/analytics/identify.ts @@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise { if (!billing.userId) return; identify(billing.userId, { ...(billing.userEmail ? { email: billing.userEmail } : {}), - plan: billing.subscriptionPlan, + plan: billing.subscriptionPlanId, status: billing.subscriptionStatus, }); } catch (err) { diff --git a/apps/x/packages/core/src/billing/billing.ts b/apps/x/packages/core/src/billing/billing.ts index 0b088be4..1b421acf 100644 --- a/apps/x/packages/core/src/billing/billing.ts +++ b/apps/x/packages/core/src/billing/billing.ts @@ -1,8 +1,10 @@ import { getAccessToken } from '../auth/tokens.js'; import { API_URL } from '../config/env.js'; -import type { BillingInfo, BillingPlan } from '@x/shared/dist/billing.js'; +import type { BillingInfo, BillingPlanId } from '@x/shared/dist/billing.js'; +import { getRowboatConfig } from '../config/rowboat.js'; export async function getBillingInfo(): Promise { + const config = await getRowboatConfig(); const accessToken = await getAccessToken(); const response = await fetch(`${API_URL}/v1/me`, { headers: { Authorization: `Bearer ${accessToken}` }, @@ -16,7 +18,7 @@ export async function getBillingInfo(): Promise { email: string; }; billing: { - plan: BillingPlan | null; + planId: BillingPlanId | null; status: string | null; trialExpiresAt: string | null; usage: { @@ -37,9 +39,10 @@ export async function getBillingInfo(): Promise { return { userEmail: body.user.email ?? null, userId: body.user.id ?? null, - subscriptionPlan: body.billing.plan, + subscriptionPlanId: body.billing.planId, subscriptionStatus: body.billing.status, trialExpiresAt: body.billing.trialExpiresAt ?? null, + catalog: config.billing, monthly: body.billing.usage.monthly, daily: body.billing.usage.daily, }; diff --git a/apps/x/packages/shared/src/billing.ts b/apps/x/packages/shared/src/billing.ts index 59fe68d3..fb140166 100644 --- a/apps/x/packages/shared/src/billing.ts +++ b/apps/x/packages/shared/src/billing.ts @@ -1,7 +1,26 @@ import { z } from 'zod'; -export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']); -export type BillingPlan = z.infer; +export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']); +export type BillingPlanCategory = z.infer; + +export const BillingPlanIdSchema = z.string().min(1); +export type BillingPlanId = z.infer; + +export const BillingCatalogPlanSchema = z.object({ + id: BillingPlanIdSchema, + category: BillingPlanCategorySchema, + displayName: z.string(), + monthlyCredits: z.number(), + dailyCredits: z.number(), + monthlyPriceCents: z.number().nullable(), + archived: z.boolean().optional(), +}); +export type BillingCatalogPlan = z.infer; + +export const BillingCatalogSchema = z.object({ + plans: z.array(BillingCatalogPlanSchema), +}); +export type BillingCatalog = z.infer; export const BillingUsageBucketSchema = z.object({ sanctionedCredits: z.number(), @@ -13,12 +32,21 @@ export type BillingUsageBucket = z.infer; export const BillingInfoSchema = z.object({ userEmail: z.string().nullable(), userId: z.string().nullable(), - subscriptionPlan: BillingPlanSchema.nullable(), + subscriptionPlanId: BillingPlanIdSchema.nullable(), subscriptionStatus: z.string().nullable(), trialExpiresAt: z.string().nullable(), + catalog: BillingCatalogSchema, monthly: BillingUsageBucketSchema, daily: BillingUsageBucketSchema.extend({ usageDay: z.string(), }), }); export type BillingInfo = z.infer; + +export function getBillingPlanData( + catalog: BillingCatalog, + planId: string | null | undefined, +): BillingCatalogPlan | null { + if (!planId) return null; + return catalog.plans.find((plan) => plan.id === planId) ?? null; +} diff --git a/apps/x/packages/shared/src/rowboat-account.ts b/apps/x/packages/shared/src/rowboat-account.ts index c6b7cff4..2f5a32c7 100644 --- a/apps/x/packages/shared/src/rowboat-account.ts +++ b/apps/x/packages/shared/src/rowboat-account.ts @@ -1,7 +1,9 @@ import { z } from 'zod'; +import { BillingCatalogSchema } from './billing.js'; export const RowboatApiConfig = z.object({ appUrl: z.string(), websocketApiUrl: z.string(), supabaseUrl: z.string(), + billing: BillingCatalogSchema, });