Remove billing and checkout redirect buttons and related services

Deleted the billing-redirect-button, checkout-redirect-button, and embedded-stripe-checkout components. Additionally, removed the shadcn directory, which encompassed billing-related icons. This change streamlines the subscription settings interface and organizes the system's payment management. This update is a stepping stone towards improving the billing system's overall architecture.
This commit is contained in:
giancarlo
2024-03-25 11:39:41 +08:00
parent 78c704e54d
commit cb8b23e8c0
123 changed files with 1674 additions and 3071 deletions

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,75 @@
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.paymentType === 'recurring' ? 'subscription' : 'payment';
// TODO: support multiple line items and per-seat pricing
const lineItem: Stripe.Checkout.SessionCreateParams.LineItem = {
quantity: 1,
price: params.planId,
};
const subscriptionData: Stripe.Checkout.SessionCreateParams.SubscriptionData =
{
trial_period_days: params.trialPeriodDays,
metadata: {
accountId: params.accountId,
},
};
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,
};
return stripe.checkout.sessions.create({
mode,
ui_mode: uiMode,
line_items: [lineItem],
client_reference_id: clientReferenceId,
subscription_data: subscriptionData,
...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,65 @@
import 'server-only';
import type { Stripe } from 'stripe';
import { z } from 'zod';
import { BillingStrategyProviderService } from '@kit/billing';
import {
CancelSubscriptionParamsSchema,
CreateBillingCheckoutSchema,
CreateBillingPortalSessionSchema,
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();
return await stripe.subscriptions.retrieve(params.sessionId);
}
private async stripeProvider(): Promise<Stripe> {
return createStripeClient();
}
}

View File

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

View File

@@ -0,0 +1,208 @@
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 Subscription = Database['public']['Tables']['subscriptions'];
type InsertSubscriptionParams = Omit<
Subscription['Insert'],
'billing_customer_id'
>;
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 signature = `stripe-signature`;
const { STRIPE_WEBHOOK_SECRET } = StripeServerEnvSchema.parse({
STRIPE_WEBHOOK_SECRET: process.env.STRIPE_WEBHOOK_SECRET,
});
const stripe = await this.loadStripe();
const event = stripe.webhooks.constructEvent(
body,
signature,
STRIPE_WEBHOOK_SECRET,
);
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: InsertSubscriptionParams,
customerId: string,
) => Promise<unknown>;
onSubscriptionUpdated: (data: Subscription['Update']) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: 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.onSubscriptionUpdated,
);
}
case 'customer.subscription.deleted': {
return this.handleSubscriptionDeletedEvent(
event,
params.onSubscriptionDeleted,
);
}
default: {
Logger.info(
{
eventType: event.type,
name: this.namespace,
},
`Unhandled Stripe event type: ${event.type}`,
);
return;
}
}
}
private async handleCheckoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
onCheckoutCompletedCallback: (
data: InsertSubscriptionParams,
customerId: string,
) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
const session = event.data.object;
const subscriptionId = session.subscription as string;
const subscription = await stripe.subscriptions.retrieve(subscriptionId);
const accountId = session.client_reference_id!;
const customerId = session.customer as string;
// TODO: support tiered pricing calculations
// the amount total is amount in cents (e.g. 1000 = $10.00)
// TODO: convert or store the amount in cents?
const amount = session.amount_total ?? 0;
const payload = this.buildSubscriptionPayload<typeof accountId>({
subscription,
accountId,
amount,
});
return onCheckoutCompletedCallback(payload, customerId);
}
private async handleSubscriptionUpdatedEvent(
event: Stripe.CustomerSubscriptionUpdatedEvent,
onSubscriptionUpdatedCallback: (
data: Subscription['Update'],
) => Promise<unknown>,
) {
const subscription = event.data.object;
const amount = subscription.items.data.reduce((acc, item) => {
return (acc + (item.plan.amount ?? 0)) * (item.quantity ?? 1);
}, 0);
const payload = this.buildSubscriptionPayload<undefined>({
subscription,
amount,
});
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<
AccountId extends string | undefined,
>(params: {
subscription: Stripe.Subscription;
amount: number;
// we only need the account id if we
// are creating a subscription for an account
accountId?: AccountId;
}): AccountId extends string
? InsertSubscriptionParams
: Subscription['Update'] {
const { subscription } = params;
const lineItem = subscription.items.data[0];
const price = lineItem?.price;
const priceId = price?.id!;
const interval = price?.recurring?.interval ?? null;
const data = {
billing_provider: this.provider,
id: subscription.id,
status: subscription.status,
price_amount: params.amount,
cancel_at_period_end: subscription.cancel_at_period_end ?? false,
interval: interval as string,
currency: price?.currency!,
product_id: price?.product as string,
variant_id: priceId,
interval_count: price?.recurring?.interval_count ?? 1,
};
if (params.accountId !== undefined) {
return {
...data,
account_id: params.accountId,
} satisfies InsertSubscriptionParams;
}
return data as Subscription['Update'];
}
}