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:
@@ -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>
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>;
|
||||
|
||||
@@ -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');
|
||||
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user