diff --git a/.gitignore b/.gitignore index facedd673..28611ada8 100644 --- a/.gitignore +++ b/.gitignore @@ -44,4 +44,7 @@ yarn-error.log* .zed # contentlayer -.contentlayer/ \ No newline at end of file +.contentlayer/ + +# ts-node cache +node-compile-cache/ diff --git a/apps/web/app/home/(user)/billing/page.tsx b/apps/web/app/home/(user)/billing/page.tsx index 263724073..b2b3e5246 100644 --- a/apps/web/app/home/(user)/billing/page.tsx +++ b/apps/web/app/home/(user)/billing/page.tsx @@ -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; + } | null = null; + + if (data) { + const firstLineItem = data.items[0]; + + if (firstLineItem) { + productPlan = await resolveProductPlan( + billingConfig, + firstLineItem.variant_id, + data.currency, + ); + } + } + return ( <> ) : ( )} diff --git a/apps/web/app/home/[account]/billing/page.tsx b/apps/web/app/home/[account]/billing/page.tsx index 63ce466e7..d18a5e5ef 100644 --- a/apps/web/app/home/[account]/billing/page.tsx +++ b/apps/web/app/home/[account]/billing/page.tsx @@ -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; + } | 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 ( ); } return ( - + ); }} diff --git a/packages/billing/core/src/services/billing-strategy-provider.service.ts b/packages/billing/core/src/services/billing-strategy-provider.service.ts index 792851ca4..662c99cfd 100644 --- a/packages/billing/core/src/services/billing-strategy-provider.service.ts +++ b/packages/billing/core/src/services/billing-strategy-provider.service.ts @@ -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< diff --git a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx index 80012262d..6156e8ae3 100644 --- a/packages/billing/gateway/src/components/current-lifetime-order-card.tsx +++ b/packages/billing/gateway/src/components/current-lifetime-order-card.tsx @@ -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) { 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 ( diff --git a/packages/billing/gateway/src/components/current-subscription-card.tsx b/packages/billing/gateway/src/components/current-subscription-card.tsx index bf608e1af..4aba8f18a 100644 --- a/packages/billing/gateway/src/components/current-subscription-card.tsx +++ b/packages/billing/gateway/src/components/current-subscription-card.tsx @@ -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) { 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 ( diff --git a/packages/billing/gateway/src/index.ts b/packages/billing/gateway/src/index.ts index 2fc5c9211..55d0c27a8 100644 --- a/packages/billing/gateway/src/index.ts +++ b/packages/billing/gateway/src/index.ts @@ -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'; diff --git a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts index 551153106..0a68a4eba 100644 --- a/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts +++ b/packages/billing/gateway/src/server/services/billing-gateway/billing-gateway.service.ts @@ -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 diff --git a/packages/billing/gateway/src/server/utils/resolve-product-plan.ts b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts new file mode 100644 index 000000000..1054a61de --- /dev/null +++ b/packages/billing/gateway/src/server/utils/resolve-product-plan.ts @@ -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; +}> { + // 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 }; +} diff --git a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts index b81b7c08a..327b8bed0 100644 --- a/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts +++ b/packages/billing/lemon-squeezy/src/services/lemon-squeezy-billing-strategy.service.ts @@ -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, }; } } diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index 54374e984..36ce117cb 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -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');