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:
committed by
GitHub
parent
2b21b7bed4
commit
856e9612c4
5
.gitignore
vendored
5
.gitignore
vendored
@@ -44,4 +44,7 @@ yarn-error.log*
|
|||||||
.zed
|
.zed
|
||||||
|
|
||||||
# contentlayer
|
# contentlayer
|
||||||
.contentlayer/
|
.contentlayer/
|
||||||
|
|
||||||
|
# ts-node cache
|
||||||
|
node-compile-cache/
|
||||||
|
|||||||
@@ -1,3 +1,7 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PlanSchema, ProductSchema } from '@kit/billing';
|
||||||
|
import { resolveProductPlan } from '@kit/billing-gateway';
|
||||||
import {
|
import {
|
||||||
BillingPortalCard,
|
BillingPortalCard,
|
||||||
CurrentLifetimeOrderCard,
|
CurrentLifetimeOrderCard,
|
||||||
@@ -33,6 +37,23 @@ async function PersonalAccountBillingPage() {
|
|||||||
|
|
||||||
const [data, customerId] = await loadPersonalAccountBillingPageData(user.id);
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<HomeLayoutPageHeader
|
<HomeLayoutPageHeader
|
||||||
@@ -56,12 +77,14 @@ async function PersonalAccountBillingPage() {
|
|||||||
{'active' in data ? (
|
{'active' in data ? (
|
||||||
<CurrentSubscriptionCard
|
<CurrentSubscriptionCard
|
||||||
subscription={data}
|
subscription={data}
|
||||||
config={billingConfig}
|
product={productPlan!.product}
|
||||||
|
plan={productPlan!.plan}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<CurrentLifetimeOrderCard
|
<CurrentLifetimeOrderCard
|
||||||
order={data}
|
order={data}
|
||||||
config={billingConfig}
|
product={productPlan!.product}
|
||||||
|
plan={productPlan!.plan}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,8 @@
|
|||||||
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { PlanSchema, ProductSchema } from '@kit/billing';
|
||||||
|
import { resolveProductPlan } from '@kit/billing-gateway';
|
||||||
import {
|
import {
|
||||||
BillingPortalCard,
|
BillingPortalCard,
|
||||||
CurrentLifetimeOrderCard,
|
CurrentLifetimeOrderCard,
|
||||||
@@ -43,6 +46,23 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
|
|||||||
|
|
||||||
const [data, customerId] = await loadTeamAccountBillingPage(accountId);
|
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 =
|
const canManageBilling =
|
||||||
workspace.account.permissions.includes('billing.manage');
|
workspace.account.permissions.includes('billing.manage');
|
||||||
|
|
||||||
@@ -98,13 +118,18 @@ async function TeamAccountBillingPage({ params }: TeamAccountBillingPageProps) {
|
|||||||
return (
|
return (
|
||||||
<CurrentSubscriptionCard
|
<CurrentSubscriptionCard
|
||||||
subscription={data}
|
subscription={data}
|
||||||
config={billingConfig}
|
product={productPlan!.product}
|
||||||
|
plan={productPlan!.plan}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<CurrentLifetimeOrderCard order={data} config={billingConfig} />
|
<CurrentLifetimeOrderCard
|
||||||
|
order={data}
|
||||||
|
product={productPlan!.product}
|
||||||
|
plan={productPlan!.plan}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}}
|
}}
|
||||||
</If>
|
</If>
|
||||||
|
|||||||
@@ -63,8 +63,11 @@ export abstract class BillingStrategyProviderService {
|
|||||||
abstract getPlanById(planId: string): Promise<{
|
abstract getPlanById(planId: string): Promise<{
|
||||||
id: string;
|
id: string;
|
||||||
name: string;
|
name: string;
|
||||||
|
description?: string;
|
||||||
interval: string;
|
interval: string;
|
||||||
amount: number;
|
amount: number;
|
||||||
|
type: 'recurring' | 'one_time';
|
||||||
|
intervalCount?: number;
|
||||||
}>;
|
}>;
|
||||||
|
|
||||||
abstract getSubscription(subscriptionId: string): Promise<
|
abstract getSubscription(subscriptionId: string): Promise<
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { BadgeCheck } from 'lucide-react';
|
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 { Tables } from '@kit/supabase/database';
|
||||||
import {
|
import {
|
||||||
Card,
|
Card,
|
||||||
@@ -22,12 +22,14 @@ interface Props {
|
|||||||
items: LineItem[];
|
items: LineItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
config: BillingConfig;
|
product: ProductSchema;
|
||||||
|
plan: ReturnType<(typeof PlanSchema)['parse']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CurrentLifetimeOrderCard({
|
export function CurrentLifetimeOrderCard({
|
||||||
order,
|
order,
|
||||||
config,
|
product,
|
||||||
|
plan,
|
||||||
}: React.PropsWithChildren<Props>) {
|
}: React.PropsWithChildren<Props>) {
|
||||||
const lineItems = order.items;
|
const lineItems = order.items;
|
||||||
const firstLineItem = lineItems[0];
|
const firstLineItem = lineItems[0];
|
||||||
@@ -36,17 +38,6 @@ export function CurrentLifetimeOrderCard({
|
|||||||
throw new Error('No line items found in subscription');
|
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;
|
const productLineItems = plan.lineItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { formatDate } from 'date-fns';
|
import { formatDate } from 'date-fns';
|
||||||
import { BadgeCheck } from 'lucide-react';
|
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 { Tables } from '@kit/supabase/database';
|
||||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||||
import {
|
import {
|
||||||
@@ -26,12 +26,14 @@ interface Props {
|
|||||||
items: LineItem[];
|
items: LineItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
config: BillingConfig;
|
product: ProductSchema;
|
||||||
|
plan: ReturnType<(typeof PlanSchema)['parse']>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function CurrentSubscriptionCard({
|
export function CurrentSubscriptionCard({
|
||||||
subscription,
|
subscription,
|
||||||
config,
|
product,
|
||||||
|
plan,
|
||||||
}: React.PropsWithChildren<Props>) {
|
}: React.PropsWithChildren<Props>) {
|
||||||
const lineItems = subscription.items;
|
const lineItems = subscription.items;
|
||||||
const firstLineItem = lineItems[0];
|
const firstLineItem = lineItems[0];
|
||||||
@@ -40,17 +42,6 @@ export function CurrentSubscriptionCard({
|
|||||||
throw new Error('No line items found in subscription');
|
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;
|
const productLineItems = plan.lineItems;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -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-gateway/billing-gateway-provider-factory';
|
||||||
export * from './server/services/billing-event-handler/billing-event-handler-provider';
|
export * from './server/services/billing-event-handler/billing-event-handler-provider';
|
||||||
export * from './server/services/billing-webhooks/billing-webhooks.service';
|
export * from './server/services/billing-webhooks/billing-webhooks.service';
|
||||||
|
export * from './server/utils/resolve-product-plan';
|
||||||
|
|||||||
@@ -114,6 +114,16 @@ class BillingGatewayService {
|
|||||||
return strategy.queryUsage(payload);
|
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.
|
* Updates a subscription with the specified parameters.
|
||||||
* @param params
|
* @param params
|
||||||
|
|||||||
@@ -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 };
|
||||||
|
}
|
||||||
@@ -481,7 +481,12 @@ export class LemonSqueezyBillingStrategyService
|
|||||||
id: data.data.id,
|
id: data.data.id,
|
||||||
name: attrs.name,
|
name: attrs.name,
|
||||||
interval: attrs.interval ?? '',
|
interval: attrs.interval ?? '',
|
||||||
|
description: attrs.description ?? '',
|
||||||
amount: attrs.price,
|
amount: attrs.price,
|
||||||
|
type: attrs.is_subscription
|
||||||
|
? ('recurring' as const)
|
||||||
|
: ('one_time' as const),
|
||||||
|
intervalCount: attrs.interval_count ?? undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -341,15 +341,20 @@ export class StripeBillingStrategyService
|
|||||||
const stripe = await this.stripeProvider();
|
const stripe = await this.stripeProvider();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const plan = await stripe.plans.retrieve(planId);
|
const price = await stripe.prices.retrieve(planId, {
|
||||||
|
expand: ['product'],
|
||||||
|
});
|
||||||
|
|
||||||
logger.info(ctx, 'Plan retrieved successfully');
|
logger.info(ctx, 'Plan retrieved successfully');
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: plan.id,
|
id: price.id,
|
||||||
name: plan.nickname ?? '',
|
name: (price.product as Stripe.Product).name,
|
||||||
amount: plan.amount ?? 0,
|
description: (price.product as Stripe.Product).description || '',
|
||||||
interval: plan.interval,
|
amount: price.unit_amount ? price.unit_amount / 100 : 0,
|
||||||
|
type: price.type,
|
||||||
|
interval: price.recurring?.interval ?? 'month',
|
||||||
|
intervalCount: price.recurring?.interval_count,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error({ ...ctx, error }, 'Failed to retrieve plan');
|
logger.error({ ...ctx, error }, 'Failed to retrieve plan');
|
||||||
|
|||||||
Reference in New Issue
Block a user