Implement new billing-gateway and update related services
Created a new package named billing-gateway which implements interfaces for different billing providers and provides a centralized service for payments. This will potentially help to maintain cleaner code by reducing direct dependencies on specific payment providers in the core application code. Additionally, made adjustments in existing services, like Stripe, to comply with this change. The relevant interfaces and types have been exported and imported accordingly.
This commit is contained in:
@@ -0,0 +1,63 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useTransition } from 'react';
|
||||
|
||||
import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components';
|
||||
import {
|
||||
Card,
|
||||
CardContent,
|
||||
CardDescription,
|
||||
CardHeader,
|
||||
CardTitle,
|
||||
} from '@kit/ui/card';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
|
||||
import { createPersonalAccountCheckoutSession } from '../server-actions';
|
||||
|
||||
export function PersonalAccountCheckoutForm() {
|
||||
const [pending, startTransition] = useTransition();
|
||||
const [checkoutToken, setCheckoutToken] = useState<string | null>(null);
|
||||
|
||||
// If the checkout token is set, render the embedded checkout component
|
||||
if (checkoutToken) {
|
||||
return (
|
||||
<EmbeddedCheckout
|
||||
checkoutToken={checkoutToken}
|
||||
provider={billingConfig.provider}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// Otherwise, render the plan picker component
|
||||
return (
|
||||
<div className={'mx-auto w-full max-w-2xl'}>
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Manage your Plan</CardTitle>
|
||||
|
||||
<CardDescription>
|
||||
You can change your plan at any time.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
|
||||
<CardContent>
|
||||
<PlanPicker
|
||||
pending={pending}
|
||||
config={billingConfig}
|
||||
onSubmit={({ planId }) => {
|
||||
startTransition(async () => {
|
||||
const { checkoutToken } =
|
||||
await createPersonalAccountCheckoutSession({
|
||||
planId,
|
||||
});
|
||||
|
||||
setCheckoutToken(checkoutToken);
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,8 +1,10 @@
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PageBody, PageHeader } from '@kit/ui/page';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { PersonalAccountCheckoutForm } from './components/personal-account-checkout-form';
|
||||
|
||||
function PersonalAccountBillingPage() {
|
||||
return (
|
||||
<>
|
||||
@@ -11,7 +13,9 @@ function PersonalAccountBillingPage() {
|
||||
description={<Trans i18nKey={'common:billingTabDescription'} />}
|
||||
/>
|
||||
|
||||
<PageBody></PageBody>
|
||||
<PageBody>
|
||||
<PersonalAccountCheckoutForm />
|
||||
</PageBody>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
123
apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts
Normal file
123
apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts
Normal file
@@ -0,0 +1,123 @@
|
||||
'use server';
|
||||
|
||||
import { URL } from 'next/dist/compiled/@edge-runtime/primitives';
|
||||
import { headers } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
|
||||
import { z } from 'zod';
|
||||
|
||||
import { getProductPlanPairFromId } from '@kit/billing';
|
||||
import { getGatewayProvider } from '@kit/billing-gateway';
|
||||
import { getSupabaseServerActionClient } from '@kit/supabase/server-actions-client';
|
||||
|
||||
import billingConfig from '~/config/billing.config';
|
||||
import pathsConfig from '~/config/paths.config';
|
||||
|
||||
/**
|
||||
* 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(params: {
|
||||
planId: string;
|
||||
}) {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const planId = z.string().min(1).parse(params.planId);
|
||||
const service = await getGatewayProvider(client);
|
||||
const productPlanPairFromId = getProductPlanPairFromId(billingConfig, planId);
|
||||
|
||||
if (!productPlanPairFromId) {
|
||||
throw new Error('Product not found');
|
||||
}
|
||||
|
||||
// in the case of personal accounts
|
||||
// the account ID is the same as the user ID
|
||||
const accountId = data.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);
|
||||
|
||||
// retrieve the product and plan from the billing configuration
|
||||
const { product, plan } = productPlanPairFromId;
|
||||
|
||||
// call the payment gateway to create the checkout session
|
||||
const { checkoutToken } = await service.createCheckoutSession({
|
||||
paymentType: product.paymentType,
|
||||
returnUrl,
|
||||
accountId,
|
||||
planId,
|
||||
trialPeriodDays: plan.trialPeriodDays,
|
||||
customerEmail: data.user.email,
|
||||
customerId,
|
||||
});
|
||||
|
||||
// return the checkout token to the client
|
||||
// so we can call the payment gateway to complete the checkout
|
||||
return {
|
||||
checkoutToken,
|
||||
};
|
||||
}
|
||||
|
||||
export async function createBillingPortalSession() {
|
||||
const client = getSupabaseServerActionClient();
|
||||
const { data, error } = await client.auth.getUser();
|
||||
|
||||
if (error ?? !data.user) {
|
||||
throw new Error('Authentication required');
|
||||
}
|
||||
|
||||
const service = await getGatewayProvider(client);
|
||||
|
||||
const accountId = data.user.id;
|
||||
const customerId = await getCustomerIdFromAccountId(accountId);
|
||||
const returnUrl = getBillingPortalReturnUrl();
|
||||
|
||||
const { url } = await service.createBillingPortalSession({
|
||||
customerId,
|
||||
returnUrl,
|
||||
});
|
||||
|
||||
return redirect(url);
|
||||
}
|
||||
|
||||
function getCheckoutSessionReturnUrl() {
|
||||
const origin = headers().get('origin')!;
|
||||
|
||||
return new URL(
|
||||
pathsConfig.app.personalAccountBillingReturn,
|
||||
origin,
|
||||
).toString();
|
||||
}
|
||||
|
||||
function getBillingPortalReturnUrl() {
|
||||
const origin = headers().get('origin')!;
|
||||
|
||||
return new URL(pathsConfig.app.accountBilling, origin).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;
|
||||
}
|
||||
@@ -3,19 +3,20 @@ import { notFound } from 'next/navigation';
|
||||
|
||||
import type { SupabaseClient } from '@supabase/supabase-js';
|
||||
|
||||
import ExistingUserInviteForm from '~/join/_components/ExistingUserInviteForm';
|
||||
import NewUserInviteForm from '~/join/_components/NewUserInviteForm';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
import { Logger } from '@kit/shared/logger';
|
||||
import { Database } from '@kit/supabase/database';
|
||||
import { getSupabaseServerComponentClient } from '@kit/supabase/server-component-client';
|
||||
import { Heading } from '@kit/ui/heading';
|
||||
import { If } from '@kit/ui/if';
|
||||
import { Trans } from '@kit/ui/trans';
|
||||
|
||||
import ExistingUserInviteForm from '~/join/_components/ExistingUserInviteForm';
|
||||
import NewUserInviteForm from '~/join/_components/NewUserInviteForm';
|
||||
import { withI18n } from '~/lib/i18n/with-i18n';
|
||||
|
||||
interface Context {
|
||||
params: {
|
||||
code: string;
|
||||
searchParams: {
|
||||
invite_token: string;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -23,9 +24,9 @@ export const metadata = {
|
||||
title: `Join Organization`,
|
||||
};
|
||||
|
||||
async function InvitePage({ params }: Context) {
|
||||
const code = params.code;
|
||||
const data = await loadInviteData(code);
|
||||
async function JoinTeamAccountPage({ searchParams }: Context) {
|
||||
const token = searchParams.invite_token;
|
||||
const data = await getInviteDataFromInviteToken(token);
|
||||
|
||||
if (!data.membership) {
|
||||
notFound();
|
||||
@@ -62,16 +63,19 @@ async function InvitePage({ params }: Context) {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<If condition={data.session} fallback={<NewUserInviteForm code={code} />}>
|
||||
{(session) => <ExistingUserInviteForm code={code} session={session} />}
|
||||
<If
|
||||
condition={data.session}
|
||||
fallback={<NewUserInviteForm code={token} />}
|
||||
>
|
||||
{(session) => <ExistingUserInviteForm code={token} session={session} />}
|
||||
</If>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default withI18n(InvitePage);
|
||||
export default withI18n(JoinTeamAccountPage);
|
||||
|
||||
async function loadInviteData(code: string) {
|
||||
async function getInviteDataFromInviteToken(code: string) {
|
||||
const client = getSupabaseServerComponentClient();
|
||||
|
||||
// we use an admin client to be able to read the pending membership
|
||||
Reference in New Issue
Block a user