Add Lemon Squeezy Billing System

This commit is contained in:
giancarlo
2024-04-01 21:43:18 +08:00
parent 84a4b45bcd
commit 8784a40a69
59 changed files with 424 additions and 74 deletions

View File

@@ -0,0 +1,52 @@
{
"name": "@kit/stripe",
"private": true,
"version": "0.1.0",
"scripts": {
"clean": "git clean -xdf .turbo node_modules",
"format": "prettier --check \"**/*.{ts,tsx}\"",
"lint": "eslint .",
"typecheck": "tsc --noEmit",
"start": "docker run --rm -it --name=stripe -v ~/.config/stripe:/root/.config/stripe stripe/stripe-cli:latest listen --forward-to http://host.docker.internal:3000/api/billing/webhook"
},
"prettier": "@kit/prettier-config",
"exports": {
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"peerDependencies": {
"@kit/billing": "0.1.0",
"@kit/shared": "0.1.0",
"@kit/supabase": "0.1.0",
"@kit/ui": "0.1.0"
},
"dependencies": {
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.1.0",
"stripe": "^14.22.0"
},
"devDependencies": {
"@kit/billing": "workspace:^",
"@kit/eslint-config": "workspace:*",
"@kit/prettier-config": "workspace:*",
"@kit/shared": "workspace:^",
"@kit/supabase": "workspace:^",
"@kit/tailwind-config": "workspace:*",
"@kit/tsconfig": "workspace:*",
"@kit/ui": "workspace:^"
},
"eslintConfig": {
"root": true,
"extends": [
"@kit/eslint-config/base",
"@kit/eslint-config/react"
]
},
"typesVersions": {
"*": {
"*": [
"src/*"
]
}
}
}

View File

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

View File

@@ -0,0 +1,75 @@
'use client';
import { useState } from 'react';
import {
EmbeddedCheckout,
EmbeddedCheckoutProvider,
} from '@stripe/react-stripe-js';
import { loadStripe } from '@stripe/stripe-js';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@kit/ui/dialog';
import { StripeClientEnvSchema } from '../schema/stripe-client-env.schema';
const { publishableKey } = StripeClientEnvSchema.parse({
publishableKey: process.env.NEXT_PUBLIC_STRIPE_PUBLISHABLE_KEY,
});
const stripePromise = loadStripe(publishableKey);
export function StripeCheckout({
checkoutToken,
onClose,
}: React.PropsWithChildren<{
checkoutToken: string;
onClose?: () => void;
}>) {
return (
<EmbeddedCheckoutPopup key={checkoutToken} onClose={onClose}>
<EmbeddedCheckoutProvider
stripe={stripePromise}
options={{ clientSecret: checkoutToken }}
>
<EmbeddedCheckout className={'EmbeddedCheckoutClassName'} />
</EmbeddedCheckoutProvider>
</EmbeddedCheckoutPopup>
);
}
function EmbeddedCheckoutPopup({
onClose,
children,
}: React.PropsWithChildren<{
onClose?: () => void;
}>) {
const [open, setOpen] = useState(true);
const className = `bg-white p-4 max-h-[98vh] overflow-y-auto shadow-transparent border`;
return (
<Dialog
defaultOpen
open={open}
onOpenChange={(open) => {
if (!open && onClose) {
onClose();
}
setOpen(open);
}}
>
<DialogContent
className={className}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<div>{children}</div>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,2 @@
export { StripeBillingStrategyService } from './services/stripe-billing-strategy.service';
export { StripeWebhookHandlerService } from './services/stripe-webhook-handler.service';

View File

@@ -0,0 +1,15 @@
import { z } from 'zod';
export const StripeClientEnvSchema = z
.object({
publishableKey: z.string().min(1),
})
.refine(
(schema) => {
return schema.publishableKey.startsWith('pk_');
},
{
path: ['publishableKey'],
message: `Stripe publishable key must start with 'pk_'`,
},
);

View File

@@ -0,0 +1,25 @@
import { z } from 'zod';
export const StripeServerEnvSchema = z
.object({
secretKey: z.string().min(1),
webhooksSecret: z.string().min(1),
})
.refine(
(schema) => {
return schema.secretKey.startsWith('sk_');
},
{
path: ['STRIPE_SECRET_KEY'],
message: `Stripe secret key must start with 'sk_'`,
},
)
.refine(
(schema) => {
return schema.webhooksSecret.startsWith('whsec_');
},
{
path: ['STRIPE_WEBHOOK_SECRET'],
message: `Stripe webhook secret must start with 'whsec_'`,
},
);

View File

@@ -0,0 +1,18 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import { CreateBillingPortalSessionSchema } from '@kit/billing/schema';
/**
* @name createStripeBillingPortalSession
* @description Create a Stripe billing portal session for a user
*/
export async function createStripeBillingPortalSession(
stripe: Stripe,
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
return stripe.billingPortal.sessions.create({
customer: params.customerId,
return_url: params.returnUrl,
});
}

View File

@@ -0,0 +1,88 @@
import type { Stripe } from 'stripe';
import { z } from 'zod';
import { CreateBillingCheckoutSchema } from '@kit/billing/schema';
/**
* @name createStripeCheckout
* @description Creates a Stripe Checkout session, and returns an Object
* containing the session, which you can use to redirect the user to the
* checkout page
*/
export async function createStripeCheckout(
stripe: Stripe,
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
// in MakerKit, a subscription belongs to an organization,
// rather than to a user
// if you wish to change it, use the current user ID instead
const clientReferenceId = params.accountId;
// we pass an optional customer ID, so we do not duplicate the Stripe
// customers if an organization subscribes multiple times
const customer = params.customerId ?? undefined;
// docs: https://stripe.com/docs/billing/subscriptions/build-subscription
const mode: Stripe.Checkout.SessionCreateParams.Mode =
params.plan.paymentType === 'recurring' ? 'subscription' : 'payment';
// this should only be set if the mode is 'subscription'
const subscriptionData:
| Stripe.Checkout.SessionCreateParams.SubscriptionData
| undefined =
mode === 'subscription'
? {
trial_period_days: params.trialDays,
metadata: {
accountId: params.accountId,
},
}
: undefined;
const urls = getUrls({
returnUrl: params.returnUrl,
});
// we use the embedded mode, so the user does not leave the page
const uiMode = 'embedded';
const customerData = customer
? {
customer,
}
: {
customer_email: params.customerEmail,
};
const lineItems = params.plan.lineItems.map((item) => {
if (item.type === 'metered') {
return {
price: item.id,
};
}
return {
price: item.id,
quantity: 1,
};
});
return stripe.checkout.sessions.create({
mode,
ui_mode: uiMode,
line_items: lineItems,
client_reference_id: clientReferenceId,
subscription_data: subscriptionData,
customer_creation: 'always',
...customerData,
...urls,
});
}
function getUrls(params: { returnUrl: string }) {
const returnUrl = `${params.returnUrl}?session_id={CHECKOUT_SESSION_ID}`;
return {
return_url: returnUrl,
};
}

View File

@@ -0,0 +1,86 @@
import 'server-only';
import type { Stripe } from 'stripe';
import { z } from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
ReportBillingUsageSchema,
RetrieveCheckoutSessionSchema,
} from '@kit/billing/schema';
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
import { createStripeCheckout } from './create-stripe-checkout';
import { createStripeClient } from './stripe-sdk';
export class StripeBillingStrategyService
implements BillingStrategyProviderService
{
async createCheckoutSession(
params: z.infer<typeof CreateBillingCheckoutSchema>,
) {
const stripe = await this.stripeProvider();
const { client_secret } = await createStripeCheckout(stripe, params);
if (!client_secret) {
throw new Error('Failed to create checkout session');
}
return { checkoutToken: client_secret };
}
async createBillingPortalSession(
params: z.infer<typeof CreateBillingPortalSessionSchema>,
) {
const stripe = await this.stripeProvider();
return createStripeBillingPortalSession(stripe, params);
}
async cancelSubscription(
params: z.infer<typeof CancelSubscriptionParamsSchema>,
) {
const stripe = await this.stripeProvider();
await stripe.subscriptions.cancel(params.subscriptionId, {
invoice_now: params.invoiceNow ?? true,
});
return { success: true };
}
async retrieveCheckoutSession(
params: z.infer<typeof RetrieveCheckoutSessionSchema>,
) {
const stripe = await this.stripeProvider();
const session = await stripe.checkout.sessions.retrieve(params.sessionId);
const isSessionOpen = session.status === 'open';
return {
checkoutToken: session.client_secret,
isSessionOpen,
status: session.status ?? 'complete',
customer: {
email: session.customer_details?.email ?? null,
},
};
}
async reportUsage(params: z.infer<typeof ReportBillingUsageSchema>) {
const stripe = await this.stripeProvider();
await stripe.subscriptionItems.createUsageRecord(params.subscriptionId, {
quantity: params.usage.quantity,
});
return { success: true };
}
private async stripeProvider(): Promise<Stripe> {
return createStripeClient();
}
}

View File

@@ -0,0 +1,22 @@
import 'server-only';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
const STRIPE_API_VERSION = '2023-10-16';
/**
* @description returns a Stripe instance
*/
export async function createStripeClient() {
const { default: Stripe } = await import('stripe');
// Parse the environment variables and validate them
const stripeServerEnv = StripeServerEnvSchema.parse({
secretKey: process.env.STRIPE_SECRET_KEY,
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
return new Stripe(stripeServerEnv.secretKey, {
apiVersion: STRIPE_API_VERSION,
});
}

View File

@@ -0,0 +1,308 @@
import Stripe from 'stripe';
import { BillingWebhookHandlerService } from '@kit/billing';
import { Logger } from '@kit/shared/logger';
import { Database } from '@kit/supabase/database';
import { StripeServerEnvSchema } from '../schema/stripe-server-env.schema';
import { createStripeClient } from './stripe-sdk';
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
type UpsertOrderParams =
Database['public']['Functions']['upsert_order']['Args'];
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService
{
private stripe: Stripe | undefined;
private readonly provider: Database['public']['Enums']['billing_provider'] =
'stripe';
private readonly namespace = 'billing.stripe';
/**
* @description Verifies the webhook signature - should throw an error if the signature is invalid
*/
async verifyWebhookSignature(request: Request) {
const body = await request.clone().text();
const signatureKey = `stripe-signature`;
const signature = request.headers.get(signatureKey)!;
const { webhooksSecret } = StripeServerEnvSchema.parse({
secretKey: process.env.STRIPE_SECRET_KEY,
webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET,
});
const stripe = await this.loadStripe();
const event = stripe.webhooks.constructEvent(
body,
signature,
webhooksSecret,
);
if (!event) {
throw new Error('Invalid signature');
}
return event;
}
private async loadStripe() {
if (!this.stripe) {
this.stripe = await createStripeClient();
}
return this.stripe;
}
async handleWebhookEvent(
event: Stripe.Event,
params: {
onCheckoutSessionCompleted: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>;
onSubscriptionUpdated: (
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
onPaymentSucceeded: (sessionId: string) => Promise<unknown>;
onPaymentFailed: (sessionId: string) => Promise<unknown>;
},
) {
switch (event.type) {
case 'checkout.session.completed': {
return this.handleCheckoutSessionCompleted(
event,
params.onCheckoutSessionCompleted,
);
}
case 'customer.subscription.updated': {
return this.handleSubscriptionUpdatedEvent(
event,
params.onCheckoutSessionCompleted,
);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeletedEvent(
event,
params.onSubscriptionDeleted,
);
}
case 'checkout.session.async_payment_failed': {
return this.handleAsyncPaymentFailed(event, params.onPaymentFailed);
}
case 'checkout.session.async_payment_succeeded': {
return this.handleAsyncPaymentSucceeded(
event,
params.onPaymentSucceeded,
);
}
default: {
Logger.info(
{
eventType: event.type,
name: this.namespace,
},
`Unhandled Stripe event type: ${event.type}`,
);
return;
}
}
}
private async handleCheckoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
onCheckoutCompletedCallback: (
data: UpsertSubscriptionParams | UpsertOrderParams,
) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
const session = event.data.object;
const isSubscription = session.mode === 'subscription';
const accountId = session.client_reference_id!;
const customerId = session.customer as string;
if (isSubscription) {
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const payload = this.buildSubscriptionPayload({
accountId,
customerId,
id: subscription.id,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onCheckoutCompletedCallback(payload);
} else {
const sessionId = event.data.object.id;
const sessionWithLineItems = await stripe.checkout.sessions.retrieve(
event.data.object.id,
{
expand: ['line_items'],
},
);
const lineItems = sessionWithLineItems.line_items?.data ?? [];
const paymentStatus = sessionWithLineItems.payment_status;
const status = paymentStatus === 'unpaid' ? 'pending' : 'succeeded';
const currency = event.data.object.currency as string;
const payload: UpsertOrderParams = {
target_account_id: accountId,
target_customer_id: customerId,
target_order_id: sessionId,
billing_provider: this.provider,
status: status,
currency: currency,
total_amount: sessionWithLineItems.amount_total ?? 0,
line_items: lineItems.map((item) => {
const price = item.price as Stripe.Price;
return {
id: item.id,
product_id: price.product as string,
variant_id: price.id,
price_amount: price.unit_amount,
quantity: item.quantity,
};
}),
};
return onCheckoutCompletedCallback(payload);
}
}
private handleAsyncPaymentFailed(
event: Stripe.CheckoutSessionAsyncPaymentFailedEvent,
onPaymentFailed: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentFailed(sessionId);
}
private handleAsyncPaymentSucceeded(
event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent,
onPaymentSucceeded: (sessionId: string) => Promise<unknown>,
) {
const sessionId = event.data.object.id;
return onPaymentSucceeded(sessionId);
}
private handleSubscriptionUpdatedEvent(
event: Stripe.CustomerSubscriptionUpdatedEvent,
onSubscriptionUpdatedCallback: (
subscription: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
const subscription = event.data.object;
const subscriptionId = subscription.id;
const accountId = subscription.metadata.account_id as string;
const payload = this.buildSubscriptionPayload({
customerId: subscription.customer as string,
id: subscriptionId,
accountId,
lineItems: subscription.items.data,
status: subscription.status,
currency: subscription.currency,
periodStartsAt: subscription.current_period_start,
periodEndsAt: subscription.current_period_end,
cancelAtPeriodEnd: subscription.cancel_at_period_end,
trialStartsAt: subscription.trial_start,
trialEndsAt: subscription.trial_end,
});
return onSubscriptionUpdatedCallback(payload);
}
private handleSubscriptionDeletedEvent(
subscription: Stripe.CustomerSubscriptionDeletedEvent,
onSubscriptionDeletedCallback: (subscriptionId: string) => Promise<unknown>,
) {
// Here we don't need to do anything, so we just return the callback
return onSubscriptionDeletedCallback(subscription.id);
}
private buildSubscriptionPayload<
LineItem extends {
id: string;
quantity?: number;
price?: Stripe.Price;
},
>(params: {
id: string;
accountId: string;
customerId: string;
lineItems: LineItem[];
status: Stripe.Subscription.Status;
currency: string;
cancelAtPeriodEnd: boolean;
periodStartsAt: number;
periodEndsAt: number;
trialStartsAt: number | null;
trialEndsAt: number | null;
}): UpsertSubscriptionParams {
const active = params.status === 'active' || params.status === 'trialing';
const lineItems = params.lineItems.map((item) => {
const quantity = item.quantity ?? 1;
return {
id: item.id,
quantity,
subscription_id: params.id,
product_id: item.price?.product as string,
variant_id: item.price?.id,
price_amount: item.price?.unit_amount,
interval: item.price?.recurring?.interval as string,
interval_count: item.price?.recurring?.interval_count as number,
};
});
// otherwise we are updating a subscription
// and we only need to return the update payload
return {
target_subscription_id: params.id,
target_account_id: params.accountId,
target_customer_id: params.customerId,
billing_provider: this.provider,
status: params.status,
line_items: lineItems,
active,
currency: params.currency,
cancel_at_period_end: params.cancelAtPeriodEnd ?? false,
period_starts_at: getISOString(params.periodStartsAt) as string,
period_ends_at: getISOString(params.periodEndsAt) as string,
trial_starts_at: getISOString(params.trialStartsAt),
trial_ends_at: getISOString(params.trialEndsAt),
};
}
}
function getISOString(date: number | null) {
return date ? new Date(date * 1000).toISOString() : undefined;
}

View File

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