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:
@@ -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>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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) => {
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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',
|
||||||
|
);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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');
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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, {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
Reference in New Issue
Block a user