B2B-88: add starter kit structure and elements

This commit is contained in:
devmc-ee
2025-06-08 16:18:30 +03:00
parent 657a36a298
commit e7b25600cb
1280 changed files with 77893 additions and 5688 deletions

View 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).

View 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
View File

@@ -0,0 +1 @@
../../../../tooling/eslint

1
packages/analytics/node_modules/@kit/prettier-config generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/prettier

1
packages/analytics/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../tooling/typescript

1
packages/analytics/node_modules/@types/node generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/@types+node@22.15.30/node_modules/@types/node

View 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/*"
]
}
}
}

View 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),
),
);
},
};
}

View 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,
},
});

View 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'),
};

View 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 {}

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View 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": "*"
}
}
```

View 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
View File

@@ -0,0 +1 @@
../../../../../tooling/eslint

1
packages/billing/core/node_modules/@kit/prettier-config generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../tooling/prettier

1
packages/billing/core/node_modules/@kit/supabase generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../supabase

1
packages/billing/core/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../tooling/typescript

1
packages/billing/core/node_modules/@kit/ui generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../ui

1
packages/billing/core/node_modules/zod generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View 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/*"
]
}
}
}

View 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;
}

View File

@@ -0,0 +1,3 @@
export * from './create-billing-schema';
export * from './services/billing-strategy-provider.service';
export * from './services/billing-webhook-handler.service';

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const CancelSubscriptionParamsSchema = z.object({
subscriptionId: z.string(),
invoiceNow: z.boolean().optional(),
});

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const CreateBillingPortalSessionSchema = z.object({
returnUrl: z.string().url(),
customerId: z.string().min(1),
});

View File

@@ -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(),
});

View 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';

View File

@@ -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]),
});

View File

@@ -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(),
}),
});

View File

@@ -0,0 +1,5 @@
import { z } from 'zod';
export const RetrieveCheckoutSessionSchema = z.object({
sessionId: z.string(),
});

View File

@@ -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),
});

View File

@@ -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;
}
>;
}

View File

@@ -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>;
}

View 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'];

View File

@@ -0,0 +1,8 @@
{
"extends": "@kit/tsconfig/base.json",
"compilerOptions": {
"tsBuildInfoFile": "node_modules/.cache/tsbuildinfo.json"
},
"include": ["*.ts", "src"],
"exclude": ["node_modules"]
}

View 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.

View 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
View 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
View 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
View 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

View File

@@ -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
View File

@@ -0,0 +1 @@
../../../core

View File

@@ -0,0 +1 @@
../../../../../tooling/eslint

View File

@@ -0,0 +1 @@
../../../lemon-squeezy

View File

@@ -0,0 +1 @@
../../../../../tooling/prettier

1
packages/billing/gateway/node_modules/@kit/shared generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../shared

1
packages/billing/gateway/node_modules/@kit/stripe generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../stripe

1
packages/billing/gateway/node_modules/@kit/supabase generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../supabase

1
packages/billing/gateway/node_modules/@kit/tsconfig generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../../tooling/typescript

1
packages/billing/gateway/node_modules/@kit/ui generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../ui

View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View 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/*"
]
}
}
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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'
}
/>
);
}

View 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';

View 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>;
}

View File

@@ -0,0 +1 @@
export * from './pricing-table';

View File

@@ -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>;
}

View 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>
);
}

View 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>
);
}

View 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';

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -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,
});
}
}

View File

@@ -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;
}

View File

@@ -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');
});

View File

@@ -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);
}
}

View File

@@ -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,
});
}
}

View 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"]
}

View 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).

View 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
View 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

View File

@@ -0,0 +1 @@
../../../core

View File

@@ -0,0 +1 @@
../../../../../tooling/eslint

View File

@@ -0,0 +1 @@
../../../../../tooling/prettier

1
packages/billing/lemon-squeezy/node_modules/@kit/shared generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../shared

View File

@@ -0,0 +1 @@
../../../../supabase

View File

@@ -0,0 +1 @@
../../../../../tooling/typescript

1
packages/billing/lemon-squeezy/node_modules/@kit/ui generated vendored Symbolic link
View File

@@ -0,0 +1 @@
../../../../ui

View File

@@ -0,0 +1 @@
../../../../../node_modules/.pnpm/@lemonsqueezy+lemonsqueezy.js@4.0.0/node_modules/@lemonsqueezy/lemonsqueezy.js

View File

@@ -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
View File

@@ -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
View File

@@ -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
View File

@@ -0,0 +1 @@
../../../../node_modules/.pnpm/zod@3.25.56/node_modules/zod

View 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/*"
]
}
}
}

View File

@@ -0,0 +1 @@
export * from './lemon-squeezy-embedded-checkout';

View File

@@ -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