Improve billing plan lookup (#270)

Retrieve plan from Stripe/LS if not found in billing configuration. Useful for legacy plans.
This commit is contained in:
Giancarlo Buomprisco
2025-06-13 16:45:55 +07:00
committed by GitHub
parent 2b21b7bed4
commit 856e9612c4
11 changed files with 183 additions and 38 deletions

5
.gitignore vendored
View File

@@ -44,4 +44,7 @@ yarn-error.log*
.zed
# contentlayer
.contentlayer/
.contentlayer/
# ts-node cache
node-compile-cache/

View File

@@ -1,3 +1,7 @@
import { z } from 'zod';
import { PlanSchema, ProductSchema } from '@kit/billing';
import { resolveProductPlan } from '@kit/billing-gateway';
import {
BillingPortalCard,
CurrentLifetimeOrderCard,
@@ -33,6 +37,23 @@ async function PersonalAccountBillingPage() {
const [data, customerId] = await loadPersonalAccountBillingPageData(user.id);
let productPlan: {
product: ProductSchema;
plan: z.infer<typeof PlanSchema>;
} | null = null;
if (data) {
const firstLineItem = data.items[0];
if (firstLineItem) {
productPlan = await resolveProductPlan(
billingConfig,
firstLineItem.variant_id,
data.currency,
);
}
}
return (
<>
<HomeLayoutPageHeader
@@ -56,12 +77,14 @@ async function PersonalAccountBillingPage() {
{'active' in data ? (
<CurrentSubscriptionCard
subscription={data}
config={billingConfig}
product={productPlan!.product}
plan={productPlan!.plan}
/>
) : (
<CurrentLifetimeOrderCard
order={data}
config={billingConfig}
product={productPlan!.product}
plan={productPlan!.plan}
/>
)}

View File

@@ -1,5 +1,8 @@
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { z } from 'zod';
import { PlanSchema, ProductSchema } from '@kit/billing';
import { resolveProductPlan } from '@kit/billing-gateway';
import {
BillingPortalCard,
CurrentLifetimeOrderCard,
@@ -43,6 +46,23 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
const [data, customerId] = await loadTeamAccountBillingPage(accountId);
let productPlan: {
product: ProductSchema;
plan: z.infer<typeof PlanSchema>;
} | null = null;
if (data) {
const firstLineItem = data.items[0];
if (firstLineItem) {
productPlan = await resolveProductPlan(
billingConfig,
firstLineItem.variant_id,
data.currency,
);
}
}
const canManageBilling =
workspace.account.permissions.includes('billing.manage');
@@ -98,13 +118,18 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
return (
<CurrentSubscriptionCard
subscription={data}
config={billingConfig}
product={productPlan!.product}
plan={productPlan!.plan}
/>
);
}
return (
<CurrentLifetimeOrderCard order={data} config={billingConfig} />
<CurrentLifetimeOrderCard
order={data}
product={productPlan!.product}
plan={productPlan!.plan}
/>
);
}}
</If>

View File

@@ -63,8 +63,11 @@ export abstract class BillingStrategyProviderService {
abstract getPlanById(planId: string): Promise<{
id: string;
name: string;
description?: string;
interval: string;
amount: number;
type: 'recurring' | 'one_time';
intervalCount?: number;
}>;
abstract getSubscription(subscriptionId: string): Promise<

View File

@@ -1,6 +1,6 @@
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { PlanSchema, type ProductSchema } from '@kit/billing';
import { Tables } from '@kit/supabase/database';
import {
Card,
@@ -22,12 +22,14 @@ interface Props {
items: LineItem[];
};
config: BillingConfig;
product: ProductSchema;
plan: ReturnType<(typeof PlanSchema)['parse']>;
}
export function CurrentLifetimeOrderCard({
order,
config,
product,
plan,
}: React.PropsWithChildren<Props>) {
const lineItems = order.items;
const firstLineItem = lineItems[0];
@@ -36,17 +38,6 @@ export function CurrentLifetimeOrderCard({
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product || !plan) {
throw new Error(
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (

View File

@@ -1,7 +1,7 @@
import { formatDate } from 'date-fns';
import { BadgeCheck } from 'lucide-react';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { PlanSchema, type ProductSchema } from '@kit/billing';
import { Tables } from '@kit/supabase/database';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
@@ -26,12 +26,14 @@ interface Props {
items: LineItem[];
};
config: BillingConfig;
product: ProductSchema;
plan: ReturnType<(typeof PlanSchema)['parse']>;
}
export function CurrentSubscriptionCard({
subscription,
config,
product,
plan,
}: React.PropsWithChildren<Props>) {
const lineItems = subscription.items;
const firstLineItem = lineItems[0];
@@ -40,17 +42,6 @@ export function CurrentSubscriptionCard({
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product || !plan) {
throw new Error(
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (

View File

@@ -2,3 +2,4 @@ export * from './server/services/billing-gateway/billing-gateway.service';
export * from './server/services/billing-gateway/billing-gateway-provider-factory';
export * from './server/services/billing-event-handler/billing-event-handler-provider';
export * from './server/services/billing-webhooks/billing-webhooks.service';
export * from './server/utils/resolve-product-plan';

View File

@@ -114,6 +114,16 @@ class BillingGatewayService {
return strategy.queryUsage(payload);
}
/**
* Retrieves plan details from the billing provider.
* @param planId - The identifier of the plan on the provider side.
*/
async getPlanById(planId: string) {
const strategy = await this.getStrategy();
return strategy.getPlanById(planId);
}
/**
* Updates a subscription with the specified parameters.
* @param params

View File

@@ -0,0 +1,88 @@
import { z } from 'zod';
import {
BillingConfig,
LineItemSchema,
PlanSchema,
type ProductSchema,
getProductPlanPairByVariantId,
} from '@kit/billing';
import { getBillingGatewayProvider } from '@kit/billing-gateway';
import { getSupabaseServerClient } from '@kit/supabase/server-client';
/**
* @name resolveProductPlan
* @description
* Tries to find a product and plan in the local billing config.
* Falls back to fetching the plan details from the billing provider if missing.
*/
export async function resolveProductPlan(
config: BillingConfig,
variantId: string,
currency: string,
): Promise<{
product: ProductSchema;
plan: z.infer<typeof PlanSchema>;
}> {
// we can't always guarantee that the plan will be present in the local config
// so we need to fallback to fetching the plan details from the billing provider
try {
// attempt to get the plan details from the local config
return getProductPlanPairByVariantId(config, variantId);
} catch {
// retrieve the plan details from the billing provider
return fetchPlanDetailsFromProvider({ variantId, currency });
}
}
/**
* @name fetchPlanDetailsFromProvider
* @description
* Fetches the plan details from the billing provider
* @param variantId - The variant ID of the plan
* @param currency - The currency of the plan
* @returns The product and plan objects
*/
async function fetchPlanDetailsFromProvider({
variantId,
currency,
}: {
variantId: string;
currency: string;
}) {
const client = getSupabaseServerClient();
const gateway = await getBillingGatewayProvider(client);
const providerPlan = await gateway.getPlanById(variantId);
const plan = PlanSchema.parse({
id: providerPlan.id,
name: providerPlan.name,
description: providerPlan.description ?? providerPlan.name,
interval: providerPlan.interval as 'month' | 'year' | undefined,
paymentType: providerPlan.type,
lineItems: [
LineItemSchema.parse({
id: providerPlan.id,
name: providerPlan.name,
cost: providerPlan.amount,
// support only flat plans - tiered plans are not supported
// however, users can clarify the plan details in the description
type: 'flat',
}),
],
});
// create a minimal product and plan object so we can
// display the plan details in the UI
const product: ProductSchema = {
id: providerPlan.id,
name: providerPlan.name,
description: providerPlan.description ?? '',
currency,
features: [''],
plans: [],
};
return { product, plan };
}

View File

@@ -481,7 +481,12 @@ export class LemonSqueezyBillingStrategyService
id: data.data.id,
name: attrs.name,
interval: attrs.interval ?? '',
description: attrs.description ?? '',
amount: attrs.price,
type: attrs.is_subscription
? ('recurring' as const)
: ('one_time' as const),
intervalCount: attrs.interval_count ?? undefined,
};
}
}

View File

@@ -341,15 +341,20 @@ export class StripeBillingStrategyService
const stripe = await this.stripeProvider();
try {
const plan = await stripe.plans.retrieve(planId);
const price = await stripe.prices.retrieve(planId, {
expand: ['product'],
});
logger.info(ctx, 'Plan retrieved successfully');
return {
id: plan.id,
name: plan.nickname ?? '',
amount: plan.amount ?? 0,
interval: plan.interval,
id: price.id,
name: (price.product as Stripe.Product).name,
description: (price.product as Stripe.Product).description || '',
amount: price.unit_amount ? price.unit_amount / 100 : 0,
type: price.type,
interval: price.recurring?.interval ?? 'month',
intervalCount: price.recurring?.interval_count,
};
} catch (error) {
logger.error({ ...ctx, error }, 'Failed to retrieve plan');