B2B-88: add starter kit structure and elements
This commit is contained in:
5
packages/analytics/README.md
Normal file
5
packages/analytics/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Analytics - @kit/analytics
|
||||
|
||||
@kit/analytics Package provides a simple and consistent API for tracking analytics events in web applications.
|
||||
|
||||
Please refer to the [documentation](https://makerkit.dev/docs/next-supabase-turbo/analytics/analytics-and-events).
|
||||
3
packages/analytics/eslint.config.mjs
Normal file
3
packages/analytics/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
1
packages/analytics/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/analytics/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/eslint
|
||||
1
packages/analytics/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/analytics/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/prettier
|
||||
1
packages/analytics/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/analytics/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../tooling/typescript
|
||||
1
packages/analytics/node_modules/@types/node
generated
vendored
Symbolic link
1
packages/analytics/node_modules/@types/node
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/@types+node@22.15.30/node_modules/@types/node
|
||||
28
packages/analytics/package.json
Normal file
28
packages/analytics/package.json
Normal file
@@ -0,0 +1,28 @@
|
||||
{
|
||||
"name": "@kit/analytics",
|
||||
"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"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@types/node": "^22.15.18"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
94
packages/analytics/src/analytics-manager.ts
Normal file
94
packages/analytics/src/analytics-manager.ts
Normal file
@@ -0,0 +1,94 @@
|
||||
import { NullAnalyticsService } from './null-analytics-service';
|
||||
import type {
|
||||
AnalyticsManager,
|
||||
AnalyticsService,
|
||||
CreateAnalyticsManagerOptions,
|
||||
} from './types';
|
||||
|
||||
export function createAnalyticsManager<T extends string, Config extends object>(
|
||||
options: CreateAnalyticsManagerOptions<T, Config>,
|
||||
): AnalyticsManager {
|
||||
const activeServices = new Map<T, AnalyticsService>();
|
||||
|
||||
const getActiveServices = (): AnalyticsService[] => {
|
||||
if (activeServices.size === 0) {
|
||||
console.debug(
|
||||
'No active analytics services. Using NullAnalyticsService.',
|
||||
);
|
||||
|
||||
return [NullAnalyticsService];
|
||||
}
|
||||
|
||||
return Array.from(activeServices.values());
|
||||
};
|
||||
|
||||
const registerActiveServices = (
|
||||
options: CreateAnalyticsManagerOptions<T, Config>,
|
||||
) => {
|
||||
Object.keys(options.providers).forEach((provider) => {
|
||||
const providerKey = provider as keyof typeof options.providers;
|
||||
const factory = options.providers[providerKey];
|
||||
|
||||
if (!factory) {
|
||||
console.warn(
|
||||
`Analytics provider '${provider}' not registered. Skipping initialization.`,
|
||||
);
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const service = factory();
|
||||
activeServices.set(provider as T, service);
|
||||
|
||||
void service.initialize();
|
||||
});
|
||||
};
|
||||
|
||||
registerActiveServices(options);
|
||||
|
||||
return {
|
||||
addProvider: (provider: T, config: Config) => {
|
||||
const factory = options.providers[provider];
|
||||
|
||||
if (!factory) {
|
||||
console.warn(
|
||||
`Analytics provider '${provider}' not registered. Skipping initialization.`,
|
||||
);
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
const service = factory(config);
|
||||
activeServices.set(provider, service);
|
||||
|
||||
return service.initialize();
|
||||
},
|
||||
|
||||
removeProvider: (provider: T) => {
|
||||
activeServices.delete(provider);
|
||||
},
|
||||
|
||||
identify: (userId: string, traits?: Record<string, string>) => {
|
||||
return Promise.all(
|
||||
getActiveServices().map((service) => service.identify(userId, traits)),
|
||||
);
|
||||
},
|
||||
|
||||
trackPageView: (path: string) => {
|
||||
return Promise.all(
|
||||
getActiveServices().map((service) => service.trackPageView(path)),
|
||||
);
|
||||
},
|
||||
|
||||
trackEvent: (
|
||||
eventName: string,
|
||||
eventProperties?: Record<string, string | string[]>,
|
||||
) => {
|
||||
return Promise.all(
|
||||
getActiveServices().map((service) =>
|
||||
service.trackEvent(eventName, eventProperties),
|
||||
),
|
||||
);
|
||||
},
|
||||
};
|
||||
}
|
||||
9
packages/analytics/src/index.ts
Normal file
9
packages/analytics/src/index.ts
Normal file
@@ -0,0 +1,9 @@
|
||||
import { createAnalyticsManager } from './analytics-manager';
|
||||
import { NullAnalyticsService } from './null-analytics-service';
|
||||
import type { AnalyticsManager } from './types';
|
||||
|
||||
export const analytics: AnalyticsManager = createAnalyticsManager({
|
||||
providers: {
|
||||
null: () => NullAnalyticsService,
|
||||
},
|
||||
});
|
||||
23
packages/analytics/src/null-analytics-service.ts
Normal file
23
packages/analytics/src/null-analytics-service.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { AnalyticsService } from './types';
|
||||
|
||||
const noop = (event: string) => {
|
||||
// do nothing - this is to prevent errors when the analytics service is not initialized
|
||||
|
||||
return async (...args: unknown[]) => {
|
||||
console.debug(
|
||||
`Noop analytics service called with event: ${event}`,
|
||||
...args.filter(Boolean),
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
/**
|
||||
* Null analytics service that does nothing. It is initialized with a noop function. This is useful for testing or when
|
||||
* the user is calling analytics methods before the analytics service is initialized.
|
||||
*/
|
||||
export const NullAnalyticsService: AnalyticsService = {
|
||||
initialize: noop('initialize'),
|
||||
trackPageView: noop('trackPageView'),
|
||||
trackEvent: noop('trackEvent'),
|
||||
identify: noop('identify'),
|
||||
};
|
||||
41
packages/analytics/src/types.ts
Normal file
41
packages/analytics/src/types.ts
Normal file
@@ -0,0 +1,41 @@
|
||||
interface TrackEvent {
|
||||
trackEvent(
|
||||
eventName: string,
|
||||
eventProperties?: Record<string, string | string[]>,
|
||||
): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface TrackPageView {
|
||||
trackPageView(path: string): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface Identify {
|
||||
identify(userId: string, traits?: Record<string, string>): Promise<unknown>;
|
||||
}
|
||||
|
||||
interface ProviderManager {
|
||||
addProvider(provider: string, config: object): Promise<unknown>;
|
||||
|
||||
removeProvider(provider: string): void;
|
||||
}
|
||||
|
||||
export interface AnalyticsService extends TrackPageView, TrackEvent, Identify {
|
||||
initialize(): Promise<unknown>;
|
||||
}
|
||||
|
||||
export type AnalyticsProviderFactory<Config extends object> = (
|
||||
config?: Config,
|
||||
) => AnalyticsService;
|
||||
|
||||
export interface CreateAnalyticsManagerOptions<
|
||||
T extends string,
|
||||
Config extends object,
|
||||
> {
|
||||
providers: Record<T, AnalyticsProviderFactory<Config>>;
|
||||
}
|
||||
|
||||
export interface AnalyticsManager
|
||||
extends TrackPageView,
|
||||
TrackEvent,
|
||||
Identify,
|
||||
ProviderManager {}
|
||||
8
packages/analytics/tsconfig.json
Normal file
8
packages/analytics/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"]
|
||||
}
|
||||
14
packages/billing/core/README.md
Normal file
14
packages/billing/core/README.md
Normal file
@@ -0,0 +1,14 @@
|
||||
# Billing - @kit/billing
|
||||
|
||||
This package is responsible for managing billing and subscription related operations.
|
||||
|
||||
Make sure the app installs the `@kit/billing` package before using it.
|
||||
|
||||
```json
|
||||
{
|
||||
"name": "my-app",
|
||||
"dependencies": {
|
||||
"@kit/billing": "*"
|
||||
}
|
||||
}
|
||||
```
|
||||
3
packages/billing/core/eslint.config.mjs
Normal file
3
packages/billing/core/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
1
packages/billing/core/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/billing/core/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/billing/core/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../supabase
|
||||
1
packages/billing/core/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/billing/core/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/billing/core/node_modules/zod
generated
vendored
Symbolic link
1
packages/billing/core/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
33
packages/billing/core/package.json
Normal file
33
packages/billing/core/package.json
Normal file
@@ -0,0 +1,33 @@
|
||||
{
|
||||
"name": "@kit/billing",
|
||||
"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/*",
|
||||
"./schema": "./src/schema/index.ts",
|
||||
"./types": "./src/types/index.ts"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
446
packages/billing/core/src/create-billing-schema.ts
Normal file
446
packages/billing/core/src/create-billing-schema.ts
Normal file
@@ -0,0 +1,446 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export enum LineItemType {
|
||||
Flat = 'flat',
|
||||
PerSeat = 'per_seat',
|
||||
Metered = 'metered',
|
||||
}
|
||||
|
||||
const BillingIntervalSchema = z.enum(['month', 'year']);
|
||||
const LineItemTypeSchema = z.enum(['flat', 'per_seat', 'metered']);
|
||||
|
||||
export const BillingProviderSchema = z.enum([
|
||||
'stripe',
|
||||
'paddle',
|
||||
'lemon-squeezy',
|
||||
]);
|
||||
|
||||
export const PaymentTypeSchema = z.enum(['one-time', 'recurring']);
|
||||
|
||||
export const LineItemSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string({
|
||||
description:
|
||||
'Unique identifier for the line item. Defined by the Provider.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the line item. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
description: z
|
||||
.string({
|
||||
description:
|
||||
'Description of the line item. Displayed to the user and will replace the auto-generated description inferred' +
|
||||
' from the line item. This is useful if you want to provide a more detailed description to the user.',
|
||||
})
|
||||
.optional(),
|
||||
cost: z
|
||||
.number({
|
||||
description: 'Cost of the line item. Displayed to the user.',
|
||||
})
|
||||
.min(0),
|
||||
type: LineItemTypeSchema,
|
||||
unit: z
|
||||
.string({
|
||||
description:
|
||||
'Unit of the line item. Displayed to the user. Example "seat" or "GB"',
|
||||
})
|
||||
.optional(),
|
||||
setupFee: z
|
||||
.number({
|
||||
description: `Lemon Squeezy only: If true, in addition to the cost, a setup fee will be charged.`,
|
||||
})
|
||||
.positive()
|
||||
.optional(),
|
||||
tiers: z
|
||||
.array(
|
||||
z.object({
|
||||
cost: z.number().min(0),
|
||||
upTo: z.union([z.number().min(0), z.literal('unlimited')]),
|
||||
}),
|
||||
)
|
||||
.optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) =>
|
||||
data.type !== LineItemType.Metered ||
|
||||
(data.unit && data.tiers !== undefined),
|
||||
{
|
||||
message: 'Metered line items must have a unit and tiers',
|
||||
path: ['type', 'unit', 'tiers'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.type === LineItemType.Metered) {
|
||||
return data.cost === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Metered line items must have a cost of 0. Please add a different line item type for a flat fee (Stripe)',
|
||||
path: ['type', 'cost'],
|
||||
},
|
||||
);
|
||||
|
||||
export const PlanSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string({
|
||||
description: 'Unique identifier for the plan. Defined by yourself.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the plan. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
interval: BillingIntervalSchema.optional(),
|
||||
custom: z.boolean().default(false).optional(),
|
||||
label: z.string().min(1).optional(),
|
||||
buttonLabel: z.string().min(1).optional(),
|
||||
href: z.string().min(1).optional(),
|
||||
lineItems: z.array(LineItemSchema).refine(
|
||||
(schema) => {
|
||||
const types = schema.map((item) => item.type);
|
||||
|
||||
const perSeat = types.filter(
|
||||
(type) => type === LineItemType.PerSeat,
|
||||
).length;
|
||||
|
||||
const flat = types.filter((type) => type === LineItemType.Flat).length;
|
||||
|
||||
return perSeat <= 1 && flat <= 1;
|
||||
},
|
||||
{
|
||||
message: 'Plans can only have one per-seat and one flat line item',
|
||||
path: ['lineItems'],
|
||||
},
|
||||
),
|
||||
trialDays: z
|
||||
.number({
|
||||
description:
|
||||
'Number of days for the trial period. Leave empty for no trial.',
|
||||
})
|
||||
.positive()
|
||||
.optional(),
|
||||
paymentType: PaymentTypeSchema,
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.custom) {
|
||||
return data.lineItems.length === 0;
|
||||
}
|
||||
|
||||
return data.lineItems.length > 0;
|
||||
},
|
||||
{
|
||||
message: 'Non-Custom Plans must have at least one line item',
|
||||
path: ['lineItems'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.custom) {
|
||||
return data.lineItems.length === 0;
|
||||
}
|
||||
|
||||
return data.lineItems.length > 0;
|
||||
},
|
||||
{
|
||||
message: 'Custom Plans must have 0 line items',
|
||||
path: ['lineItems'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => data.paymentType !== 'one-time' || data.interval === undefined,
|
||||
{
|
||||
message: 'One-time plans must not have an interval',
|
||||
path: ['paymentType', 'interval'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => data.paymentType !== 'recurring' || data.interval !== undefined,
|
||||
{
|
||||
message: 'Recurring plans must have an interval',
|
||||
path: ['paymentType', 'interval'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(item) => {
|
||||
// metered line items can be shared across plans
|
||||
const lineItems = item.lineItems.filter(
|
||||
(item) => item.type !== LineItemType.Metered,
|
||||
);
|
||||
|
||||
const ids = lineItems.map((item) => item.id);
|
||||
|
||||
return ids.length === new Set(ids).size;
|
||||
},
|
||||
{
|
||||
message: 'Line item IDs must be unique',
|
||||
path: ['lineItems'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(data) => {
|
||||
if (data.paymentType === 'one-time') {
|
||||
const nonFlatLineItems = data.lineItems.filter(
|
||||
(item) => item.type !== LineItemType.Flat,
|
||||
);
|
||||
|
||||
return nonFlatLineItems.length === 0;
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'One-time plans must not have non-flat line items',
|
||||
path: ['paymentType', 'lineItems'],
|
||||
},
|
||||
);
|
||||
|
||||
const ProductSchema = z
|
||||
.object({
|
||||
id: z
|
||||
.string({
|
||||
description:
|
||||
'Unique identifier for the product. Defined by th Provider.',
|
||||
})
|
||||
.min(1),
|
||||
name: z
|
||||
.string({
|
||||
description: 'Name of the product. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
description: z
|
||||
.string({
|
||||
description: 'Description of the product. Displayed to the user.',
|
||||
})
|
||||
.min(1),
|
||||
currency: z
|
||||
.string({
|
||||
description: 'Currency code for the product. Displayed to the user.',
|
||||
})
|
||||
.min(3)
|
||||
.max(3),
|
||||
badge: z
|
||||
.string({
|
||||
description:
|
||||
'Badge for the product. Displayed to the user. Example: "Popular"',
|
||||
})
|
||||
.optional(),
|
||||
features: z
|
||||
.array(
|
||||
z.string({
|
||||
description: 'Features of the product. Displayed to the user.',
|
||||
}),
|
||||
)
|
||||
.nonempty(),
|
||||
enableDiscountField: z
|
||||
.boolean({
|
||||
description: 'Enable discount field for the product in the checkout.',
|
||||
})
|
||||
.optional(),
|
||||
highlighted: z
|
||||
.boolean({
|
||||
description: 'Highlight this product. Displayed to the user.',
|
||||
})
|
||||
.optional(),
|
||||
hidden: z
|
||||
.boolean({
|
||||
description: 'Hide this product from being displayed to users.',
|
||||
})
|
||||
.optional(),
|
||||
plans: z.array(PlanSchema),
|
||||
})
|
||||
.refine((data) => data.plans.length > 0, {
|
||||
message: 'Products must have at least one plan',
|
||||
path: ['plans'],
|
||||
})
|
||||
.refine(
|
||||
(item) => {
|
||||
const planIds = item.plans.map((plan) => plan.id);
|
||||
|
||||
return planIds.length === new Set(planIds).size;
|
||||
},
|
||||
{
|
||||
message: 'Plan IDs must be unique',
|
||||
path: ['plans'],
|
||||
},
|
||||
);
|
||||
|
||||
const BillingSchema = z
|
||||
.object({
|
||||
provider: BillingProviderSchema,
|
||||
products: z.array(ProductSchema).nonempty(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
const ids = data.products.flatMap((product) =>
|
||||
product.plans.flatMap((plan) => plan.lineItems.map((item) => item.id)),
|
||||
);
|
||||
|
||||
return ids.length === new Set(ids).size;
|
||||
},
|
||||
{
|
||||
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 false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message: 'Lemon Squeezy only supports one line item per plan',
|
||||
path: ['provider', 'products'],
|
||||
},
|
||||
)
|
||||
.refine(
|
||||
(schema) => {
|
||||
if (schema.provider !== 'lemon-squeezy') {
|
||||
// Check if there are any flat fee metered items
|
||||
const setupFeeItems = schema.products.flatMap((product) =>
|
||||
product.plans.flatMap((plan) =>
|
||||
plan.lineItems.filter((item) => item.setupFee),
|
||||
),
|
||||
);
|
||||
|
||||
// If there are any flat fee metered items, return an error
|
||||
if (setupFeeItems.length > 0) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
},
|
||||
{
|
||||
message:
|
||||
'Setup fee metered items are only supported by Lemon Squeezy. For Stripe and Paddle, please use a separate line item for the setup fee.',
|
||||
path: ['products', 'plans', 'lineItems'],
|
||||
},
|
||||
);
|
||||
|
||||
export function createBillingSchema(config: z.infer<typeof BillingSchema>) {
|
||||
return BillingSchema.parse(config);
|
||||
}
|
||||
|
||||
export type BillingConfig = z.infer<typeof BillingSchema>;
|
||||
export type ProductSchema = z.infer<typeof ProductSchema>;
|
||||
|
||||
export function getPlanIntervals(config: z.infer<typeof BillingSchema>) {
|
||||
const intervals = config.products
|
||||
.flatMap((product) => product.plans.map((plan) => plan.interval))
|
||||
.filter(Boolean);
|
||||
|
||||
return Array.from(new Set(intervals));
|
||||
}
|
||||
|
||||
/**
|
||||
* @name getPrimaryLineItem
|
||||
* @description Get the primary line item for a plan
|
||||
* By default, the primary line item is the first line item in the plan for Lemon Squeezy
|
||||
* For other providers, the primary line item is the first flat line item in the plan. If there are no flat line items,
|
||||
* the first line item is returned.
|
||||
*
|
||||
* @param config
|
||||
* @param planId
|
||||
*/
|
||||
export function getPrimaryLineItem(
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.id === planId) {
|
||||
// Lemon Squeezy only supports one line item per plan
|
||||
if (config.provider === 'lemon-squeezy') {
|
||||
return plan.lineItems[0];
|
||||
}
|
||||
|
||||
const flatLineItem = plan.lineItems.find(
|
||||
(item) => item.type === LineItemType.Flat,
|
||||
);
|
||||
|
||||
if (flatLineItem) {
|
||||
return flatLineItem;
|
||||
}
|
||||
|
||||
return plan.lineItems[0];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Base line item not found');
|
||||
}
|
||||
|
||||
export function getProductPlanPair(
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
for (const plan of product.plans) {
|
||||
if (plan.id === planId) {
|
||||
return { product, plan };
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
export function getProductPlanPairByVariantId(
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
planId: string,
|
||||
) {
|
||||
for (const product of config.products) {
|
||||
for (const plan of product.plans) {
|
||||
for (const lineItem of plan.lineItems) {
|
||||
if (lineItem.id === planId) {
|
||||
return { product, plan };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error('Plan not found');
|
||||
}
|
||||
|
||||
export type PlanTypeMap = Map<string, z.infer<typeof LineItemTypeSchema>>;
|
||||
|
||||
/**
|
||||
* @name getPlanTypesMap
|
||||
* @description Get all line item types for all plans in the config
|
||||
* @param config
|
||||
*/
|
||||
export function getPlanTypesMap(
|
||||
config: z.infer<typeof BillingSchema>,
|
||||
): PlanTypeMap {
|
||||
const planTypes: PlanTypeMap = new Map();
|
||||
|
||||
for (const product of config.products) {
|
||||
for (const plan of product.plans) {
|
||||
for (const lineItem of plan.lineItems) {
|
||||
planTypes.set(lineItem.id, lineItem.type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return planTypes;
|
||||
}
|
||||
3
packages/billing/core/src/index.ts
Normal file
3
packages/billing/core/src/index.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
export * from './create-billing-schema';
|
||||
export * from './services/billing-strategy-provider.service';
|
||||
export * from './services/billing-webhook-handler.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,19 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PlanSchema } from '../create-billing-schema';
|
||||
|
||||
export const CreateBillingCheckoutSchema = z.object({
|
||||
returnUrl: z.string().url(),
|
||||
accountId: z.string().uuid(),
|
||||
plan: PlanSchema,
|
||||
customerId: z.string().optional(),
|
||||
customerEmail: z.string().email().optional(),
|
||||
enableDiscountField: z.boolean().optional(),
|
||||
variantQuantities: z.array(
|
||||
z.object({
|
||||
variantId: z.string().min(1),
|
||||
quantity: z.number(),
|
||||
}),
|
||||
),
|
||||
metadata: z.record(z.string()).optional(),
|
||||
});
|
||||
7
packages/billing/core/src/schema/index.ts
Normal file
7
packages/billing/core/src/schema/index.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
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';
|
||||
export * from './report-billing-usage.schema';
|
||||
export * from './update-subscription-params.schema';
|
||||
export * from './query-billing-usage.schema';
|
||||
@@ -0,0 +1,32 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const TimeFilter = z.object(
|
||||
{
|
||||
startTime: z.number(),
|
||||
endTime: z.number(),
|
||||
},
|
||||
{
|
||||
description: `The time range to filter the usage records. Used for Stripe`,
|
||||
},
|
||||
);
|
||||
|
||||
const PageFilter = z.object(
|
||||
{
|
||||
page: z.number(),
|
||||
size: z.number(),
|
||||
},
|
||||
{
|
||||
description: `The page and size to filter the usage records. Used for LS`,
|
||||
},
|
||||
);
|
||||
|
||||
export const QueryBillingUsageSchema = z.object({
|
||||
id: z.string({
|
||||
description:
|
||||
'The id of the usage record. For Stripe a meter ID, for LS a subscription item ID.',
|
||||
}),
|
||||
customerId: z.string({
|
||||
description: 'The id of the customer in the billing system',
|
||||
}),
|
||||
filter: z.union([TimeFilter, PageFilter]),
|
||||
});
|
||||
@@ -0,0 +1,17 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const ReportBillingUsageSchema = z.object({
|
||||
id: z.string({
|
||||
description:
|
||||
'The id of the usage record. For Stripe a customer ID, for LS a subscription item ID.',
|
||||
}),
|
||||
eventName: z
|
||||
.string({
|
||||
description: 'The name of the event that triggered the usage',
|
||||
})
|
||||
.optional(),
|
||||
usage: z.object({
|
||||
quantity: z.number(),
|
||||
action: z.enum(['increment', 'set']).optional(),
|
||||
}),
|
||||
});
|
||||
@@ -0,0 +1,5 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const RetrieveCheckoutSessionSchema = z.object({
|
||||
sessionId: z.string(),
|
||||
});
|
||||
@@ -0,0 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const UpdateSubscriptionParamsSchema = z.object({
|
||||
subscriptionId: z.string().min(1),
|
||||
subscriptionItemId: z.string().min(1),
|
||||
quantity: z.number().min(1),
|
||||
});
|
||||
@@ -0,0 +1,77 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
QueryBillingUsageSchema,
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
UpdateSubscriptionParamsSchema,
|
||||
} from '../schema';
|
||||
import { UpsertSubscriptionParams } from '../types';
|
||||
|
||||
export abstract class BillingStrategyProviderService {
|
||||
abstract createBillingPortalSession(
|
||||
params: z.infer<typeof CreateBillingPortalSessionSchema>,
|
||||
): Promise<{
|
||||
url: string;
|
||||
}>;
|
||||
|
||||
abstract retrieveCheckoutSession(
|
||||
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
|
||||
): Promise<{
|
||||
checkoutToken: string | null;
|
||||
status: 'complete' | 'expired' | 'open';
|
||||
isSessionOpen: boolean;
|
||||
|
||||
customer: {
|
||||
email: string | null;
|
||||
};
|
||||
}>;
|
||||
|
||||
abstract createCheckoutSession(
|
||||
params: z.infer<typeof CreateBillingCheckoutSchema>,
|
||||
): Promise<{
|
||||
checkoutToken: string;
|
||||
}>;
|
||||
|
||||
abstract cancelSubscription(
|
||||
params: z.infer<typeof CancelSubscriptionParamsSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
abstract reportUsage(
|
||||
params: z.infer<typeof ReportBillingUsageSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
abstract queryUsage(
|
||||
params: z.infer<typeof QueryBillingUsageSchema>,
|
||||
): Promise<{
|
||||
value: number;
|
||||
}>;
|
||||
|
||||
abstract updateSubscriptionItem(
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
}>;
|
||||
|
||||
abstract getPlanById(planId: string): Promise<{
|
||||
id: string;
|
||||
name: string;
|
||||
interval: string;
|
||||
amount: number;
|
||||
}>;
|
||||
|
||||
abstract getSubscription(subscriptionId: string): Promise<
|
||||
UpsertSubscriptionParams & {
|
||||
// we can't always guarantee that the target account id will be present
|
||||
// so we need to make it optional and let the consumer handle it
|
||||
target_account_id: string | undefined;
|
||||
}
|
||||
>;
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { UpsertOrderParams, UpsertSubscriptionParams } from '../types';
|
||||
|
||||
/**
|
||||
* @name BillingWebhookHandlerService
|
||||
* @description Represents an abstract class for handling billing webhook events.
|
||||
*/
|
||||
export abstract class BillingWebhookHandlerService {
|
||||
/**
|
||||
* @name verifyWebhookSignature
|
||||
* @description Verify the webhook signature
|
||||
* @param request
|
||||
*/
|
||||
abstract verifyWebhookSignature(request: Request): Promise<unknown>;
|
||||
|
||||
/**
|
||||
* @name handleWebhookEvent
|
||||
* @description Handle the webhook event from the billing provider
|
||||
* @param event
|
||||
* @param params
|
||||
*/
|
||||
abstract handleWebhookEvent(
|
||||
event: unknown,
|
||||
params: {
|
||||
// this method is called when a checkout session is completed
|
||||
onCheckoutSessionCompleted: (
|
||||
subscription: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is updated
|
||||
onSubscriptionUpdated: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// this method is called when a subscription is deleted
|
||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is succeeded. This is used for
|
||||
// one-time payments
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when a payment is failed. This is used for
|
||||
// one-time payments
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||
|
||||
// this method is called when an invoice is paid. We don't have a specific use case for this
|
||||
// but it's extremely common for credit-based systems
|
||||
onInvoicePaid: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
|
||||
// generic handler for any event
|
||||
onEvent?: (data: unknown) => Promise<unknown>;
|
||||
},
|
||||
): Promise<unknown>;
|
||||
}
|
||||
22
packages/billing/core/src/types/index.ts
Normal file
22
packages/billing/core/src/types/index.ts
Normal file
@@ -0,0 +1,22 @@
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
export type UpsertSubscriptionParams =
|
||||
Database['public']['Functions']['upsert_subscription']['Args'] & {
|
||||
line_items: Array<LineItem>;
|
||||
};
|
||||
|
||||
interface LineItem {
|
||||
id: string;
|
||||
quantity: number;
|
||||
subscription_id: string;
|
||||
subscription_item_id: string;
|
||||
product_id: string;
|
||||
variant_id: string;
|
||||
price_amount: number | null | undefined;
|
||||
interval: string;
|
||||
interval_count: number;
|
||||
type: 'flat' | 'metered' | 'per_seat' | undefined;
|
||||
}
|
||||
|
||||
export type UpsertOrderParams =
|
||||
Database['public']['Functions']['upsert_order']['Args'];
|
||||
8
packages/billing/core/tsconfig.json
Normal file
8
packages/billing/core/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"]
|
||||
}
|
||||
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.
|
||||
3
packages/billing/gateway/eslint.config.mjs
Normal file
3
packages/billing/gateway/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
17
packages/billing/gateway/node_modules/.bin/next
generated
vendored
Executable file
17
packages/billing/gateway/node_modules/.bin/next
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../next/dist/bin/next" "$@"
|
||||
fi
|
||||
17
packages/billing/gateway/node_modules/.bin/tsc
generated
vendored
Executable file
17
packages/billing/gateway/node_modules/.bin/tsc
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsc" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsc" "$@"
|
||||
fi
|
||||
17
packages/billing/gateway/node_modules/.bin/tsserver
generated
vendored
Executable file
17
packages/billing/gateway/node_modules/.bin/tsserver
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/typescript@5.8.3/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsserver" "$@"
|
||||
else
|
||||
exec node "$basedir/../../../../../node_modules/.pnpm/typescript@5.8.3/node_modules/typescript/bin/tsserver" "$@"
|
||||
fi
|
||||
1
packages/billing/gateway/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@hookform/resolvers
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@hookform+resolvers@5.0.1_react-hook-form@7.57.0_react@19.1.0_/node_modules/@hookform/resolvers
|
||||
1
packages/billing/gateway/node_modules/@kit/billing
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/billing
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../core
|
||||
1
packages/billing/gateway/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/billing/gateway/node_modules/@kit/lemon-squeezy
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/lemon-squeezy
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../lemon-squeezy
|
||||
1
packages/billing/gateway/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/billing/gateway/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../shared
|
||||
1
packages/billing/gateway/node_modules/@kit/stripe
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/stripe
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../stripe
|
||||
1
packages/billing/gateway/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../supabase
|
||||
1
packages/billing/gateway/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/billing/gateway/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/billing/gateway/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@supabase/supabase-js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@supabase+supabase-js@2.49.4/node_modules/@supabase/supabase-js
|
||||
1
packages/billing/gateway/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/billing/gateway/node_modules/date-fns
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/date-fns
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/date-fns@4.1.0/node_modules/date-fns
|
||||
1
packages/billing/gateway/node_modules/lucide-react
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/lucide-react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/lucide-react@0.510.0_react@19.1.0/node_modules/lucide-react
|
||||
1
packages/billing/gateway/node_modules/next
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next
|
||||
1
packages/billing/gateway/node_modules/react
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/billing/gateway/node_modules/react-hook-form
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/react-hook-form
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react-hook-form@7.57.0_react@19.1.0/node_modules/react-hook-form
|
||||
1
packages/billing/gateway/node_modules/react-i18next
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/react-i18next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react-i18next@15.5.2_i18next@25.1.3_typescript@5.8.3__react-dom@19.1.0_react@19.1.0__react@19.1.0_typescript@5.8.3/node_modules/react-i18next
|
||||
1
packages/billing/gateway/node_modules/zod
generated
vendored
Symbolic link
1
packages/billing/gateway/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
46
packages/billing/gateway/package.json
Normal file
46
packages/billing/gateway/package.json
Normal file
@@ -0,0 +1,46 @@
|
||||
{
|
||||
"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",
|
||||
"./checkout": "./src/components/embedded-checkout.tsx",
|
||||
"./marketing": "./src/components/marketing.tsx"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@hookform/resolvers": "^5.0.1",
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/lemon-squeezy": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/stripe": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@supabase/supabase-js": "2.49.4",
|
||||
"@types/react": "19.1.4",
|
||||
"date-fns": "^4.1.0",
|
||||
"lucide-react": "^0.510.0",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"react-hook-form": "^7.56.3",
|
||||
"react-i18next": "^15.5.1",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"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 data-test={'manage-billing-redirect-button'}>
|
||||
<span>
|
||||
<Trans i18nKey="billing:billingPortalCardButton" />
|
||||
</span>
|
||||
|
||||
<ArrowUpRight className={'h-4'} />
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
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.
|
||||
**/
|
||||
export function BillingSessionStatus({
|
||||
customerEmail,
|
||||
redirectPath,
|
||||
}: React.PropsWithChildren<{
|
||||
customerEmail: string;
|
||||
redirectPath: string;
|
||||
}>) {
|
||||
return (
|
||||
<section
|
||||
data-test={'payment-return-success'}
|
||||
className={
|
||||
'fade-in dark:border-border mx-auto max-w-xl rounded-xl border border-transparent p-16 xl:drop-shadow-2xl' +
|
||||
' bg-background animate-in slide-in-from-bottom-8 ease-out' +
|
||||
' zoom-in-50 dark:shadow-primary/20 duration-1000 dark:shadow-2xl'
|
||||
}
|
||||
>
|
||||
<div
|
||||
className={
|
||||
'flex flex-col items-center justify-center space-y-6 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={'text-muted-foreground flex flex-col space-y-4'}>
|
||||
<p>
|
||||
<Trans
|
||||
i18nKey={'billing:checkoutSuccessDescription'}
|
||||
values={{ customerEmail }}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<Button data-test={'checkout-success-back-link'} asChild>
|
||||
<Link href={redirectPath}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing:checkoutSuccessBackButton'} />
|
||||
</span>
|
||||
|
||||
<ChevronRight className={'h-4'} />
|
||||
</Link>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
|
||||
import { Tables } 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 = Tables<'orders'>;
|
||||
type LineItem = Tables<'order_items'>;
|
||||
|
||||
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={'gap-y-4 text-sm'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center gap-x-3 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 gap-y-1">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={productLineItems}
|
||||
currency={order.currency}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { Enums } 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: 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,50 @@
|
||||
import { Enums } from '@kit/supabase/database';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type Status = Enums<'subscription_status'> | 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 data-test={'current-plan-card-status-badge'} variant={variant}>
|
||||
<Trans i18nKey={text} />
|
||||
</Badge>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
import { formatDate } from 'date-fns';
|
||||
import { BadgeCheck } from 'lucide-react';
|
||||
|
||||
import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing';
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert';
|
||||
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 = Tables<'subscriptions'>;
|
||||
type LineItem = Tables<'subscription_items'>;
|
||||
|
||||
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-4 border-t pt-4 text-sm'}>
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
<div className={'flex items-center gap-x-3 text-lg font-semibold'}>
|
||||
<BadgeCheck
|
||||
className={
|
||||
's-6 fill-green-500 text-white dark:fill-white dark:text-black'
|
||||
}
|
||||
/>
|
||||
|
||||
<span data-test={'current-plan-card-product-name'}>
|
||||
<Trans i18nKey={product.name} defaults={product.name} />
|
||||
</span>
|
||||
|
||||
<CurrentPlanBadge status={subscription.status} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className={'text-muted-foreground'}>
|
||||
<Trans
|
||||
i18nKey={product.description}
|
||||
defaults={product.description}
|
||||
/>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/*
|
||||
Only show the alert if the subscription requires action
|
||||
(e.g. trial ending soon, subscription canceled, etc.)
|
||||
*/}
|
||||
<If condition={!subscription.active}>
|
||||
<div data-test={'current-plan-card-status-alert'}>
|
||||
<CurrentPlanAlert status={subscription.status} />
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<If condition={subscription.status === 'trialing'}>
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<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}>
|
||||
<Alert variant={'warning'}>
|
||||
<AlertTitle>
|
||||
<Trans i18nKey="billing:subscriptionCancelled" />
|
||||
</AlertTitle>
|
||||
|
||||
<AlertDescription>
|
||||
<Trans i18nKey="billing:cancelSubscriptionDate" />:
|
||||
<span className={'ml-1'}>
|
||||
{formatDate(subscription.period_ends_at ?? '', 'P')}
|
||||
</span>
|
||||
</AlertDescription>
|
||||
</Alert>
|
||||
</If>
|
||||
|
||||
<div className="flex flex-col gap-y-1">
|
||||
<span className="font-semibold">
|
||||
<Trans i18nKey="billing:detailsLabel" />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={productLineItems}
|
||||
currency={subscription.currency}
|
||||
selectedInterval={firstLineItem.interval}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
115
packages/billing/gateway/src/components/embedded-checkout.tsx
Normal file
115
packages/billing/gateway/src/components/embedded-checkout.tsx
Normal file
@@ -0,0 +1,115 @@
|
||||
import { Suspense, forwardRef, lazy, memo, useMemo } from 'react';
|
||||
|
||||
import { Enums } from '@kit/supabase/database';
|
||||
import { LoadingOverlay } from '@kit/ui/loading-overlay';
|
||||
|
||||
type BillingProvider = 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}
|
||||
/>
|
||||
|
||||
<BlurryBackdrop />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function loadCheckoutComponent(provider: BillingProvider) {
|
||||
switch (provider) {
|
||||
case 'stripe': {
|
||||
return buildLazyComponent(() => {
|
||||
return import('@kit/stripe/components').then(({ StripeCheckout }) => {
|
||||
return {
|
||||
default: StripeCheckout,
|
||||
};
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
case 'lemon-squeezy': {
|
||||
return buildLazyComponent(() => {
|
||||
return import('@kit/lemon-squeezy/components').then(
|
||||
({ LemonSqueezyEmbeddedCheckout }) => {
|
||||
return {
|
||||
default: LemonSqueezyEmbeddedCheckout,
|
||||
};
|
||||
},
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
|
||||
function BlurryBackdrop() {
|
||||
return (
|
||||
<div
|
||||
className={
|
||||
'bg-background/30 fixed left-0 top-0 w-full backdrop-blur-sm' +
|
||||
' !m-0 h-full'
|
||||
}
|
||||
/>
|
||||
);
|
||||
}
|
||||
5
packages/billing/gateway/src/components/index.ts
Normal file
5
packages/billing/gateway/src/components/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './plan-picker';
|
||||
export * from './current-subscription-card';
|
||||
export * from './current-lifetime-order-card';
|
||||
export * from './billing-session-status';
|
||||
export * from './billing-portal-card';
|
||||
304
packages/billing/gateway/src/components/line-item-details.tsx
Normal file
304
packages/billing/gateway/src/components/line-item-details.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
'use client';
|
||||
|
||||
import { PlusSquare } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LineItemSchema } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
const className = 'flex text-secondary-foreground items-center text-sm';
|
||||
|
||||
export function LineItemDetails(
|
||||
props: React.PropsWithChildren<{
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
currency: string;
|
||||
selectedInterval?: string | undefined;
|
||||
}>,
|
||||
) {
|
||||
const locale = useTranslation().i18n.language;
|
||||
const currencyCode = props?.currency.toLowerCase();
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-1'}>
|
||||
{props.lineItems.map((item, index) => {
|
||||
// If the item has a description, we render it as a simple text
|
||||
// and pass the item as values to the translation so we can use
|
||||
// the item properties in the translation.
|
||||
if (item.description) {
|
||||
return (
|
||||
<div key={index} className={className}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-4'} />
|
||||
|
||||
<Trans
|
||||
i18nKey={item.description}
|
||||
values={item}
|
||||
defaults={item.description}
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SetupFee = () => (
|
||||
<If condition={item.setupFee}>
|
||||
<div className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:setupFee'}
|
||||
values={{
|
||||
setupFee: formatCurrency({
|
||||
currencyCode,
|
||||
value: item.setupFee as number,
|
||||
locale,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</If>
|
||||
);
|
||||
|
||||
const FlatFee = () => (
|
||||
<div className={'flex flex-col'}>
|
||||
<div className={cn(className, 'space-x-1')}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing:basePlan'} />
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span>
|
||||
<If
|
||||
condition={props.selectedInterval}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
(
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${props.selectedInterval}`}
|
||||
/>
|
||||
)
|
||||
</If>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<span>-</span>
|
||||
|
||||
<span className={'text-xs font-semibold'}>
|
||||
{formatCurrency({
|
||||
currencyCode,
|
||||
value: item.cost,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SetupFee />
|
||||
|
||||
<If condition={item.tiers?.length}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span className={'flex gap-x-2 text-sm'}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: item.unit,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<Tiers item={item} currency={props.currency} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
|
||||
const PerSeat = () => (
|
||||
<div key={index} className={'flex flex-col'}>
|
||||
<div className={className}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span>
|
||||
<Trans i18nKey={'billing:perTeamMember'} />
|
||||
</span>
|
||||
|
||||
<span>-</span>
|
||||
|
||||
<If condition={!item.tiers?.length}>
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency({
|
||||
currencyCode,
|
||||
value: item.cost,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<SetupFee />
|
||||
|
||||
<If condition={item.tiers?.length}>
|
||||
<Tiers item={item} currency={props.currency} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
|
||||
const Metered = () => (
|
||||
<div key={index} className={'flex flex-col'}>
|
||||
<div className={className}>
|
||||
<span className={'flex items-center space-x-1'}>
|
||||
<span className={'flex items-center space-x-1.5'}>
|
||||
<PlusSquare className={'w-3'} />
|
||||
|
||||
<span className={'flex space-x-1'}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: item.unit,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
|
||||
{/* If there are no tiers, there is a flat cost for usage */}
|
||||
<If condition={!item.tiers?.length}>
|
||||
<span className={'font-semibold'}>
|
||||
{formatCurrency({
|
||||
currencyCode,
|
||||
value: item.cost,
|
||||
locale,
|
||||
})}
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<SetupFee />
|
||||
|
||||
{/* If there are tiers, we render them as a list */}
|
||||
<If condition={item.tiers?.length}>
|
||||
<Tiers item={item} currency={props.currency} />
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
|
||||
switch (item.type) {
|
||||
case 'flat':
|
||||
return <FlatFee key={item.id} />;
|
||||
|
||||
case 'per_seat':
|
||||
return <PerSeat key={item.id} />;
|
||||
|
||||
case 'metered': {
|
||||
return <Metered key={item.id} />;
|
||||
}
|
||||
}
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function Tiers({
|
||||
currency,
|
||||
item,
|
||||
}: {
|
||||
currency: string;
|
||||
item: z.infer<typeof LineItemSchema>;
|
||||
}) {
|
||||
const unit = item.unit;
|
||||
const locale = useTranslation().i18n.language;
|
||||
|
||||
const tiers = item.tiers?.map((tier, index) => {
|
||||
const tiersLength = item.tiers?.length ?? 0;
|
||||
const previousTier = item.tiers?.[index - 1];
|
||||
const isLastTier = tier.upTo === 'unlimited';
|
||||
|
||||
const previousTierFrom =
|
||||
previousTier?.upTo === 'unlimited'
|
||||
? 'unlimited'
|
||||
: previousTier === undefined
|
||||
? 0
|
||||
: previousTier.upTo + 1 || 0;
|
||||
|
||||
const upTo = tier.upTo;
|
||||
const isIncluded = tier.cost === 0;
|
||||
|
||||
return (
|
||||
<span className={'text-secondary-foreground text-xs'} key={index}>
|
||||
<span>-</span>{' '}
|
||||
<If condition={isLastTier}>
|
||||
<span className={'font-bold'}>
|
||||
{formatCurrency({
|
||||
currencyCode: currency.toLowerCase(),
|
||||
value: tier.cost,
|
||||
locale,
|
||||
})}
|
||||
</span>{' '}
|
||||
<If condition={tiersLength > 1}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:andAbove'}
|
||||
values={{
|
||||
unit,
|
||||
previousTier: (previousTierFrom as number) - 1,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
<If condition={tiersLength === 1}>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:forEveryUnit'}
|
||||
values={{
|
||||
unit,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
</If>{' '}
|
||||
<If condition={!isLastTier}>
|
||||
<If condition={isIncluded}>
|
||||
<span>
|
||||
<Trans i18nKey={'billing:includedUpTo'} values={{ unit, upTo }} />
|
||||
</span>
|
||||
</If>{' '}
|
||||
<If condition={!isIncluded}>
|
||||
<span className={'font-bold'}>
|
||||
{formatCurrency({
|
||||
currencyCode: currency.toLowerCase(),
|
||||
value: tier.cost,
|
||||
locale,
|
||||
})}
|
||||
</span>{' '}
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={'billing:fromPreviousTierUpTo'}
|
||||
values={{ previousTierFrom, unit, upTo }}
|
||||
/>
|
||||
</span>
|
||||
</If>
|
||||
</If>
|
||||
</span>
|
||||
);
|
||||
});
|
||||
|
||||
return <div className={'my-1 flex flex-col space-y-1.5'}>{tiers}</div>;
|
||||
}
|
||||
1
packages/billing/gateway/src/components/marketing.tsx
Normal file
1
packages/billing/gateway/src/components/marketing.tsx
Normal file
@@ -0,0 +1 @@
|
||||
export * from './pricing-table';
|
||||
@@ -0,0 +1,98 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { LineItemSchema } from '@kit/billing';
|
||||
import { formatCurrency } from '@kit/shared/utils';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
type PlanCostDisplayProps = {
|
||||
primaryLineItem: z.infer<typeof LineItemSchema>;
|
||||
currencyCode: string;
|
||||
interval?: string;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* @name PlanCostDisplay
|
||||
* @description
|
||||
* This component is used to display the cost of a plan. It will handle
|
||||
* the display of the cost for metered plans by using the lowest tier using the format "Starting at {price} {unit}"
|
||||
*/
|
||||
export function PlanCostDisplay({
|
||||
primaryLineItem,
|
||||
currencyCode,
|
||||
interval,
|
||||
alwaysDisplayMonthlyPrice = true,
|
||||
className,
|
||||
}: PlanCostDisplayProps) {
|
||||
const { i18n } = useTranslation();
|
||||
|
||||
const { shouldDisplayTier, lowestTier, tierTranslationKey, displayCost } =
|
||||
useMemo(() => {
|
||||
const shouldDisplayTier =
|
||||
primaryLineItem.type === 'metered' &&
|
||||
Array.isArray(primaryLineItem.tiers) &&
|
||||
primaryLineItem.tiers.length > 0;
|
||||
|
||||
const isMultiTier =
|
||||
Array.isArray(primaryLineItem.tiers) &&
|
||||
primaryLineItem.tiers.length > 1;
|
||||
|
||||
const lowestTier = primaryLineItem.tiers?.reduce((acc, curr) => {
|
||||
if (acc && acc.cost < curr.cost) {
|
||||
return acc;
|
||||
}
|
||||
return curr;
|
||||
}, primaryLineItem.tiers?.[0]);
|
||||
|
||||
const isYearlyPricing = interval === 'year';
|
||||
|
||||
const cost =
|
||||
isYearlyPricing && alwaysDisplayMonthlyPrice
|
||||
? Number(primaryLineItem.cost / 12)
|
||||
: primaryLineItem.cost;
|
||||
|
||||
return {
|
||||
shouldDisplayTier,
|
||||
isMultiTier,
|
||||
lowestTier,
|
||||
tierTranslationKey: isMultiTier
|
||||
? 'billing:startingAtPriceUnit'
|
||||
: 'billing:priceUnit',
|
||||
displayCost: cost,
|
||||
};
|
||||
}, [primaryLineItem, interval, alwaysDisplayMonthlyPrice]);
|
||||
|
||||
if (shouldDisplayTier) {
|
||||
const formattedCost = formatCurrency({
|
||||
currencyCode: currencyCode.toLowerCase(),
|
||||
value: lowestTier?.cost ?? 0,
|
||||
locale: i18n.language,
|
||||
});
|
||||
|
||||
return (
|
||||
<span className={'text-lg'}>
|
||||
<Trans
|
||||
i18nKey={tierTranslationKey}
|
||||
values={{
|
||||
price: formattedCost,
|
||||
unit: primaryLineItem.unit,
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
const formattedCost = formatCurrency({
|
||||
currencyCode: currencyCode.toLowerCase(),
|
||||
value: displayCost,
|
||||
locale: i18n.language,
|
||||
});
|
||||
|
||||
return <span className={className}>{formattedCost}</span>;
|
||||
}
|
||||
500
packages/billing/gateway/src/components/plan-picker.tsx
Normal file
500
packages/billing/gateway/src/components/plan-picker.tsx
Normal file
@@ -0,0 +1,500 @@
|
||||
'use client';
|
||||
|
||||
import { useMemo } from 'react';
|
||||
|
||||
import { zodResolver } from '@hookform/resolvers/zod';
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useForm, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
type LineItemSchema,
|
||||
getPlanIntervals,
|
||||
getPrimaryLineItem,
|
||||
getProductPlanPair,
|
||||
} from '@kit/billing';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from '@kit/ui/form';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Label } from '@kit/ui/label';
|
||||
import {
|
||||
RadioGroup,
|
||||
RadioGroupItem,
|
||||
RadioGroupItemLabel,
|
||||
} from '@kit/ui/radio-group';
|
||||
import { Separator } from '@kit/ui/separator';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { LineItemDetails } from './line-item-details';
|
||||
import { PlanCostDisplay } from './plan-cost-display';
|
||||
|
||||
export function PlanPicker(
|
||||
props: React.PropsWithChildren<{
|
||||
config: BillingConfig;
|
||||
onSubmit: (data: { planId: string; productId: string }) => void;
|
||||
canStartTrial?: boolean;
|
||||
pending?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const { t } = useTranslation(`billing`);
|
||||
|
||||
const intervals = useMemo(
|
||||
() => getPlanIntervals(props.config),
|
||||
[props.config],
|
||||
) as string[];
|
||||
|
||||
const form = useForm({
|
||||
reValidateMode: 'onChange',
|
||||
mode: 'onChange',
|
||||
resolver: zodResolver(
|
||||
z
|
||||
.object({
|
||||
planId: z.string(),
|
||||
productId: z.string(),
|
||||
interval: z.string().optional(),
|
||||
})
|
||||
.refine(
|
||||
(data) => {
|
||||
try {
|
||||
const { product, plan } = getProductPlanPair(
|
||||
props.config,
|
||||
data.planId,
|
||||
);
|
||||
|
||||
return product && plan;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: t('noPlanChosen'), path: ['planId'] },
|
||||
),
|
||||
),
|
||||
defaultValues: {
|
||||
interval: intervals[0],
|
||||
planId: '',
|
||||
productId: '',
|
||||
},
|
||||
});
|
||||
|
||||
const selectedInterval = useWatch({
|
||||
name: 'interval',
|
||||
control: form.control,
|
||||
});
|
||||
|
||||
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]);
|
||||
|
||||
// display the period picker if the selected plan is recurring or if no plan is selected
|
||||
const isRecurringPlan =
|
||||
selectedPlan?.paymentType === 'recurring' || !selectedPlan;
|
||||
|
||||
// Always filter out hidden products
|
||||
const visibleProducts = props.config.products.filter(
|
||||
(product) => !product.hidden,
|
||||
);
|
||||
|
||||
return (
|
||||
<Form {...form}>
|
||||
<div
|
||||
className={'flex flex-col gap-y-4 lg:flex-row lg:gap-x-4 lg:gap-y-0'}
|
||||
>
|
||||
<form
|
||||
className={'flex w-full max-w-xl flex-col gap-y-8'}
|
||||
onSubmit={form.handleSubmit(props.onSubmit)}
|
||||
>
|
||||
<If condition={intervals.length}>
|
||||
<div
|
||||
className={cn('transition-all', {
|
||||
['pointer-events-none opacity-50']: !isRecurringPlan,
|
||||
})}
|
||||
>
|
||||
<FormField
|
||||
name={'interval'}
|
||||
render={({ field }) => {
|
||||
return (
|
||||
<FormItem className={'flex flex-col gap-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(
|
||||
'focus-within:border-primary flex items-center gap-x-2.5 rounded-md border px-2.5 py-2 transition-colors',
|
||||
{
|
||||
['bg-muted border-input']: selected,
|
||||
['hover:border-input border-transparent']:
|
||||
!selected,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<RadioGroupItem
|
||||
id={interval}
|
||||
value={interval}
|
||||
onClick={() => {
|
||||
form.setValue('interval', interval, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
if (selectedProduct) {
|
||||
const plan = selectedProduct.plans.find(
|
||||
(item) => item.interval === interval,
|
||||
);
|
||||
|
||||
form.setValue(
|
||||
'planId',
|
||||
plan?.id ?? '',
|
||||
{
|
||||
shouldValidate: true,
|
||||
shouldDirty: true,
|
||||
shouldTouch: true,
|
||||
},
|
||||
);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
['cursor-pointer']: !selected,
|
||||
})}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:billingInterval.${interval}`}
|
||||
/>
|
||||
</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<FormField
|
||||
name={'planId'}
|
||||
render={({ field }) => (
|
||||
<FormItem className={'flex flex-col gap-4'}>
|
||||
<FormLabel>
|
||||
<Trans i18nKey={'common:planPickerLabel'} />
|
||||
</FormLabel>
|
||||
|
||||
<FormControl>
|
||||
<RadioGroup value={field.value} name={field.name}>
|
||||
{visibleProducts.map((product) => {
|
||||
const plan = product.plans.find((item) => {
|
||||
if (item.paymentType === 'one-time') {
|
||||
return true;
|
||||
}
|
||||
|
||||
return item.interval === selectedInterval;
|
||||
});
|
||||
|
||||
if (!plan || plan.custom) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const planId = plan.id;
|
||||
const selected = field.value === planId;
|
||||
|
||||
const primaryLineItem = getPrimaryLineItem(
|
||||
props.config,
|
||||
planId,
|
||||
);
|
||||
|
||||
if (!primaryLineItem) {
|
||||
throw new Error(`Base line item was not found`);
|
||||
}
|
||||
|
||||
return (
|
||||
<RadioGroupItemLabel
|
||||
selected={selected}
|
||||
key={primaryLineItem.id}
|
||||
>
|
||||
<RadioGroupItem
|
||||
data-test-plan={plan.id}
|
||||
key={plan.id + selected}
|
||||
id={plan.id}
|
||||
value={plan.id}
|
||||
onClick={() => {
|
||||
if (selected) {
|
||||
return;
|
||||
}
|
||||
|
||||
form.setValue('planId', planId, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
|
||||
form.setValue('productId', product.id, {
|
||||
shouldValidate: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex w-full flex-col content-center gap-y-3 lg:flex-row lg:items-center lg:justify-between lg:space-y-0'
|
||||
}
|
||||
>
|
||||
<Label
|
||||
htmlFor={plan.id}
|
||||
className={
|
||||
'flex flex-col justify-center space-y-2'
|
||||
}
|
||||
>
|
||||
<div className={'flex items-center space-x-2.5'}>
|
||||
<span className="font-semibold">
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${product.id}.name`}
|
||||
defaults={product.name}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<If
|
||||
condition={
|
||||
plan.trialDays && props.canStartTrial
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Badge
|
||||
className={'px-1 py-0.5 text-xs'}
|
||||
variant={'success'}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={`billing:trialPeriod`}
|
||||
values={{
|
||||
period: plan.trialDays,
|
||||
}}
|
||||
/>
|
||||
</Badge>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<span className={'text-muted-foreground'}>
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${product.id}.description`}
|
||||
defaults={product.description}
|
||||
/>
|
||||
</span>
|
||||
</Label>
|
||||
|
||||
<div
|
||||
className={
|
||||
'flex flex-col gap-y-3 lg:flex-row lg:items-center lg:space-x-4 lg:space-y-0 lg:text-right'
|
||||
}
|
||||
>
|
||||
<div>
|
||||
<Price key={plan.id}>
|
||||
<PlanCostDisplay
|
||||
primaryLineItem={primaryLineItem}
|
||||
currencyCode={product.currency}
|
||||
interval={selectedInterval}
|
||||
alwaysDisplayMonthlyPrice={true}
|
||||
/>
|
||||
</Price>
|
||||
|
||||
<div>
|
||||
<span className={'text-muted-foreground'}>
|
||||
<If
|
||||
condition={
|
||||
plan.paymentType === 'recurring'
|
||||
}
|
||||
fallback={
|
||||
<Trans i18nKey={`billing:lifetime`} />
|
||||
}
|
||||
>
|
||||
<Trans i18nKey={`billing:perMonth`} />
|
||||
</If>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RadioGroupItemLabel>
|
||||
);
|
||||
})}
|
||||
</RadioGroup>
|
||||
</FormControl>
|
||||
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<div>
|
||||
<Button
|
||||
data-test="checkout-submit-button"
|
||||
disabled={props.pending ?? !form.formState.isValid}
|
||||
>
|
||||
{props.pending ? (
|
||||
t('redirectingToPayment')
|
||||
) : (
|
||||
<>
|
||||
<If
|
||||
condition={selectedPlan?.trialDays && 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';
|
||||
|
||||
// trick to force animation on re-render
|
||||
const key = Math.random();
|
||||
|
||||
return (
|
||||
<div
|
||||
key={key}
|
||||
className={
|
||||
'fade-in animate-in zoom-in-95 flex w-full flex-col space-y-4 py-2 lg:px-8'
|
||||
}
|
||||
>
|
||||
<div className={'flex flex-col space-y-0.5'}>
|
||||
<span className={'text-sm font-medium'}>
|
||||
<b>
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${selectedProduct.id}.name`}
|
||||
defaults={selectedProduct.name}
|
||||
/>
|
||||
</b>{' '}
|
||||
<If condition={isRecurring}>
|
||||
/ <Trans i18nKey={`billing:billingInterval.${selectedInterval}`} />
|
||||
</If>
|
||||
</span>
|
||||
|
||||
<p>
|
||||
<span className={'text-muted-foreground text-sm'}>
|
||||
<Trans
|
||||
i18nKey={`billing:plans.${selectedProduct.id}.description`}
|
||||
defaults={selectedProduct.description}
|
||||
/>
|
||||
</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<If condition={selectedPlan.lineItems.length > 0}>
|
||||
<Separator />
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing:detailsLabel'} />
|
||||
</span>
|
||||
|
||||
<LineItemDetails
|
||||
lineItems={selectedPlan.lineItems ?? []}
|
||||
selectedInterval={isRecurring ? selectedInterval : undefined}
|
||||
currency={selectedProduct.currency}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<span className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing:featuresLabel'} />
|
||||
</span>
|
||||
|
||||
{selectedProduct.features.map((item) => {
|
||||
return (
|
||||
<div key={item} className={'flex items-center gap-x-2 text-sm'}>
|
||||
<CheckCircle className={'h-4 text-green-500'} />
|
||||
|
||||
<span className={'text-secondary-foreground'}>
|
||||
<Trans i18nKey={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-semibold tracking-tight duration-500'
|
||||
}
|
||||
>
|
||||
{props.children}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
519
packages/billing/gateway/src/components/pricing-table.tsx
Normal file
519
packages/billing/gateway/src/components/pricing-table.tsx
Normal file
@@ -0,0 +1,519 @@
|
||||
'use client';
|
||||
|
||||
import { useState } from 'react';
|
||||
|
||||
import Link from 'next/link';
|
||||
|
||||
import { ArrowRight, CheckCircle } from 'lucide-react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
BillingConfig,
|
||||
type LineItemSchema,
|
||||
getPlanIntervals,
|
||||
getPrimaryLineItem,
|
||||
} from '@kit/billing';
|
||||
import { Badge } from '@kit/ui/badge';
|
||||
import { Button } from '@kit/ui/button';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
import { cn } from '@kit/ui/utils';
|
||||
|
||||
import { LineItemDetails } from './line-item-details';
|
||||
import { PlanCostDisplay } from './plan-cost-display';
|
||||
|
||||
interface Paths {
|
||||
signUp: string;
|
||||
return: string;
|
||||
}
|
||||
|
||||
type Interval = 'month' | 'year';
|
||||
|
||||
export function PricingTable({
|
||||
config,
|
||||
paths,
|
||||
CheckoutButtonRenderer,
|
||||
redirectToCheckout = true,
|
||||
displayPlanDetails = true,
|
||||
alwaysDisplayMonthlyPrice = true,
|
||||
}: {
|
||||
config: BillingConfig;
|
||||
paths: Paths;
|
||||
displayPlanDetails?: boolean;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
redirectToCheckout?: boolean;
|
||||
|
||||
CheckoutButtonRenderer?: React.ComponentType<{
|
||||
planId: string;
|
||||
productId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
}) {
|
||||
const intervals = getPlanIntervals(config).filter(Boolean) as Interval[];
|
||||
const [interval, setInterval] = useState(intervals[0]!);
|
||||
|
||||
// Always filter out hidden products
|
||||
const visibleProducts = config.products.filter((product) => !product.hidden);
|
||||
|
||||
return (
|
||||
<div className={'flex flex-col space-y-8 xl:space-y-12'}>
|
||||
<div className={'flex justify-center'}>
|
||||
{intervals.length > 1 ? (
|
||||
<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 lg:flex-row lg:space-x-4'
|
||||
}
|
||||
>
|
||||
{visibleProducts.map((product) => {
|
||||
const plan = product.plans.find((plan) => {
|
||||
if (plan.paymentType === 'recurring') {
|
||||
return plan.interval === interval;
|
||||
}
|
||||
|
||||
return plan;
|
||||
});
|
||||
|
||||
if (!plan) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const primaryLineItem = getPrimaryLineItem(config, plan.id);
|
||||
|
||||
if (!plan.custom && !primaryLineItem) {
|
||||
throw new Error(`Primary line item not found for plan ${plan.id}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<PricingItem
|
||||
selectable
|
||||
key={plan.id}
|
||||
plan={plan}
|
||||
redirectToCheckout={redirectToCheckout}
|
||||
primaryLineItem={primaryLineItem}
|
||||
product={product}
|
||||
paths={paths}
|
||||
displayPlanDetails={displayPlanDetails}
|
||||
alwaysDisplayMonthlyPrice={alwaysDisplayMonthlyPrice}
|
||||
CheckoutButton={CheckoutButtonRenderer}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PricingItem(
|
||||
props: React.PropsWithChildren<{
|
||||
className?: string;
|
||||
displayPlanDetails: boolean;
|
||||
|
||||
paths: Paths;
|
||||
|
||||
selectable: boolean;
|
||||
|
||||
primaryLineItem: z.infer<typeof LineItemSchema> | undefined;
|
||||
|
||||
redirectToCheckout?: boolean;
|
||||
alwaysDisplayMonthlyPrice?: boolean;
|
||||
|
||||
plan: {
|
||||
id: string;
|
||||
lineItems: z.infer<typeof LineItemSchema>[];
|
||||
interval?: Interval;
|
||||
name?: string;
|
||||
href?: string;
|
||||
label?: string;
|
||||
custom?: boolean;
|
||||
};
|
||||
|
||||
CheckoutButton?: React.ComponentType<{
|
||||
planId: string;
|
||||
productId: string;
|
||||
highlighted?: boolean;
|
||||
}>;
|
||||
|
||||
product: {
|
||||
id: string;
|
||||
name: string;
|
||||
currency: string;
|
||||
description: string;
|
||||
badge?: string;
|
||||
highlighted?: boolean;
|
||||
features: string[];
|
||||
};
|
||||
}>,
|
||||
) {
|
||||
const highlighted = props.product.highlighted ?? false;
|
||||
const lineItem = props.primaryLineItem!;
|
||||
const isCustom = props.plan.custom ?? false;
|
||||
|
||||
// we exclude flat line items from the details since
|
||||
// it doesn't need further explanation
|
||||
const lineItemsToDisplay = props.plan.lineItems.filter((item) => {
|
||||
return item.type !== 'flat';
|
||||
});
|
||||
|
||||
const interval = props.plan.interval as Interval;
|
||||
|
||||
return (
|
||||
<div
|
||||
data-cy={'subscription-plan'}
|
||||
className={cn(
|
||||
props.className,
|
||||
`s-full relative flex flex-1 grow flex-col items-stretch justify-between self-stretch rounded-lg border px-6 py-5 lg:w-4/12 xl:max-w-[20rem]`,
|
||||
{
|
||||
['border-primary']: highlighted,
|
||||
['border-border']: !highlighted,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<If condition={props.product.badge}>
|
||||
<div className={'absolute -top-2.5 left-0 flex w-full justify-center'}>
|
||||
<Badge
|
||||
className={highlighted ? '' : 'bg-background'}
|
||||
variant={highlighted ? 'default' : 'outline'}
|
||||
>
|
||||
<span>
|
||||
<Trans
|
||||
i18nKey={props.product.badge}
|
||||
defaults={props.product.badge}
|
||||
/>
|
||||
</span>
|
||||
</Badge>
|
||||
</div>
|
||||
</If>
|
||||
|
||||
<div className={'flex flex-col gap-y-5'}>
|
||||
<div className={'flex flex-col gap-y-1'}>
|
||||
<div className={'flex items-center space-x-6'}>
|
||||
<b
|
||||
className={
|
||||
'text-secondary-foreground font-heading text-xl font-medium tracking-tight'
|
||||
}
|
||||
>
|
||||
<Trans
|
||||
i18nKey={props.product.name}
|
||||
defaults={props.product.name}
|
||||
/>
|
||||
</b>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className={'mt-6 flex flex-col gap-y-1'}>
|
||||
<Price
|
||||
isMonthlyPrice={props.alwaysDisplayMonthlyPrice}
|
||||
displayBillingPeriod={!props.plan.label}
|
||||
>
|
||||
<If
|
||||
condition={!isCustom}
|
||||
fallback={
|
||||
<Trans i18nKey={props.plan.label} defaults={props.plan.label} />
|
||||
}
|
||||
>
|
||||
<PlanCostDisplay
|
||||
primaryLineItem={lineItem}
|
||||
currencyCode={props.product.currency}
|
||||
interval={interval}
|
||||
alwaysDisplayMonthlyPrice={props.alwaysDisplayMonthlyPrice}
|
||||
/>
|
||||
</If>
|
||||
</Price>
|
||||
|
||||
<If condition={props.plan.name}>
|
||||
<span
|
||||
className={cn(
|
||||
`animate-in slide-in-from-left-4 fade-in text-muted-foreground flex items-center gap-x-1 text-xs capitalize`,
|
||||
)}
|
||||
>
|
||||
<span>
|
||||
<If
|
||||
condition={props.plan.interval}
|
||||
fallback={<Trans i18nKey={'billing:lifetime'} />}
|
||||
>
|
||||
{(interval) => (
|
||||
<Trans i18nKey={`billing:billingInterval.${interval}`} />
|
||||
)}
|
||||
</If>
|
||||
</span>
|
||||
|
||||
<If condition={lineItem && lineItem?.type !== 'flat'}>
|
||||
<span>/</span>
|
||||
|
||||
<span
|
||||
className={cn(
|
||||
`animate-in slide-in-from-left-4 fade-in text-sm capitalize`,
|
||||
)}
|
||||
>
|
||||
<If condition={lineItem?.type === 'per_seat'}>
|
||||
<Trans i18nKey={'billing:perTeamMember'} />
|
||||
</If>
|
||||
|
||||
<If condition={lineItem?.unit}>
|
||||
<Trans
|
||||
i18nKey={'billing:perUnit'}
|
||||
values={{
|
||||
unit: lineItem?.unit,
|
||||
}}
|
||||
/>
|
||||
</If>
|
||||
</span>
|
||||
</If>
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
|
||||
<If condition={props.selectable}>
|
||||
<If
|
||||
condition={props.plan.id && props.CheckoutButton}
|
||||
fallback={
|
||||
<DefaultCheckoutButton
|
||||
paths={props.paths}
|
||||
product={props.product}
|
||||
highlighted={highlighted}
|
||||
plan={props.plan}
|
||||
redirectToCheckout={props.redirectToCheckout}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{(CheckoutButton) => (
|
||||
<CheckoutButton
|
||||
highlighted={highlighted}
|
||||
planId={props.plan.id}
|
||||
productId={props.product.id}
|
||||
/>
|
||||
)}
|
||||
</If>
|
||||
</If>
|
||||
|
||||
<span className={cn(`text-muted-foreground text-base tracking-tight`)}>
|
||||
<Trans
|
||||
i18nKey={props.product.description}
|
||||
defaults={props.product.description}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<div className={'h-px w-full border border-dashed'} />
|
||||
|
||||
<div className={'flex flex-col'}>
|
||||
<FeaturesList
|
||||
highlighted={highlighted}
|
||||
features={props.product.features}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<If condition={props.displayPlanDetails && lineItemsToDisplay.length}>
|
||||
<div className={'h-px w-full border border-dashed'} />
|
||||
|
||||
<div className={'flex flex-col space-y-2'}>
|
||||
<h6 className={'text-sm font-semibold'}>
|
||||
<Trans i18nKey={'billing:detailsLabel'} />
|
||||
</h6>
|
||||
|
||||
<LineItemDetails
|
||||
selectedInterval={props.plan.interval}
|
||||
currency={props.product.currency}
|
||||
lineItems={lineItemsToDisplay}
|
||||
/>
|
||||
</div>
|
||||
</If>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function FeaturesList(
|
||||
props: React.PropsWithChildren<{
|
||||
features: string[];
|
||||
highlighted: boolean;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<ul className={'flex flex-col gap-1'}>
|
||||
{props.features.map((feature) => {
|
||||
return (
|
||||
<ListItem highlighted={props.highlighted} key={feature}>
|
||||
<Trans i18nKey={feature} defaults={feature} />
|
||||
</ListItem>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
function Price({
|
||||
children,
|
||||
isMonthlyPrice = true,
|
||||
displayBillingPeriod = true,
|
||||
}: React.PropsWithChildren<{
|
||||
isMonthlyPrice?: boolean;
|
||||
displayBillingPeriod?: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<div
|
||||
className={`animate-in slide-in-from-left-4 fade-in flex items-end gap-1 duration-500`}
|
||||
>
|
||||
<span
|
||||
className={
|
||||
'font-heading flex items-center text-4xl font-medium tracking-tighter'
|
||||
}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
|
||||
<If condition={isMonthlyPrice && displayBillingPeriod}>
|
||||
<span className={'text-muted-foreground text-sm leading-loose'}>
|
||||
<span>/</span>
|
||||
|
||||
<Trans i18nKey={'billing:perMonth'} />
|
||||
</span>
|
||||
</If>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ListItem({
|
||||
children,
|
||||
highlighted,
|
||||
}: React.PropsWithChildren<{
|
||||
highlighted: boolean;
|
||||
}>) {
|
||||
return (
|
||||
<li className={'flex items-center gap-x-2.5'}>
|
||||
<CheckCircle
|
||||
className={cn('h-4 min-h-4 w-4 min-w-4', {
|
||||
'text-secondary-foreground': highlighted,
|
||||
'text-muted-foreground': !highlighted,
|
||||
})}
|
||||
/>
|
||||
|
||||
<span
|
||||
className={cn('text-sm', {
|
||||
'text-muted-foreground': !highlighted,
|
||||
'text-secondary-foreground': highlighted,
|
||||
})}
|
||||
>
|
||||
{children}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
}
|
||||
|
||||
function PlanIntervalSwitcher(
|
||||
props: React.PropsWithChildren<{
|
||||
intervals: Interval[];
|
||||
interval: Interval;
|
||||
setInterval: (interval: Interval) => void;
|
||||
}>,
|
||||
) {
|
||||
return (
|
||||
<div className={'flex gap-x-1 rounded-full border p-1.5'}>
|
||||
{props.intervals.map((plan, index) => {
|
||||
const selected = plan === props.interval;
|
||||
|
||||
const className = cn(
|
||||
'animate-in fade-in !outline-hidden rounded-full transition-all focus:!ring-0',
|
||||
{
|
||||
'border-r-transparent': index === 0,
|
||||
['hover:text-primary text-muted-foreground']: !selected,
|
||||
['cursor-default font-semibold']: selected,
|
||||
['hover:bg-initial']: !selected,
|
||||
},
|
||||
);
|
||||
|
||||
return (
|
||||
<Button
|
||||
key={plan}
|
||||
size={'sm'}
|
||||
variant={selected ? 'default' : 'ghost'}
|
||||
className={className}
|
||||
onClick={() => props.setInterval(plan)}
|
||||
>
|
||||
<span className={'flex items-center'}>
|
||||
<CheckCircle
|
||||
className={cn('animate-in fade-in zoom-in-95 h-3.5', {
|
||||
hidden: !selected,
|
||||
'slide-in-from-left-4': index === 0,
|
||||
'slide-in-from-right-4': index === props.intervals.length - 1,
|
||||
})}
|
||||
/>
|
||||
|
||||
<span className={'capitalize'}>
|
||||
<Trans i18nKey={`common:billingInterval.${plan}`} />
|
||||
</span>
|
||||
</span>
|
||||
</Button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DefaultCheckoutButton(
|
||||
props: React.PropsWithChildren<{
|
||||
plan: {
|
||||
id: string;
|
||||
name?: string | undefined;
|
||||
href?: string;
|
||||
buttonLabel?: string;
|
||||
};
|
||||
|
||||
product: {
|
||||
name: string;
|
||||
};
|
||||
|
||||
paths: Paths;
|
||||
redirectToCheckout?: boolean;
|
||||
|
||||
highlighted?: boolean;
|
||||
}>,
|
||||
) {
|
||||
const { t } = useTranslation('billing');
|
||||
|
||||
const signUpPath = props.paths.signUp;
|
||||
|
||||
const searchParams = new URLSearchParams({
|
||||
next: props.paths.return,
|
||||
plan: props.plan.id,
|
||||
redirectToCheckout: props.redirectToCheckout ? 'true' : 'false',
|
||||
});
|
||||
|
||||
const linkHref =
|
||||
props.plan.href ?? `${signUpPath}?${searchParams.toString()}`;
|
||||
|
||||
const label = props.plan.buttonLabel ?? 'common:getStartedWithPlan';
|
||||
|
||||
return (
|
||||
<Link className={'w-full'} href={linkHref}>
|
||||
<Button
|
||||
size={'lg'}
|
||||
className={'h-12 w-full rounded-lg'}
|
||||
variant={props.highlighted ? 'default' : 'secondary'}
|
||||
>
|
||||
<span className={'text-base font-medium tracking-tight'}>
|
||||
<Trans
|
||||
i18nKey={label}
|
||||
defaults={label}
|
||||
values={{
|
||||
plan: t(props.product.name, {
|
||||
defaultValue: props.product.name,
|
||||
}),
|
||||
}}
|
||||
/>
|
||||
</span>
|
||||
|
||||
<ArrowRight className={'ml-2 h-4'} />
|
||||
</Button>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
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-event-handler-provider';
|
||||
export * from './server/services/billing-webhooks/billing-webhooks.service';
|
||||
@@ -0,0 +1,48 @@
|
||||
import 'server-only';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type BillingProviderSchema,
|
||||
BillingWebhookHandlerService,
|
||||
type PlanTypeMap,
|
||||
} from '@kit/billing';
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
/**
|
||||
* @description Creates a registry for billing webhook handlers
|
||||
* @param planTypesMap - A map of plan types as setup by the user in the billing config
|
||||
* @returns The billing webhook handler registry
|
||||
*/
|
||||
export function createBillingEventHandlerFactoryService(
|
||||
planTypesMap: PlanTypeMap,
|
||||
) {
|
||||
// Create a registry for billing webhook handlers
|
||||
const billingWebhookHandlerRegistry = createRegistry<
|
||||
BillingWebhookHandlerService,
|
||||
z.infer<typeof BillingProviderSchema>
|
||||
>();
|
||||
|
||||
// Register the Stripe webhook handler
|
||||
billingWebhookHandlerRegistry.register('stripe', async () => {
|
||||
const { StripeWebhookHandlerService } = await import('@kit/stripe');
|
||||
|
||||
return new StripeWebhookHandlerService(planTypesMap);
|
||||
});
|
||||
|
||||
// Register the Lemon Squeezy webhook handler
|
||||
billingWebhookHandlerRegistry.register('lemon-squeezy', async () => {
|
||||
const { LemonSqueezyWebhookHandlerService } = await import(
|
||||
'@kit/lemon-squeezy'
|
||||
);
|
||||
|
||||
return new LemonSqueezyWebhookHandlerService(planTypesMap);
|
||||
});
|
||||
|
||||
// Register Paddle webhook handler (not implemented yet)
|
||||
billingWebhookHandlerRegistry.register('paddle', () => {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
});
|
||||
|
||||
return billingWebhookHandlerRegistry;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import 'server-only';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import type { PlanTypeMap } from '@kit/billing';
|
||||
import { Database, Enums } from '@kit/supabase/database';
|
||||
|
||||
import { createBillingEventHandlerFactoryService } from './billing-event-handler-factory.service';
|
||||
import { createBillingEventHandlerService } from './billing-event-handler.service';
|
||||
|
||||
// a function that returns a Supabase client
|
||||
type ClientProvider = () => SupabaseClient<Database>;
|
||||
|
||||
// the billing provider from the database
|
||||
type BillingProvider = Enums<'billing_provider'>;
|
||||
|
||||
/**
|
||||
* @name getBillingEventHandlerService
|
||||
* @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: ClientProvider,
|
||||
provider: BillingProvider,
|
||||
planTypesMap: PlanTypeMap,
|
||||
) {
|
||||
const strategy =
|
||||
await createBillingEventHandlerFactoryService(planTypesMap).get(provider);
|
||||
|
||||
return createBillingEventHandlerService(clientProvider, strategy);
|
||||
}
|
||||
@@ -0,0 +1,285 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { BillingWebhookHandlerService } from '@kit/billing';
|
||||
import {
|
||||
UpsertOrderParams,
|
||||
UpsertSubscriptionParams,
|
||||
} from '@kit/billing/types';
|
||||
import { getLogger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
/**
|
||||
* @name CustomHandlersParams
|
||||
* @description Allow consumers to provide custom handlers for the billing events
|
||||
* that are triggered by the webhook events.
|
||||
*/
|
||||
interface CustomHandlersParams {
|
||||
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
|
||||
onSubscriptionUpdated: (
|
||||
subscription: UpsertSubscriptionParams,
|
||||
) => Promise<unknown>;
|
||||
onCheckoutSessionCompleted: (
|
||||
subscription: UpsertSubscriptionParams | UpsertOrderParams,
|
||||
customerId: string,
|
||||
) => Promise<unknown>;
|
||||
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
|
||||
onPaymentFailed: (sessionId: string) => Promise<unknown>;
|
||||
onInvoicePaid: (subscription: UpsertSubscriptionParams) => Promise<unknown>;
|
||||
onEvent(event: unknown): Promise<unknown>;
|
||||
}
|
||||
|
||||
/**
|
||||
* @name createBillingEventHandlerService
|
||||
* @description Create a new instance of the `BillingEventHandlerService` class
|
||||
* @param clientProvider
|
||||
* @param strategy
|
||||
*/
|
||||
export function createBillingEventHandlerService(
|
||||
clientProvider: () => SupabaseClient<Database>,
|
||||
strategy: BillingWebhookHandlerService,
|
||||
) {
|
||||
return new BillingEventHandlerService(clientProvider, strategy);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name BillingEventHandlerService
|
||||
* @description This class is used to handle the webhook events from the billing provider
|
||||
*/
|
||||
class BillingEventHandlerService {
|
||||
private readonly namespace = 'billing';
|
||||
|
||||
constructor(
|
||||
private readonly clientProvider: () => SupabaseClient<Database>,
|
||||
private readonly strategy: BillingWebhookHandlerService,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @name handleWebhookEvent
|
||||
* @description Handle the webhook event from the billing provider
|
||||
* @param request
|
||||
* @param params
|
||||
*/
|
||||
async handleWebhookEvent(
|
||||
request: Request,
|
||||
params: Partial<CustomHandlersParams> = {},
|
||||
) {
|
||||
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();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
subscriptionId,
|
||||
};
|
||||
|
||||
// Handle the subscription deleted event
|
||||
// here we delete the subscription from the database
|
||||
logger.info(ctx, 'Processing subscription deleted event...');
|
||||
|
||||
const { error } = await client
|
||||
.from('subscriptions')
|
||||
.delete()
|
||||
.match({ id: subscriptionId });
|
||||
|
||||
if (error) {
|
||||
logger.error(
|
||||
{
|
||||
error,
|
||||
...ctx,
|
||||
},
|
||||
`Failed to delete subscription`,
|
||||
);
|
||||
|
||||
throw new Error('Failed to delete subscription');
|
||||
}
|
||||
|
||||
if (params.onSubscriptionDeleted) {
|
||||
await params.onSubscriptionDeleted(subscriptionId);
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Successfully deleted subscription');
|
||||
},
|
||||
onSubscriptionUpdated: async (subscription) => {
|
||||
const client = this.clientProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
if (params.onSubscriptionUpdated) {
|
||||
await params.onSubscriptionUpdated(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();
|
||||
const logger = await getLogger();
|
||||
|
||||
// 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');
|
||||
}
|
||||
|
||||
if (params.onCheckoutSessionCompleted) {
|
||||
await params.onCheckoutSessionCompleted(
|
||||
payload,
|
||||
payload.target_customer_id,
|
||||
);
|
||||
}
|
||||
|
||||
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);
|
||||
|
||||
// handle the error
|
||||
if (error) {
|
||||
logger.error({ ...ctx, error }, 'Failed to add subscription');
|
||||
|
||||
throw new Error('Failed to add subscription');
|
||||
}
|
||||
|
||||
// allow consumers to provide custom handlers for the event
|
||||
if (params.onCheckoutSessionCompleted) {
|
||||
await params.onCheckoutSessionCompleted(
|
||||
payload,
|
||||
payload.target_customer_id,
|
||||
);
|
||||
}
|
||||
|
||||
// all good
|
||||
logger.info(ctx, 'Successfully added subscription');
|
||||
}
|
||||
},
|
||||
onPaymentSucceeded: async (sessionId: string) => {
|
||||
const client = this.clientProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
// Handle the payment succeeded event
|
||||
// here we update the payment status in the database
|
||||
logger.info(ctx, 'Processing payment succeeded event...');
|
||||
|
||||
const { error } = await client
|
||||
.from('orders')
|
||||
.update({ status: 'succeeded' })
|
||||
.match({ id: sessionId });
|
||||
|
||||
// handle the error
|
||||
if (error) {
|
||||
logger.error({ error, ...ctx }, 'Failed to update payment status');
|
||||
|
||||
throw new Error('Failed to update payment status');
|
||||
}
|
||||
|
||||
// allow consumers to provide custom handlers for the event
|
||||
if (params.onPaymentSucceeded) {
|
||||
await params.onPaymentSucceeded(sessionId);
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Successfully updated payment status');
|
||||
},
|
||||
onPaymentFailed: async (sessionId: string) => {
|
||||
const client = this.clientProvider();
|
||||
const logger = await getLogger();
|
||||
|
||||
const ctx = {
|
||||
namespace: this.namespace,
|
||||
sessionId,
|
||||
};
|
||||
|
||||
// Handle the payment failed event
|
||||
// here we update the payment status in the database
|
||||
logger.info(ctx, 'Processing payment failed event');
|
||||
|
||||
const { error } = await client
|
||||
.from('orders')
|
||||
.update({ status: 'failed' })
|
||||
.match({ id: sessionId });
|
||||
|
||||
if (error) {
|
||||
logger.error({ error, ...ctx }, 'Failed to update payment status');
|
||||
|
||||
throw new Error('Failed to update payment status');
|
||||
}
|
||||
|
||||
// allow consumers to provide custom handlers for the event
|
||||
if (params.onPaymentFailed) {
|
||||
await params.onPaymentFailed(sessionId);
|
||||
}
|
||||
|
||||
logger.info(ctx, 'Successfully updated payment status');
|
||||
},
|
||||
onInvoicePaid: async (payload) => {
|
||||
if (params.onInvoicePaid) {
|
||||
return params.onInvoicePaid(payload);
|
||||
}
|
||||
},
|
||||
onEvent: params.onEvent,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import 'server-only';
|
||||
|
||||
import { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import { Database } from '@kit/supabase/database';
|
||||
|
||||
import { createBillingGatewayService } 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: SupabaseClient<Database>,
|
||||
) {
|
||||
const provider = await getBillingProvider(client);
|
||||
|
||||
return createBillingGatewayService(provider);
|
||||
}
|
||||
|
||||
async function getBillingProvider(client: SupabaseClient<Database>) {
|
||||
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,34 @@
|
||||
import 'server-only';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
type BillingProviderSchema,
|
||||
BillingStrategyProviderService,
|
||||
} from '@kit/billing';
|
||||
import { createRegistry } from '@kit/shared/registry';
|
||||
|
||||
// Create a registry for billing strategy providers
|
||||
export const billingStrategyRegistry = createRegistry<
|
||||
BillingStrategyProviderService,
|
||||
z.infer<typeof BillingProviderSchema>
|
||||
>();
|
||||
|
||||
// Register the Stripe billing strategy
|
||||
billingStrategyRegistry.register('stripe', async () => {
|
||||
const { StripeBillingStrategyService } = await import('@kit/stripe');
|
||||
return new StripeBillingStrategyService();
|
||||
});
|
||||
|
||||
// Register the Lemon Squeezy billing strategy
|
||||
billingStrategyRegistry.register('lemon-squeezy', async () => {
|
||||
const { LemonSqueezyBillingStrategyService } = await import(
|
||||
'@kit/lemon-squeezy'
|
||||
);
|
||||
return new LemonSqueezyBillingStrategyService();
|
||||
});
|
||||
|
||||
// Register Paddle billing strategy (not implemented yet)
|
||||
billingStrategyRegistry.register('paddle', () => {
|
||||
throw new Error('Paddle is not supported yet');
|
||||
});
|
||||
@@ -0,0 +1,143 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
import type { BillingProviderSchema } from '@kit/billing';
|
||||
import {
|
||||
CancelSubscriptionParamsSchema,
|
||||
CreateBillingCheckoutSchema,
|
||||
CreateBillingPortalSessionSchema,
|
||||
QueryBillingUsageSchema,
|
||||
ReportBillingUsageSchema,
|
||||
RetrieveCheckoutSessionSchema,
|
||||
UpdateSubscriptionParamsSchema,
|
||||
} from '@kit/billing/schema';
|
||||
|
||||
import { billingStrategyRegistry } from './billing-gateway-registry';
|
||||
|
||||
export function createBillingGatewayService(
|
||||
provider: z.infer<typeof BillingProviderSchema>,
|
||||
) {
|
||||
return new BillingGatewayService(provider);
|
||||
}
|
||||
|
||||
/**
|
||||
* @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);
|
||||
*/
|
||||
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 this.getStrategy();
|
||||
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 this.getStrategy();
|
||||
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 this.getStrategy();
|
||||
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 this.getStrategy();
|
||||
const payload = CancelSubscriptionParamsSchema.parse(params);
|
||||
|
||||
return strategy.cancelSubscription(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Reports the usage of the billing.
|
||||
* @description This is used to report the usage of the billing to the provider.
|
||||
* @param params
|
||||
*/
|
||||
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = ReportBillingUsageSchema.parse(params);
|
||||
|
||||
return strategy.reportUsage(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* @name queryUsage
|
||||
* @description Queries the usage of the metered billing.
|
||||
* @param params
|
||||
*/
|
||||
async queryUsage(params: z.infer<typeof QueryBillingUsageSchema>) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = QueryBillingUsageSchema.parse(params);
|
||||
|
||||
return strategy.queryUsage(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates a subscription with the specified parameters.
|
||||
* @param params
|
||||
*/
|
||||
async updateSubscriptionItem(
|
||||
params: z.infer<typeof UpdateSubscriptionParamsSchema>,
|
||||
) {
|
||||
const strategy = await this.getStrategy();
|
||||
const payload = UpdateSubscriptionParamsSchema.parse(params);
|
||||
|
||||
return strategy.updateSubscriptionItem(payload);
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieves a subscription from the provider.
|
||||
* @param subscriptionId
|
||||
*/
|
||||
async getSubscription(subscriptionId: string) {
|
||||
const strategy = await this.getStrategy();
|
||||
|
||||
return strategy.getSubscription(subscriptionId);
|
||||
}
|
||||
|
||||
private getStrategy() {
|
||||
return billingStrategyRegistry.get(this.provider);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import 'server-only';
|
||||
|
||||
import { Tables } from '@kit/supabase/database';
|
||||
|
||||
import { createBillingGatewayService } from '../billing-gateway/billing-gateway.service';
|
||||
|
||||
type Subscription = Tables<'subscriptions'>;
|
||||
|
||||
export function createBillingWebhooksService() {
|
||||
return new BillingWebhooksService();
|
||||
}
|
||||
|
||||
/**
|
||||
* @name BillingWebhooksService
|
||||
* @description Service for handling billing webhooks.
|
||||
*/
|
||||
class BillingWebhooksService {
|
||||
/**
|
||||
* @name handleSubscriptionDeletedWebhook
|
||||
* @description Handles the webhook for when a subscription is deleted.
|
||||
* @param subscription
|
||||
*/
|
||||
async handleSubscriptionDeletedWebhook(subscription: Subscription) {
|
||||
const gateway = createBillingGatewayService(subscription.billing_provider);
|
||||
|
||||
const subscriptionData = await gateway.getSubscription(subscription.id);
|
||||
const isCanceled = subscriptionData.status === 'canceled';
|
||||
|
||||
if (isCanceled) {
|
||||
return;
|
||||
}
|
||||
|
||||
return gateway.cancelSubscription({
|
||||
subscriptionId: subscription.id,
|
||||
});
|
||||
}
|
||||
}
|
||||
9
packages/billing/gateway/tsconfig.json
Normal file
9
packages/billing/gateway/tsconfig.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"extends": "@kit/tsconfig/base.json",
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json",
|
||||
"types": ["react/experimental"]
|
||||
},
|
||||
"include": ["*.ts", "src"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
5
packages/billing/lemon-squeezy/README.md
Normal file
5
packages/billing/lemon-squeezy/README.md
Normal file
@@ -0,0 +1,5 @@
|
||||
# Billing / Lemon Squeezy - @kit/lemon-squeezy
|
||||
|
||||
This package is responsible for handling all billing related operations using Lemon Squeezy.
|
||||
|
||||
Please refer to the [documentation](https://makerkit.dev/docs/next-supabase-turbo/billing/lemon-squeezy).
|
||||
3
packages/billing/lemon-squeezy/eslint.config.mjs
Normal file
3
packages/billing/lemon-squeezy/eslint.config.mjs
Normal file
@@ -0,0 +1,3 @@
|
||||
import eslintConfigBase from '@kit/eslint-config/base.js';
|
||||
|
||||
export default eslintConfigBase;
|
||||
17
packages/billing/lemon-squeezy/node_modules/.bin/next
generated
vendored
Executable file
17
packages/billing/lemon-squeezy/node_modules/.bin/next
generated
vendored
Executable file
@@ -0,0 +1,17 @@
|
||||
#!/bin/sh
|
||||
basedir=$(dirname "$(echo "$0" | sed -e 's,\\,/,g')")
|
||||
|
||||
case `uname` in
|
||||
*CYGWIN*) basedir=`cygpath -w "$basedir"`;;
|
||||
esac
|
||||
|
||||
if [ -z "$NODE_PATH" ]; then
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules"
|
||||
else
|
||||
export NODE_PATH="/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/bin/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/dist/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules:/Users/devmcee/dev/mountbirch/MRB2B/node_modules/.pnpm/node_modules:$NODE_PATH"
|
||||
fi
|
||||
if [ -x "$basedir/node" ]; then
|
||||
exec "$basedir/node" "$basedir/../next/dist/bin/next" "$@"
|
||||
else
|
||||
exec node "$basedir/../next/dist/bin/next" "$@"
|
||||
fi
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/billing
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/billing
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../core
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/eslint-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/eslint
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/prettier-config
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/prettier
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/shared
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/shared
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../shared
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/supabase
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/supabase
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../supabase
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/tsconfig
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../tooling/typescript
|
||||
1
packages/billing/lemon-squeezy/node_modules/@kit/ui
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@kit/ui
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../ui
|
||||
1
packages/billing/lemon-squeezy/node_modules/@lemonsqueezy/lemonsqueezy.js
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@lemonsqueezy/lemonsqueezy.js
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@lemonsqueezy+lemonsqueezy.js@4.0.0/node_modules/@lemonsqueezy/lemonsqueezy.js
|
||||
1
packages/billing/lemon-squeezy/node_modules/@types/react
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/@types/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../../node_modules/.pnpm/@types+react@19.1.4/node_modules/@types/react
|
||||
1
packages/billing/lemon-squeezy/node_modules/next
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/next
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/next@15.3.2_@babel+core@7.27.4_@opentelemetry+api@1.9.0_babel-plugin-react-compiler@19.1.0-rc_krzs4il3c2axvegn27goximifi/node_modules/next
|
||||
1
packages/billing/lemon-squeezy/node_modules/react
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/react
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/react@19.1.0/node_modules/react
|
||||
1
packages/billing/lemon-squeezy/node_modules/zod
generated
vendored
Symbolic link
1
packages/billing/lemon-squeezy/node_modules/zod
generated
vendored
Symbolic link
@@ -0,0 +1 @@
|
||||
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod
|
||||
39
packages/billing/lemon-squeezy/package.json
Normal file
39
packages/billing/lemon-squeezy/package.json
Normal file
@@ -0,0 +1,39 @@
|
||||
{
|
||||
"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"
|
||||
},
|
||||
"prettier": "@kit/prettier-config",
|
||||
"exports": {
|
||||
".": "./src/index.ts",
|
||||
"./components": "./src/components/index.ts"
|
||||
},
|
||||
"dependencies": {
|
||||
"@lemonsqueezy/lemonsqueezy.js": "4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kit/billing": "workspace:*",
|
||||
"@kit/eslint-config": "workspace:*",
|
||||
"@kit/prettier-config": "workspace:*",
|
||||
"@kit/shared": "workspace:*",
|
||||
"@kit/supabase": "workspace:*",
|
||||
"@kit/tsconfig": "workspace:*",
|
||||
"@kit/ui": "workspace:*",
|
||||
"@types/react": "19.1.4",
|
||||
"next": "15.3.2",
|
||||
"react": "19.1.0",
|
||||
"zod": "^3.24.4"
|
||||
},
|
||||
"typesVersions": {
|
||||
"*": {
|
||||
"*": [
|
||||
"src/*"
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
1
packages/billing/lemon-squeezy/src/components/index.ts
Normal file
1
packages/billing/lemon-squeezy/src/components/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './lemon-squeezy-embedded-checkout';
|
||||
@@ -0,0 +1,68 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface LemonSqueezyWindow extends Window {
|
||||
createLemonSqueezy: () => void;
|
||||
LemonSqueezy: {
|
||||
Setup: (options: {
|
||||
eventHandler: (event: { event: string } | string) => void;
|
||||
}) => void;
|
||||
Refresh: () => void;
|
||||
Url: {
|
||||
Open: (url: string) => void;
|
||||
Close: () => void;
|
||||
};
|
||||
};
|
||||
}
|
||||
|
||||
export function LemonSqueezyEmbeddedCheckout(props: {
|
||||
checkoutToken: string;
|
||||
onClose?: () => void;
|
||||
}) {
|
||||
useLoadScript(props.checkoutToken, props.onClose);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function useLoadScript(
|
||||
checkoutToken: string,
|
||||
onClose: (() => void) | undefined,
|
||||
) {
|
||||
useEffect(() => {
|
||||
const script = document.createElement('script');
|
||||
|
||||
script.src = 'https://app.lemonsqueezy.com/js/lemon.js';
|
||||
|
||||
script.onload = () => {
|
||||
const win = window as unknown as LemonSqueezyWindow;
|
||||
|
||||
win.createLemonSqueezy();
|
||||
win.LemonSqueezy.Url.Open(checkoutToken);
|
||||
|
||||
if (onClose) {
|
||||
win.LemonSqueezy.Setup({
|
||||
eventHandler: (event) => {
|
||||
if (typeof event === 'string') {
|
||||
if (event === 'close') {
|
||||
onClose();
|
||||
}
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
if (event.event === 'PaymentMethodUpdate.Closed') {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.body.appendChild(script);
|
||||
|
||||
return () => {
|
||||
document.body.removeChild(script);
|
||||
};
|
||||
}, [checkoutToken, onClose]);
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user