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

@@ -14,12 +14,16 @@
".": "./src/index.ts",
"./components": "./src/components/index.ts"
},
"peerDependencies": {
"@kit/billing": "0.1.0",
"@kit/ui": "0.1.0",
"@kit/shared": "0.1.0",
"@kit/supabase": "0.1.0"
},
"dependencies": {
"stripe": "^14.21.0",
"@stripe/react-stripe-js": "^2.6.2",
"@stripe/stripe-js": "^3.0.10",
"@kit/billing": "0.1.0",
"@kit/ui": "0.1.0"
"@stripe/stripe-js": "^3.0.10"
},
"devDependencies": {
"@kit/prettier-config": "0.1.0",

View File

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

View File

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

View File

@@ -0,0 +1,6 @@
import { z } from 'zod';
export const StripeServerEnvSchema = z.object({
STRIPE_SECRET_KEY: z.string().min(1),
STRIPE_WEBHOOK_SECRET: z.string().min(1),
});

View File

@@ -1,328 +0,0 @@
'use server';
import { RedirectType } from 'next/dist/client/components/redirect';
import { headers } from 'next/headers';
import { redirect } from 'next/navigation';
import type { SupabaseClient } from '@supabase/supabase-js';
import { join } from 'path';
import { z } from 'zod';
import getSupabaseServerActionClient from '@packages/supabase/action-client';
import { withSession } from '@kit/generic/actions-utils';
import getLogger from '@kit/logger';
import pathsConfig from '@/config/paths.config';
import pricingConfig from '@/config/pricing.config';
import { getUserMembershipByOrganization } from '@/lib/memberships/queries';
import {
getOrganizationByCustomerId,
getOrganizationByUid,
} from '@/lib/organizations/database/queries';
import { canChangeBilling } from '@/lib/organizations/permissions';
import requireSession from '@/lib/user/require-session';
import { stripe } from './stripe.service';
export const createCheckoutAction = withSession(
async (_, formData: FormData) => {
const logger = getLogger();
const bodyResult = await getCheckoutBodySchema().safeParseAsync(
Object.fromEntries(formData),
);
const redirectToErrorPage = (error?: string) => {
const referer = headers().get('referer')!;
const url = join(referer, `?error=true`);
logger.error({ error }, `Could not create Stripe Checkout session`);
return redirect(url);
};
// Validate the body schema
if (!bodyResult.success) {
return redirectToErrorPage(`Invalid request body`);
}
const { organizationUid, priceId, returnUrl } = bodyResult.data;
// create the Supabase client
const client = getSupabaseServerActionClient();
// require the user to be logged in
const sessionResult = await requireSession(client);
const userId = sessionResult.user.id;
const customerEmail = sessionResult.user.email;
const { error, data } = await getOrganizationByUid(client, organizationUid);
if (error) {
return redirectToErrorPage(`Organization not found`);
}
const customerId = data?.subscription?.customerId;
if (customerId) {
logger.info({ customerId }, `Customer ID found for organization`);
}
const plan = getPlanByPriceId(priceId);
// check if the plan exists in the appConfig.
if (!plan) {
console.warn(
`Plan not found for price ID "${priceId}". Did you forget to add it to the configuration? If the Price ID is incorrect, the checkout will be rejected. Please check the Stripe dashboard`,
);
}
// check the user's role has access to the checkout
const canChangeBilling = await getUserCanAccessCheckout(client, {
organizationUid,
userId,
});
// disallow if the user doesn't have permissions to change
// billing account based on its role. To change the logic, please update
// {@link canChangeBilling}
if (!canChangeBilling) {
logger.debug(
{
userId,
organizationUid,
},
`User attempted to access checkout but lacked permissions`,
);
return redirectToErrorPage(
`You do not have permission to access this page`,
);
}
const trialPeriodDays =
plan && 'trialPeriodDays' in plan
? (plan.trialPeriodDays as number)
: undefined;
// create the Stripe Checkout session
const session = await stripe
.createCheckout({
returnUrl,
organizationUid,
priceId,
customerId,
trialPeriodDays,
customerEmail,
embedded: true,
})
.catch((e) => {
logger.error(e, `Stripe Checkout error`);
});
// if there was an error, redirect to the error page
if (!session) {
return redirectToErrorPage();
}
logger.info(
{
id: session.id,
organizationUid,
},
`Created Stripe Checkout session`,
);
if (!session.client_secret) {
logger.error(
{ id: session.id },
`Stripe Checkout session missing client secret`,
);
return redirectToErrorPage();
}
// if the checkout is embedded, we need to render the checkout
// therefore, we send the clientSecret back to the client
logger.info(
{ id: session.id },
`Using embedded checkout mode. Sending client secret back to client.`,
);
return {
clientSecret: session.client_secret,
};
},
);
/**
* @name getUserCanAccessCheckout
* @description check if the user has permissions to access the checkout
* @param client
* @param params
*/
async function getUserCanAccessCheckout(
client: SupabaseClient,
params: {
organizationUid: string;
userId: string;
},
) {
try {
const { role } = await getUserMembershipByOrganization(client, params);
if (role === undefined) {
return false;
}
return canChangeBilling(role);
} catch (error) {
getLogger().error({ error }, `Could not retrieve user role`);
return false;
}
}
export const createBillingPortalSessionAction = withSession(
async (formData: FormData) => {
const body = Object.fromEntries(formData);
const bodyResult = await getBillingPortalBodySchema().safeParseAsync(body);
const referrerPath = getReferrer();
// Validate the body schema
if (!bodyResult.success) {
return redirectToErrorPage(referrerPath);
}
const { customerId } = bodyResult.data;
const client = getSupabaseServerActionClient();
const logger = getLogger();
const session = await requireSession(client);
const userId = session.user.id;
// get permissions to see if the user can access the portal
const canAccess = await getUserCanAccessCustomerPortal(client, {
customerId,
userId,
});
// validate that the user can access the portal
if (!canAccess) {
return redirectToErrorPage(referrerPath);
}
const referer = headers().get('referer');
const origin = headers().get('origin');
const returnUrl = referer || origin || pathsConfig.appHome;
// get the Stripe Billing Portal session
const { url } = await stripe
.createBillingPortalSession({
returnUrl,
customerId,
})
.catch((e) => {
logger.error(e, `Stripe Billing Portal redirect error`);
return redirectToErrorPage(referrerPath);
});
// redirect to the Stripe Billing Portal
return redirect(url, RedirectType.replace);
},
);
/**
* @name getUserCanAccessCustomerPortal
* @description Returns whether a user {@link userId} has access to the
* Stripe portal of an organization with customer ID {@link customerId}
*/
async function getUserCanAccessCustomerPortal(
client: SupabaseClient,
params: {
customerId: string;
userId: string;
},
) {
const logger = getLogger();
const { data: organization, error } = await getOrganizationByCustomerId(
client,
params.customerId,
);
if (error) {
logger.error(
{
error,
customerId: params.customerId,
},
`Could not retrieve organization by Customer ID`,
);
return false;
}
try {
const organizationUid = organization.uuid;
const { role } = await getUserMembershipByOrganization(client, {
organizationUid,
userId: params.userId,
});
if (role === undefined) {
return false;
}
return canChangeBilling(role);
} catch (error) {
logger.error({ error }, `Could not retrieve user role`);
return false;
}
}
function getBillingPortalBodySchema() {
return z.object({
customerId: z.string().min(1),
});
}
function getCheckoutBodySchema() {
return z.object({
organizationUid: z.string().uuid(),
priceId: z.string().min(1),
returnUrl: z.string().min(1),
});
}
function getPlanByPriceId(priceId: string) {
const products = pricingConfig.products;
type Plan = (typeof products)[0]['plans'][0];
return products.reduce<Maybe<Plan>>((acc, product) => {
if (acc) {
return acc;
}
return product.plans.find(({ stripePriceId }) => stripePriceId === priceId);
}, undefined);
}
function redirectToErrorPage(referrerPath: string) {
const url = join(referrerPath, `?error=true`);
return redirect(url);
}
function getReferrer() {
const referer = headers().get('referer');
const origin = headers().get('origin');
return referer || origin || pathsConfig.appHome;
}

View File

@@ -10,8 +10,8 @@ import {
RetrieveCheckoutSessionSchema,
} from '@kit/billing/schema';
import { createStripeCheckout } from './create-checkout';
import { createStripeBillingPortalSession } from './create-stripe-billing-portal-session';
import { createStripeCheckout } from './create-stripe-checkout';
import { createStripeClient } from './stripe-sdk';
export class StripeBillingStrategyService
@@ -22,7 +22,13 @@ export class StripeBillingStrategyService
) {
const stripe = await this.stripeProvider();
return createStripeCheckout(stripe, params);
const { client_secret } = await createStripeCheckout(stripe, params);
if (!client_secret) {
throw new Error('Failed to create checkout session');
}
return { checkoutToken: client_secret };
}
async createBillingPortalSession(

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

View File

@@ -1,20 +0,0 @@
import { invariant } from '@epic-web/invariant';
import 'server-only';
const STRIPE_API_VERSION = '2023-10-16';
/**
* @description returns a Stripe instance
*/
export async function createStripeClient() {
const { default: Stripe } = await import('stripe');
invariant(
process.env.STRIPE_SECRET_KEY,
`'STRIPE_SECRET_KEY' environment variable was not provided`,
);
return new Stripe(process.env.STRIPE_SECRET_KEY, {
apiVersion: STRIPE_API_VERSION,
});
}