Add UserBillingService for handling checkout and billing portal sessions
This update refactors the handling of checkout and billing portal sessions into a new UserBillingService. The new service centralizes related functions that were previously scattered throughout server-actions and ensures that all related functionality is handled in a single place. This improves maintainability and coherence of the code related to billing sessions.
This commit is contained in:
@@ -0,0 +1,6 @@
|
|||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
export const PersonalAccountCheckoutSchema = z.object({
|
||||||
|
planId: z.string().min(1),
|
||||||
|
productId: z.string().min(1),
|
||||||
|
});
|
||||||
@@ -0,0 +1,196 @@
|
|||||||
|
import { SupabaseClient } from '@supabase/supabase-js';
|
||||||
|
|
||||||
|
import 'server-only';
|
||||||
|
import { z } from 'zod';
|
||||||
|
|
||||||
|
import { getProductPlanPair } from '@kit/billing';
|
||||||
|
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
||||||
|
import { Logger } from '@kit/shared/logger';
|
||||||
|
import { Database } from '@kit/supabase/database';
|
||||||
|
import { requireUser } from '@kit/supabase/require-user';
|
||||||
|
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||||
|
|
||||||
|
import { PersonalAccountCheckoutSchema } from '~/(dashboard)/home/(user)/billing/_lib/schema/personal-account-checkout.schema';
|
||||||
|
import appConfig from '~/config/app.config';
|
||||||
|
import billingConfig from '~/config/billing.config';
|
||||||
|
import pathsConfig from '~/config/paths.config';
|
||||||
|
|
||||||
|
export class UserBillingService {
|
||||||
|
constructor(private readonly client: SupabaseClient<Database>) {}
|
||||||
|
|
||||||
|
async createCheckoutSession({
|
||||||
|
planId,
|
||||||
|
productId,
|
||||||
|
}: z.infer<typeof PersonalAccountCheckoutSchema>) {
|
||||||
|
// get the authenticated user
|
||||||
|
const { data: user, error } = await requireUser(this.client);
|
||||||
|
|
||||||
|
if (error ?? !user) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await getBillingGatewayProvider(this.client);
|
||||||
|
|
||||||
|
// in the case of personal accounts
|
||||||
|
// the account ID is the same as the user ID
|
||||||
|
const accountId = user.id;
|
||||||
|
|
||||||
|
// the return URL for the checkout session
|
||||||
|
const returnUrl = getCheckoutSessionReturnUrl();
|
||||||
|
|
||||||
|
// find the customer ID for the account if it exists
|
||||||
|
// (eg. if the account has been billed before)
|
||||||
|
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||||
|
|
||||||
|
const product = billingConfig.products.find(
|
||||||
|
(item) => item.id === productId,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!product) {
|
||||||
|
throw new Error('Product not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { plan } = getProductPlanPair(billingConfig, planId);
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
planId,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
`User requested a personal account checkout session. Contacting provider...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// call the payment gateway to create the checkout session
|
||||||
|
const { checkoutToken } = await service.createCheckoutSession({
|
||||||
|
returnUrl,
|
||||||
|
accountId,
|
||||||
|
customerEmail: user.email,
|
||||||
|
customerId,
|
||||||
|
plan,
|
||||||
|
variantQuantities: [],
|
||||||
|
});
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
userId: user.id,
|
||||||
|
},
|
||||||
|
`Checkout session created. Returning checkout token to client...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// return the checkout token to the client
|
||||||
|
// so we can call the payment gateway to complete the checkout
|
||||||
|
return {
|
||||||
|
checkoutToken,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
planId,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
error,
|
||||||
|
},
|
||||||
|
`Checkout session not created due to an error`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(`Failed to create a checkout session`);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async createBillingPortalSession() {
|
||||||
|
const { data, error } = await requireUser(this.client);
|
||||||
|
|
||||||
|
if (error ?? !data) {
|
||||||
|
throw new Error('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const service = await getBillingGatewayProvider(this.client);
|
||||||
|
|
||||||
|
const accountId = data.id;
|
||||||
|
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||||
|
const returnUrl = getBillingPortalReturnUrl();
|
||||||
|
|
||||||
|
if (!customerId) {
|
||||||
|
throw new Error('Customer not found');
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
`User requested a Billing Portal session. Contacting provider...`,
|
||||||
|
);
|
||||||
|
|
||||||
|
let url: string;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const session = await service.createBillingPortalSession({
|
||||||
|
customerId,
|
||||||
|
returnUrl,
|
||||||
|
});
|
||||||
|
|
||||||
|
url = session.url;
|
||||||
|
} catch (error) {
|
||||||
|
Logger.error(
|
||||||
|
{
|
||||||
|
error,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
`Failed to create a Billing Portal session`,
|
||||||
|
);
|
||||||
|
|
||||||
|
throw new Error(
|
||||||
|
`Encountered an error creating the Billing Portal session`,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Logger.info(
|
||||||
|
{
|
||||||
|
name: `billing.personal-account`,
|
||||||
|
customerId,
|
||||||
|
accountId,
|
||||||
|
},
|
||||||
|
`Session successfully created.`,
|
||||||
|
);
|
||||||
|
|
||||||
|
// redirect user to billing portal
|
||||||
|
return url;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function getCustomerIdFromAccountId(accountId: string) {
|
||||||
|
const client = getSupabaseServerActionClient();
|
||||||
|
|
||||||
|
const { data, error } = await client
|
||||||
|
.from('billing_customers')
|
||||||
|
.select('customer_id')
|
||||||
|
.eq('account_id', accountId)
|
||||||
|
.maybeSingle();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return data?.customer_id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCheckoutSessionReturnUrl() {
|
||||||
|
return new URL(
|
||||||
|
pathsConfig.app.personalAccountBillingReturn,
|
||||||
|
appConfig.url,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
function getBillingPortalReturnUrl() {
|
||||||
|
return new URL(
|
||||||
|
pathsConfig.app.personalAccountBilling,
|
||||||
|
appConfig.url,
|
||||||
|
).toString();
|
||||||
|
}
|
||||||
@@ -4,199 +4,30 @@ import { redirect } from 'next/navigation';
|
|||||||
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getProductPlanPair } from '@kit/billing';
|
|
||||||
import { getBillingGatewayProvider } from '@kit/billing-gateway';
|
|
||||||
import { Logger } from '@kit/shared/logger';
|
|
||||||
import { requireUser } from '@kit/supabase/require-user';
|
|
||||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||||
|
|
||||||
import appConfig from '~/config/app.config';
|
import { PersonalAccountCheckoutSchema } from './_lib/schema/personal-account-checkout.schema';
|
||||||
import billingConfig from '~/config/billing.config';
|
import { UserBillingService } from './_lib/server/user-billing.service';
|
||||||
import pathsConfig from '~/config/paths.config';
|
|
||||||
|
|
||||||
const CreateCheckoutSchema = z.object({
|
|
||||||
planId: z.string(),
|
|
||||||
productId: z.string(),
|
|
||||||
});
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Creates a checkout session for a personal account.
|
* @description Creates a checkout session for a personal account.
|
||||||
*
|
|
||||||
* @param {object} params - The parameters for creating the checkout session.
|
|
||||||
* @param {string} params.planId - The ID of the plan to be associated with the account.
|
|
||||||
*/
|
*/
|
||||||
export async function createPersonalAccountCheckoutSession(
|
export async function createPersonalAccountCheckoutSession(
|
||||||
params: z.infer<typeof CreateCheckoutSchema>,
|
params: z.infer<typeof PersonalAccountCheckoutSchema>,
|
||||||
) {
|
) {
|
||||||
// parse the parameters
|
// parse the parameters
|
||||||
const { planId, productId } = CreateCheckoutSchema.parse(params);
|
const data = PersonalAccountCheckoutSchema.parse(params);
|
||||||
|
const service = new UserBillingService(getSupabaseServerActionClient());
|
||||||
|
|
||||||
// get the authenticated user
|
return await service.createCheckoutSession(data);
|
||||||
const client = getSupabaseServerActionClient();
|
|
||||||
const { data: user, error } = await requireUser(client);
|
|
||||||
|
|
||||||
if (error ?? !user) {
|
|
||||||
throw new Error('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = await getBillingGatewayProvider(client);
|
|
||||||
|
|
||||||
// in the case of personal accounts
|
|
||||||
// the account ID is the same as the user ID
|
|
||||||
const accountId = user.id;
|
|
||||||
|
|
||||||
// the return URL for the checkout session
|
|
||||||
const returnUrl = getCheckoutSessionReturnUrl();
|
|
||||||
|
|
||||||
// find the customer ID for the account if it exists
|
|
||||||
// (eg. if the account has been billed before)
|
|
||||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
|
||||||
|
|
||||||
const product = billingConfig.products.find((item) => item.id === productId);
|
|
||||||
|
|
||||||
if (!product) {
|
|
||||||
throw new Error('Product not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
const { plan } = getProductPlanPair(billingConfig, planId);
|
|
||||||
|
|
||||||
Logger.info(
|
|
||||||
{
|
|
||||||
name: `billing.personal-account`,
|
|
||||||
planId,
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
`User requested a personal account checkout session. Contacting provider...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// call the payment gateway to create the checkout session
|
|
||||||
const { checkoutToken } = await service.createCheckoutSession({
|
|
||||||
returnUrl,
|
|
||||||
accountId,
|
|
||||||
customerEmail: user.email,
|
|
||||||
customerId,
|
|
||||||
plan,
|
|
||||||
variantQuantities: [],
|
|
||||||
});
|
|
||||||
|
|
||||||
Logger.info(
|
|
||||||
{
|
|
||||||
userId: user.id,
|
|
||||||
},
|
|
||||||
`Checkout session created. Returning checkout token to client...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// return the checkout token to the client
|
|
||||||
// so we can call the payment gateway to complete the checkout
|
|
||||||
return {
|
|
||||||
checkoutToken,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(
|
|
||||||
{
|
|
||||||
name: `billing.personal-account`,
|
|
||||||
planId,
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
error,
|
|
||||||
},
|
|
||||||
`Checkout session not created due to an error`,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new Error(`Failed to create a checkout session`);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @description Creates a billing Portal session for a personal account
|
||||||
|
*/
|
||||||
export async function createPersonalAccountBillingPortalSession() {
|
export async function createPersonalAccountBillingPortalSession() {
|
||||||
const client = getSupabaseServerActionClient();
|
const service = new UserBillingService(getSupabaseServerActionClient());
|
||||||
const { data, error } = await client.auth.getUser();
|
const url = await service.createBillingPortalSession();
|
||||||
|
|
||||||
if (error ?? !data.user) {
|
|
||||||
throw new Error('Authentication required');
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = await getBillingGatewayProvider(client);
|
|
||||||
|
|
||||||
const accountId = data.user.id;
|
|
||||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
|
||||||
const returnUrl = getBillingPortalReturnUrl();
|
|
||||||
|
|
||||||
if (!customerId) {
|
|
||||||
throw new Error('Customer not found');
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(
|
|
||||||
{
|
|
||||||
name: `billing.personal-account`,
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
`User requested a Billing Portal session. Contacting provider...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
let url: string;
|
|
||||||
|
|
||||||
try {
|
|
||||||
const session = await service.createBillingPortalSession({
|
|
||||||
customerId,
|
|
||||||
returnUrl,
|
|
||||||
});
|
|
||||||
|
|
||||||
url = session.url;
|
|
||||||
} catch (error) {
|
|
||||||
Logger.error(
|
|
||||||
{
|
|
||||||
error,
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
`Failed to create a Billing Portal session`,
|
|
||||||
);
|
|
||||||
|
|
||||||
throw new Error(`Encountered an error creating the Billing Portal session`);
|
|
||||||
}
|
|
||||||
|
|
||||||
Logger.info(
|
|
||||||
{
|
|
||||||
name: `billing.personal-account`,
|
|
||||||
customerId,
|
|
||||||
accountId,
|
|
||||||
},
|
|
||||||
`Session successfully created. Redirecting user...`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// redirect user to billing portal
|
|
||||||
return redirect(url);
|
return redirect(url);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getCheckoutSessionReturnUrl() {
|
|
||||||
return new URL(
|
|
||||||
pathsConfig.app.personalAccountBillingReturn,
|
|
||||||
appConfig.url,
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
function getBillingPortalReturnUrl() {
|
|
||||||
return new URL(
|
|
||||||
pathsConfig.app.personalAccountBilling,
|
|
||||||
appConfig.url,
|
|
||||||
).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
async function getCustomerIdFromAccountId(accountId: string) {
|
|
||||||
const client = getSupabaseServerActionClient();
|
|
||||||
|
|
||||||
const { data, error } = await client
|
|
||||||
.from('billing_customers')
|
|
||||||
.select('customer_id')
|
|
||||||
.eq('account_id', accountId)
|
|
||||||
.maybeSingle();
|
|
||||||
|
|
||||||
if (error) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
|
|
||||||
return data?.customer_id;
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user