Implement new billing-gateway and update related services
Created a new package named billing-gateway which implements interfaces for different billing providers and provides a centralized service for payments. This will potentially help to maintain cleaner code by reducing direct dependencies on specific payment providers in the core application code. Additionally, made adjustments in existing services, like Stripe, to comply with this change. The relevant interfaces and types have been exported and imported accordingly.
This commit is contained in:
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.
|
||||
48
packages/billing-gateway/package.json
Normal file
48
packages/billing-gateway/package.json
Normal file
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"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": {
|
||||
"react": "^18.2.0",
|
||||
"react-dom": "^18.2.0",
|
||||
"zod": "^3.22.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"@kit/ui": "0.1.0",
|
||||
"@kit/stripe": "0.1.0",
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/supabase": "^0.1.0",
|
||||
"lucide-react": "^0.361.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
"@kit/eslint-config": "0.2.0",
|
||||
"@kit/tailwind-config": "0.1.0",
|
||||
"@kit/tsconfig": "0.1.0"
|
||||
},
|
||||
"eslintConfig": {
|
||||
"root": true,
|
||||
"extends": [
|
||||
"@kit/eslint-config/base",
|
||||
"@kit/eslint-config/react"
|
||||
]
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider, BillingStrategyProviderService } from '@kit/billing';
|
||||
|
||||
export class BillingGatewayFactoryService {
|
||||
static async GetProviderStrategy(
|
||||
provider: z.infer<typeof BillingProvider>,
|
||||
): Promise<BillingStrategyProviderService> {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
const { StripeBillingStrategyService } = await import('@kit/stripe');
|
||||
|
||||
return new StripeBillingStrategyService();
|
||||
}
|
||||
|
||||
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}`);
|
||||
}
|
||||
}
|
||||
}
|
||||
93
packages/billing-gateway/src/billing-gateway-service.ts
Normal file
93
packages/billing-gateway/src/billing-gateway-service.ts
Normal file
@@ -0,0 +1,93 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingProvider } 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 BillingProvider>) {}
|
||||
|
||||
/**
|
||||
* 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 @@
|
||||
export function CurrentPlanCard(props: React.PropsWithChildren<{}>) {}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { lazy } from 'react';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
type BillingProvider = Database['public']['Enums']['billing_provider'];
|
||||
|
||||
export function EmbeddedCheckout(
|
||||
props: React.PropsWithChildren<{
|
||||
checkoutToken: string;
|
||||
provider: BillingProvider;
|
||||
}>,
|
||||
) {
|
||||
const CheckoutComponent = loadCheckoutComponent(props.provider);
|
||||
|
||||
return <CheckoutComponent checkoutToken={props.checkoutToken} />;
|
||||
}
|
||||
|
||||
function loadCheckoutComponent(provider: BillingProvider) {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
return lazy(() => {
|
||||
return import('@kit/stripe/components').then((c) => ({
|
||||
default: c.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}`);
|
||||
}
|
||||
}
|
||||
3
packages/billing-gateway/src/components/index.ts
Normal file
3
packages/billing-gateway/src/components/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './plan-picker';
|
||||
export * from './current-plan-card';
|
||||
export * from './embedded-checkout';
|
||||
222
packages/billing-gateway/src/components/plan-picker.tsx
Normal file
222
packages/billing-gateway/src/components/plan-picker.tsx
Normal file
@@ -0,0 +1,222 @@
|
||||
'use client';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRightIcon } from 'lucide-react';
|
||||
import { useForm } from 'react-hook-form';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { BillingSchema } from '@kit/billing';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: z.infer<typeof BillingSchema>;
|
||||
onSubmit: (data: { planId: string }) => void;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const intervals = props.config.products.reduce<string[]>((acc, item) => {
|
||||
return Array.from(
|
||||
new Set([...acc, ...item.plans.map((plan) => plan.interval)]),
|
||||
);
|
||||
}, []);
|
||||
|
||||
const form = useForm({
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
interval: z.string(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const planFound = props.config.products
|
||||
.flatMap((item) => item.plans)
|
||||
.some((plan) => plan.id === data.planId);
|
||||
|
||||
if (!planFound) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return intervals.includes(data.interval);
|
||||
},
|
||||
{ message: 'Invalid plan', path: ['planId'] },
|
||||
),
|
||||
),
|
||||
defaultValues: {
|
||||
interval: intervals[0],
|
||||
planId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedInterval = form.watch('interval');
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<form
|
||||
className={'flex flex-col space-y-4'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<FormField
|
||||
name={'interval'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'rounded-md border p-4'}>
|
||||
<FormLabel>Choose your billing interval</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name} value={field.value}>
|
||||
{intervals.map((interval) => {
|
||||
return (
|
||||
<div
|
||||
key={interval}
|
||||
className={'flex items-center space-x-2'}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('interval', interval);
|
||||
}}
|
||||
/>
|
||||
|
||||
<span className={'text-sm font-bold'}>
|
||||
<Trans
|
||||
i18nKey={`common.billingInterval.${interval}`}
|
||||
defaults={interval}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Pick your preferred plan</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup name={field.name}>
|
||||
{props.config.products.map((item) => {
|
||||
const variant = item.plans.find(
|
||||
(plan) => plan.interval === selectedInterval,
|
||||
);
|
||||
|
||||
if (!variant) {
|
||||
throw new Error('No plan found');
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel key={variant.id}>
|
||||
<RadioGroupItem
|
||||
id={variant.id}
|
||||
value={variant.id}
|
||||
onClick={() => {
|
||||
form.setValue('planId', variant.id);
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={'flex w-full items-center justify-between'}
|
||||
>
|
||||
<Label
|
||||
htmlFor={variant.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-1.5'
|
||||
}
|
||||
>
|
||||
<span className="font-bold">{item.name}</span>
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
{item.description}
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div className={'text-right'}>
|
||||
<div>
|
||||
<Price key={variant.id}>
|
||||
<span>
|
||||
{formatCurrency(
|
||||
item.currency.toLowerCase(),
|
||||
variant.price,
|
||||
)}
|
||||
</span>
|
||||
</Price>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
per {variant.interval}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupItemLabel>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button disabled={props.pending}>
|
||||
{props.pending ? (
|
||||
'Processing...'
|
||||
) : (
|
||||
<>
|
||||
<span>Proceed to payment</span>
|
||||
<ArrowRightIcon className={'ml-2 h-4 w-4'} />
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
</div>
|
||||
</form>
|
||||
</Form>
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
);
|
||||
}
|
||||
|
||||
function formatCurrency(currencyCode: string, value: string) {
|
||||
return new Intl.NumberFormat('en-US', {
|
||||
style: 'currency',
|
||||
currency: currencyCode,
|
||||
}).format(value);
|
||||
}
|
||||
33
packages/billing-gateway/src/gateway-provider-factory.ts
Normal file
33
packages/billing-gateway/src/gateway-provider-factory.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
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.
|
||||
* @param {ReturnType<typeof getSupabaseServerActionClient>} client - The Supabase server action client.
|
||||
*
|
||||
*/
|
||||
export async function getGatewayProvider(
|
||||
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;
|
||||
}
|
||||
2
packages/billing-gateway/src/index.ts
Normal file
2
packages/billing-gateway/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './billing-gateway-service';
|
||||
export * from './gateway-provider-factory';
|
||||
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"]
|
||||
}
|
||||
@@ -10,8 +10,9 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/create-billing-schema.ts",
|
||||
"./components/*": "./src/components/*"
|
||||
".": "./src/index.ts",
|
||||
"./components/*": "./src/components/*",
|
||||
"./schema": "./src/schema/index.ts"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^18.2.0",
|
||||
|
||||
@@ -2,7 +2,8 @@ import { z } from 'zod';
|
||||
|
||||
const Interval = z.enum(['month', 'year']);
|
||||
const PaymentType = z.enum(['recurring', 'one-time']);
|
||||
const BillingProvider = z.enum(['stripe']);
|
||||
|
||||
export const BillingProvider = z.enum(['stripe', 'paddle', 'lemon-squeezy']);
|
||||
|
||||
const PlanSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
@@ -72,7 +73,6 @@ export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
* Returns an array of billing plans based on the provided configuration.
|
||||
*
|
||||
* @param {Object} config - The configuration object containing product and plan information.
|
||||
* @return {Array} - An array of billing plans.
|
||||
*/
|
||||
export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
|
||||
return config.products.flatMap((product) => product.plans);
|
||||
@@ -82,7 +82,6 @@ export function getBillingPlans(config: z.infer<typeof BillingSchema>) {
|
||||
* Retrieves the intervals of all plans specified in the given configuration.
|
||||
*
|
||||
* @param {Object} config - The billing configuration containing products and plans.
|
||||
* @returns {Array} - An array of intervals.
|
||||
*/
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
return Array.from(
|
||||
@@ -93,3 +92,18 @@ export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function getProductPlanPairFromId(
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
const plan = product.plans.find((plan) => plan.id === planId);
|
||||
|
||||
if (plan) {
|
||||
return { product, plan };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
2
packages/billing/src/index.ts
Normal file
2
packages/billing/src/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './create-billing-schema';
|
||||
export * from './services/billing-strategy-provider.service';
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CancelSubscriptionParamsSchema = z.object({
|
||||
subscriptionId: z.string(),
|
||||
invoiceNow: z.boolean().optional(),
|
||||
});
|
||||
@@ -0,0 +1,6 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateBillingPortalSessionSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
customerId: z.string().min(1),
|
||||
});
|
||||
@@ -0,0 +1,13 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const CreateBillingCheckoutSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
accountId: z.string(),
|
||||
planId: z.string(),
|
||||
paymentType: z.enum(['recurring', 'one-time']),
|
||||
|
||||
trialPeriodDays: z.number().optional(),
|
||||
|
||||
customerId: z.string().optional(),
|
||||
customerEmail: z.string().optional(),
|
||||
});
|
||||
4
packages/billing/src/schema/index.ts
Normal file
4
packages/billing/src/schema/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './create-billing-checkout.schema';
|
||||
export * from './create-biling-portal-session.schema';
|
||||
export * from './retrieve-checkout-session.schema';
|
||||
export * from './cancel-subscription-params.schema';
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RetrieveCheckoutSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '../schema';
|
||||
|
||||
export abstract class BillingStrategyProviderService {
|
||||
abstract createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
): Promise<{
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
abstract retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
): Promise<unknown>;
|
||||
|
||||
abstract createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
): Promise<{
|
||||
checkoutToken: string;
|
||||
}>;
|
||||
|
||||
abstract cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
}
|
||||
@@ -11,10 +11,15 @@
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts"
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"stripe": "^14.21.0"
|
||||
"stripe": "^14.21.0",
|
||||
"@stripe/react-stripe-js": "^2.6.2",
|
||||
"@stripe/stripe-js": "^3.0.10",
|
||||
"@kit/billing": "0.1.0",
|
||||
"@kit/ui": "0.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/prettier-config": "0.1.0",
|
||||
|
||||
1
packages/stripe/src/components/index.ts
Normal file
1
packages/stripe/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './stripe-embedded-checkout';
|
||||
86
packages/stripe/src/components/stripe-embedded-checkout.tsx
Normal file
86
packages/stripe/src/components/stripe-embedded-checkout.tsx
Normal file
@@ -0,0 +1,86 @@
|
||||
'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 { cn } from '@kit/ui/utils';
|
||||
|
||||
const STRIPE_PUBLISHABLE_KEY = process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY;
|
||||
|
||||
if (!STRIPE_PUBLISHABLE_KEY) {
|
||||
throw new Error(
|
||||
'Missing NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY environment variable. Did you forget to add it to your .env file?',
|
||||
);
|
||||
}
|
||||
|
||||
const stripePromise = loadStripe(STRIPE_PUBLISHABLE_KEY);
|
||||
|
||||
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 = cn({
|
||||
[`bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border border-gray-200 dark:border-dark-700`]:
|
||||
true,
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
defaultOpen
|
||||
open={open}
|
||||
onOpenChange={(open) => {
|
||||
if (!open && onClose) {
|
||||
onClose();
|
||||
}
|
||||
|
||||
setOpen(open);
|
||||
}}
|
||||
>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Complete your purchase</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogContent
|
||||
className={className}
|
||||
onOpenAutoFocus={(e) => e.preventDefault()}
|
||||
onInteractOutside={(e) => e.preventDefault()}
|
||||
>
|
||||
<div>{children}</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +1,7 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface CreateStripeCheckoutParams {
|
||||
returnUrl: string;
|
||||
organizationUid: string;
|
||||
priceId: string;
|
||||
customerId?: string;
|
||||
trialPeriodDays?: number | undefined;
|
||||
customerEmail?: string;
|
||||
embedded: boolean;
|
||||
}
|
||||
import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
|
||||
|
||||
/**
|
||||
* @name createStripeCheckout
|
||||
@@ -16,43 +9,43 @@ export interface CreateStripeCheckoutParams {
|
||||
* containing the session, which you can use to redirect the user to the
|
||||
* checkout page
|
||||
*/
|
||||
export default async function createStripeCheckout(
|
||||
export async function createStripeCheckout(
|
||||
stripe: Stripe,
|
||||
params: CreateStripeCheckoutParams,
|
||||
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.organizationUid;
|
||||
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;
|
||||
|
||||
// if it's a one-time payment
|
||||
// you should change this to "payment"
|
||||
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
|
||||
const mode: Stripe.Checkout.SessionCreateParams.Mode = 'subscription';
|
||||
const mode: Stripe.Checkout.SessionCreateParams.Mode =
|
||||
params.paymentType === 'recurring' ? 'subscription' : 'payment';
|
||||
|
||||
// TODO: support multiple line items and per-seat pricing
|
||||
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
|
||||
quantity: 1,
|
||||
price: params.priceId,
|
||||
price: params.planId,
|
||||
};
|
||||
|
||||
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
|
||||
{
|
||||
trial_period_days: params.trialPeriodDays,
|
||||
metadata: {
|
||||
organizationUid: params.organizationUid,
|
||||
accountId: params.accountId,
|
||||
},
|
||||
};
|
||||
|
||||
const urls = getUrls({
|
||||
embedded: params.embedded,
|
||||
returnUrl: params.returnUrl,
|
||||
});
|
||||
|
||||
const uiMode = params.embedded ? 'embedded' : 'hosted';
|
||||
// we use the embedded mode, so the user does not leave the page
|
||||
const uiMode = 'embedded';
|
||||
|
||||
const customerData = customer
|
||||
? {
|
||||
@@ -66,24 +59,17 @@ export default async function createStripeCheckout(
|
||||
mode,
|
||||
ui_mode: uiMode,
|
||||
line_items: [lineItem],
|
||||
client_reference_id: clientReferenceId.toString(),
|
||||
client_reference_id: clientReferenceId,
|
||||
subscription_data: subscriptionData,
|
||||
...customerData,
|
||||
...urls,
|
||||
});
|
||||
}
|
||||
|
||||
function getUrls(params: { returnUrl: string; embedded?: boolean }) {
|
||||
const successUrl = `${params.returnUrl}?success=true`;
|
||||
const cancelUrl = `${params.returnUrl}?cancel=true`;
|
||||
const returnUrl = `${params.returnUrl}/return?session_id={CHECKOUT_SESSION_ID}`;
|
||||
function getUrls(params: { returnUrl: string }) {
|
||||
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
|
||||
|
||||
return params.embedded
|
||||
? {
|
||||
return_url: returnUrl,
|
||||
}
|
||||
: {
|
||||
success_url: successUrl,
|
||||
cancel_url: cancelUrl,
|
||||
};
|
||||
return {
|
||||
return_url: returnUrl,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
export interface CreateBillingPortalSessionParams {
|
||||
customerId: string;
|
||||
returnUrl: string;
|
||||
}
|
||||
import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
|
||||
|
||||
/**
|
||||
* @name createStripeBillingPortalSession
|
||||
@@ -11,7 +9,7 @@ export interface CreateBillingPortalSessionParams {
|
||||
*/
|
||||
export async function createStripeBillingPortalSession(
|
||||
stripe: Stripe,
|
||||
params: CreateBillingPortalSessionParams,
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
return stripe.billingPortal.sessions.create({
|
||||
customer: params.customerId,
|
||||
|
||||
@@ -1 +1 @@
|
||||
export { stripe } from './stripe.service';
|
||||
export { StripeBillingStrategyService } from './stripe.service';
|
||||
|
||||
@@ -1,37 +1,59 @@
|
||||
import 'server-only';
|
||||
import type { Stripe } from 'stripe';
|
||||
import { z } from 'zod';
|
||||
|
||||
import createStripeCheckout, {
|
||||
CreateStripeCheckoutParams,
|
||||
} from './create-checkout';
|
||||
import { BillingStrategyProviderService } from '@kit/billing';
|
||||
import {
|
||||
CreateBillingPortalSessionParams,
|
||||
createStripeBillingPortalSession,
|
||||
} from './create-stripe-billing-portal-session';
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { createStripeCheckout } from './create-checkout';
|
||||
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
|
||||
import { createStripeClient } from './stripe-sdk';
|
||||
|
||||
class StripeService {
|
||||
constructor(private readonly stripeProvider: () => Promise<Stripe>) {}
|
||||
|
||||
async createCheckout(params: CreateStripeCheckoutParams) {
|
||||
export class StripeBillingStrategyService
|
||||
implements BillingStrategyProviderService
|
||||
{
|
||||
async createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
return createStripeCheckout(stripe, params);
|
||||
}
|
||||
|
||||
async createBillingPortalSession(params: CreateBillingPortalSessionParams) {
|
||||
async createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
return createStripeBillingPortalSession(stripe, params);
|
||||
}
|
||||
|
||||
async cancelSubscription(subscriptionId: string) {
|
||||
async cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
) {
|
||||
const stripe = await this.stripeProvider();
|
||||
|
||||
return stripe.subscriptions.cancel(subscriptionId, {
|
||||
invoice_now: true,
|
||||
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();
|
||||
|
||||
return await stripe.subscriptions.retrieve(params.sessionId);
|
||||
}
|
||||
|
||||
private async stripeProvider(): Promise<Stripe> {
|
||||
return createStripeClient();
|
||||
}
|
||||
}
|
||||
|
||||
export const stripe = new StripeService(createStripeClient);
|
||||
|
||||
@@ -6,19 +6,15 @@ import 'server-only';
|
||||
import { Database } from '../database.types';
|
||||
import { getSupabaseClientKeys } from '../get-supabase-client-keys';
|
||||
|
||||
const createServerSupabaseClient = <GenericSchema = Database>() => {
|
||||
const createServerSupabaseClient = () => {
|
||||
const keys = getSupabaseClientKeys();
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, keys.anonKey, {
|
||||
return createServerClient<Database>(keys.url, keys.anonKey, {
|
||||
cookies: getCookiesStrategy(),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSupabaseServerActionClient = <
|
||||
GenericSchema = Database,
|
||||
>(params?: {
|
||||
admin: false;
|
||||
}) => {
|
||||
export const getSupabaseServerActionClient = (params?: { admin: false }) => {
|
||||
const keys = getSupabaseClientKeys();
|
||||
const admin = params?.admin ?? false;
|
||||
|
||||
@@ -35,7 +31,7 @@ export const getSupabaseServerActionClient = <
|
||||
throw new Error('Supabase Service Role Key not provided');
|
||||
}
|
||||
|
||||
return createServerClient<GenericSchema>(keys.url, serviceRoleKey, {
|
||||
return createServerClient<Database>(keys.url, serviceRoleKey, {
|
||||
auth: {
|
||||
persistSession: false,
|
||||
},
|
||||
@@ -43,7 +39,7 @@ export const getSupabaseServerActionClient = <
|
||||
});
|
||||
}
|
||||
|
||||
return createServerSupabaseClient<GenericSchema>();
|
||||
return createServerSupabaseClient();
|
||||
};
|
||||
|
||||
function getCookiesStrategy() {
|
||||
|
||||
@@ -5,4 +5,20 @@ This package is responsible for managing the UI components and styles across the
|
||||
This package define two sets of components:
|
||||
|
||||
- `shadn-ui`: A set of UI components that can be used across the app using shadn UI
|
||||
- `makerkit`: Components specific to MakerKit
|
||||
- `makerkit`: Components specific to MakerKit
|
||||
|
||||
## Installing a Shadcn UI component
|
||||
|
||||
To install a Shadcn UI component, you can use the following command in the root of the repository:
|
||||
|
||||
```bash
|
||||
npx shadcn-ui@latest add <component> --path=packages/ui/src/shadcn
|
||||
```
|
||||
|
||||
For example, to install the `Button` component, you can use the following command:
|
||||
|
||||
```bash
|
||||
npx shadcn-ui@latest add button --path=packages/ui/src/shadcn
|
||||
```
|
||||
|
||||
We pass the `--path` flag to specify the path where the component should be installed.
|
||||
@@ -25,6 +25,7 @@
|
||||
"@radix-ui/react-radio-group": "^1.1.3",
|
||||
"@radix-ui/react-alert-dialog": "^1.0.5",
|
||||
"@radix-ui/react-navigation-menu": "^1.1.4",
|
||||
"@radix-ui/react-separator": "^1.0.3",
|
||||
"class-variance-authority": "^0.7.0",
|
||||
"react-top-loading-bar": "2.3.1",
|
||||
"clsx": "^2.1.0",
|
||||
@@ -91,6 +92,8 @@
|
||||
"./heading": "./src/shadcn/heading.tsx",
|
||||
"./alert": "./src/shadcn/alert.tsx",
|
||||
"./badge": "./src/shadcn/badge.tsx",
|
||||
"./radio-group": "./src/shadcn/radio-group.tsx",
|
||||
"./separator": "./src/shadcn/separator.tsx",
|
||||
"./utils": "./src/utils/index.ts",
|
||||
"./if": "./src/makerkit/if.tsx",
|
||||
"./trans": "./src/makerkit/trans.tsx",
|
||||
|
||||
@@ -42,4 +42,24 @@ const RadioGroupItem = React.forwardRef<
|
||||
});
|
||||
RadioGroupItem.displayName = RadioGroupPrimitive.Item.displayName;
|
||||
|
||||
export { RadioGroup, RadioGroupItem };
|
||||
const RadioGroupItemLabel = (
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
}>,
|
||||
) => {
|
||||
return (
|
||||
<label
|
||||
className={cn(
|
||||
props.className,
|
||||
'flex cursor-pointer rounded-md' +
|
||||
' items-center space-x-4 border border-input hover:bg-muted' +
|
||||
' transition-duration-500 p-4 text-sm transition-colors focus-within:border-primary',
|
||||
)}
|
||||
>
|
||||
{props.children}
|
||||
</label>
|
||||
);
|
||||
};
|
||||
RadioGroupItemLabel.displayName = 'RadioGroupItemLabel';
|
||||
|
||||
export { RadioGroup, RadioGroupItem, RadioGroupItemLabel };
|
||||
|
||||
31
packages/ui/src/shadcn/separator.tsx
Normal file
31
packages/ui/src/shadcn/separator.tsx
Normal file
@@ -0,0 +1,31 @@
|
||||
"use client"
|
||||
|
||||
import * as React from "react"
|
||||
import * as SeparatorPrimitive from "@radix-ui/react-separator"
|
||||
|
||||
import { cn } from "@kit/ui/utils"
|
||||
|
||||
const Separator = React.forwardRef<
|
||||
React.ElementRef<typeof SeparatorPrimitive.Root>,
|
||||
React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root>
|
||||
>(
|
||||
(
|
||||
{ className, orientation = "horizontal", decorative = true, ...props },
|
||||
ref
|
||||
) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn(
|
||||
"shrink-0 bg-border",
|
||||
orientation === "horizontal" ? "h-[1px] w-full" : "h-full w-[1px]",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
Reference in New Issue
Block a user