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.
This commit is contained in:
Ramnique Singh 2026-06-19 16:42:56 +05:30
parent aa8dfb74ad
commit f65f7e8fc8
7 changed files with 52 additions and 26 deletions

View file

@ -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,
});
}

View file

@ -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<string | null>(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) {
<div className="flex items-center justify-between">
<div>
<p className="text-sm font-medium capitalize">
{formatPlanName(billing.subscriptionPlan)}
{currentPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</p>
{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 ? (
<p className="text-xs text-muted-foreground capitalize">{billing.subscriptionStatus}</p>
) : null}
{!billing.subscriptionPlan && (
{!billing.subscriptionPlanId && (
<p className="text-xs text-muted-foreground">Subscribe to access AI features</p>
)}
</div>
<Button variant="outline" size="sm" onClick={() => appUrl && window.open(`${appUrl}?intent=upgrade`)}>
{!billing.subscriptionPlan ? 'Subscribe' : billing.subscriptionPlan === 'free' ? 'Upgrade' : 'Change plan'}
{!billing.subscriptionPlanId ? 'Subscribe' : currentPlan?.category === 'free' ? 'Upgrade' : 'Change plan'}
</Button>
</div>
<div className="space-y-3 border-t pt-3">

View file

@ -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<string | null>(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({
<div className="flex items-center justify-between rounded-lg border border-sidebar-border bg-sidebar-accent/20 px-3 py-2">
<div className="min-w-0">
<span className="text-xs font-medium capitalize text-sidebar-foreground">
{formatBillingPlanName(billing.subscriptionPlan)}
{currentBillingPlan?.displayName ?? (billing.subscriptionPlanId ? 'Unknown' : 'No plan')}
</span>
{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'}
</button>
</div>
</div>

View file

@ -14,7 +14,7 @@ export async function identifyIfSignedIn(): Promise<void> {
if (!billing.userId) return;
identify(billing.userId, {
...(billing.userEmail ? { email: billing.userEmail } : {}),
plan: billing.subscriptionPlan,
plan: billing.subscriptionPlanId,
status: billing.subscriptionStatus,
});
} catch (err) {

View file

@ -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<BillingInfo> {
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<BillingInfo> {
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<BillingInfo> {
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,
};

View file

@ -1,7 +1,26 @@
import { z } from 'zod';
export const BillingPlanSchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlan = z.infer<typeof BillingPlanSchema>;
export const BillingPlanCategorySchema = z.enum(['free', 'starter', 'pro']);
export type BillingPlanCategory = z.infer<typeof BillingPlanCategorySchema>;
export const BillingPlanIdSchema = z.string().min(1);
export type BillingPlanId = z.infer<typeof BillingPlanIdSchema>;
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<typeof BillingCatalogPlanSchema>;
export const BillingCatalogSchema = z.object({
plans: z.array(BillingCatalogPlanSchema),
});
export type BillingCatalog = z.infer<typeof BillingCatalogSchema>;
export const BillingUsageBucketSchema = z.object({
sanctionedCredits: z.number(),
@ -13,12 +32,21 @@ export type BillingUsageBucket = z.infer<typeof BillingUsageBucketSchema>;
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<typeof BillingInfoSchema>;
export function getBillingPlanData(
catalog: BillingCatalog,
planId: string | null | undefined,
): BillingCatalogPlan | null {
if (!planId) return null;
return catalog.plans.find((plan) => plan.id === planId) ?? null;
}

View file

@ -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,
});