Refactor billing gateway and enhance localization

Refactored the 'plan-picker' component in the billing gateway to remove unwanted line items and improve checkout session and subscription handling. Enhanced the localization support by adding translations in the plan picker and introduced a new function to check trial eligibility so that existing customers can't start a new trial period. These changes enhance the usability of the application across different regions and provide accurate trial period conditions.
This commit is contained in:
giancarlo
2024-04-01 11:52:35 +08:00
parent 248ab7ef72
commit d6004f2f7e
15 changed files with 877 additions and 346 deletions

View File

@@ -5,7 +5,7 @@ import { useState, useTransition } from 'react';
import { ExclamationTriangleIcon } from '@radix-ui/react-icons';
import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components';
import { Alert, AlertTitle } from '@kit/ui/alert';
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
import {
Card,
CardContent,
@@ -91,8 +91,12 @@ function ErrorAlert() {
<ExclamationTriangleIcon className={'h-4'} />
<AlertTitle>
<Trans i18nKey={'common:genericError'} />
<Trans i18nKey={'common:planPickerAlertErrorTitle'} />
</AlertTitle>
<AlertDescription>
<Trans i18nKey={'common:planPickerAlertErrorDescription'} />
</AlertDescription>
</Alert>
);
}

View File

@@ -40,22 +40,29 @@ async function PersonalAccountBillingPage() {
<PageBody>
<div className={'flex flex-col space-y-8'}>
<If
condition={subscription}
fallback={<PersonalAccountCheckoutForm customerId={customerId} />}
>
{(subscription) => (
<CurrentPlanCard
subscription={subscription}
config={billingConfig}
/>
)}
<If condition={!subscription}>
<PersonalAccountCheckoutForm customerId={customerId} />
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</If>
<If condition={customerId}>
<form action={createPersonalAccountBillingPortalSession}>
<BillingPortalCard />
</form>
<If condition={subscription}>
{(subscription) => (
<div
className={'mx-auto flex w-full max-w-2xl flex-col space-y-4'}
>
<CurrentPlanCard
subscription={subscription}
config={billingConfig}
/>
<If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</div>
)}
</If>
</div>
</PageBody>
@@ -65,6 +72,14 @@ async function PersonalAccountBillingPage() {
export default withI18n(PersonalAccountBillingPage);
function CustomerBillingPortalForm() {
return (
<form action={createPersonalAccountBillingPortalSession}>
<BillingPortalCard />
</form>
);
}
async function loadData(client: SupabaseClient<Database>) {
const { data, error } = await client.auth.getUser();

View File

@@ -14,16 +14,24 @@ import appConfig from '~/config/app.config';
import billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.config';
const CreateCheckoutSchema = z.object({
planId: z.string(),
productId: z.string(),
});
/**
* Creates a checkout session for a personal account.
*
* @param {object} params - The parameters for creating the checkout session.
* @param {string} params.planId - The ID of the plan to be associated with the account.
*/
export async function createPersonalAccountCheckoutSession(params: {
planId: string;
productId: string;
}) {
export async function createPersonalAccountCheckoutSession(
params: z.infer<typeof CreateCheckoutSchema>,
) {
// parse the parameters
const { planId, productId } = CreateCheckoutSchema.parse(params);
// get the authenticated user
const client = getSupabaseServerActionClient();
const { data: user, error } = await requireUser(client);
@@ -31,13 +39,6 @@ export async function createPersonalAccountCheckoutSession(params: {
throw new Error('Authentication required');
}
const { planId, productId } = z
.object({
planId: z.string().min(1),
productId: z.string().min(1),
})
.parse(params);
Logger.info(
{
planId,

View File

@@ -52,31 +52,33 @@ async function TeamAccountBillingPage({ params }: Params) {
<CannotManageBillingAlert />
</If>
<div className={'flex flex-col space-y-4'}>
<If
condition={subscription}
fallback={
<If condition={canManageBilling}>
<TeamAccountCheckoutForm
customerId={customerId}
accountId={accountId}
/>
</If>
}
>
{(data) => (
<CurrentPlanCard subscription={data} config={billingConfig} />
)}
</If>
<div>
<div className={'flex flex-col space-y-2'}>
<If
condition={subscription}
fallback={
<If condition={canManageBilling}>
<TeamAccountCheckoutForm
customerId={customerId}
accountId={accountId}
/>
</If>
}
>
{(data) => (
<CurrentPlanCard subscription={data} config={billingConfig} />
)}
</If>
<If condition={customerId && canManageBilling}>
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={params.account} />
<If condition={customerId && canManageBilling}>
<form action={createBillingPortalSession}>
<input type="hidden" name={'accountId'} value={accountId} />
<input type="hidden" name={'slug'} value={params.account} />
<BillingPortalCard />
</form>
</If>
<BillingPortalCard />
</form>
</If>
</div>
</div>
</div>
</PageBody>

View File

@@ -37,6 +37,9 @@
"detailsLabel": "Details",
"planPickerLabel": "Pick your preferred plan",
"planCardLabel": "Manage your Plan",
"planPickerAlertErrorTitle": "Error requesting checkout",
"planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.",
"cancelSubscriptionDate": "Your subscription will be cancelled at the end of the period",
"status": {
"free": {
"badge": "Free Plan",

View File

@@ -1,12 +1,7 @@
import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import {
BillingConfig,
getBaseLineItem,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
import { Database } from '@kit/supabase/database';
import {
Accordion,
@@ -26,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
import { CurrentPlanAlert } from './current-plan-alert';
import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
type LineItem = Database['public']['Tables']['subscription_items']['Row'];
@@ -42,19 +38,26 @@ export function CurrentPlanCard({
subscription,
config,
}: React.PropsWithChildren<Props>) {
// line items have the same product id
const lineItem = subscription.items[0] as LineItem;
const lineItems = subscription.items;
const firstLineItem = lineItems[0];
const product = config.products.find(
(product) => product.id === lineItem.product_id,
if (!firstLineItem) {
throw new Error('No line items found in subscription');
}
const { product, plan } = getProductPlanPairByVariantId(
config,
firstLineItem.variant_id,
);
if (!product) {
if (!product || !plan) {
throw new Error(
'Product not found. Make sure the product exists in the billing config.',
'Product or plan not found. Did you forget to add it to the billing config?',
);
}
const productLineItems = plan.lineItems;
return (
<Card>
<CardHeader>
@@ -113,8 +116,7 @@ export function CurrentPlanCard({
<If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col">
<span className="font-medium">
Your subscription will be cancelled at the end of the
period
<Trans i18nKey="billing:cancelSubscriptionDate" />
</span>
<div className={'text-muted-foreground'}>
@@ -126,7 +128,21 @@ export function CurrentPlanCard({
</If>
<div className="flex flex-col space-y-1">
<span className="font-medium">Features</span>
<span className="font-semibold">
<Trans i18nKey="billing:detailsLabel" />
</span>
<LineItemDetails
lineItems={productLineItems}
currency={subscription.currency}
selectedInterval={firstLineItem.interval}
/>
</div>
<div className="flex flex-col space-y-1">
<span className="font-semibold">
<Trans i18nKey="billing:featuresLabel" />
</span>
<ul className={'flex flex-col space-y-0.5'}>
{product.features.map((item) => {

View File

@@ -0,0 +1,95 @@
import { z } from 'zod';
import { LineItemSchema } from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Trans } from '@kit/ui/trans';
export function LineItemDetails(
props: React.PropsWithChildren<{
lineItems: z.infer<typeof LineItemSchema>[];
currency: string;
selectedInterval: string;
}>,
) {
return (
<div className={'flex flex-col divide-y'}>
{props.lineItems.map((item) => {
switch (item.type) {
case 'base':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
/>
</span>
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'per-seat':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}>
{formatCurrency(props.currency.toLowerCase(), item.cost)}
</span>
</div>
);
case 'metered':
return (
<div
key={item.id}
className={'flex items-center justify-between py-1.5 text-sm'}
>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span>
<span className={'font-semibold'}>
{formatCurrency(props?.currency.toLowerCase(), item.cost)}
</span>
</div>
);
}
})}
</div>
);
}

View File

@@ -10,6 +10,7 @@ import { z } from 'zod';
import {
BillingConfig,
LineItemSchema,
getBaseLineItem,
getPlanIntervals,
getProductPlanPair,
@@ -36,6 +37,8 @@ import {
import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
export function PlanPicker(
props: React.PropsWithChildren<{
config: BillingConfig;
@@ -55,7 +58,8 @@ export function PlanPicker(
resolver: zodResolver(
z
.object({
planId: z.string(),
planId: z.string().min(1),
productId: z.string().min(1),
interval: z.string().min(1),
})
.refine(
@@ -143,6 +147,10 @@ export function PlanPicker(
shouldValidate: true,
});
form.setValue('productId', '', {
shouldValidate: true,
});
form.setValue('interval', interval, {
shouldValidate: true,
});
@@ -311,167 +319,97 @@ export function PlanPicker(
</div>
</form>
<If condition={selectedPlan && selectedProduct}>
<div
className={
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
}
>
<div className={'flex flex-col space-y-0.5'}>
<Heading level={5}>
<b>
<Trans
i18nKey={`billing:products.${selectedProduct?.id}.name`}
defaults={selectedProduct?.name}
/>
</b>{' '}
/{' '}
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</Heading>
<p>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${selectedProduct?.id}.description`}
defaults={selectedProduct?.description}
/>
</span>
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<div className={'flex flex-col divide-y'}>
{selectedPlan?.lineItems.map((item) => {
switch (item.type) {
case 'base':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span className={'flex space-x-2'}>
<span>
<Trans i18nKey={'billing:flatSubscription'} />
</span>
<span>/</span>
<span>
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</span>
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
case 'per-seat':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span>
<Trans i18nKey={'billing:perTeamMember'} />
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
case 'metered':
return (
<div
key={item.id}
className={
'flex items-center justify-between py-1.5 text-sm'
}
>
<span>
<Trans
i18nKey={'billing:perUnit'}
values={{
unit: item.unit,
}}
/>
{item.included ? (
<Trans
i18nKey={'billing:perUnitIncluded'}
values={{
included: item.included,
}}
/>
) : (
''
)}
</span>
<span className={'font-semibold'}>
{formatCurrency(
selectedProduct?.currency.toLowerCase(),
item.cost,
)}
</span>
</div>
);
}
})}
</div>
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{selectedProduct?.features.map((item) => {
return (
<div
key={item}
className={'flex items-center space-x-2 text-sm'}
>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:features.${item}`}
defaults={item}
/>
</span>
</div>
);
})}
</div>
</div>
</If>
{selectedPlan && selectedInterval && selectedProduct ? (
<PlanDetails
selectedInterval={selectedInterval}
selectedPlan={selectedPlan}
selectedProduct={selectedProduct}
/>
) : null}
</div>
</Form>
);
}
function PlanDetails({
selectedProduct,
selectedInterval,
selectedPlan,
}: {
selectedProduct: {
id: string;
name: string;
description: string;
currency: string;
features: string[];
};
selectedInterval: string;
selectedPlan: {
lineItems: z.infer<typeof LineItemSchema>[];
};
}) {
return (
<div
className={
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
}
>
<div className={'flex flex-col space-y-0.5'}>
<Heading level={5}>
<b>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.name`}
defaults={selectedProduct.name}
/>
</b>{' '}
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
</Heading>
<p>
<span className={'text-muted-foreground'}>
<Trans
i18nKey={`billing:products.${selectedProduct.id}.description`}
defaults={selectedProduct.description}
/>
</span>
</p>
</div>
<div className={'flex flex-col space-y-1'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:detailsLabel'} />
</span>
<LineItemDetails
lineItems={selectedPlan.lineItems ?? []}
selectedInterval={selectedInterval}
currency={selectedProduct.currency}
/>
</div>
<div className={'flex flex-col space-y-2'}>
<span className={'font-semibold'}>
<Trans i18nKey={'billing:featuresLabel'} />
</span>
{selectedProduct.features.map((item) => {
return (
<div key={item} className={'flex items-center space-x-2 text-sm'}>
<CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}>
<Trans i18nKey={`billing:features.${item}`} defaults={item} />
</span>
</div>
);
})}
</div>
</div>
);
}
function Price(props: React.PropsWithChildren) {
return (
<span

View File

@@ -5,6 +5,8 @@ import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
export class BillingEventHandlerService {
private readonly namespace = 'billing';
constructor(
private readonly clientProvider: () => SupabaseClient<Database>,
private readonly strategy: BillingWebhookHandlerService,
@@ -25,7 +27,7 @@ export class BillingEventHandlerService {
// here we delete the subscription from the database
Logger.info(
{
namespace: 'billing',
namespace: this.namespace,
subscriptionId,
},
'Processing subscription deleted event',
@@ -42,7 +44,7 @@ export class BillingEventHandlerService {
Logger.info(
{
namespace: 'billing',
namespace: this.namespace,
subscriptionId,
},
'Successfully deleted subscription',
@@ -52,22 +54,18 @@ export class BillingEventHandlerService {
const client = this.clientProvider();
const ctx = {
namespace: 'billing',
subscriptionId: subscription.subscription_id,
namespace: this.namespace,
subscriptionId: subscription.target_subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
customerId: subscription.customer_id,
accountId: subscription.target_account_id,
customerId: subscription.target_customer_id,
};
Logger.info(ctx, 'Processing subscription updated event');
// Handle the subscription updated event
// here we update the subscription in the database
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: subscription.customer_id,
account_id: subscription.account_id,
});
const { error } = await client.rpc('upsert_subscription', subscription);
if (error) {
Logger.error(
@@ -83,32 +81,114 @@ export class BillingEventHandlerService {
Logger.info(ctx, 'Successfully updated subscription');
},
onCheckoutSessionCompleted: async (subscription, customerId) => {
onCheckoutSessionCompleted: async (payload, customerId) => {
// Handle the checkout session completed event
// here we add the subscription to the database
const client = this.clientProvider();
const ctx = {
namespace: 'billing',
subscriptionId: subscription.subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
};
// Check if the payload contains an order_id
// if it does, we add an order, otherwise we add a subscription
if ('order_id' in payload) {
const ctx = {
namespace: this.namespace,
orderId: payload.order_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
};
Logger.info(ctx, 'Processing checkout session completed event...');
Logger.info(ctx, 'Processing order completed event...');
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: customerId,
});
const { error } = await client.rpc('upsert_order', payload);
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add order');
throw new Error('Failed to add order');
}
Logger.info(ctx, 'Successfully added order');
} else {
const ctx = {
namespace: this.namespace,
subscriptionId: payload.target_subscription_id,
provider: payload.billing_provider,
accountId: payload.target_account_id,
customerId,
};
Logger.info(ctx, 'Processing checkout session completed event...');
const { error } = await client.rpc('upsert_subscription', payload);
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add subscription');
throw new Error('Failed to add subscription');
}
Logger.info(ctx, 'Successfully added subscription');
}
},
onPaymentSucceeded: async (sessionId: string) => {
const client = this.clientProvider();
// Handle the payment succeeded event
// here we update the payment status in the database
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Processing payment succeeded event',
);
const { error } = await client
.from('orders')
.update({ status: 'succeeded' })
.match({ session_id: sessionId });
if (error) {
Logger.error({ ...ctx, error }, 'Failed to add subscription');
throw new Error('Failed to add subscription');
throw new Error('Failed to update payment status');
}
Logger.info(ctx, 'Successfully added subscription');
Logger.info(
{
namespace: 'billing',
sessionId,
},
'Successfully updated payment status',
);
},
onPaymentFailed: async (sessionId: string) => {
const client = this.clientProvider();
// Handle the payment failed event
// here we update the payment status in the database
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Processing payment failed event',
);
const { error } = await client
.from('orders')
.update({ status: 'failed' })
.match({ session_id: sessionId });
if (error) {
throw new Error('Failed to update payment status');
}
Logger.info(
{
namespace: this.namespace,
sessionId,
},
'Successfully updated payment status',
);
},
});
}

View File

@@ -226,3 +226,20 @@ export function getProductPlanPair(
throw new Error('Plan not found');
}
export function getProductPlanPairByVariantId(
config: z.infer<typeof BillingSchema>,
planId: string,
) {
for (const product of config.products) {
for (const plan of product.plans) {
for (const lineItem of plan.lineItems) {
if (lineItem.id === planId) {
return { product, plan };
}
}
}
}
throw new Error('Plan not found');
}

View File

@@ -3,26 +3,42 @@ import { Database } from '@kit/supabase/database';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
/**
* Represents an abstract class for handling billing webhook events.
* @name BillingWebhookHandlerService
* @description Represents an abstract class for handling billing webhook events.
*/
export abstract class BillingWebhookHandlerService {
// Verifies the webhook signature - should throw an error if the signature is invalid
abstract verifyWebhookSignature(request: Request): Promise<unknown>;
abstract handleWebhookEvent(
event: unknown,
params: {
// this method is called when a checkout session is completed
onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams,
subscription: UpsertSubscriptionParams | UpsertOrderParams,
customerId: string,
) => Promise<unknown>;
// this method is called when a subscription is updated
onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams,
customerId: string,
) => Promise<unknown>;
// this method is called when a subscription is deleted
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
// this method is called when a payment is succeeded. This is used for
// one-time payments
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
// this method is called when a payment is failed. This is used for
// one-time payments
onPaymentFailed: (sessionId: string) => Promise<unknown>;
},
): Promise<unknown>;
}

View File

@@ -12,8 +12,8 @@ export async function createStripeClient() {
// Parse the environment variables and validate them
const stripeServerEnv = StripeServerEnvSchema.parse({
STRIPE_SECRET_KEY: process.env.STRIPE_SECRET_KEY,
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
secretKey: process.env.STRIPE_SECRET_KEY,
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
return new Stripe(stripeServerEnv.secretKey, {

View File

@@ -10,6 +10,9 @@ import { createStripeClient } from './stripe-sdk';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService
{
@@ -60,13 +63,14 @@ export class StripeWebhookHandlerService
event: Stripe.Event,
params: {
onCheckoutSessionCompleted: (
data: UpsertSubscriptionParams,
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
onSubscriptionUpdated: (
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
},
) {
switch (event.type) {
@@ -80,7 +84,7 @@ export class StripeWebhookHandlerService
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdatedEvent(
event,
params.onSubscriptionUpdated,
params.onCheckoutSessionCompleted,
);
}
@@ -91,6 +95,17 @@ export class StripeWebhookHandlerService
);
}
case 'checkout.session.async_payment_failed': {
return this.handleAsyncPaymentFailed(event, params.onPaymentFailed);
}
case 'checkout.session.async_payment_succeeded': {
return this.handleAsyncPaymentSucceeded(
event,
params.onPaymentSucceeded,
);
}
default: {
Logger.info(
{
@@ -108,55 +123,116 @@ export class StripeWebhookHandlerService
private async handleCheckoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
onCheckoutCompletedCallback: (
data: UpsertSubscriptionParams,
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
const session = event.data.object;
// TODO: handle one-off payments
// is subscription there?
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const isSubscription = session.mode === 'subscription';
const accountId = session.client_reference_id!;
const customerId = session.customer as string;
// TODO: support tiered pricing calculations
// the amount total is amount in cents (e.g. 1000 = $10.00)
// TODO: convert or store the amount in cents?
const amount = session.amount_total ?? 0;
if (isSubscription) {
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const payload = this.buildSubscriptionPayload({
subscription,
amount,
accountId,
customerId,
});
const payload = this.buildSubscriptionPayload({
accountId,
customerId,
id: subscription.id,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onCheckoutCompletedCallback(payload);
return onCheckoutCompletedCallback(payload);
} else {
const sessionId = event.data.object.id;
const sessionWithLineItems = await stripe.checkout.sessions.retrieve(
event.data.object.id,
{
expand: ['line_items'],
},
);
const lineItems = sessionWithLineItems.line_items?.data ?? [];
const paymentStatus = sessionWithLineItems.payment_status;
const status = paymentStatus === 'unpaid' ? 'pending' : 'succeeded';
const currency = event.data.object.currency as string;
const payload: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: customerId,
order_id: sessionId,
billing_provider: this.provider,
status: status,
currency: currency,
total_amount: sessionWithLineItems.amount_total ?? 0,
line_items: lineItems.map((item) => {
const price = item.price as Stripe.Price;
return {
id: item.id,
product_id: price.product as string,
variant_id: price.id,
price_amount: price.unit_amount,
quantity: item.quantity,
};
}),
};
return onCheckoutCompletedCallback(payload);
}
}
private async handleSubscriptionUpdatedEvent(
private handleAsyncPaymentFailed(
event: Stripe.CheckoutSessionAsyncPaymentFailedEvent,
onPaymentFailed: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentFailed(sessionId);
}
private handleAsyncPaymentSucceeded(
event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent,
onPaymentSucceeded: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentSucceeded(sessionId);
}
private handleSubscriptionUpdatedEvent(
event: Stripe.CustomerSubscriptionUpdatedEvent,
onSubscriptionUpdatedCallback: (
data: UpsertSubscriptionParams,
subscription: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
const subscription = event.data.object;
const subscriptionId = subscription.id;
const accountId = subscription.metadata.account_id as string;
const customerId = subscription.customer as string;
const amount = subscription.items.data.reduce((acc, item) => {
return (acc + (item.plan.amount ?? 0)) * (item.quantity ?? 1);
}, 0);
const payload = this.buildSubscriptionPayload({
subscription,
amount,
customerId: subscription.customer as string,
id: subscriptionId,
accountId,
customerId,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onSubscriptionUpdatedCallback(payload);
@@ -171,52 +247,58 @@ export class StripeWebhookHandlerService
return onSubscriptionDeletedCallback(subscription.id);
}
private buildSubscriptionPayload(params: {
subscription: Stripe.Subscription;
amount: number;
private buildSubscriptionPayload<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
status: Stripe.Subscription.Status;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const { subscription } = params;
const currency = subscription.currency;
const active = params.status === 'active' || params.status === 'trialing';
const active =
subscription.status === 'active' || subscription.status === 'trialing';
const lineItems = subscription.items.data.map((item) => {
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
return {
id: item.id,
subscription_id: subscription.id,
product_id: item.price.product as string,
variant_id: item.price.id,
price_amount: item.price.unit_amount,
quantity,
interval: item.price.recurring?.interval as string,
interval_count: item.price.recurring?.interval_count as number,
subscription_id: params.id,
product_id: item.price?.product as string,
variant_id: item.price?.id,
price_amount: item.price?.unit_amount,
interval: item.price?.recurring?.interval as string,
interval_count: item.price?.recurring?.interval_count as number,
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
line_items: lineItems,
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: this.provider,
subscription_id: subscription.id,
status: subscription.status,
total_amount: params.amount,
status: params.status,
line_items: lineItems,
active,
currency,
cancel_at_period_end: subscription.cancel_at_period_end ?? false,
period_starts_at: getISOString(
subscription.current_period_start,
) as string,
period_ends_at: getISOString(subscription.current_period_end) as string,
trial_starts_at: getISOString(subscription.trial_start),
trial_ends_at: getISOString(subscription.trial_end),
account_id: params.accountId,
customer_id: params.customerId,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt),
trial_ends_at: getISOString(params.trialEndsAt),
};
}
}

View File

@@ -364,6 +364,115 @@ export type Database = {
},
]
}
order_items: {
Row: {
created_at: string
order_id: string
price_amount: number | null
product_id: string
quantity: number
updated_at: string
variant_id: string
}
Insert: {
created_at?: string
order_id: string
price_amount?: number | null
product_id: string
quantity?: number
updated_at?: string
variant_id: string
}
Update: {
created_at?: string
order_id?: string
price_amount?: number | null
product_id?: string
quantity?: number
updated_at?: string
variant_id?: string
}
Relationships: [
{
foreignKeyName: "order_items_order_id_fkey"
columns: ["order_id"]
isOneToOne: false
referencedRelation: "orders"
referencedColumns: ["id"]
},
]
}
orders: {
Row: {
account_id: string
billing_customer_id: number
billing_provider: Database["public"]["Enums"]["billing_provider"]
created_at: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at: string
variant_id: string
}
Insert: {
account_id: string
billing_customer_id: number
billing_provider: Database["public"]["Enums"]["billing_provider"]
created_at?: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at?: string
variant_id: string
}
Update: {
account_id?: string
billing_customer_id?: number
billing_provider?: Database["public"]["Enums"]["billing_provider"]
created_at?: string
currency?: string
id?: string
product_id?: string
status?: Database["public"]["Enums"]["payment_status"]
total_amount?: number
updated_at?: string
variant_id?: string
}
Relationships: [
{
foreignKeyName: "orders_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "orders_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_account_workspace"
referencedColumns: ["id"]
},
{
foreignKeyName: "orders_account_id_fkey"
columns: ["account_id"]
isOneToOne: false
referencedRelation: "user_accounts"
referencedColumns: ["id"]
},
{
foreignKeyName: "orders_billing_customer_id_fkey"
columns: ["billing_customer_id"]
isOneToOne: false
referencedRelation: "billing_customers"
referencedColumns: ["id"]
},
]
}
role_permissions: {
Row: {
id: number
@@ -436,7 +545,6 @@ export type Database = {
subscription_items: {
Row: {
created_at: string
id: string
interval: string
interval_count: number
price_amount: number | null
@@ -448,7 +556,6 @@ export type Database = {
}
Insert: {
created_at?: string
id: string
interval: string
interval_count: number
price_amount?: number | null
@@ -460,7 +567,6 @@ export type Database = {
}
Update: {
created_at?: string
id?: string
interval?: string
interval_count?: number
price_amount?: number | null
@@ -781,19 +887,43 @@ export type Database = {
}
Returns: unknown
}
upsert_order: {
Args: {
target_account_id: string
target_customer_id: string
order_id: string
status: Database["public"]["Enums"]["payment_status"]
billing_provider: Database["public"]["Enums"]["billing_provider"]
total_amount: number
currency: string
line_items: Json
}
Returns: {
account_id: string
billing_customer_id: number
billing_provider: Database["public"]["Enums"]["billing_provider"]
created_at: string
currency: string
id: string
product_id: string
status: Database["public"]["Enums"]["payment_status"]
total_amount: number
updated_at: string
variant_id: string
}
}
upsert_subscription: {
Args: {
account_id: string
subscription_id: string
target_account_id: string
target_customer_id: string
target_subscription_id: string
active: boolean
total_amount: number
status: Database["public"]["Enums"]["subscription_status"]
billing_provider: Database["public"]["Enums"]["billing_provider"]
cancel_at_period_end: boolean
currency: string
period_starts_at: string
period_ends_at: string
customer_id: string
line_items: Json
trial_starts_at?: string
trial_ends_at?: string
@@ -827,6 +957,7 @@ export type Database = {
| "members.manage"
| "invites.manage"
billing_provider: "stripe" | "lemon-squeezy" | "paddle"
payment_status: "pending" | "succeeded" | "failed"
subscription_status:
| "active"
| "trialing"

View File

@@ -118,14 +118,14 @@ create type public.subscription_status as ENUM(
'paused'
);
/* Subscription Type
- We create the subscription type for the Supabase MakerKit. These types are used to manage the type of the subscriptions
- The types are 'ONE_OFF' and 'RECURRING'.
- You can add more types as needed.
/*
Payment Status
- We create the payment status for the Supabase MakerKit. These statuses are used to manage the status of the payments
*/
create type public.subscription_type as enum (
'one-off',
'recurring'
create type public.payment_status as ENUM(
'pending',
'succeeded',
'failed'
);
/*
@@ -633,8 +633,6 @@ using (
)
);
/*
* -------------------------------------------------------
* Section: Account Roles
@@ -915,7 +913,8 @@ create table
id serial primary key,
email text,
provider public.billing_provider not null,
customer_id text not null
customer_id text not null,
unique (account_id, customer_id, provider)
);
comment on table public.billing_customers is 'The billing customers for an account';
@@ -959,8 +958,6 @@ create table if not exists public.subscriptions (
account_id uuid references public.accounts (id) on delete cascade not null,
billing_customer_id int references public.billing_customers on delete cascade not null,
status public.subscription_status not null,
type public.subscription_type not null default 'recurring',
total_amount numeric not null,
active bool not null,
billing_provider public.billing_provider not null,
cancel_at_period_end bool not null,
@@ -979,8 +976,6 @@ comment on column public.subscriptions.account_id is 'The account the subscripti
comment on column public.subscriptions.billing_provider is 'The provider of the subscription';
comment on column public.subscriptions.total_amount is 'The total price amount for the subscription';
comment on column public.subscriptions.cancel_at_period_end is 'Whether the subscription will be canceled at the end of the period';
comment on column public.subscriptions.currency is 'The currency for the subscription';
@@ -1018,17 +1013,16 @@ select
-- Functions
create or replace function public.upsert_subscription (
account_id uuid,
subscription_id text,
target_account_id uuid,
target_customer_id varchar(255),
target_subscription_id text,
active bool,
total_amount numeric,
status public.subscription_status,
billing_provider public.billing_provider,
cancel_at_period_end bool,
currency varchar(3),
period_starts_at timestamptz,
period_ends_at timestamptz,
customer_id varchar(255),
line_items jsonb,
trial_starts_at timestamptz default null,
trial_ends_at timestamptz default null,
@@ -1039,7 +1033,7 @@ declare
new_billing_customer_id int;
begin
insert into public.billing_customers(account_id, provider, customer_id)
values (account_id, billing_provider, customer_id)
values (target_account_id, billing_provider, target_customer_id)
on conflict (account_id, provider, customer_id) do update
set provider = excluded.provider
returning id into new_billing_customer_id;
@@ -1049,7 +1043,6 @@ begin
billing_customer_id,
id,
active,
total_amount,
status,
type,
billing_provider,
@@ -1060,11 +1053,10 @@ begin
trial_starts_at,
trial_ends_at)
values (
account_id,
target_account_id,
new_billing_customer_id,
subscription_id,
active,
total_amount,
status,
type,
billing_provider,
@@ -1125,23 +1117,21 @@ $$ language plpgsql;
grant execute on function public.upsert_subscription (
uuid,
varchar,
text,
bool,
numeric,
public.subscription_status,
public.billing_provider,
bool,
varchar,
timestamptz,
timestamptz,
varchar,
jsonb,
timestamptz,
timestamptz,
public.subscription_type
) to service_role;
/* -------------------------------------------------------
* Section: Subscription Items
* We create the schema for the subscription items. Subscription items are the items in a subscription.
@@ -1149,7 +1139,6 @@ grant execute on function public.upsert_subscription (
* -------------------------------------------------------
*/
create table if not exists public.subscription_items (
id text not null primary key,
subscription_id text references public.subscriptions (id) on delete cascade not null,
product_id varchar(255) not null,
variant_id varchar(255) not null,
@@ -1158,7 +1147,8 @@ create table if not exists public.subscription_items (
interval varchar(255) not null,
interval_count integer not null check (interval_count > 0),
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp
updated_at timestamptz not null default current_timestamp,
unique (subscription_id, product_id, variant_id)
);
comment on table public.subscription_items is 'The items in a subscription';
@@ -1188,6 +1178,147 @@ select
)
);
/**
* -------------------------------------------------------
* Section: Orders
* We create the schema for the subscription items. Subscription items are the items in a subscription.
* For example, a subscription might have a subscription item with the product ID 'prod_123' and the variant ID 'var_123'.
* -------------------------------------------------------
*/
create table if not exists public.orders (
id text not null primary key,
account_id uuid references public.accounts (id) on delete cascade not null,
billing_customer_id int references public.billing_customers on delete cascade not null,
status public.payment_status not null,
billing_provider public.billing_provider not null,
total_amount numeric not null,
currency varchar(3) not null,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp
);
-- Open up access to subscription_items table for authenticated users and service_role
grant select on table public.orders to authenticated, service_role;
grant insert, update, delete on table public.orders to service_role;
-- RLS
alter table public.orders enable row level security;
-- SELECT
-- Users can read orders on an account they are a member of or the account is their own
create policy orders_read_self on public.orders for
select
to authenticated using (
account_id = auth.uid () or has_role_on_account (account_id)
);
/**
* -------------------------------------------------------
* Section: Order Items
* We create the schema for the order items. Order items are the items in an order.
* -------------------------------------------------------
*/
create table if not exists public.order_items (
order_id text references public.orders (id) on delete cascade not null,
product_id text not null,
variant_id text not null,
price_amount numeric,
quantity integer not null default 1,
created_at timestamptz not null default current_timestamp,
updated_at timestamptz not null default current_timestamp,
unique (order_id, product_id, variant_id)
);
-- Open up access to order_items table for authenticated users and service_role
grant select on table public.order_items to authenticated, service_role;
-- RLS
alter table public.order_items enable row level security;
-- SELECT
-- Users can read order items on an order they are a member of
create policy order_items_read_self on public.order_items for
select
to authenticated using (
exists (
select 1 from public.orders where id = order_id and (account_id = auth.uid () or has_role_on_account (account_id))
)
);
-- Functions
create or replace function public.upsert_order(
target_account_id uuid,
target_customer_id varchar(255),
order_id text,
status public.payment_status,
billing_provider public.billing_provider,
total_amount numeric,
currency varchar(3),
line_items jsonb
) returns public.orders as $$
declare
new_order public.orders;
new_billing_customer_id int;
begin
insert into public.billing_customers(account_id, provider, customer_id)
values (target_account_id, target_billing_provider, target_customer_id)
on conflict (account_id, provider, customer_id) do update
set provider = excluded.provider
returning id into new_billing_customer_id;
insert into public.orders(
account_id,
billing_customer_id,
id,
status,
billing_provider,
total_amount,
currency)
values (
target_account_id,
new_billing_customer_id,
order_id,
status,
billing_provider,
total_amount,
currency)
on conflict (id) do update
set status = excluded.status,
total_amount = excluded.total_amount,
currency = excluded.currency
returning * into new_order;
insert into public.order_items(
order_id,
product_id,
variant_id,
price_amount,
quantity)
select
target_order_id,
(line_item ->> 'product_id')::varchar,
(line_item ->> 'variant_id')::varchar,
(line_item ->> 'price_amount')::numeric,
(line_item ->> 'quantity')::integer
from jsonb_array_elements(line_items) as line_item
on conflict (order_id, product_id, variant_id) do update
set price_amount = excluded.price_amount,
quantity = excluded.quantity;
return new_order;
end;
$$ language plpgsql;
grant execute on function public.upsert_order (
uuid,
varchar,
text,
public.payment_status,
public.billing_provider,
numeric,
varchar,
jsonb
) to service_role;
/*
* -------------------------------------------------------
* Section: Functions