From d6004f2f7e4a2ba9415cb3832fc7fe93969b74dd Mon Sep 17 00:00:00 2001 From: giancarlo Date: Mon, 1 Apr 2024 11:52:35 +0800 Subject: [PATCH] Refactor billing gateway and enhance localization Refactored the 'plan-picker' component in the billing gateway to remove unwanted line items and improve checkout session and subscription handling. Enhanced the localization support by adding translations in the plan picker and introduced a new function to check trial eligibility so that existing customers can't start a new trial period. These changes enhance the usability of the application across different regions and provide accurate trial period conditions. --- .../personal-account-checkout-form.tsx | 8 +- .../(dashboard)/home/(user)/billing/page.tsx | 43 ++- .../home/(user)/billing/server-actions.ts | 23 +- .../home/[account]/billing/page.tsx | 48 ++-- apps/web/public/locales/en/billing.json | 3 + .../src/components/current-plan-card.tsx | 46 ++-- .../src/components/line-item-details.tsx | 95 +++++++ .../src/components/plan-picker.tsx | 252 +++++++----------- .../billing-event-handler.service.ts | 134 ++++++++-- packages/billing/src/create-billing-schema.ts | 17 ++ .../billing-webhook-handler.service.ts | 20 +- packages/stripe/src/services/stripe-sdk.ts | 4 +- .../stripe-webhook-handler.service.ts | 200 ++++++++++---- packages/supabase/src/database.types.ts | 145 +++++++++- supabase/migrations/20221215192558_schema.sql | 185 +++++++++++-- 15 files changed, 877 insertions(+), 346 deletions(-) create mode 100644 packages/billing-gateway/src/components/line-item-details.tsx diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx index 0a462d17c..243b40340 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/_components/personal-account-checkout-form.tsx @@ -5,7 +5,7 @@ import { useState, useTransition } from 'react'; import { ExclamationTriangleIcon } from '@radix-ui/react-icons'; import { EmbeddedCheckout, PlanPicker } from '@kit/billing-gateway/components'; -import { Alert, AlertTitle } from '@kit/ui/alert'; +import { Alert, AlertDescription, AlertTitle } from '@kit/ui/alert'; import { Card, CardContent, @@ -91,8 +91,12 @@ function ErrorAlert() { - + + + + + ); } diff --git a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx index b06f959ff..a369d5a2c 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/(user)/billing/page.tsx @@ -40,22 +40,29 @@ async function PersonalAccountBillingPage() {
- } - > - {(subscription) => ( - - )} + + + + + + - -
- - + + {(subscription) => ( +
+ + + + + +
+ )}
@@ -65,6 +72,14 @@ async function PersonalAccountBillingPage() { export default withI18n(PersonalAccountBillingPage); +function CustomerBillingPortalForm() { + return ( +
+ + + ); +} + async function loadData(client: SupabaseClient) { const { data, error } = await client.auth.getUser(); diff --git a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts index 96443a083..0345e43c5 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/server-actions.ts @@ -14,16 +14,24 @@ import appConfig from '~/config/app.config'; import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; +const CreateCheckoutSchema = z.object({ + planId: z.string(), + productId: z.string(), +}); + /** * 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; - productId: string; -}) { +export async function createPersonalAccountCheckoutSession( + params: z.infer, +) { + // parse the parameters + const { planId, productId } = CreateCheckoutSchema.parse(params); + + // get the authenticated user const client = getSupabaseServerActionClient(); const { data: user, error } = await requireUser(client); @@ -31,13 +39,6 @@ export async function createPersonalAccountCheckoutSession(params: { throw new Error('Authentication required'); } - const { planId, productId } = z - .object({ - planId: z.string().min(1), - productId: z.string().min(1), - }) - .parse(params); - Logger.info( { planId, diff --git a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx index e889d4033..9bb81dff0 100644 --- a/apps/web/app/(dashboard)/home/[account]/billing/page.tsx +++ b/apps/web/app/(dashboard)/home/[account]/billing/page.tsx @@ -52,31 +52,33 @@ async function TeamAccountBillingPage({ params }: Params) { -
- - - - } - > - {(data) => ( - - )} - +
+
+ + + + } + > + {(data) => ( + + )} + - -
- - + + + + - - -
+ + +
+
diff --git a/apps/web/public/locales/en/billing.json b/apps/web/public/locales/en/billing.json index b7ead8855..fc168a2c2 100644 --- a/apps/web/public/locales/en/billing.json +++ b/apps/web/public/locales/en/billing.json @@ -37,6 +37,9 @@ "detailsLabel": "Details", "planPickerLabel": "Pick your preferred plan", "planCardLabel": "Manage your Plan", + "planPickerAlertErrorTitle": "Error requesting checkout", + "planPickerAlertErrorDescription": "There was an error requesting checkout. Please try again later.", + "cancelSubscriptionDate": "Your subscription will be cancelled at the end of the period", "status": { "free": { "badge": "Free Plan", diff --git a/packages/billing-gateway/src/components/current-plan-card.tsx b/packages/billing-gateway/src/components/current-plan-card.tsx index 04be4d283..70a9905ce 100644 --- a/packages/billing-gateway/src/components/current-plan-card.tsx +++ b/packages/billing-gateway/src/components/current-plan-card.tsx @@ -1,12 +1,7 @@ import { formatDate } from 'date-fns'; import { BadgeCheck, CheckCircle2 } from 'lucide-react'; -import { - BillingConfig, - getBaseLineItem, - getProductPlanPair, -} from '@kit/billing'; -import { formatCurrency } from '@kit/shared/utils'; +import { BillingConfig, getProductPlanPairByVariantId } from '@kit/billing'; import { Database } from '@kit/supabase/database'; import { Accordion, @@ -26,6 +21,7 @@ import { Trans } from '@kit/ui/trans'; import { CurrentPlanAlert } from './current-plan-alert'; import { CurrentPlanBadge } from './current-plan-badge'; +import { LineItemDetails } from './line-item-details'; type Subscription = Database['public']['Tables']['subscriptions']['Row']; type LineItem = Database['public']['Tables']['subscription_items']['Row']; @@ -42,19 +38,26 @@ export function CurrentPlanCard({ subscription, config, }: React.PropsWithChildren) { - // line items have the same product id - const lineItem = subscription.items[0] as LineItem; + const lineItems = subscription.items; + const firstLineItem = lineItems[0]; - const product = config.products.find( - (product) => product.id === lineItem.product_id, + if (!firstLineItem) { + throw new Error('No line items found in subscription'); + } + + const { product, plan } = getProductPlanPairByVariantId( + config, + firstLineItem.variant_id, ); - if (!product) { + if (!product || !plan) { throw new Error( - 'Product not found. Make sure the product exists in the billing config.', + 'Product or plan not found. Did you forget to add it to the billing config?', ); } + const productLineItems = plan.lineItems; + return ( @@ -113,8 +116,7 @@ export function CurrentPlanCard({
- Your subscription will be cancelled at the end of the - period +
@@ -126,7 +128,21 @@ export function CurrentPlanCard({
- Features + + + + + +
+ +
+ + +
    {product.features.map((item) => { diff --git a/packages/billing-gateway/src/components/line-item-details.tsx b/packages/billing-gateway/src/components/line-item-details.tsx new file mode 100644 index 000000000..ea5238d53 --- /dev/null +++ b/packages/billing-gateway/src/components/line-item-details.tsx @@ -0,0 +1,95 @@ +import { z } from 'zod'; + +import { LineItemSchema } from '@kit/billing'; +import { formatCurrency } from '@kit/shared/utils'; +import { Trans } from '@kit/ui/trans'; + +export function LineItemDetails( + props: React.PropsWithChildren<{ + lineItems: z.infer[]; + currency: string; + selectedInterval: string; + }>, +) { + return ( +
    + {props.lineItems.map((item) => { + switch (item.type) { + case 'base': + return ( +
    + + + + + + / + + + + + + + + {formatCurrency(props?.currency.toLowerCase(), item.cost)} + +
    + ); + + case 'per-seat': + return ( +
    + + + + + + {formatCurrency(props.currency.toLowerCase(), item.cost)} + +
    + ); + + case 'metered': + return ( +
    + + + + {item.included ? ( + + ) : ( + '' + )} + + + + {formatCurrency(props?.currency.toLowerCase(), item.cost)} + +
    + ); + } + })} +
    + ); +} diff --git a/packages/billing-gateway/src/components/plan-picker.tsx b/packages/billing-gateway/src/components/plan-picker.tsx index 77d837927..f7981b69c 100644 --- a/packages/billing-gateway/src/components/plan-picker.tsx +++ b/packages/billing-gateway/src/components/plan-picker.tsx @@ -10,6 +10,7 @@ import { z } from 'zod'; import { BillingConfig, + LineItemSchema, getBaseLineItem, getPlanIntervals, getProductPlanPair, @@ -36,6 +37,8 @@ import { import { Trans } from '@kit/ui/trans'; import { cn } from '@kit/ui/utils'; +import { LineItemDetails } from './line-item-details'; + export function PlanPicker( props: React.PropsWithChildren<{ config: BillingConfig; @@ -55,7 +58,8 @@ export function PlanPicker( resolver: zodResolver( z .object({ - planId: z.string(), + planId: z.string().min(1), + productId: z.string().min(1), interval: z.string().min(1), }) .refine( @@ -143,6 +147,10 @@ export function PlanPicker( shouldValidate: true, }); + form.setValue('productId', '', { + shouldValidate: true, + }); + form.setValue('interval', interval, { shouldValidate: true, }); @@ -311,167 +319,97 @@ export function PlanPicker(
- -
-
- - - - {' '} - /{' '} - - - -

- - - -

-
- -
- - - - -
- {selectedPlan?.lineItems.map((item) => { - switch (item.type) { - case 'base': - return ( -
- - - - - - / - - - - - - - - {formatCurrency( - selectedProduct?.currency.toLowerCase(), - item.cost, - )} - -
- ); - - case 'per-seat': - return ( -
- - - - - - {formatCurrency( - selectedProduct?.currency.toLowerCase(), - item.cost, - )} - -
- ); - - case 'metered': - return ( -
- - - - {item.included ? ( - - ) : ( - '' - )} - - - - {formatCurrency( - selectedProduct?.currency.toLowerCase(), - item.cost, - )} - -
- ); - } - })} -
-
- -
- - - - - {selectedProduct?.features.map((item) => { - return ( -
- - - - - -
- ); - })} -
-
-
+ {selectedPlan && selectedInterval && selectedProduct ? ( + + ) : null}
); } +function PlanDetails({ + selectedProduct, + selectedInterval, + selectedPlan, +}: { + selectedProduct: { + id: string; + name: string; + description: string; + currency: string; + features: string[]; + }; + + selectedInterval: string; + + selectedPlan: { + lineItems: z.infer[]; + }; +}) { + return ( +
+
+ + + + {' '} + / + + +

+ + + +

+
+ +
+ + + + + +
+ +
+ + + + + {selectedProduct.features.map((item) => { + return ( +
+ + + + + +
+ ); + })} +
+
+ ); +} + function Price(props: React.PropsWithChildren) { return ( SupabaseClient, private readonly strategy: BillingWebhookHandlerService, @@ -25,7 +27,7 @@ export class BillingEventHandlerService { // here we delete the subscription from the database Logger.info( { - namespace: 'billing', + namespace: this.namespace, subscriptionId, }, 'Processing subscription deleted event', @@ -42,7 +44,7 @@ export class BillingEventHandlerService { Logger.info( { - namespace: 'billing', + namespace: this.namespace, subscriptionId, }, 'Successfully deleted subscription', @@ -52,22 +54,18 @@ export class BillingEventHandlerService { const client = this.clientProvider(); const ctx = { - namespace: 'billing', - subscriptionId: subscription.subscription_id, + namespace: this.namespace, + subscriptionId: subscription.target_subscription_id, provider: subscription.billing_provider, - accountId: subscription.account_id, - customerId: subscription.customer_id, + accountId: subscription.target_account_id, + customerId: subscription.target_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.rpc('upsert_subscription', { - ...subscription, - customer_id: subscription.customer_id, - account_id: subscription.account_id, - }); + const { error } = await client.rpc('upsert_subscription', subscription); if (error) { Logger.error( @@ -83,32 +81,114 @@ export class BillingEventHandlerService { Logger.info(ctx, 'Successfully updated subscription'); }, - onCheckoutSessionCompleted: async (subscription, customerId) => { + onCheckoutSessionCompleted: async (payload, customerId) => { // Handle the checkout session completed event // here we add the subscription to the database const client = this.clientProvider(); - const ctx = { - namespace: 'billing', - subscriptionId: subscription.subscription_id, - provider: subscription.billing_provider, - accountId: subscription.account_id, - }; + // Check if the payload contains an order_id + // if it does, we add an order, otherwise we add a subscription + if ('order_id' in payload) { + const ctx = { + namespace: this.namespace, + orderId: payload.order_id, + provider: payload.billing_provider, + accountId: payload.target_account_id, + customerId, + }; - Logger.info(ctx, 'Processing checkout session completed event...'); + Logger.info(ctx, 'Processing order completed event...'); - const { error } = await client.rpc('upsert_subscription', { - ...subscription, - customer_id: customerId, - }); + const { error } = await client.rpc('upsert_order', payload); + + if (error) { + Logger.error({ ...ctx, error }, 'Failed to add order'); + + throw new Error('Failed to add order'); + } + + Logger.info(ctx, 'Successfully added order'); + } else { + const ctx = { + namespace: this.namespace, + subscriptionId: payload.target_subscription_id, + provider: payload.billing_provider, + accountId: payload.target_account_id, + customerId, + }; + + Logger.info(ctx, 'Processing checkout session completed event...'); + + const { error } = await client.rpc('upsert_subscription', payload); + + if (error) { + Logger.error({ ...ctx, error }, 'Failed to add subscription'); + + throw new Error('Failed to add subscription'); + } + + Logger.info(ctx, 'Successfully added subscription'); + } + }, + onPaymentSucceeded: async (sessionId: string) => { + const client = this.clientProvider(); + + // Handle the payment succeeded event + // here we update the payment status in the database + Logger.info( + { + namespace: this.namespace, + sessionId, + }, + 'Processing payment succeeded event', + ); + + const { error } = await client + .from('orders') + .update({ status: 'succeeded' }) + .match({ session_id: sessionId }); if (error) { - Logger.error({ ...ctx, error }, 'Failed to add subscription'); - - throw new Error('Failed to add subscription'); + throw new Error('Failed to update payment status'); } - Logger.info(ctx, 'Successfully added subscription'); + Logger.info( + { + namespace: 'billing', + sessionId, + }, + 'Successfully updated payment status', + ); + }, + onPaymentFailed: async (sessionId: string) => { + const client = this.clientProvider(); + + // Handle the payment failed event + // here we update the payment status in the database + Logger.info( + { + namespace: this.namespace, + sessionId, + }, + 'Processing payment failed event', + ); + + const { error } = await client + .from('orders') + .update({ status: 'failed' }) + .match({ session_id: sessionId }); + + if (error) { + throw new Error('Failed to update payment status'); + } + + Logger.info( + { + namespace: this.namespace, + sessionId, + }, + 'Successfully updated payment status', + ); }, }); } diff --git a/packages/billing/src/create-billing-schema.ts b/packages/billing/src/create-billing-schema.ts index fc288b593..1d9a73282 100644 --- a/packages/billing/src/create-billing-schema.ts +++ b/packages/billing/src/create-billing-schema.ts @@ -226,3 +226,20 @@ export function getProductPlanPair( throw new Error('Plan not found'); } + +export function getProductPlanPairByVariantId( + config: z.infer, + planId: string, +) { + for (const product of config.products) { + for (const plan of product.plans) { + for (const lineItem of plan.lineItems) { + if (lineItem.id === planId) { + return { product, plan }; + } + } + } + } + + throw new Error('Plan not found'); +} diff --git a/packages/billing/src/services/billing-webhook-handler.service.ts b/packages/billing/src/services/billing-webhook-handler.service.ts index c14e284ae..182592180 100644 --- a/packages/billing/src/services/billing-webhook-handler.service.ts +++ b/packages/billing/src/services/billing-webhook-handler.service.ts @@ -3,26 +3,42 @@ import { Database } from '@kit/supabase/database'; type UpsertSubscriptionParams = Database['public']['Functions']['upsert_subscription']['Args']; +type UpsertOrderParams = + Database['public']['Functions']['upsert_order']['Args']; + /** - * Represents an abstract class for handling billing webhook events. + * @name BillingWebhookHandlerService + * @description Represents an abstract class for handling billing webhook events. */ export abstract class BillingWebhookHandlerService { + // Verifies the webhook signature - should throw an error if the signature is invalid abstract verifyWebhookSignature(request: Request): Promise; abstract handleWebhookEvent( event: unknown, params: { + // this method is called when a checkout session is completed onCheckoutSessionCompleted: ( - subscription: UpsertSubscriptionParams, + subscription: UpsertSubscriptionParams | UpsertOrderParams, customerId: string, ) => Promise; + // this method is called when a subscription is updated onSubscriptionUpdated: ( subscription: UpsertSubscriptionParams, customerId: string, ) => Promise; + // this method is called when a subscription is deleted onSubscriptionDeleted: (subscriptionId: string) => Promise; + + // this method is called when a payment is succeeded. This is used for + // one-time payments + onPaymentSucceeded: (sessionId: string) => Promise; + + // this method is called when a payment is failed. This is used for + // one-time payments + onPaymentFailed: (sessionId: string) => Promise; }, ): Promise; } diff --git a/packages/stripe/src/services/stripe-sdk.ts b/packages/stripe/src/services/stripe-sdk.ts index f0d9299cf..cd15ae250 100644 --- a/packages/stripe/src/services/stripe-sdk.ts +++ b/packages/stripe/src/services/stripe-sdk.ts @@ -12,8 +12,8 @@ export async function createStripeClient() { // 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, + secretKey: process.env.STRIPE_SECRET_KEY, + webhooksSecret: process.env.STRIPE_WEBHOOK_SECRET, }); return new Stripe(stripeServerEnv.secretKey, { diff --git a/packages/stripe/src/services/stripe-webhook-handler.service.ts b/packages/stripe/src/services/stripe-webhook-handler.service.ts index e9eb36dec..3c2efc025 100644 --- a/packages/stripe/src/services/stripe-webhook-handler.service.ts +++ b/packages/stripe/src/services/stripe-webhook-handler.service.ts @@ -10,6 +10,9 @@ import { createStripeClient } from './stripe-sdk'; type UpsertSubscriptionParams = Database['public']['Functions']['upsert_subscription']['Args']; +type UpsertOrderParams = + Database['public']['Functions']['upsert_order']['Args']; + export class StripeWebhookHandlerService implements BillingWebhookHandlerService { @@ -60,13 +63,14 @@ export class StripeWebhookHandlerService event: Stripe.Event, params: { onCheckoutSessionCompleted: ( - data: UpsertSubscriptionParams, + data: UpsertSubscriptionParams | UpsertOrderParams, ) => Promise; - onSubscriptionUpdated: ( data: UpsertSubscriptionParams, ) => Promise; onSubscriptionDeleted: (subscriptionId: string) => Promise; + onPaymentSucceeded: (sessionId: string) => Promise; + onPaymentFailed: (sessionId: string) => Promise; }, ) { switch (event.type) { @@ -80,7 +84,7 @@ export class StripeWebhookHandlerService case 'customer.subscription.updated': { return this.handleSubscriptionUpdatedEvent( event, - params.onSubscriptionUpdated, + params.onCheckoutSessionCompleted, ); } @@ -91,6 +95,17 @@ export class StripeWebhookHandlerService ); } + case 'checkout.session.async_payment_failed': { + return this.handleAsyncPaymentFailed(event, params.onPaymentFailed); + } + + case 'checkout.session.async_payment_succeeded': { + return this.handleAsyncPaymentSucceeded( + event, + params.onPaymentSucceeded, + ); + } + default: { Logger.info( { @@ -108,55 +123,116 @@ export class StripeWebhookHandlerService private async handleCheckoutSessionCompleted( event: Stripe.CheckoutSessionCompletedEvent, onCheckoutCompletedCallback: ( - data: UpsertSubscriptionParams, + data: UpsertSubscriptionParams | UpsertOrderParams, ) => Promise, ) { const stripe = await this.loadStripe(); const session = event.data.object; - - // TODO: handle one-off payments - // is subscription there? - const subscriptionId = session.subscription as string; - const subscription = await stripe.subscriptions.retrieve(subscriptionId); + const isSubscription = session.mode === 'subscription'; 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; + if (isSubscription) { + const subscriptionId = session.subscription as string; + const subscription = await stripe.subscriptions.retrieve(subscriptionId); - const payload = this.buildSubscriptionPayload({ - subscription, - amount, - accountId, - customerId, - }); + const payload = this.buildSubscriptionPayload({ + accountId, + customerId, + id: subscription.id, + lineItems: subscription.items.data, + status: subscription.status, + currency: subscription.currency, + periodStartsAt: subscription.current_period_start, + periodEndsAt: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + trialStartsAt: subscription.trial_start, + trialEndsAt: subscription.trial_end, + }); - return onCheckoutCompletedCallback(payload); + return onCheckoutCompletedCallback(payload); + } else { + const sessionId = event.data.object.id; + + const sessionWithLineItems = await stripe.checkout.sessions.retrieve( + event.data.object.id, + { + expand: ['line_items'], + }, + ); + + const lineItems = sessionWithLineItems.line_items?.data ?? []; + const paymentStatus = sessionWithLineItems.payment_status; + const status = paymentStatus === 'unpaid' ? 'pending' : 'succeeded'; + const currency = event.data.object.currency as string; + + const payload: UpsertOrderParams = { + target_account_id: accountId, + target_customer_id: customerId, + order_id: sessionId, + billing_provider: this.provider, + status: status, + currency: currency, + total_amount: sessionWithLineItems.amount_total ?? 0, + line_items: lineItems.map((item) => { + const price = item.price as Stripe.Price; + + return { + id: item.id, + product_id: price.product as string, + variant_id: price.id, + price_amount: price.unit_amount, + quantity: item.quantity, + }; + }), + }; + + return onCheckoutCompletedCallback(payload); + } } - private async handleSubscriptionUpdatedEvent( + private handleAsyncPaymentFailed( + event: Stripe.CheckoutSessionAsyncPaymentFailedEvent, + onPaymentFailed: (sessionId: string) => Promise, + ) { + const sessionId = event.data.object.id; + + return onPaymentFailed(sessionId); + } + + private handleAsyncPaymentSucceeded( + event: Stripe.CheckoutSessionAsyncPaymentSucceededEvent, + onPaymentSucceeded: (sessionId: string) => Promise, + ) { + const sessionId = event.data.object.id; + + return onPaymentSucceeded(sessionId); + } + + private handleSubscriptionUpdatedEvent( event: Stripe.CustomerSubscriptionUpdatedEvent, onSubscriptionUpdatedCallback: ( - data: UpsertSubscriptionParams, + subscription: UpsertSubscriptionParams, ) => Promise, ) { const subscription = event.data.object; + const subscriptionId = subscription.id; 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); - }, 0); const payload = this.buildSubscriptionPayload({ - subscription, - amount, + customerId: subscription.customer as string, + id: subscriptionId, accountId, - customerId, + lineItems: subscription.items.data, + status: subscription.status, + currency: subscription.currency, + periodStartsAt: subscription.current_period_start, + periodEndsAt: subscription.current_period_end, + cancelAtPeriodEnd: subscription.cancel_at_period_end, + trialStartsAt: subscription.trial_start, + trialEndsAt: subscription.trial_end, }); return onSubscriptionUpdatedCallback(payload); @@ -171,52 +247,58 @@ export class StripeWebhookHandlerService return onSubscriptionDeletedCallback(subscription.id); } - private buildSubscriptionPayload(params: { - subscription: Stripe.Subscription; - amount: number; + private buildSubscriptionPayload< + LineItem extends { + id: string; + quantity?: number; + price?: Stripe.Price; + }, + >(params: { + id: string; accountId: string; customerId: string; + lineItems: LineItem[]; + status: Stripe.Subscription.Status; + currency: string; + cancelAtPeriodEnd: boolean; + periodStartsAt: number; + periodEndsAt: number; + trialStartsAt: number | null; + trialEndsAt: number | null; }): UpsertSubscriptionParams { - const { subscription } = params; - const currency = subscription.currency; + const active = params.status === 'active' || params.status === 'trialing'; - const active = - subscription.status === 'active' || subscription.status === 'trialing'; - - const lineItems = subscription.items.data.map((item) => { + const lineItems = params.lineItems.map((item) => { const quantity = item.quantity ?? 1; return { 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, + subscription_id: params.id, + product_id: item.price?.product as string, + variant_id: item.price?.id, + price_amount: item.price?.unit_amount, + 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 { - line_items: lineItems, + target_subscription_id: params.id, + target_account_id: params.accountId, + target_customer_id: params.customerId, billing_provider: this.provider, - subscription_id: subscription.id, - status: subscription.status, - total_amount: params.amount, + status: params.status, + line_items: lineItems, 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, + currency: params.currency, + cancel_at_period_end: params.cancelAtPeriodEnd ?? false, + period_starts_at: getISOString(params.periodStartsAt) as string, + period_ends_at: getISOString(params.periodEndsAt) as string, + trial_starts_at: getISOString(params.trialStartsAt), + trial_ends_at: getISOString(params.trialEndsAt), }; } } diff --git a/packages/supabase/src/database.types.ts b/packages/supabase/src/database.types.ts index f2f1a449a..dcd647ee5 100644 --- a/packages/supabase/src/database.types.ts +++ b/packages/supabase/src/database.types.ts @@ -364,6 +364,115 @@ export type Database = { }, ] } + order_items: { + Row: { + created_at: string + order_id: string + price_amount: number | null + product_id: string + quantity: number + updated_at: string + variant_id: string + } + Insert: { + created_at?: string + order_id: string + price_amount?: number | null + product_id: string + quantity?: number + updated_at?: string + variant_id: string + } + Update: { + created_at?: string + order_id?: string + price_amount?: number | null + product_id?: string + quantity?: number + updated_at?: string + variant_id?: string + } + Relationships: [ + { + foreignKeyName: "order_items_order_id_fkey" + columns: ["order_id"] + isOneToOne: false + referencedRelation: "orders" + referencedColumns: ["id"] + }, + ] + } + orders: { + Row: { + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at: string + currency: string + id: string + product_id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at: string + variant_id: string + } + Insert: { + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at?: string + currency: string + id: string + product_id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at?: string + variant_id: string + } + Update: { + account_id?: string + billing_customer_id?: number + billing_provider?: Database["public"]["Enums"]["billing_provider"] + created_at?: string + currency?: string + id?: string + product_id?: string + status?: Database["public"]["Enums"]["payment_status"] + total_amount?: number + updated_at?: string + variant_id?: string + } + Relationships: [ + { + foreignKeyName: "orders_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "orders_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_account_workspace" + referencedColumns: ["id"] + }, + { + foreignKeyName: "orders_account_id_fkey" + columns: ["account_id"] + isOneToOne: false + referencedRelation: "user_accounts" + referencedColumns: ["id"] + }, + { + foreignKeyName: "orders_billing_customer_id_fkey" + columns: ["billing_customer_id"] + isOneToOne: false + referencedRelation: "billing_customers" + referencedColumns: ["id"] + }, + ] + } role_permissions: { Row: { id: number @@ -436,7 +545,6 @@ export type Database = { subscription_items: { Row: { created_at: string - id: string interval: string interval_count: number price_amount: number | null @@ -448,7 +556,6 @@ export type Database = { } Insert: { created_at?: string - id: string interval: string interval_count: number price_amount?: number | null @@ -460,7 +567,6 @@ export type Database = { } Update: { created_at?: string - id?: string interval?: string interval_count?: number price_amount?: number | null @@ -781,19 +887,43 @@ export type Database = { } Returns: unknown } + upsert_order: { + Args: { + target_account_id: string + target_customer_id: string + order_id: string + status: Database["public"]["Enums"]["payment_status"] + billing_provider: Database["public"]["Enums"]["billing_provider"] + total_amount: number + currency: string + line_items: Json + } + Returns: { + account_id: string + billing_customer_id: number + billing_provider: Database["public"]["Enums"]["billing_provider"] + created_at: string + currency: string + id: string + product_id: string + status: Database["public"]["Enums"]["payment_status"] + total_amount: number + updated_at: string + variant_id: string + } + } upsert_subscription: { Args: { - account_id: string - subscription_id: string + target_account_id: string + target_customer_id: string + target_subscription_id: string active: boolean - total_amount: number status: Database["public"]["Enums"]["subscription_status"] billing_provider: Database["public"]["Enums"]["billing_provider"] cancel_at_period_end: boolean currency: string period_starts_at: string period_ends_at: string - customer_id: string line_items: Json trial_starts_at?: string trial_ends_at?: string @@ -827,6 +957,7 @@ export type Database = { | "members.manage" | "invites.manage" billing_provider: "stripe" | "lemon-squeezy" | "paddle" + payment_status: "pending" | "succeeded" | "failed" subscription_status: | "active" | "trialing" diff --git a/supabase/migrations/20221215192558_schema.sql b/supabase/migrations/20221215192558_schema.sql index f95847e41..75d8131bc 100644 --- a/supabase/migrations/20221215192558_schema.sql +++ b/supabase/migrations/20221215192558_schema.sql @@ -118,14 +118,14 @@ create type public.subscription_status as ENUM( 'paused' ); -/* Subscription Type -- We create the subscription type for the Supabase MakerKit. These types are used to manage the type of the subscriptions -- The types are 'ONE_OFF' and 'RECURRING'. -- You can add more types as needed. +/* +Payment Status +- We create the payment status for the Supabase MakerKit. These statuses are used to manage the status of the payments */ -create type public.subscription_type as enum ( - 'one-off', - 'recurring' +create type public.payment_status as ENUM( + 'pending', + 'succeeded', + 'failed' ); /* @@ -633,8 +633,6 @@ using ( ) ); - - /* * ------------------------------------------------------- * Section: Account Roles @@ -915,7 +913,8 @@ create table id serial primary key, email text, provider public.billing_provider not null, - customer_id text not null + customer_id text not null, + unique (account_id, customer_id, provider) ); comment on table public.billing_customers is 'The billing customers for an account'; @@ -959,8 +958,6 @@ create table if not exists public.subscriptions ( account_id uuid references public.accounts (id) on delete cascade not null, billing_customer_id int references public.billing_customers on delete cascade not null, status public.subscription_status not null, - type public.subscription_type not null default 'recurring', - total_amount numeric not null, active bool not null, billing_provider public.billing_provider not null, cancel_at_period_end bool not null, @@ -979,8 +976,6 @@ comment on column public.subscriptions.account_id is 'The account the subscripti comment on column public.subscriptions.billing_provider is 'The provider of the subscription'; -comment on column public.subscriptions.total_amount is 'The total price amount for the subscription'; - comment on column public.subscriptions.cancel_at_period_end is 'Whether the subscription will be canceled at the end of the period'; comment on column public.subscriptions.currency is 'The currency for the subscription'; @@ -1018,17 +1013,16 @@ select -- Functions create or replace function public.upsert_subscription ( - account_id uuid, - subscription_id text, + target_account_id uuid, + target_customer_id varchar(255), + target_subscription_id text, active bool, - total_amount numeric, status public.subscription_status, billing_provider public.billing_provider, cancel_at_period_end bool, currency varchar(3), period_starts_at timestamptz, period_ends_at timestamptz, - customer_id varchar(255), line_items jsonb, trial_starts_at timestamptz default null, trial_ends_at timestamptz default null, @@ -1039,7 +1033,7 @@ declare new_billing_customer_id int; begin insert into public.billing_customers(account_id, provider, customer_id) - values (account_id, billing_provider, customer_id) + values (target_account_id, billing_provider, target_customer_id) on conflict (account_id, provider, customer_id) do update set provider = excluded.provider returning id into new_billing_customer_id; @@ -1049,7 +1043,6 @@ begin billing_customer_id, id, active, - total_amount, status, type, billing_provider, @@ -1060,11 +1053,10 @@ begin trial_starts_at, trial_ends_at) values ( - account_id, + target_account_id, new_billing_customer_id, subscription_id, active, - total_amount, status, type, billing_provider, @@ -1125,23 +1117,21 @@ $$ language plpgsql; grant execute on function public.upsert_subscription ( uuid, + varchar, text, bool, - numeric, public.subscription_status, public.billing_provider, bool, varchar, timestamptz, timestamptz, - varchar, jsonb, timestamptz, timestamptz, public.subscription_type ) to service_role; - /* ------------------------------------------------------- * Section: Subscription Items * We create the schema for the subscription items. Subscription items are the items in a subscription. @@ -1149,7 +1139,6 @@ grant execute on function public.upsert_subscription ( * ------------------------------------------------------- */ create table if not exists public.subscription_items ( - id text not null primary key, subscription_id text references public.subscriptions (id) on delete cascade not null, product_id varchar(255) not null, variant_id varchar(255) not null, @@ -1158,7 +1147,8 @@ create table if not exists public.subscription_items ( interval varchar(255) not null, interval_count integer not null check (interval_count > 0), created_at timestamptz not null default current_timestamp, - updated_at timestamptz not null default current_timestamp + updated_at timestamptz not null default current_timestamp, + unique (subscription_id, product_id, variant_id) ); comment on table public.subscription_items is 'The items in a subscription'; @@ -1188,6 +1178,147 @@ select ) ); +/** +* ------------------------------------------------------- +* Section: Orders +* We create the schema for the subscription items. Subscription items are the items in a subscription. +* For example, a subscription might have a subscription item with the product ID 'prod_123' and the variant ID 'var_123'. +* ------------------------------------------------------- +*/ +create table if not exists public.orders ( + id text not null primary key, + account_id uuid references public.accounts (id) on delete cascade not null, + billing_customer_id int references public.billing_customers on delete cascade not null, + status public.payment_status not null, + billing_provider public.billing_provider not null, + total_amount numeric not null, + currency varchar(3) not null, + created_at timestamptz not null default current_timestamp, + updated_at timestamptz not null default current_timestamp +); + +-- Open up access to subscription_items table for authenticated users and service_role +grant select on table public.orders to authenticated, service_role; +grant insert, update, delete on table public.orders to service_role; + +-- RLS +alter table public.orders enable row level security; + +-- SELECT +-- Users can read orders on an account they are a member of or the account is their own +create policy orders_read_self on public.orders for +select + to authenticated using ( + account_id = auth.uid () or has_role_on_account (account_id) + ); + +/** +* ------------------------------------------------------- +* Section: Order Items +* We create the schema for the order items. Order items are the items in an order. +* ------------------------------------------------------- +*/ +create table if not exists public.order_items ( + order_id text references public.orders (id) on delete cascade not null, + product_id text not null, + variant_id text not null, + price_amount numeric, + quantity integer not null default 1, + created_at timestamptz not null default current_timestamp, + updated_at timestamptz not null default current_timestamp, + unique (order_id, product_id, variant_id) +); + +-- Open up access to order_items table for authenticated users and service_role +grant select on table public.order_items to authenticated, service_role; + +-- RLS +alter table public.order_items enable row level security; + +-- SELECT +-- Users can read order items on an order they are a member of +create policy order_items_read_self on public.order_items for +select + to authenticated using ( + exists ( + select 1 from public.orders where id = order_id and (account_id = auth.uid () or has_role_on_account (account_id)) + ) + ); + +-- Functions +create or replace function public.upsert_order( + target_account_id uuid, + target_customer_id varchar(255), + order_id text, + status public.payment_status, + billing_provider public.billing_provider, + total_amount numeric, + currency varchar(3), + line_items jsonb +) returns public.orders as $$ +declare + new_order public.orders; + new_billing_customer_id int; +begin + insert into public.billing_customers(account_id, provider, customer_id) + values (target_account_id, target_billing_provider, target_customer_id) + on conflict (account_id, provider, customer_id) do update + set provider = excluded.provider + returning id into new_billing_customer_id; + + insert into public.orders( + account_id, + billing_customer_id, + id, + status, + billing_provider, + total_amount, + currency) + values ( + target_account_id, + new_billing_customer_id, + order_id, + status, + billing_provider, + total_amount, + currency) + on conflict (id) do update + set status = excluded.status, + total_amount = excluded.total_amount, + currency = excluded.currency + returning * into new_order; + + insert into public.order_items( + order_id, + product_id, + variant_id, + price_amount, + quantity) + select + target_order_id, + (line_item ->> 'product_id')::varchar, + (line_item ->> 'variant_id')::varchar, + (line_item ->> 'price_amount')::numeric, + (line_item ->> 'quantity')::integer + from jsonb_array_elements(line_items) as line_item + on conflict (order_id, product_id, variant_id) do update + set price_amount = excluded.price_amount, + quantity = excluded.quantity; + + return new_order; + end; +$$ language plpgsql; + +grant execute on function public.upsert_order ( + uuid, + varchar, + text, + public.payment_status, + public.billing_provider, + numeric, + varchar, + jsonb + ) to service_role; /* * ------------------------------------------------------- * Section: Functions