Add Lemon Squeezy Billing System
This commit is contained in:
@@ -176,7 +176,23 @@ const BillingSchema = z
|
||||
message: 'Line item IDs must be unique',
|
||||
path: ['products'],
|
||||
},
|
||||
);
|
||||
)
|
||||
.refine((schema) => {
|
||||
if (schema.provider === 'lemon-squeezy') {
|
||||
for (const product of schema.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.lineItems.length > 1) {
|
||||
return {
|
||||
message: 'Only one line item is allowed for Lemon Squeezy',
|
||||
path: ['products', 'plans'],
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
});
|
||||
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
3
packages/billing/gateway/README.md
Normal file
3
packages/billing/gateway/README.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# Billing - @kit/billing-gateway
|
||||
|
||||
This package is responsible for handling all billing related operations. It is a gateway to the billing service.
|
||||
55
packages/billing/gateway/package.json
Normal file
55
packages/billing/gateway/package.json
Normal file
@@ -0,0 +1,55 @@
|
||||
{
|
||||
"name": "@kit/billing-gateway",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/shared": "^0.1.0",
|
||||
"@kit/stripe": "0.1.0",
|
||||
"@kit/supabase": "^0.1.0",
|
||||
"@kit/ui": "0.1.0",
|
||||
"@supabase/supabase-js": "^2.40.0",
|
||||
"lucide-react": "^0.363.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:^",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:^",
|
||||
"@kit/stripe": "workspace:^",
|
||||
"@kit/lemon-squeezy": "workspace:^",
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^",
|
||||
"@supabase/supabase-js": "^2.41.1",
|
||||
"lucide-react": "^0.363.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
'use client';
|
||||
|
||||
import { ArrowUpRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function BillingPortalCard() {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing:billingPortalCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing:billingPortalCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-2'}>
|
||||
<div>
|
||||
<Button>
|
||||
<span>
|
||||
<Trans i18nKey="billing:billingPortalCardButton" />
|
||||
</span>
|
||||
|
||||
<ArrowUpRight className={'h-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { Check, ChevronRight } from 'lucide-react';
|
||||
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
/**
|
||||
* Retrieves the session status for a Stripe checkout session.
|
||||
* Since we should only arrive here for a successful checkout, we only check
|
||||
* for the `paid` status.
|
||||
*
|
||||
* @param {Stripe.Checkout.Session['status']} status - The status of the Stripe checkout session.
|
||||
* @param {string} customerEmail - The email address of the customer associated with the session.
|
||||
*
|
||||
* @returns {ReactElement} - The component to render based on the session status.
|
||||
*/
|
||||
export function BillingSessionStatus({
|
||||
customerEmail,
|
||||
redirectPath,
|
||||
}: React.PropsWithChildren<{
|
||||
customerEmail: string;
|
||||
redirectPath: string;
|
||||
}>) {
|
||||
return (
|
||||
<SuccessSessionStatus
|
||||
redirectPath={redirectPath}
|
||||
customerEmail={customerEmail}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function SuccessSessionStatus({
|
||||
customerEmail,
|
||||
redirectPath,
|
||||
}: React.PropsWithChildren<{
|
||||
customerEmail: string;
|
||||
redirectPath: string;
|
||||
}>) {
|
||||
return (
|
||||
<section
|
||||
data-test={'payment-return-success'}
|
||||
className={
|
||||
'fade-in mx-auto max-w-xl rounded-xl border p-16 xl:drop-shadow-sm' +
|
||||
' dark:border-dark-800 border-gray-100' +
|
||||
' bg-background animate-in slide-in-from-bottom-8 ease-out' +
|
||||
' zoom-in-50 dark:shadow-primary/40 duration-1000 dark:shadow-2xl'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-4 text-center'
|
||||
}
|
||||
>
|
||||
<Check
|
||||
className={
|
||||
'h-16 w-16 rounded-full bg-green-500 p-1 text-white ring-8' +
|
||||
' ring-green-500/30 dark:ring-green-500/50'
|
||||
}
|
||||
/>
|
||||
|
||||
<Heading level={3}>
|
||||
<span className={'mr-4 font-semibold'}>
|
||||
<Trans i18nKey={'billing:checkoutSuccessTitle'} />
|
||||
</span>
|
||||
🎉
|
||||
</Heading>
|
||||
|
||||
<div
|
||||
className={'flex flex-col space-y-4 text-gray-500 dark:text-gray-400'}
|
||||
>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'billing:checkoutSuccessDescription'}
|
||||
values={{ customerEmail }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button data-test={'checkout-success-back-button'} variant={'outline'}>
|
||||
<Link href={redirectPath}>
|
||||
<span className={'flex items-center space-x-2.5'}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
|
||||
<ChevronRight className={'h-4'} />
|
||||
</span>
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { CurrentPlanBadge } from './current-plan-badge';
|
||||
import { LineItemDetails } from './line-item-details';
|
||||
|
||||
type Order = Database['public']['Tables']['orders']['Row'];
|
||||
type LineItem = Database['public']['Tables']['order_items']['Row'];
|
||||
|
||||
interface Props {
|
||||
order: Order & {
|
||||
items: LineItem[];
|
||||
};
|
||||
|
||||
config: BillingConfig;
|
||||
}
|
||||
|
||||
export function CurrentLifetimeOrderCard({
|
||||
order,
|
||||
config,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const lineItems = order.items;
|
||||
const firstLineItem = lineItems[0];
|
||||
|
||||
if (!firstLineItem) {
|
||||
throw new Error('No line items found in subscription');
|
||||
}
|
||||
|
||||
const { product, plan } = getProductPlanPairByVariantId(
|
||||
config,
|
||||
firstLineItem.variant_id,
|
||||
);
|
||||
|
||||
if (!product || !plan) {
|
||||
throw new Error(
|
||||
'Product or plan not found. Did you forget to add it to the billing config?',
|
||||
);
|
||||
}
|
||||
|
||||
const productLineItems = plan.lineItems;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing:planCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing:planCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-3 text-sm'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center space-x-2 text-lg font-semibold'}>
|
||||
<BadgeCheck
|
||||
className={
|
||||
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
|
||||
}
|
||||
/>
|
||||
|
||||
<span>{product.name}</span>
|
||||
|
||||
<CurrentPlanBadge status={order.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={productLineItems}
|
||||
currency={order.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function CurrentPlanAlert(
|
||||
props: React.PropsWithoutRef<{
|
||||
status: Database['public']['Enums']['subscription_status'];
|
||||
}>,
|
||||
) {
|
||||
let variant: 'success' | 'warning' | 'destructive';
|
||||
const prefix = 'billing:status';
|
||||
|
||||
const text = `${prefix}.${props.status}.description`;
|
||||
const title = `${prefix}.${props.status}.heading`;
|
||||
|
||||
switch (props.status) {
|
||||
case 'active':
|
||||
variant = 'success';
|
||||
break;
|
||||
case 'trialing':
|
||||
variant = 'success';
|
||||
break;
|
||||
case 'past_due':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'canceled':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'unpaid':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'incomplete':
|
||||
variant = 'warning';
|
||||
break;
|
||||
case 'incomplete_expired':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'paused':
|
||||
variant = 'warning';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Alert variant={variant}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey={title} />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey={text} />
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Status =
|
||||
| Database['public']['Enums']['subscription_status']
|
||||
| Database['public']['Enums']['payment_status'];
|
||||
|
||||
export function CurrentPlanBadge(
|
||||
props: React.PropsWithoutRef<{
|
||||
status: Status;
|
||||
}>,
|
||||
) {
|
||||
let variant: 'success' | 'warning' | 'destructive';
|
||||
const text = `billing:status.${props.status}.badge`;
|
||||
|
||||
switch (props.status) {
|
||||
case 'active':
|
||||
case 'succeeded':
|
||||
variant = 'success';
|
||||
break;
|
||||
case 'trialing':
|
||||
variant = 'success';
|
||||
break;
|
||||
case 'past_due':
|
||||
case 'failed':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'canceled':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'unpaid':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'incomplete':
|
||||
case 'pending':
|
||||
variant = 'warning';
|
||||
break;
|
||||
case 'incomplete_expired':
|
||||
variant = 'destructive';
|
||||
break;
|
||||
case 'paused':
|
||||
variant = 'warning';
|
||||
break;
|
||||
}
|
||||
|
||||
return (
|
||||
<Badge variant={variant}>
|
||||
<Trans i18nKey={text} />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { formatDate } from 'date-fns';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { CurrentPlanAlert } from './current-plan-alert';
|
||||
import { CurrentPlanBadge } from './current-plan-badge';
|
||||
import { LineItemDetails } from './line-item-details';
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
|
||||
type LineItem = Database['public']['Tables']['subscription_items']['Row'];
|
||||
|
||||
interface Props {
|
||||
subscription: Subscription & {
|
||||
items: LineItem[];
|
||||
};
|
||||
|
||||
config: BillingConfig;
|
||||
}
|
||||
|
||||
export function CurrentSubscriptionCard({
|
||||
subscription,
|
||||
config,
|
||||
}: React.PropsWithChildren<Props>) {
|
||||
const lineItems = subscription.items;
|
||||
const firstLineItem = lineItems[0];
|
||||
|
||||
if (!firstLineItem) {
|
||||
throw new Error('No line items found in subscription');
|
||||
}
|
||||
|
||||
const { product, plan } = getProductPlanPairByVariantId(
|
||||
config,
|
||||
firstLineItem.variant_id,
|
||||
);
|
||||
|
||||
if (!product || !plan) {
|
||||
throw new Error(
|
||||
'Product or plan not found. Did you forget to add it to the billing config?',
|
||||
);
|
||||
}
|
||||
|
||||
const productLineItems = plan.lineItems;
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>
|
||||
<Trans i18nKey="billing:planCardTitle" />
|
||||
</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
<Trans i18nKey="billing:planCardDescription" />
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent className={'space-y-3 text-sm'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center space-x-2 text-lg font-semibold'}>
|
||||
<BadgeCheck
|
||||
className={
|
||||
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
|
||||
}
|
||||
/>
|
||||
|
||||
<span>{product.name}</span>
|
||||
|
||||
<CurrentPlanBadge status={subscription.status} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Only show the alert if the subscription requires action
|
||||
(e.g. trial ending soon, subscription canceled, etc.)
|
||||
*/}
|
||||
<If condition={!subscription.active}>
|
||||
<div>
|
||||
<CurrentPlanAlert status={subscription.status} />
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div>
|
||||
<If condition={subscription.status === 'trialing'}>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:trialEndsOn" />
|
||||
</span>
|
||||
|
||||
<div className={'text-muted-foreground'}>
|
||||
<span>
|
||||
{subscription.trial_ends_at
|
||||
? formatDate(subscription.trial_ends_at, 'P')
|
||||
: ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={subscription.cancel_at_period_end}>
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:cancelSubscriptionDate" />
|
||||
</span>
|
||||
|
||||
<div className={'text-muted-foreground'}>
|
||||
<span>
|
||||
{formatDate(subscription.period_ends_at ?? '', 'P')}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div className="flex flex-col space-y-0.5">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={productLineItems}
|
||||
currency={subscription.currency}
|
||||
selectedInterval={firstLineItem.interval}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,92 @@
|
||||
import { Suspense, forwardRef, lazy, memo, useMemo } from 'react';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
type BillingProvider = Database['public']['Enums']['billing_provider'];
|
||||
|
||||
const Fallback = <LoadingOverlay fullPage={false} />;
|
||||
|
||||
export function EmbeddedCheckout(
|
||||
props: React.PropsWithChildren<{
|
||||
checkoutToken: string;
|
||||
provider: BillingProvider;
|
||||
onClose?: () => void;
|
||||
}>,
|
||||
) {
|
||||
const CheckoutComponent = useMemo(
|
||||
() => loadCheckoutComponent(props.provider),
|
||||
[props.provider],
|
||||
);
|
||||
|
||||
return (
|
||||
<CheckoutComponent
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
function loadCheckoutComponent(provider: BillingProvider) {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
return buildLazyComponent(() => {
|
||||
return import('@kit/stripe/components').then(({ StripeCheckout }) => {
|
||||
return {
|
||||
default: StripeCheckout,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
throw new Error('Lemon Squeezy is not yet supported');
|
||||
}
|
||||
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not yet supported');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported provider: ${provider as string}`);
|
||||
}
|
||||
}
|
||||
|
||||
function buildLazyComponent<
|
||||
Component extends React.ComponentType<{
|
||||
onClose: (() => unknown) | undefined;
|
||||
checkoutToken: string;
|
||||
}>,
|
||||
>(
|
||||
load: () => Promise<{
|
||||
default: Component;
|
||||
}>,
|
||||
fallback = Fallback,
|
||||
) {
|
||||
let LoadedComponent: ReturnType<typeof lazy<Component>> | null = null;
|
||||
|
||||
const LazyComponent = forwardRef<
|
||||
React.ElementRef<'div'>,
|
||||
{
|
||||
onClose: (() => unknown) | undefined;
|
||||
checkoutToken: string;
|
||||
}
|
||||
>(function LazyDynamicComponent(props, ref) {
|
||||
if (!LoadedComponent) {
|
||||
LoadedComponent = lazy(load);
|
||||
}
|
||||
|
||||
return (
|
||||
<Suspense fallback={fallback}>
|
||||
{/* @ts-expect-error: weird TS */}
|
||||
<LoadedComponent
|
||||
onClose={props.onClose}
|
||||
checkoutToken={props.checkoutToken}
|
||||
ref={ref}
|
||||
/>
|
||||
</Suspense>
|
||||
);
|
||||
});
|
||||
|
||||
return memo(LazyComponent);
|
||||
}
|
||||
7
packages/billing/gateway/src/components/index.ts
Normal file
7
packages/billing/gateway/src/components/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
export * from './plan-picker';
|
||||
export * from './current-subscription-card';
|
||||
export * from './current-lifetime-order-card';
|
||||
export * from './embedded-checkout';
|
||||
export * from './billing-session-status';
|
||||
export * from './billing-portal-card';
|
||||
export * from './pricing-table';
|
||||
101
packages/billing/gateway/src/components/line-item-details.tsx
Normal file
101
packages/billing/gateway/src/components/line-item-details.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { LineItemSchema } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function LineItemDetails(
|
||||
props: React.PropsWithChildren<{
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
currency: string;
|
||||
selectedInterval?: string | undefined;
|
||||
}>,
|
||||
) {
|
||||
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:basePlan'} />
|
||||
</span>
|
||||
|
||||
<span>/</span>
|
||||
|
||||
<span>
|
||||
<If
|
||||
condition={props.selectedInterval}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
|
||||
/>
|
||||
</If>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
452
packages/billing/gateway/src/components/plan-picker.tsx
Normal file
452
packages/billing/gateway/src/components/plan-picker.tsx
Normal file
@@ -0,0 +1,452 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
LineItemSchema,
|
||||
getBaseLineItem,
|
||||
getPlanIntervals,
|
||||
getProductPlanPair,
|
||||
} from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { LineItemDetails } from './line-item-details';
|
||||
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: BillingConfig;
|
||||
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||
canStartTrial?: boolean;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
[props.config],
|
||||
) as string[];
|
||||
|
||||
const form = useForm({
|
||||
reValidateMode: 'onChange',
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string().min(1),
|
||||
productId: z.string().min(1),
|
||||
interval: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
try {
|
||||
const { product, plan } = getProductPlanPair(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
|
||||
return product && plan;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: `Please pick a plan to continue`, path: ['planId'] },
|
||||
),
|
||||
),
|
||||
defaultValues: {
|
||||
interval: intervals[0],
|
||||
planId: '',
|
||||
productId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const { interval: selectedInterval } = form.watch();
|
||||
const planId = form.getValues('planId');
|
||||
|
||||
const { plan: selectedPlan, product: selectedProduct } = useMemo(() => {
|
||||
try {
|
||||
return getProductPlanPair(props.config, planId);
|
||||
} catch {
|
||||
return {
|
||||
plan: null,
|
||||
product: null,
|
||||
};
|
||||
}
|
||||
}, [props.config, planId]);
|
||||
|
||||
const { t } = useTranslation(`billing`);
|
||||
|
||||
// display the period picker if the selected plan is recurring or if no plan is selected
|
||||
const isRecurringPlan =
|
||||
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col space-y-4 lg:flex-row lg:space-x-4 lg:space-y-0'
|
||||
}
|
||||
>
|
||||
<form
|
||||
className={'flex w-full max-w-xl flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<div
|
||||
className={cn('transition-all', {
|
||||
['pointer-events-none opacity-50']: !isRecurringPlan,
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={'interval'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'rounded-md border p-4'}>
|
||||
<FormLabel htmlFor={'plan-picker-id'}>
|
||||
<Trans i18nKey={'common:billingInterval.label'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl id={'plan-picker-id'}>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
<div className={'flex space-x-2.5'}>
|
||||
{intervals.map((interval) => {
|
||||
const selected = field.value === interval;
|
||||
|
||||
return (
|
||||
<label
|
||||
htmlFor={interval}
|
||||
key={interval}
|
||||
className={cn(
|
||||
'hover:bg-muted flex items-center space-x-2 rounded-md border border-transparent px-4 py-2',
|
||||
{
|
||||
['border-border']: selected,
|
||||
['hover:bg-muted']: !selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('planId', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', '', {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${interval}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<FormField
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:planPickerLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((product) => {
|
||||
const plan = product.plans.find((item) => {
|
||||
if (item.paymentType === 'one-time') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.interval === selectedInterval;
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseLineItem = getBaseLineItem(
|
||||
props.config,
|
||||
plan.id,
|
||||
);
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={field.value === plan.id}
|
||||
key={plan.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', plan.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex w-full items-center justify-between'
|
||||
}
|
||||
>
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2'
|
||||
}
|
||||
>
|
||||
<span className="font-bold">
|
||||
<Trans
|
||||
i18nKey={`billing:products.${product.id}.name`}
|
||||
defaults={product.name}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans
|
||||
i18nKey={`billing:products.${product.id}.description`}
|
||||
defaults={product.description}
|
||||
/>
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex items-center space-x-4 text-right'
|
||||
}
|
||||
>
|
||||
<If
|
||||
condition={
|
||||
plan.trialPeriod && props.canStartTrial
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Badge variant={'success'}>
|
||||
<Trans
|
||||
i18nKey={`billing:trialPeriod`}
|
||||
values={{
|
||||
period: plan.trialPeriod,
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div>
|
||||
<Price key={plan.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
product.currency.toLowerCase(),
|
||||
baseLineItem.cost,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
<If
|
||||
condition={
|
||||
plan.paymentType === 'recurring'
|
||||
}
|
||||
fallback={
|
||||
<Trans i18nKey={`billing:lifetime`} />
|
||||
}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:perPeriod`}
|
||||
values={{
|
||||
period: selectedInterval,
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupItemLabel>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending ?? !form.formState.isValid}>
|
||||
{props.pending ? (
|
||||
t('processing')
|
||||
) : (
|
||||
<>
|
||||
<If
|
||||
condition={selectedPlan?.trialPeriod && props.canStartTrial}
|
||||
fallback={t(`proceedToPayment`)}
|
||||
>
|
||||
<span>{t(`startTrial`)}</span>
|
||||
</If>
|
||||
|
||||
<ArrowRight className={'ml-2 h-4 w-4'} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{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>[];
|
||||
paymentType: string;
|
||||
};
|
||||
}) {
|
||||
const isRecurring = selectedPlan.paymentType === 'recurring';
|
||||
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'fade-in animate-in zoom-in-90 flex w-full flex-col space-y-4 rounded-lg border p-4'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-0.5'}>
|
||||
<Heading level={5}>
|
||||
<b>
|
||||
<Trans
|
||||
i18nKey={`billing:products.${selectedProduct.id}.name`}
|
||||
defaults={selectedProduct.name}
|
||||
/>
|
||||
</b>{' '}
|
||||
<If condition={isRecurring}>
|
||||
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
|
||||
</If>
|
||||
</Heading>
|
||||
|
||||
<p>
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans
|
||||
i18nKey={`billing:products.${selectedProduct.id}.description`}
|
||||
defaults={selectedProduct.description}
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<span className={'font-semibold'}>
|
||||
<Trans i18nKey={'billing:detailsLabel'} />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={selectedPlan.lineItems ?? []}
|
||||
selectedInterval={isRecurring ? selectedInterval : undefined}
|
||||
currency={selectedProduct.currency}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'font-semibold'}>
|
||||
<Trans i18nKey={'billing:featuresLabel'} />
|
||||
</span>
|
||||
|
||||
{selectedProduct.features.map((item) => {
|
||||
return (
|
||||
<div key={item} className={'flex items-center space-x-2 text-sm'}>
|
||||
<CheckCircle className={'h-4 text-green-500'} />
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans i18nKey={`billing:features.${item}`} defaults={item} />
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Price(props: React.PropsWithChildren) {
|
||||
return (
|
||||
<span
|
||||
className={
|
||||
'animate-in slide-in-from-left-4 fade-in text-xl font-bold duration-500'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
326
packages/billing/gateway/src/components/pricing-table.tsx
Normal file
326
packages/billing/gateway/src/components/pricing-table.tsx
Normal file
@@ -0,0 +1,326 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { CheckCircle, Sparkles } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getBaseLineItem, getPlanIntervals } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
}
|
||||
|
||||
export function PricingTable({
|
||||
config,
|
||||
paths,
|
||||
CheckoutButtonRenderer,
|
||||
}: {
|
||||
config: BillingConfig;
|
||||
paths: Paths;
|
||||
|
||||
CheckoutButtonRenderer?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config).filter(Boolean) as string[];
|
||||
const [interval, setInterval] = useState(intervals[0]!);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
{intervals.length ? (
|
||||
<PlanIntervalSwitcher
|
||||
intervals={intervals}
|
||||
interval={interval}
|
||||
setInterval={setInterval}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-start space-y-6 lg:space-y-0' +
|
||||
' justify-center space-x-2 lg:flex-row'
|
||||
}
|
||||
>
|
||||
{config.products.map((product) => {
|
||||
const plan = product.plans.find((plan) => plan.interval === interval);
|
||||
|
||||
if (!plan) {
|
||||
console.warn(`No plan found for ${product.name}`);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const basePlan = getBaseLineItem(config, plan.id);
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={{ ...plan, interval }}
|
||||
baseLineItem={basePlan}
|
||||
product={product}
|
||||
paths={paths}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingItem(
|
||||
props: React.PropsWithChildren<{
|
||||
paths: {
|
||||
signUp: string;
|
||||
};
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
baseLineItem: {
|
||||
id: string;
|
||||
cost: number;
|
||||
};
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
interval: string;
|
||||
name?: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
CheckoutButton?: React.ComponentType<{
|
||||
planId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
|
||||
product: {
|
||||
name: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
highlighted?: boolean;
|
||||
features: string[];
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const highlighted = props.product.highlighted ?? false;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
`
|
||||
relative flex w-full flex-col justify-between space-y-6 rounded-lg
|
||||
border p-6 lg:w-4/12 xl:max-w-xs xl:p-8 2xl:w-3/12
|
||||
`,
|
||||
)}
|
||||
>
|
||||
<div className={'flex flex-col space-y-2.5'}>
|
||||
<div className={'flex items-center space-x-2.5'}>
|
||||
<Heading level={4}>
|
||||
<b className={'font-semibold'}>{props.product.name}</b>
|
||||
</Heading>
|
||||
|
||||
<If condition={props.product.badge}>
|
||||
<div
|
||||
className={cn(
|
||||
`flex space-x-1 rounded-md px-2 py-1 text-xs font-medium`,
|
||||
{
|
||||
['text-primary-foreground bg-primary']: highlighted,
|
||||
['text-muted-foreground bg-gray-50']: !highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<If condition={highlighted}>
|
||||
<Sparkles className={'mr-1 h-4 w-4'} />
|
||||
</If>
|
||||
|
||||
<span>{props.product.badge}</span>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-sm text-gray-500 dark:text-gray-400'}>
|
||||
{props.product.description}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className={'flex items-center space-x-1'}>
|
||||
<Price>
|
||||
{formatCurrency(props.product.currency, props.baseLineItem.cost)}
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
<span className={cn(`text-muted-foreground text-base lowercase`)}>
|
||||
<span>/</span>
|
||||
<span>{props.plan.interval}</span>
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<div className={'text-current'}>
|
||||
<FeaturesList features={props.product.features} />
|
||||
</div>
|
||||
|
||||
<If condition={props.selectable}>
|
||||
<If
|
||||
condition={props.plan.id && props.CheckoutButton}
|
||||
fallback={
|
||||
<DefaultCheckoutButton
|
||||
signUpPath={props.paths.signUp}
|
||||
highlighted={highlighted}
|
||||
plan={props.plan}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(CheckoutButton) => (
|
||||
<CheckoutButton highlighted={highlighted} planId={props.plan.id} />
|
||||
)}
|
||||
</If>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesList(
|
||||
props: React.PropsWithChildren<{
|
||||
features: string[];
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<ul className={'flex flex-col space-y-2'}>
|
||||
{props.features.map((feature) => {
|
||||
return (
|
||||
<ListItem key={feature}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.features.${feature}`}
|
||||
defaults={feature}
|
||||
/>
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({ children }: React.PropsWithChildren) {
|
||||
// little trick to re-animate the price when switching plans
|
||||
const key = Math.random();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={`animate-in slide-in-from-left-4 fade-in items-center duration-500`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'flex items-center text-2xl font-bold lg:text-3xl xl:text-4xl 2xl:text-5xl'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({ children }: React.PropsWithChildren) {
|
||||
return (
|
||||
<li className={'flex items-center space-x-3 font-medium'}>
|
||||
<div>
|
||||
<CheckCircle className={'h-5 text-green-500'} />
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground text-sm'}>{children}</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: string[];
|
||||
interval: string;
|
||||
setInterval: (interval: string) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex'}>
|
||||
{props.intervals.map((plan, index) => {
|
||||
const selected = plan === props.interval;
|
||||
|
||||
const className = cn('focus:!ring-0 !outline-none', {
|
||||
'rounded-r-none border-r-transparent': index === 0,
|
||||
'rounded-l-none': index === props.intervals.length - 1,
|
||||
['hover:bg-gray-50 dark:hover:bg-background/80']: !selected,
|
||||
['text-primary-800 dark:text-primary-500 font-semibold' +
|
||||
' hover:bg-background hover:text-initial']: selected,
|
||||
});
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
variant={'outline'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<If condition={selected}>
|
||||
<CheckCircle className={'h-4 text-green-500'} />
|
||||
</If>
|
||||
|
||||
<span className={'capitalize'}>
|
||||
<Trans
|
||||
i18nKey={`common:plans.interval.${plan}`}
|
||||
defaults={plan}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultCheckoutButton(
|
||||
props: React.PropsWithChildren<{
|
||||
plan: {
|
||||
id: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
};
|
||||
|
||||
signUpPath: string;
|
||||
highlighted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const linkHref =
|
||||
props.plan.href ?? `${props.signUpPath}?utm_source=${props.plan.id}` ?? '';
|
||||
|
||||
const label = props.plan.label ?? 'common:getStarted';
|
||||
|
||||
return (
|
||||
<div className={'bottom-0 left-0 w-full p-0'}>
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
<Button
|
||||
className={'w-full'}
|
||||
variant={props.highlighted ? 'default' : 'outline'}
|
||||
>
|
||||
<Trans i18nKey={label} defaults={label} />
|
||||
</Button>
|
||||
</Link>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
4
packages/billing/gateway/src/index.ts
Normal file
4
packages/billing/gateway/src/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './server/services/billing-gateway/billing-gateway.service';
|
||||
export * from './server/services/billing-gateway/billing-gateway-provider-factory';
|
||||
export * from './server/services/billing-event-handler/billing-gateway-provider-factory';
|
||||
export * from './server/services/billing-webhooks/billing-webhooks.service';
|
||||
@@ -0,0 +1,195 @@
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { BillingWebhookHandlerService } from '@kit/billing';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export class BillingEventHandlerService {
|
||||
private readonly namespace = 'billing';
|
||||
|
||||
constructor(
|
||||
private readonly clientProvider: () => SupabaseClient<Database>,
|
||||
private readonly strategy: BillingWebhookHandlerService,
|
||||
) {}
|
||||
|
||||
async handleWebhookEvent(request: Request) {
|
||||
const event = await this.strategy.verifyWebhookSignature(request);
|
||||
|
||||
if (!event) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
return this.strategy.handleWebhookEvent(event, {
|
||||
onSubscriptionDeleted: async (subscriptionId: string) => {
|
||||
const client = this.clientProvider();
|
||||
|
||||
// Handle the subscription deleted event
|
||||
// here we delete the subscription from the database
|
||||
Logger.info(
|
||||
{
|
||||
namespace: this.namespace,
|
||||
subscriptionId,
|
||||
},
|
||||
'Processing subscription deleted event',
|
||||
);
|
||||
|
||||
const { error } = await client
|
||||
.from('subscriptions')
|
||||
.delete()
|
||||
.match({ id: subscriptionId });
|
||||
|
||||
if (error) {
|
||||
throw new Error('Failed to delete subscription');
|
||||
}
|
||||
|
||||
Logger.info(
|
||||
{
|
||||
namespace: this.namespace,
|
||||
subscriptionId,
|
||||
},
|
||||
'Successfully deleted subscription',
|
||||
);
|
||||
},
|
||||
onSubscriptionUpdated: async (subscription) => {
|
||||
const client = this.clientProvider();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
subscriptionId: subscription.target_subscription_id,
|
||||
provider: subscription.billing_provider,
|
||||
accountId: subscription.target_account_id,
|
||||
customerId: subscription.target_customer_id,
|
||||
};
|
||||
|
||||
Logger.info(ctx, 'Processing subscription updated event');
|
||||
|
||||
// Handle the subscription updated event
|
||||
// here we update the subscription in the database
|
||||
const { error } = await client.rpc('upsert_subscription', subscription);
|
||||
|
||||
if (error) {
|
||||
Logger.error(
|
||||
{
|
||||
error,
|
||||
...ctx,
|
||||
},
|
||||
'Failed to update subscription',
|
||||
);
|
||||
|
||||
throw new Error('Failed to update subscription');
|
||||
}
|
||||
|
||||
Logger.info(ctx, 'Successfully updated subscription');
|
||||
},
|
||||
onCheckoutSessionCompleted: async (payload) => {
|
||||
// Handle the checkout session completed event
|
||||
// here we add the subscription to the database
|
||||
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 ('target_order_id' in payload) {
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
orderId: payload.target_order_id,
|
||||
provider: payload.billing_provider,
|
||||
accountId: payload.target_account_id,
|
||||
customerId: payload.target_customer_id,
|
||||
};
|
||||
|
||||
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: payload.target_customer_id,
|
||||
};
|
||||
|
||||
Logger.info(ctx, 'Processing checkout session completed event...');
|
||||
|
||||
const { error } = await client.rpc('upsert_subscription', payload);
|
||||
|
||||
if (error) {
|
||||
Logger.error({ ...ctx, error }, 'Failed to add subscription');
|
||||
|
||||
throw new Error('Failed to add subscription');
|
||||
}
|
||||
|
||||
Logger.info(ctx, 'Successfully added subscription');
|
||||
}
|
||||
},
|
||||
onPaymentSucceeded: async (sessionId: string) => {
|
||||
const client = this.clientProvider();
|
||||
|
||||
// Handle the payment succeeded event
|
||||
// here we update the payment status in the database
|
||||
Logger.info(
|
||||
{
|
||||
namespace: this.namespace,
|
||||
sessionId,
|
||||
},
|
||||
'Processing payment succeeded event',
|
||||
);
|
||||
|
||||
const { error } = await client
|
||||
.from('orders')
|
||||
.update({ status: 'succeeded' })
|
||||
.match({ session_id: sessionId });
|
||||
|
||||
if (error) {
|
||||
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',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingProviderSchema,
|
||||
BillingWebhookHandlerService,
|
||||
} from '@kit/billing';
|
||||
|
||||
export class BillingEventHandlerFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
): Promise<BillingWebhookHandlerService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
const { StripeWebhookHandlerService } = await import('@kit/stripe');
|
||||
|
||||
return new StripeWebhookHandlerService();
|
||||
}
|
||||
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
throw new Error('Lemon Squeezy is not supported yet');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported billing provider: ${provider as string}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { BillingEventHandlerService } from './billing-event-handler.service';
|
||||
import { BillingEventHandlerFactoryService } from './billing-gateway-factory.service';
|
||||
|
||||
/**
|
||||
* @description This function retrieves the billing provider from the database and returns a
|
||||
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
|
||||
* defined in the host application.
|
||||
*/
|
||||
export async function getBillingEventHandlerService(
|
||||
clientProvider: () => ReturnType<typeof getSupabaseServerActionClient>,
|
||||
provider: Database['public']['Enums']['billing_provider'],
|
||||
) {
|
||||
const strategy =
|
||||
await BillingEventHandlerFactoryService.GetProviderStrategy(provider);
|
||||
|
||||
return new BillingEventHandlerService(clientProvider, strategy);
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingProviderSchema,
|
||||
BillingStrategyProviderService,
|
||||
} from '@kit/billing';
|
||||
|
||||
export class BillingGatewayFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
): Promise<BillingStrategyProviderService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
const { StripeBillingStrategyService } = await import('@kit/stripe');
|
||||
|
||||
return new StripeBillingStrategyService();
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
const { LemonSqueezyBillingStrategyService } = await import(
|
||||
'@kit/lemon-squeezy'
|
||||
);
|
||||
|
||||
return new LemonSqueezyBillingStrategyService();
|
||||
}
|
||||
|
||||
case 'paddle': {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unsupported billing provider: ${provider as string}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import { BillingGatewayService } from './billing-gateway.service';
|
||||
|
||||
/**
|
||||
* @description This function retrieves the billing provider from the database and returns a
|
||||
* new instance of the `BillingGatewayService` class. This class is used to interact with the server actions
|
||||
* defined in the host application.
|
||||
*/
|
||||
export async function getBillingGatewayProvider(
|
||||
client: ReturnType<typeof getSupabaseServerActionClient>,
|
||||
) {
|
||||
const provider = await getBillingProvider(client);
|
||||
|
||||
return new BillingGatewayService(provider);
|
||||
}
|
||||
|
||||
async function getBillingProvider(
|
||||
client: ReturnType<typeof getSupabaseServerActionClient>,
|
||||
) {
|
||||
const { data, error } = await client
|
||||
.from('config')
|
||||
.select('billing_provider')
|
||||
.single();
|
||||
|
||||
if (error ?? !data.billing_provider) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return data.billing_provider;
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProviderSchema } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { BillingGatewayFactoryService } from './billing-gateway-factory.service';
|
||||
|
||||
/**
|
||||
* @description The billing gateway service to interact with the billing provider of choice (e.g. Stripe)
|
||||
* @class BillingGatewayService
|
||||
* @param {BillingProvider} provider - The billing provider to use
|
||||
* @example
|
||||
*
|
||||
* const provider = 'stripe';
|
||||
* const billingGatewayService = new BillingGatewayService(provider);
|
||||
*/
|
||||
export class BillingGatewayService {
|
||||
constructor(
|
||||
private readonly provider: z.infer<typeof BillingProviderSchema>,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Creates a checkout session for billing.
|
||||
*
|
||||
* @param {CreateBillingCheckoutSchema} params - The parameters for creating the checkout session.
|
||||
*
|
||||
*/
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CreateBillingCheckoutSchema.parse(params);
|
||||
|
||||
return strategy.createCheckoutSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves the checkout session from the specified provider.
|
||||
*
|
||||
* @param {RetrieveCheckoutSessionSchema} params - The parameters to retrieve the checkout session.
|
||||
*/
|
||||
async retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = RetrieveCheckoutSessionSchema.parse(params);
|
||||
|
||||
return strategy.retrieveCheckoutSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a billing portal session for the specified parameters.
|
||||
*
|
||||
* @param {CreateBillingPortalSessionSchema} params - The parameters to create the billing portal session.
|
||||
*/
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CreateBillingPortalSessionSchema.parse(params);
|
||||
|
||||
return strategy.createBillingPortalSession(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancels a subscription.
|
||||
*
|
||||
* @param {CancelSubscriptionParamsSchema} params - The parameters for cancelling the subscription.
|
||||
*/
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const strategy = await BillingGatewayFactoryService.GetProviderStrategy(
|
||||
this.provider,
|
||||
);
|
||||
|
||||
const payload = CancelSubscriptionParamsSchema.parse(params);
|
||||
|
||||
return strategy.cancelSubscription(payload);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { BillingGatewayService } from '../billing-gateway/billing-gateway.service';
|
||||
|
||||
type Subscription = Database['public']['Tables']['subscriptions']['Row'];
|
||||
|
||||
export class BillingWebhooksService {
|
||||
async handleSubscriptionDeletedWebhook(subscription: Subscription) {
|
||||
const gateway = new BillingGatewayService(subscription.billing_provider);
|
||||
|
||||
await gateway.cancelSubscription({
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
8
packages/billing/gateway/tsconfig.json
Normal file
8
packages/billing/gateway/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
50
packages/billing/lemon-squeezy/package.json
Normal file
50
packages/billing/lemon-squeezy/package.json
Normal file
@@ -0,0 +1,50 @@
|
||||
{
|
||||
"name": "@kit/lemon-squeezy",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lemonsqueezy/lemonsqueezy.js": "2.2.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:^",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:^",
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/billing/lemon-squeezy/src/index.ts
Normal file
1
packages/billing/lemon-squeezy/src/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './services/lemon-squeezy-billing-strategy.service';
|
||||
@@ -0,0 +1,14 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const getLemonSqueezyEnv = () =>
|
||||
z
|
||||
.object({
|
||||
secretKey: z.string().min(1),
|
||||
webhooksSecret: z.string().min(1),
|
||||
storeId: z.number().positive(),
|
||||
})
|
||||
.parse({
|
||||
secretKey: process.env.LEMON_SQUEEZY_SECRET_KEY,
|
||||
webhooksSecret: process.env.LEMON_SQUEEZY_WEBHOOK_SECRET,
|
||||
storeId: process.env.LEMON_SQUEEZY_STORE_ID,
|
||||
});
|
||||
@@ -0,0 +1,27 @@
|
||||
import { getCustomer } from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
|
||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
|
||||
/**
|
||||
* Creates a LemonSqueezy billing portal session for the given parameters.
|
||||
*
|
||||
* @param {object} params - The parameters required to create the billing portal session.
|
||||
* @return {Promise<string>} - A promise that resolves to the URL of the customer portal.
|
||||
* @throws {Error} - If no customer is found with the given customerId.
|
||||
*/
|
||||
export async function createLemonSqueezyBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
const customer = await getCustomer(params.customerId);
|
||||
|
||||
if (!customer?.data) {
|
||||
throw new Error('No customer found');
|
||||
}
|
||||
|
||||
return customer.data.data.attributes.urls.customer_portal;
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import {
|
||||
NewCheckout,
|
||||
createCheckout,
|
||||
getCustomer,
|
||||
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
|
||||
|
||||
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
||||
import { initializeLemonSqueezyClient } from './lemon-squeezy-sdk';
|
||||
|
||||
/**
|
||||
* Creates a checkout for a Lemon Squeezy product.
|
||||
*
|
||||
* @param {object} params - The parameters for creating the checkout.
|
||||
* @return {Promise<object>} - A promise that resolves to the created Lemon Squeezy checkout.
|
||||
* @throws {Error} - If no line items are found in the subscription.
|
||||
*/
|
||||
export async function createLemonSqueezyCheckout(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
await initializeLemonSqueezyClient();
|
||||
|
||||
const lineItem = params.plan.lineItems[0];
|
||||
|
||||
if (!lineItem) {
|
||||
throw new Error('No line items found in subscription');
|
||||
}
|
||||
|
||||
const env = getLemonSqueezyEnv();
|
||||
const storeId = env.storeId;
|
||||
const variantId = lineItem.id;
|
||||
|
||||
const urls = getUrls({
|
||||
returnUrl: params.returnUrl,
|
||||
});
|
||||
|
||||
const customer = params.customerId
|
||||
? await getCustomer(params.customerId)
|
||||
: null;
|
||||
|
||||
let customerEmail = params.customerEmail;
|
||||
|
||||
// if we can find an existing customer using the ID,
|
||||
// we use the email from the customer object so that we can
|
||||
// link the previous subscription to this one
|
||||
// otherwise it will create a new customer if another email is provided (ex. a different team member)
|
||||
if (customer?.data) {
|
||||
customerEmail = customer.data.data.attributes.email;
|
||||
}
|
||||
|
||||
const newCheckout: NewCheckout = {
|
||||
checkoutOptions: {
|
||||
embed: true,
|
||||
media: true,
|
||||
logo: true,
|
||||
},
|
||||
checkoutData: {
|
||||
email: customerEmail,
|
||||
custom: {
|
||||
account_id: params.accountId,
|
||||
},
|
||||
},
|
||||
productOptions: {
|
||||
redirectUrl: urls.return_url,
|
||||
},
|
||||
expiresAt: null,
|
||||
preview: true,
|
||||
testMode: process.env.NODE_ENV !== 'production',
|
||||
};
|
||||
|
||||
return createCheckout(storeId, variantId, newCheckout);
|
||||
}
|
||||
|
||||
function getUrls(params: { returnUrl: string }) {
|
||||
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
return {
|
||||
return_url: returnUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import {
|
||||
cancelSubscription,
|
||||
createUsageRecord,
|
||||
getCheckout,
|
||||
} from '@lemonsqueezy/lemonsqueezy.js';
|
||||
import 'server-only';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { createLemonSqueezyBillingPortalSession } from './create-lemon-squeezy-billing-portal-session';
|
||||
import { createLemonSqueezyCheckout } from './create-lemon-squeezy-checkout';
|
||||
|
||||
export class LemonSqueezyBillingStrategyService
|
||||
implements BillingStrategyProviderService
|
||||
{
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const { data: response } = await createLemonSqueezyCheckout(params);
|
||||
|
||||
if (!response?.data.id) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
return { checkoutToken: response.data.id };
|
||||
}
|
||||
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const url = await createLemonSqueezyBillingPortalSession(params);
|
||||
|
||||
if (!url) {
|
||||
throw new Error('Failed to create billing portal session');
|
||||
}
|
||||
|
||||
return { url };
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
await cancelSubscription(params.subscriptionId);
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const session = await getCheckout(params.sessionId);
|
||||
|
||||
if (!session.data) {
|
||||
throw new Error('Failed to retrieve checkout session');
|
||||
}
|
||||
|
||||
const data = session.data.data;
|
||||
|
||||
return {
|
||||
checkoutToken: data.id,
|
||||
isSessionOpen: false,
|
||||
status: 'complete' as const,
|
||||
customer: {
|
||||
email: data.attributes.checkout_data.email,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const { error } = await createUsageRecord({
|
||||
quantity: params.usage.quantity,
|
||||
subscriptionItemId: params.subscriptionId,
|
||||
});
|
||||
|
||||
if (error) {
|
||||
throw new Error('Failed to report usage');
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import 'server-only';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
|
||||
import { getLemonSqueezyEnv } from '../schema/lemon-squeezy-server-env.schema';
|
||||
|
||||
/**
|
||||
* @description Initialize the Lemon Squeezy client
|
||||
*/
|
||||
export async function initializeLemonSqueezyClient() {
|
||||
const { lemonSqueezySetup } = await import('@lemonsqueezy/lemonsqueezy.js');
|
||||
const env = getLemonSqueezyEnv();
|
||||
|
||||
lemonSqueezySetup({
|
||||
apiKey: env.secretKey,
|
||||
onError(error) {
|
||||
Logger.error(
|
||||
{
|
||||
name: `billing.lemon-squeezy`,
|
||||
error: error.message,
|
||||
},
|
||||
'Error in Lemon Squeezy SDK',
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
8
packages/billing/lemon-squeezy/tsconfig.json
Normal file
8
packages/billing/lemon-squeezy/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
52
packages/billing/stripe/package.json
Normal file
52
packages/billing/stripe/package.json
Normal file
@@ -0,0 +1,52 @@
|
||||
{
|
||||
"name": "@kit/stripe",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"scripts": {
|
||||
"clean": "git clean -xdf .turbo node_modules",
|
||||
"format": "prettier --check \"**/*.{ts,tsx}\"",
|
||||
"lint": "eslint .",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/shared": "0.1.0",
|
||||
"@kit/supabase": "0.1.0",
|
||||
"@kit/ui": "0.1.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^3.1.0",
|
||||
"stripe": "^14.22.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:^",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:^",
|
||||
"@kit/supabase": "workspace:^",
|
||||
"@kit/tailwind-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:^"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/billing/stripe/src/components/index.ts
Normal file
1
packages/billing/stripe/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './stripe-embedded-checkout';
|
||||
@@ -0,0 +1,75 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import {
|
||||
EmbeddedCheckout,
|
||||
EmbeddedCheckoutProvider,
|
||||
} from '@stripe/react-stripe-js';
|
||||
import { loadStripe } from '@stripe/stripe-js';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@kit/ui/dialog';
|
||||
|
||||
import { StripeClientEnvSchema } from '../schema/stripe-client-env.schema';
|
||||
|
||||
const { publishableKey } = StripeClientEnvSchema.parse({
|
||||
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
|
||||
});
|
||||
|
||||
const stripePromise = loadStripe(publishableKey);
|
||||
|
||||
export function StripeCheckout({
|
||||
checkoutToken,
|
||||
onClose,
|
||||
}: React.PropsWithChildren<{
|
||||
checkoutToken: string;
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
return (
|
||||
<EmbeddedCheckoutPopup key={checkoutToken} onClose={onClose}>
|
||||
<EmbeddedCheckoutProvider
|
||||
stripe={stripePromise}
|
||||
options={{ clientSecret: checkoutToken }}
|
||||
>
|
||||
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
|
||||
</EmbeddedCheckoutProvider>
|
||||
</EmbeddedCheckoutPopup>
|
||||
);
|
||||
}
|
||||
|
||||
function EmbeddedCheckoutPopup({
|
||||
onClose,
|
||||
children,
|
||||
}: React.PropsWithChildren<{
|
||||
onClose?: () => void;
|
||||
}>) {
|
||||
const [open, setOpen] = useState(true);
|
||||
const className = `bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border`;
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
defaultOpen
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogContent
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
packages/billing/stripe/src/index.ts
Normal file
2
packages/billing/stripe/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { StripeBillingStrategyService } from './services/stripe-billing-strategy.service';
|
||||
export { StripeWebhookHandlerService } from './services/stripe-webhook-handler.service';
|
||||
@@ -0,0 +1,15 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeClientEnvSchema = z
|
||||
.object({
|
||||
publishableKey: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.publishableKey.startsWith('pk_');
|
||||
},
|
||||
{
|
||||
path: ['publishableKey'],
|
||||
message: `Stripe publishable key must start with 'pk_'`,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,25 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const StripeServerEnvSchema = z
|
||||
.object({
|
||||
secretKey: z.string().min(1),
|
||||
webhooksSecret: z.string().min(1),
|
||||
})
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.secretKey.startsWith('sk_');
|
||||
},
|
||||
{
|
||||
path: ['STRIPE_SECRET_KEY'],
|
||||
message: `Stripe secret key must start with 'sk_'`,
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
return schema.webhooksSecret.startsWith('whsec_');
|
||||
},
|
||||
{
|
||||
path: ['STRIPE_WEBHOOK_SECRET'],
|
||||
message: `Stripe webhook secret must start with 'whsec_'`,
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,18 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
|
||||
/**
|
||||
* @name createStripeBillingPortalSession
|
||||
* @description Create a Stripe billing portal session for a user
|
||||
*/
|
||||
export async function createStripeBillingPortalSession(
|
||||
stripe: Stripe,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
return stripe.billingPortal.sessions.create({
|
||||
customer: params.customerId,
|
||||
return_url: params.returnUrl,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
|
||||
|
||||
/**
|
||||
* @name createStripeCheckout
|
||||
* @description Creates a Stripe Checkout session, and returns an Object
|
||||
* containing the session, which you can use to redirect the user to the
|
||||
* checkout page
|
||||
*/
|
||||
export async function createStripeCheckout(
|
||||
stripe: Stripe,
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
// in MakerKit, a subscription belongs to an organization,
|
||||
// rather than to a user
|
||||
// if you wish to change it, use the current user ID instead
|
||||
const clientReferenceId = params.accountId;
|
||||
|
||||
// we pass an optional customer ID, so we do not duplicate the Stripe
|
||||
// customers if an organization subscribes multiple times
|
||||
const customer = params.customerId ?? undefined;
|
||||
|
||||
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
|
||||
const mode: Stripe.Checkout.SessionCreateParams.Mode =
|
||||
params.plan.paymentType === 'recurring' ? 'subscription' : 'payment';
|
||||
|
||||
// this should only be set if the mode is 'subscription'
|
||||
const subscriptionData:
|
||||
| Stripe.Checkout.SessionCreateParams.SubscriptionData
|
||||
| undefined =
|
||||
mode === 'subscription'
|
||||
? {
|
||||
trial_period_days: params.trialDays,
|
||||
metadata: {
|
||||
accountId: params.accountId,
|
||||
},
|
||||
}
|
||||
: undefined;
|
||||
|
||||
const urls = getUrls({
|
||||
returnUrl: params.returnUrl,
|
||||
});
|
||||
|
||||
// we use the embedded mode, so the user does not leave the page
|
||||
const uiMode = 'embedded';
|
||||
|
||||
const customerData = customer
|
||||
? {
|
||||
customer,
|
||||
}
|
||||
: {
|
||||
customer_email: params.customerEmail,
|
||||
};
|
||||
|
||||
const lineItems = params.plan.lineItems.map((item) => {
|
||||
if (item.type === 'metered') {
|
||||
return {
|
||||
price: item.id,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
price: item.id,
|
||||
quantity: 1,
|
||||
};
|
||||
});
|
||||
|
||||
return stripe.checkout.sessions.create({
|
||||
mode,
|
||||
ui_mode: uiMode,
|
||||
line_items: lineItems,
|
||||
client_reference_id: clientReferenceId,
|
||||
subscription_data: subscriptionData,
|
||||
customer_creation: 'always',
|
||||
...customerData,
|
||||
...urls,
|
||||
});
|
||||
}
|
||||
|
||||
function getUrls(params: { returnUrl: string }) {
|
||||
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
return {
|
||||
return_url: returnUrl,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import 'server-only';
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
|
||||
import { createStripeCheckout } from './create-stripe-checkout';
|
||||
import { createStripeClient } from './stripe-sdk';
|
||||
|
||||
export class StripeBillingStrategyService
|
||||
implements BillingStrategyProviderService
|
||||
{
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
const { client_secret } = await createStripeCheckout(stripe, params);
|
||||
|
||||
if (!client_secret) {
|
||||
throw new Error('Failed to create checkout session');
|
||||
}
|
||||
|
||||
return { checkoutToken: client_secret };
|
||||
}
|
||||
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
return createStripeBillingPortalSession(stripe, params);
|
||||
}
|
||||
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
await stripe.subscriptions.cancel(params.subscriptionId, {
|
||||
invoice_now: params.invoiceNow ?? true,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
async retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
const session = await stripe.checkout.sessions.retrieve(params.sessionId);
|
||||
const isSessionOpen = session.status === 'open';
|
||||
|
||||
return {
|
||||
checkoutToken: session.client_secret,
|
||||
isSessionOpen,
|
||||
status: session.status ?? 'complete',
|
||||
customer: {
|
||||
email: session.customer_details?.email ?? null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
await stripe.subscriptionItems.createUsageRecord(params.subscriptionId, {
|
||||
quantity: params.usage.quantity,
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
private async stripeProvider(): Promise<Stripe> {
|
||||
return createStripeClient();
|
||||
}
|
||||
}
|
||||
22
packages/billing/stripe/src/services/stripe-sdk.ts
Normal file
22
packages/billing/stripe/src/services/stripe-sdk.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import 'server-only';
|
||||
|
||||
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
|
||||
|
||||
const STRIPE_API_VERSION = '2023-10-16';
|
||||
|
||||
/**
|
||||
* @description returns a Stripe instance
|
||||
*/
|
||||
export async function createStripeClient() {
|
||||
const { default: Stripe } = await import('stripe');
|
||||
|
||||
// Parse the environment variables and validate them
|
||||
const stripeServerEnv = StripeServerEnvSchema.parse({
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
return new Stripe(stripeServerEnv.secretKey, {
|
||||
apiVersion: STRIPE_API_VERSION,
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
import Stripe from 'stripe';
|
||||
|
||||
import { BillingWebhookHandlerService } from '@kit/billing';
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
|
||||
import { createStripeClient } from './stripe-sdk';
|
||||
|
||||
type UpsertSubscriptionParams =
|
||||
Database['public']['Functions']['upsert_subscription']['Args'];
|
||||
|
||||
type UpsertOrderParams =
|
||||
Database['public']['Functions']['upsert_order']['Args'];
|
||||
|
||||
export class StripeWebhookHandlerService
|
||||
implements BillingWebhookHandlerService
|
||||
{
|
||||
private stripe: Stripe | undefined;
|
||||
|
||||
private readonly provider: Database['public']['Enums']['billing_provider'] =
|
||||
'stripe';
|
||||
|
||||
private readonly namespace = 'billing.stripe';
|
||||
|
||||
/**
|
||||
* @description Verifies the webhook signature - should throw an error if the signature is invalid
|
||||
*/
|
||||
async verifyWebhookSignature(request: Request) {
|
||||
const body = await request.clone().text();
|
||||
const signatureKey = `stripe-signature`;
|
||||
const signature = request.headers.get(signatureKey)!;
|
||||
|
||||
const { webhooksSecret } = StripeServerEnvSchema.parse({
|
||||
secretKey: process.env.STRIPE_SECRET_KEY,
|
||||
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
|
||||
});
|
||||
|
||||
const stripe = await this.loadStripe();
|
||||
|
||||
const event = stripe.webhooks.constructEvent(
|
||||
body,
|
||||
signature,
|
||||
webhooksSecret,
|
||||
);
|
||||
|
||||
if (!event) {
|
||||
throw new Error('Invalid signature');
|
||||
}
|
||||
|
||||
return event;
|
||||
}
|
||||
|
||||
private async loadStripe() {
|
||||
if (!this.stripe) {
|
||||
this.stripe = await createStripeClient();
|
||||
}
|
||||
|
||||
return this.stripe;
|
||||
}
|
||||
|
||||
async handleWebhookEvent(
|
||||
event: Stripe.Event,
|
||||
params: {
|
||||
onCheckoutSessionCompleted: (
|
||||
data: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>;
|
||||
onSubscriptionUpdated: (
|
||||
data: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||
},
|
||||
) {
|
||||
switch (event.type) {
|
||||
case 'checkout.session.completed': {
|
||||
return this.handleCheckoutSessionCompleted(
|
||||
event,
|
||||
params.onCheckoutSessionCompleted,
|
||||
);
|
||||
}
|
||||
|
||||
case 'customer.subscription.updated': {
|
||||
return this.handleSubscriptionUpdatedEvent(
|
||||
event,
|
||||
params.onCheckoutSessionCompleted,
|
||||
);
|
||||
}
|
||||
|
||||
case 'customer.subscription.deleted': {
|
||||
return this.handleSubscriptionDeletedEvent(
|
||||
event,
|
||||
params.onSubscriptionDeleted,
|
||||
);
|
||||
}
|
||||
|
||||
case 'checkout.session.async_payment_failed': {
|
||||
return this.handleAsyncPaymentFailed(event, params.onPaymentFailed);
|
||||
}
|
||||
|
||||
case 'checkout.session.async_payment_succeeded': {
|
||||
return this.handleAsyncPaymentSucceeded(
|
||||
event,
|
||||
params.onPaymentSucceeded,
|
||||
);
|
||||
}
|
||||
|
||||
default: {
|
||||
Logger.info(
|
||||
{
|
||||
eventType: event.type,
|
||||
name: this.namespace,
|
||||
},
|
||||
`Unhandled Stripe event type: ${event.type}`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async handleCheckoutSessionCompleted(
|
||||
event: Stripe.CheckoutSessionCompletedEvent,
|
||||
onCheckoutCompletedCallback: (
|
||||
data: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const stripe = await this.loadStripe();
|
||||
|
||||
const session = event.data.object;
|
||||
const isSubscription = session.mode === 'subscription';
|
||||
|
||||
const accountId = session.client_reference_id!;
|
||||
const customerId = session.customer as string;
|
||||
|
||||
if (isSubscription) {
|
||||
const subscriptionId = session.subscription as string;
|
||||
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
|
||||
|
||||
const payload = this.buildSubscriptionPayload({
|
||||
accountId,
|
||||
customerId,
|
||||
id: subscription.id,
|
||||
lineItems: subscription.items.data,
|
||||
status: subscription.status,
|
||||
currency: subscription.currency,
|
||||
periodStartsAt: subscription.current_period_start,
|
||||
periodEndsAt: subscription.current_period_end,
|
||||
cancelAtPeriodEnd: subscription.cancel_at_period_end,
|
||||
trialStartsAt: subscription.trial_start,
|
||||
trialEndsAt: subscription.trial_end,
|
||||
});
|
||||
|
||||
return onCheckoutCompletedCallback(payload);
|
||||
} 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,
|
||||
target_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 handleAsyncPaymentFailed(
|
||||
event: Stripe.CheckoutSessionAsyncPaymentFailedEvent,
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>,
|
||||
) {
|
||||
const sessionId = event.data.object.id;
|
||||
|
||||
return onPaymentFailed(sessionId);
|
||||
}
|
||||
|
||||
private handleAsyncPaymentSucceeded(
|
||||
event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent,
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>,
|
||||
) {
|
||||
const sessionId = event.data.object.id;
|
||||
|
||||
return onPaymentSucceeded(sessionId);
|
||||
}
|
||||
|
||||
private handleSubscriptionUpdatedEvent(
|
||||
event: Stripe.CustomerSubscriptionUpdatedEvent,
|
||||
onSubscriptionUpdatedCallback: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>,
|
||||
) {
|
||||
const subscription = event.data.object;
|
||||
const subscriptionId = subscription.id;
|
||||
const accountId = subscription.metadata.account_id as string;
|
||||
|
||||
const payload = this.buildSubscriptionPayload({
|
||||
customerId: subscription.customer as string,
|
||||
id: subscriptionId,
|
||||
accountId,
|
||||
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);
|
||||
}
|
||||
|
||||
private handleSubscriptionDeletedEvent(
|
||||
subscription: Stripe.CustomerSubscriptionDeletedEvent,
|
||||
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
|
||||
) {
|
||||
// Here we don't need to do anything, so we just return the callback
|
||||
|
||||
return onSubscriptionDeletedCallback(subscription.id);
|
||||
}
|
||||
|
||||
private buildSubscriptionPayload<
|
||||
LineItem extends {
|
||||
id: string;
|
||||
quantity?: number;
|
||||
price?: Stripe.Price;
|
||||
},
|
||||
>(params: {
|
||||
id: string;
|
||||
accountId: string;
|
||||
customerId: string;
|
||||
lineItems: LineItem[];
|
||||
status: Stripe.Subscription.Status;
|
||||
currency: string;
|
||||
cancelAtPeriodEnd: boolean;
|
||||
periodStartsAt: number;
|
||||
periodEndsAt: number;
|
||||
trialStartsAt: number | null;
|
||||
trialEndsAt: number | null;
|
||||
}): UpsertSubscriptionParams {
|
||||
const active = params.status === 'active' || params.status === 'trialing';
|
||||
|
||||
const lineItems = params.lineItems.map((item) => {
|
||||
const quantity = item.quantity ?? 1;
|
||||
|
||||
return {
|
||||
id: item.id,
|
||||
quantity,
|
||||
subscription_id: params.id,
|
||||
product_id: item.price?.product as string,
|
||||
variant_id: item.price?.id,
|
||||
price_amount: item.price?.unit_amount,
|
||||
interval: item.price?.recurring?.interval as string,
|
||||
interval_count: item.price?.recurring?.interval_count as number,
|
||||
};
|
||||
});
|
||||
|
||||
// otherwise we are updating a subscription
|
||||
// and we only need to return the update payload
|
||||
return {
|
||||
target_subscription_id: params.id,
|
||||
target_account_id: params.accountId,
|
||||
target_customer_id: params.customerId,
|
||||
billing_provider: this.provider,
|
||||
status: params.status,
|
||||
line_items: lineItems,
|
||||
active,
|
||||
currency: params.currency,
|
||||
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
|
||||
period_starts_at: getISOString(params.periodStartsAt) as string,
|
||||
period_ends_at: getISOString(params.periodEndsAt) as string,
|
||||
trial_starts_at: getISOString(params.trialStartsAt),
|
||||
trial_ends_at: getISOString(params.trialEndsAt),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getISOString(date: number | null) {
|
||||
return date ? new Date(date * 1000).toISOString() : undefined;
|
||||
}
|
||||
8
packages/billing/stripe/tsconfig.json
Normal file
8
packages/billing/stripe/tsconfig.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
Reference in New Issue
Block a user