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

View File

@@ -40,22 +40,29 @@ async function PersonalAccountBillingPage() {
<PageBody> <PageBody>
<div className={'flex flex-col space-y-8'}> <div className={'flex flex-col space-y-8'}>
<If <If condition={!subscription}>
condition={subscription} <PersonalAccountCheckoutForm customerId={customerId} />
fallback={<PersonalAccountCheckoutForm customerId={customerId} />}
> <If condition={customerId}>
<CustomerBillingPortalForm />
</If>
</If>
<If condition={subscription}>
{(subscription) => ( {(subscription) => (
<div
className={'mx-auto flex w-full max-w-2xl flex-col space-y-4'}
>
<CurrentPlanCard <CurrentPlanCard
subscription={subscription} subscription={subscription}
config={billingConfig} config={billingConfig}
/> />
)}
</If>
<If condition={customerId}> <If condition={customerId}>
<form action={createPersonalAccountBillingPortalSession}> <CustomerBillingPortalForm />
<BillingPortalCard /> </If>
</form> </div>
)}
</If> </If>
</div> </div>
</PageBody> </PageBody>
@@ -65,6 +72,14 @@ async function PersonalAccountBillingPage() {
export default withI18n(PersonalAccountBillingPage); export default withI18n(PersonalAccountBillingPage);
function CustomerBillingPortalForm() {
return (
<form action={createPersonalAccountBillingPortalSession}>
<BillingPortalCard />
</form>
);
}
async function loadData(client: SupabaseClient<Database>) { async function loadData(client: SupabaseClient<Database>) {
const { data, error } = await client.auth.getUser(); 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 billingConfig from '~/config/billing.config';
import pathsConfig from '~/config/paths.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. * Creates a checkout session for a personal account.
* *
* @param {object} params - The parameters for creating the checkout session. * @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. * @param {string} params.planId - The ID of the plan to be associated with the account.
*/ */
export async function createPersonalAccountCheckoutSession(params: { export async function createPersonalAccountCheckoutSession(
planId: string; params: z.infer<typeof CreateCheckoutSchema>,
productId: string; ) {
}) { // parse the parameters
const { planId, productId } = CreateCheckoutSchema.parse(params);
// get the authenticated user
const client = getSupabaseServerActionClient(); const client = getSupabaseServerActionClient();
const { data: user, error } = await requireUser(client); const { data: user, error } = await requireUser(client);
@@ -31,13 +39,6 @@ export async function createPersonalAccountCheckoutSession(params: {
throw new Error('Authentication required'); throw new Error('Authentication required');
} }
const { planId, productId } = z
.object({
planId: z.string().min(1),
productId: z.string().min(1),
})
.parse(params);
Logger.info( Logger.info(
{ {
planId, planId,

View File

@@ -52,7 +52,8 @@ async function TeamAccountBillingPage({ params }: Params) {
<CannotManageBillingAlert /> <CannotManageBillingAlert />
</If> </If>
<div className={'flex flex-col space-y-4'}> <div>
<div className={'flex flex-col space-y-2'}>
<If <If
condition={subscription} condition={subscription}
fallback={ fallback={
@@ -79,6 +80,7 @@ async function TeamAccountBillingPage({ params }: Params) {
</If> </If>
</div> </div>
</div> </div>
</div>
</PageBody> </PageBody>
</> </>
); );

View File

@@ -37,6 +37,9 @@
"detailsLabel": "Details", "detailsLabel": "Details",
"planPickerLabel": "Pick your preferred plan", "planPickerLabel": "Pick your preferred plan",
"planCardLabel": "Manage your 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": { "status": {
"free": { "free": {
"badge": "Free Plan", "badge": "Free Plan",

View File

@@ -1,12 +1,7 @@
import { formatDate } from 'date-fns'; import { formatDate } from 'date-fns';
import { BadgeCheck, CheckCircle2 } from 'lucide-react'; import { BadgeCheck, CheckCircle2 } from 'lucide-react';
import { import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
BillingConfig,
getBaseLineItem,
getProductPlanPair,
} from '@kit/billing';
import { formatCurrency } from '@kit/shared/utils';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
import { import {
Accordion, Accordion,
@@ -26,6 +21,7 @@ import { Trans } from '@kit/ui/trans';
import { CurrentPlanAlert } from './current-plan-alert'; import { CurrentPlanAlert } from './current-plan-alert';
import { CurrentPlanBadge } from './current-plan-badge'; import { CurrentPlanBadge } from './current-plan-badge';
import { LineItemDetails } from './line-item-details';
type Subscription = Database['public']['Tables']['subscriptions']['Row']; type Subscription = Database['public']['Tables']['subscriptions']['Row'];
type LineItem = Database['public']['Tables']['subscription_items']['Row']; type LineItem = Database['public']['Tables']['subscription_items']['Row'];
@@ -42,19 +38,26 @@ export function CurrentPlanCard({
subscription, subscription,
config, config,
}: React.PropsWithChildren<Props>) { }: React.PropsWithChildren<Props>) {
// line items have the same product id const lineItems = subscription.items;
const lineItem = subscription.items[0] as LineItem; const firstLineItem = lineItems[0];
const product = config.products.find( if (!firstLineItem) {
(product) => product.id === lineItem.product_id, 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( 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 ( return (
<Card> <Card>
<CardHeader> <CardHeader>
@@ -113,8 +116,7 @@ export function CurrentPlanCard({
<If condition={subscription.cancel_at_period_end}> <If condition={subscription.cancel_at_period_end}>
<div className="flex flex-col"> <div className="flex flex-col">
<span className="font-medium"> <span className="font-medium">
Your subscription will be cancelled at the end of the <Trans i18nKey="billing:cancelSubscriptionDate" />
period
</span> </span>
<div className={'text-muted-foreground'}> <div className={'text-muted-foreground'}>
@@ -126,7 +128,21 @@ export function CurrentPlanCard({
</If> </If>
<div className="flex flex-col space-y-1"> <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'}> <ul className={'flex flex-col space-y-0.5'}>
{product.features.map((item) => { {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 { import {
BillingConfig, BillingConfig,
LineItemSchema,
getBaseLineItem, getBaseLineItem,
getPlanIntervals, getPlanIntervals,
getProductPlanPair, getProductPlanPair,
@@ -36,6 +37,8 @@ import {
import { Trans } from '@kit/ui/trans'; import { Trans } from '@kit/ui/trans';
import { cn } from '@kit/ui/utils'; import { cn } from '@kit/ui/utils';
import { LineItemDetails } from './line-item-details';
export function PlanPicker( export function PlanPicker(
props: React.PropsWithChildren<{ props: React.PropsWithChildren<{
config: BillingConfig; config: BillingConfig;
@@ -55,7 +58,8 @@ export function PlanPicker(
resolver: zodResolver( resolver: zodResolver(
z z
.object({ .object({
planId: z.string(), planId: z.string().min(1),
productId: z.string().min(1),
interval: z.string().min(1), interval: z.string().min(1),
}) })
.refine( .refine(
@@ -143,6 +147,10 @@ export function PlanPicker(
shouldValidate: true, shouldValidate: true,
}); });
form.setValue('productId', '', {
shouldValidate: true,
});
form.setValue('interval', interval, { form.setValue('interval', interval, {
shouldValidate: true, shouldValidate: true,
}); });
@@ -311,7 +319,38 @@ export function PlanPicker(
</div> </div>
</form> </form>
<If condition={selectedPlan && selectedProduct}> {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 <div
className={ className={
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4' 'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
@@ -321,21 +360,18 @@ export function PlanPicker(
<Heading level={5}> <Heading level={5}>
<b> <b>
<Trans <Trans
i18nKey={`billing:products.${selectedProduct?.id}.name`} i18nKey={`billing:products.${selectedProduct.id}.name`}
defaults={selectedProduct?.name} defaults={selectedProduct.name}
/> />
</b>{' '} </b>{' '}
/{' '} / <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
<Trans
i18nKey={`billing:billingInterval.${selectedInterval}`}
/>
</Heading> </Heading>
<p> <p>
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
<Trans <Trans
i18nKey={`billing:products.${selectedProduct?.id}.description`} i18nKey={`billing:products.${selectedProduct.id}.description`}
defaults={selectedProduct?.description} defaults={selectedProduct.description}
/> />
</span> </span>
</p> </p>
@@ -346,100 +382,11 @@ export function PlanPicker(
<Trans i18nKey={'billing:detailsLabel'} /> <Trans i18nKey={'billing:detailsLabel'} />
</span> </span>
<div className={'flex flex-col divide-y'}> <LineItemDetails
{selectedPlan?.lineItems.map((item) => { lineItems={selectedPlan.lineItems ?? []}
switch (item.type) { selectedInterval={selectedInterval}
case 'base': currency={selectedProduct.currency}
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>
<div className={'flex flex-col space-y-2'}> <div className={'flex flex-col space-y-2'}>
@@ -447,28 +394,19 @@ export function PlanPicker(
<Trans i18nKey={'billing:featuresLabel'} /> <Trans i18nKey={'billing:featuresLabel'} />
</span> </span>
{selectedProduct?.features.map((item) => { {selectedProduct.features.map((item) => {
return ( return (
<div <div key={item} className={'flex items-center space-x-2 text-sm'}>
key={item}
className={'flex items-center space-x-2 text-sm'}
>
<CheckCircle className={'h-4 text-green-500'} /> <CheckCircle className={'h-4 text-green-500'} />
<span className={'text-muted-foreground'}> <span className={'text-muted-foreground'}>
<Trans <Trans i18nKey={`billing:features.${item}`} defaults={item} />
i18nKey={`billing:features.${item}`}
defaults={item}
/>
</span> </span>
</div> </div>
); );
})} })}
</div> </div>
</div> </div>
</If>
</div>
</Form>
); );
} }

View File

@@ -5,6 +5,8 @@ import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database'; import { Database } from '@kit/supabase/database';
export class BillingEventHandlerService { export class BillingEventHandlerService {
private readonly namespace = 'billing';
constructor( constructor(
private readonly clientProvider: () => SupabaseClient<Database>, private readonly clientProvider: () => SupabaseClient<Database>,
private readonly strategy: BillingWebhookHandlerService, private readonly strategy: BillingWebhookHandlerService,
@@ -25,7 +27,7 @@ export class BillingEventHandlerService {
// here we delete the subscription from the database // here we delete the subscription from the database
Logger.info( Logger.info(
{ {
namespace: 'billing', namespace: this.namespace,
subscriptionId, subscriptionId,
}, },
'Processing subscription deleted event', 'Processing subscription deleted event',
@@ -42,7 +44,7 @@ export class BillingEventHandlerService {
Logger.info( Logger.info(
{ {
namespace: 'billing', namespace: this.namespace,
subscriptionId, subscriptionId,
}, },
'Successfully deleted subscription', 'Successfully deleted subscription',
@@ -52,22 +54,18 @@ export class BillingEventHandlerService {
const client = this.clientProvider(); const client = this.clientProvider();
const ctx = { const ctx = {
namespace: 'billing', namespace: this.namespace,
subscriptionId: subscription.subscription_id, subscriptionId: subscription.target_subscription_id,
provider: subscription.billing_provider, provider: subscription.billing_provider,
accountId: subscription.account_id, accountId: subscription.target_account_id,
customerId: subscription.customer_id, customerId: subscription.target_customer_id,
}; };
Logger.info(ctx, 'Processing subscription updated event'); Logger.info(ctx, 'Processing subscription updated event');
// Handle the subscription updated event // Handle the subscription updated event
// here we update the subscription in the database // here we update the subscription in the database
const { error } = await client.rpc('upsert_subscription', { const { error } = await client.rpc('upsert_subscription', subscription);
...subscription,
customer_id: subscription.customer_id,
account_id: subscription.account_id,
});
if (error) { if (error) {
Logger.error( Logger.error(
@@ -83,24 +81,45 @@ export class BillingEventHandlerService {
Logger.info(ctx, 'Successfully updated subscription'); Logger.info(ctx, 'Successfully updated subscription');
}, },
onCheckoutSessionCompleted: async (subscription, customerId) => { onCheckoutSessionCompleted: async (payload, customerId) => {
// Handle the checkout session completed event // Handle the checkout session completed event
// here we add the subscription to the database // here we add the subscription to the database
const client = this.clientProvider(); const client = this.clientProvider();
// 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 = { const ctx = {
namespace: 'billing', namespace: this.namespace,
subscriptionId: subscription.subscription_id, orderId: payload.order_id,
provider: subscription.billing_provider, provider: payload.billing_provider,
accountId: subscription.account_id, accountId: payload.target_account_id,
customerId,
};
Logger.info(ctx, 'Processing order completed event...');
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...'); Logger.info(ctx, 'Processing checkout session completed event...');
const { error } = await client.rpc('upsert_subscription', { const { error } = await client.rpc('upsert_subscription', payload);
...subscription,
customer_id: customerId,
});
if (error) { if (error) {
Logger.error({ ...ctx, error }, 'Failed to add subscription'); Logger.error({ ...ctx, error }, 'Failed to add subscription');
@@ -109,6 +128,67 @@ export class BillingEventHandlerService {
} }
Logger.info(ctx, 'Successfully added 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) {
throw new Error('Failed to update payment status');
}
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'); 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 = type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args']; 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 { export abstract class BillingWebhookHandlerService {
// Verifies the webhook signature - should throw an error if the signature is invalid
abstract verifyWebhookSignature(request: Request): Promise<unknown>; abstract verifyWebhookSignature(request: Request): Promise<unknown>;
abstract handleWebhookEvent( abstract handleWebhookEvent(
event: unknown, event: unknown,
params: { params: {
// this method is called when a checkout session is completed
onCheckoutSessionCompleted: ( onCheckoutSessionCompleted: (
subscription: UpsertSubscriptionParams, subscription: UpsertSubscriptionParams | UpsertOrderParams,
customerId: string, customerId: string,
) => Promise<unknown>; ) => Promise<unknown>;
// this method is called when a subscription is updated
onSubscriptionUpdated: ( onSubscriptionUpdated: (
subscription: UpsertSubscriptionParams, subscription: UpsertSubscriptionParams,
customerId: string, customerId: string,
) => Promise<unknown>; ) => Promise<unknown>;
// this method is called when a subscription is deleted
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>; 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>; ): Promise<unknown>;
} }

View File

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

View File

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

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: { role_permissions: {
Row: { Row: {
id: number id: number
@@ -436,7 +545,6 @@ export type Database = {
subscription_items: { subscription_items: {
Row: { Row: {
created_at: string created_at: string
id: string
interval: string interval: string
interval_count: number interval_count: number
price_amount: number | null price_amount: number | null
@@ -448,7 +556,6 @@ export type Database = {
} }
Insert: { Insert: {
created_at?: string created_at?: string
id: string
interval: string interval: string
interval_count: number interval_count: number
price_amount?: number | null price_amount?: number | null
@@ -460,7 +567,6 @@ export type Database = {
} }
Update: { Update: {
created_at?: string created_at?: string
id?: string
interval?: string interval?: string
interval_count?: number interval_count?: number
price_amount?: number | null price_amount?: number | null
@@ -781,19 +887,43 @@ export type Database = {
} }
Returns: unknown 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: { upsert_subscription: {
Args: { Args: {
account_id: string target_account_id: string
subscription_id: string target_customer_id: string
target_subscription_id: string
active: boolean active: boolean
total_amount: number
status: Database["public"]["Enums"]["subscription_status"] status: Database["public"]["Enums"]["subscription_status"]
billing_provider: Database["public"]["Enums"]["billing_provider"] billing_provider: Database["public"]["Enums"]["billing_provider"]
cancel_at_period_end: boolean cancel_at_period_end: boolean
currency: string currency: string
period_starts_at: string period_starts_at: string
period_ends_at: string period_ends_at: string
customer_id: string
line_items: Json line_items: Json
trial_starts_at?: string trial_starts_at?: string
trial_ends_at?: string trial_ends_at?: string
@@ -827,6 +957,7 @@ export type Database = {
| "members.manage" | "members.manage"
| "invites.manage" | "invites.manage"
billing_provider: "stripe" | "lemon-squeezy" | "paddle" billing_provider: "stripe" | "lemon-squeezy" | "paddle"
payment_status: "pending" | "succeeded" | "failed"
subscription_status: subscription_status:
| "active" | "active"
| "trialing" | "trialing"

View File

@@ -118,14 +118,14 @@ create type public.subscription_status as ENUM(
'paused' 'paused'
); );
/* Subscription Type /*
- We create the subscription type for the Supabase MakerKit. These types are used to manage the type of the subscriptions Payment Status
- The types are 'ONE_OFF' and 'RECURRING'. - We create the payment status for the Supabase MakerKit. These statuses are used to manage the status of the payments
- You can add more types as needed.
*/ */
create type public.subscription_type as enum ( create type public.payment_status as ENUM(
'one-off', 'pending',
'recurring' 'succeeded',
'failed'
); );
/* /*
@@ -633,8 +633,6 @@ using (
) )
); );
/* /*
* ------------------------------------------------------- * -------------------------------------------------------
* Section: Account Roles * Section: Account Roles
@@ -915,7 +913,8 @@ create table
id serial primary key, id serial primary key,
email text, email text,
provider public.billing_provider not null, 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'; 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, 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, billing_customer_id int references public.billing_customers on delete cascade not null,
status public.subscription_status 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, active bool not null,
billing_provider public.billing_provider not null, billing_provider public.billing_provider not null,
cancel_at_period_end bool 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.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.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'; comment on column public.subscriptions.currency is 'The currency for the subscription';
@@ -1018,17 +1013,16 @@ select
-- Functions -- Functions
create or replace function public.upsert_subscription ( create or replace function public.upsert_subscription (
account_id uuid, target_account_id uuid,
subscription_id text, target_customer_id varchar(255),
target_subscription_id text,
active bool, active bool,
total_amount numeric,
status public.subscription_status, status public.subscription_status,
billing_provider public.billing_provider, billing_provider public.billing_provider,
cancel_at_period_end bool, cancel_at_period_end bool,
currency varchar(3), currency varchar(3),
period_starts_at timestamptz, period_starts_at timestamptz,
period_ends_at timestamptz, period_ends_at timestamptz,
customer_id varchar(255),
line_items jsonb, line_items jsonb,
trial_starts_at timestamptz default null, trial_starts_at timestamptz default null,
trial_ends_at timestamptz default null, trial_ends_at timestamptz default null,
@@ -1039,7 +1033,7 @@ declare
new_billing_customer_id int; new_billing_customer_id int;
begin begin
insert into public.billing_customers(account_id, provider, customer_id) 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 on conflict (account_id, provider, customer_id) do update
set provider = excluded.provider set provider = excluded.provider
returning id into new_billing_customer_id; returning id into new_billing_customer_id;
@@ -1049,7 +1043,6 @@ begin
billing_customer_id, billing_customer_id,
id, id,
active, active,
total_amount,
status, status,
type, type,
billing_provider, billing_provider,
@@ -1060,11 +1053,10 @@ begin
trial_starts_at, trial_starts_at,
trial_ends_at) trial_ends_at)
values ( values (
account_id, target_account_id,
new_billing_customer_id, new_billing_customer_id,
subscription_id, subscription_id,
active, active,
total_amount,
status, status,
type, type,
billing_provider, billing_provider,
@@ -1125,23 +1117,21 @@ $$ language plpgsql;
grant execute on function public.upsert_subscription ( grant execute on function public.upsert_subscription (
uuid, uuid,
varchar,
text, text,
bool, bool,
numeric,
public.subscription_status, public.subscription_status,
public.billing_provider, public.billing_provider,
bool, bool,
varchar, varchar,
timestamptz, timestamptz,
timestamptz, timestamptz,
varchar,
jsonb, jsonb,
timestamptz, timestamptz,
timestamptz, timestamptz,
public.subscription_type public.subscription_type
) to service_role; ) to service_role;
/* ------------------------------------------------------- /* -------------------------------------------------------
* Section: Subscription Items * Section: Subscription Items
* We create the schema for the subscription items. Subscription items are the items in a subscription. * 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 ( 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, subscription_id text references public.subscriptions (id) on delete cascade not null,
product_id varchar(255) not null, product_id varchar(255) not null,
variant_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 varchar(255) not null,
interval_count integer not null check (interval_count > 0), interval_count integer not null check (interval_count > 0),
created_at timestamptz not null default current_timestamp, 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'; 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 * Section: Functions