Refactor API code and simplify billing display

The code in the webhook API has been refactored to move the DatabaseWebhookHandlerService instance out of the POST function scope. Furthermore, the display of renewal plan details on the billing page has been simplified and certain parts deemed superfluous have been removed. Numerous types and interfaces in the database.types.ts file have also been corrected and formatted for consistency and improved readability.
This commit is contained in:
giancarlo
2024-03-31 15:13:44 +08:00
parent 2c0c616a2d
commit aa12ecd5a2
10 changed files with 1133 additions and 1026 deletions

View File

@@ -34,8 +34,7 @@ export function CurrentPlanCard({
subscription: Database['public']['Tables']['subscriptions']['Row'];
config: BillingConfig;
}>) {
const { plan, product } = getProductPlanPair(config, subscription.variant_id);
const baseLineItem = getBaseLineItem(config, plan.id);
const { plan, product } = getProductPlanPair(config, subscription);
return (
<Card>
@@ -61,16 +60,6 @@ export function CurrentPlanCard({
<span>{product.name}</span>
<CurrentPlanBadge status={subscription.status} />
</div>
<div className={'text-muted-foreground'}>
<Trans
i18nKey="billing:planRenewal"
values={{
interval: subscription.interval,
price: formatCurrency(product.currency, baseLineItem.cost),
}}
/>
</div>
</div>
<div>

View File

@@ -53,19 +53,21 @@ export class BillingEventHandlerService {
const ctx = {
namespace: 'billing',
subscriptionId: subscription.id,
subscriptionId: subscription.subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
customerId: subscription.customer_id,
};
Logger.info(ctx, 'Processing subscription updated event');
// Handle the subscription updated event
// here we update the subscription in the database
const { error } = await client
.from('subscriptions')
.update(subscription)
.match({ id: subscription.id });
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: subscription.customer_id,
account_id: subscription.account_id,
});
if (error) {
Logger.error(
@@ -88,24 +90,16 @@ export class BillingEventHandlerService {
const ctx = {
namespace: 'billing',
subscriptionId: subscription.id,
subscriptionId: subscription.subscription_id,
provider: subscription.billing_provider,
accountId: subscription.account_id,
};
Logger.info(ctx, 'Processing checkout session completed event...');
const { id: _, ...data } = subscription;
const { error } = await client.rpc('add_subscription', {
...data,
subscription_id: subscription.id,
const { error } = await client.rpc('upsert_subscription', {
...subscription,
customer_id: customerId,
price_amount: subscription.price_amount ?? 0,
period_starts_at: subscription.period_starts_at!,
period_ends_at: subscription.period_ends_at!,
trial_starts_at: subscription.trial_starts_at!,
trial_ends_at: subscription.trial_ends_at!,
});
if (error) {

View File

@@ -1,8 +1,7 @@
import { Database } from '@kit/supabase/database';
type SubscriptionObject = Database['public']['Tables']['subscriptions'];
type SubscriptionUpdateParams = SubscriptionObject['Update'];
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
/**
* Represents an abstract class for handling billing webhook events.
@@ -14,12 +13,13 @@ export abstract class BillingWebhookHandlerService {
event: unknown,
params: {
onCheckoutSessionCompleted: (
subscription: SubscriptionObject['Row'],
subscription: UpsertSubscriptionParams,
customerId: string,
) => Promise<unknown>;
onSubscriptionUpdated: (
subscription: SubscriptionUpdateParams,
subscription: UpsertSubscriptionParams,
customerId: string,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;

View File

@@ -10,9 +10,14 @@ export class DatabaseWebhookHandlerService {
private readonly namespace = 'database-webhook-handler';
async handleWebhook(request: Request, webhooksSecret: string) {
const json = await request.clone().json();
const { table, type } = json as RecordChange<keyof Tables>;
Logger.info(
{
name: this.namespace,
table,
type,
},
'Received webhook from DB. Processing...',
);
@@ -21,11 +26,17 @@ export class DatabaseWebhookHandlerService {
this.assertSignatureIsAuthentic(request, webhooksSecret);
// all good, handle the webhook
const json = await request.json();
await this.handleWebhookBody(json);
// create a client with admin access since we are handling webhooks
// and no user is authenticated
const client = getSupabaseRouteHandlerClient({
admin: true,
});
const { table, type } = json as RecordChange<keyof Tables>;
// handle the webhook
const service = new DatabaseWebhookRouterService(client);
await service.handleWebhook(json);
Logger.info(
{
@@ -37,16 +48,6 @@ export class DatabaseWebhookHandlerService {
);
}
private handleWebhookBody(body: RecordChange<keyof Tables>) {
const client = getSupabaseRouteHandlerClient({
admin: true,
});
const service = new DatabaseWebhookRouterService(client);
return service.handleWebhook(body);
}
private assertSignatureIsAuthentic(request: Request, webhooksSecret: string) {
const header = request.headers.get('X-Supabase-Event-Signature');

View File

@@ -7,12 +7,8 @@ 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'
>;
type UpsertSubscriptionParams =
Database['public']['Functions']['upsert_subscription']['Args'];
export class StripeWebhookHandlerService
implements BillingWebhookHandlerService
@@ -64,11 +60,12 @@ export class StripeWebhookHandlerService
event: Stripe.Event,
params: {
onCheckoutSessionCompleted: (
data: InsertSubscriptionParams,
customerId: string,
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionUpdated: (data: Subscription['Update']) => Promise<unknown>;
onSubscriptionUpdated: (
data: UpsertSubscriptionParams,
) => Promise<unknown>;
onSubscriptionDeleted: (subscriptionId: string) => Promise<unknown>;
},
) {
@@ -111,8 +108,7 @@ export class StripeWebhookHandlerService
private async handleCheckoutSessionCompleted(
event: Stripe.CheckoutSessionCompletedEvent,
onCheckoutCompletedCallback: (
data: Omit<Subscription['Insert'], 'billing_customer_id'>,
customerId: string,
data: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
const stripe = await this.loadStripe();
@@ -134,20 +130,23 @@ export class StripeWebhookHandlerService
const payload = this.buildSubscriptionPayload({
subscription,
accountId,
amount,
}) as InsertSubscriptionParams;
accountId,
customerId,
});
return onCheckoutCompletedCallback(payload, customerId);
return onCheckoutCompletedCallback(payload);
}
private async handleSubscriptionUpdatedEvent(
event: Stripe.CustomerSubscriptionUpdatedEvent,
onSubscriptionUpdatedCallback: (
data: Subscription['Update'],
data: UpsertSubscriptionParams,
) => Promise<unknown>,
) {
const subscription = event.data.object;
const accountId = subscription.metadata.account_id as string;
const customerId = subscription.customer as string;
const amount = subscription.items.data.reduce((acc, item) => {
return (acc + (item.plan.amount ?? 0)) * (item.quantity ?? 1);
@@ -156,6 +155,8 @@ export class StripeWebhookHandlerService
const payload = this.buildSubscriptionPayload({
subscription,
amount,
accountId,
customerId,
});
return onSubscriptionUpdatedCallback(payload);
@@ -173,52 +174,53 @@ export class StripeWebhookHandlerService
private buildSubscriptionPayload(params: {
subscription: Stripe.Subscription;
amount: number;
// we only need the account id if we
// are creating a subscription for an account
accountId?: string;
}) {
accountId: string;
customerId: string;
}): UpsertSubscriptionParams {
const { subscription } = params;
const lineItem = subscription.items.data[0];
const price = lineItem?.price;
const priceId = price?.id as string;
const interval = price?.recurring?.interval ?? null;
const currency = subscription.currency;
const active =
subscription.status === 'active' || subscription.status === 'trialing';
const data = {
billing_provider: this.provider,
id: subscription.id,
status: subscription.status,
active,
price_amount: params.amount,
cancel_at_period_end: subscription.cancel_at_period_end ?? false,
interval: interval as string,
currency: (price as Stripe.Price).currency,
product_id: (price as Stripe.Price).product as string,
variant_id: priceId,
interval_count: price?.recurring?.interval_count ?? 1,
period_starts_at: getISOString(subscription.current_period_start),
period_ends_at: getISOString(subscription.current_period_end),
trial_starts_at: getISOString(subscription.trial_start),
trial_ends_at: getISOString(subscription.trial_end),
} satisfies Omit<InsertSubscriptionParams, 'account_id'>;
const lineItems = subscription.items.data.map((item) => {
const quantity = item.quantity ?? 1;
// when we are creating a subscription for an account
// we need to include the account id
if (params.accountId !== undefined) {
return {
...data,
account_id: params.accountId,
id: item.id,
subscription_id: subscription.id,
product_id: item.price.product as string,
variant_id: item.price.id,
price_amount: item.price.unit_amount,
quantity,
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 data;
return {
line_items: lineItems,
billing_provider: this.provider,
subscription_id: subscription.id,
status: subscription.status,
total_amount: params.amount,
active,
currency,
cancel_at_period_end: subscription.cancel_at_period_end ?? false,
period_starts_at: getISOString(
subscription.current_period_start,
) as string,
period_ends_at: getISOString(subscription.current_period_end) as string,
trial_starts_at: getISOString(subscription.trial_start),
trial_ends_at: getISOString(subscription.trial_end),
account_id: params.accountId,
customer_id: params.customerId,
};
}
}
function getISOString(date: number | null) {
return date ? new Date(date * 1000).toISOString() : null;
return date ? new Date(date * 1000).toISOString() : undefined;
}

File diff suppressed because it is too large Load Diff