From 37c5e3ed762825ff5ee8820f6a5f5afa2652031f Mon Sep 17 00:00:00 2001 From: giancarlo Date: Wed, 10 Apr 2024 22:04:38 +0800 Subject: [PATCH] Refactor logger usage and add discount field to checkout This commit refactors the logger usage in various files to make it more streamlined and consistent. It also introduces a new 'enableDiscountField' feature for the checkout that can be toggled on specific products. This allows customers to apply discounts at checkout if the product or subscription plan has the 'enableDiscountField' set to true. --- .../_lib/server/user-billing.service.ts | 28 ++- .../_lib/server/team-billing.service.ts | 36 ++-- .../billing/core/src/create-billing-schema.ts | 13 +- .../schema/create-billing-checkout.schema.ts | 1 + .../gateway/src/components/plan-picker.tsx | 28 ++- .../services/create-lemon-squeezy-checkout.ts | 1 + .../src/services/create-stripe-checkout.ts | 1 + .../stripe-billing-strategy.service.ts | 185 +++++++----------- .../mfa/multi-factor-auth-list.tsx | 2 +- 9 files changed, 137 insertions(+), 158 deletions(-) diff --git a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts index ce5360e88..25f16f32b 100644 --- a/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts +++ b/apps/web/app/(dashboard)/home/(user)/billing/_lib/server/user-billing.service.ts @@ -17,6 +17,8 @@ import billingConfig from '~/config/billing.config'; import pathsConfig from '~/config/paths.config'; export class UserBillingService { + private readonly namespace = 'billing.personal-account'; + constructor(private readonly client: SupabaseClient) {} async createCheckoutSession({ @@ -73,6 +75,7 @@ export class UserBillingService { customerId, plan, variantQuantities: [], + enableDiscountField: product.enableDiscountField, }); logger.info( @@ -111,6 +114,7 @@ export class UserBillingService { } const service = await getBillingGatewayProvider(this.client); + const logger = await getLogger(); const accountId = data.id; const customerId = await getCustomerIdFromAccountId(accountId); @@ -120,14 +124,14 @@ export class UserBillingService { throw new Error('Customer not found'); } - const logger = await getLogger(); + const ctx = { + name: this.namespace, + customerId, + accountId, + }; logger.info( - { - name: `billing.personal-account`, - customerId, - accountId, - }, + ctx, `User requested a Billing Portal session. Contacting provider...`, ); @@ -144,8 +148,7 @@ export class UserBillingService { logger.error( { error, - customerId, - accountId, + ...ctx, }, `Failed to create a Billing Portal session`, ); @@ -155,14 +158,7 @@ export class UserBillingService { ); } - logger.info( - { - name: `billing.personal-account`, - customerId, - accountId, - }, - `Session successfully created.`, - ); + logger.info(ctx, `Session successfully created.`); // redirect user to billing portal return url; diff --git a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts index e4afa8b6c..ad18f54f9 100644 --- a/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts +++ b/apps/web/app/(dashboard)/home/[account]/_lib/server/team-billing.service.ts @@ -38,14 +38,13 @@ export class TeamBillingService { const accountId = params.accountId; const logger = await getLogger(); - logger.info( - { - userId, - accountId, - name: this.namespace, - }, - `Requested checkout session. Processing...`, - ); + const ctx = { + userId, + accountId, + name: this.namespace, + }; + + logger.info(ctx, `Requested checkout session. Processing...`); // verify permissions to manage billing const hasPermission = await getBillingPermissionsForAccountId( @@ -57,11 +56,7 @@ export class TeamBillingService { // then we should not proceed if (!hasPermission) { logger.warn( - { - userId, - accountId, - name: this.namespace, - }, + ctx, `User without permissions attempted to create checkout.`, ); @@ -74,7 +69,7 @@ export class TeamBillingService { // retrieve the plan from the configuration // so we can assign the correct checkout data - const plan = getPlan(params.productId, params.planId); + const { plan, product } = getPlanDetails(params.productId, params.planId); // find the customer ID for the account if it exists // (eg. if the account has been billed before) @@ -94,10 +89,8 @@ export class TeamBillingService { logger.info( { - userId, - accountId, + ...ctx, planId: plan.id, - namespace: this.namespace, }, `Creating checkout session...`, ); @@ -111,6 +104,7 @@ export class TeamBillingService { customerEmail, customerId, variantQuantities, + enableDiscountField: product.enableDiscountField, }); // return the checkout token to the client @@ -121,10 +115,8 @@ export class TeamBillingService { } catch (error) { logger.error( { - name: this.namespace, + ...ctx, error, - accountId, - planId: plan.id, }, `Error creating the checkout session`, ); @@ -338,7 +330,7 @@ async function getCustomerIdFromAccountId( return data?.customer_id; } -function getPlan(productId: string, planId: string) { +function getPlanDetails(productId: string, planId: string) { const product = billingConfig.products.find( (product) => product.id === productId, ); @@ -353,5 +345,5 @@ function getPlan(productId: string, planId: string) { throw new Error('Plan not found'); } - return plan; + return { plan, product }; } diff --git a/packages/billing/core/src/create-billing-schema.ts b/packages/billing/core/src/create-billing-schema.ts index bdc9c05ff..496fc1fd1 100644 --- a/packages/billing/core/src/create-billing-schema.ts +++ b/packages/billing/core/src/create-billing-schema.ts @@ -167,7 +167,18 @@ const ProductSchema = z 'Badge for the product. Displayed to the user. Example: "Popular"', }) .optional(), - features: z.array(z.string()).nonempty(), + features: z + .array( + z.string({ + description: 'Features of the product. Displayed to the user.', + }), + ) + .nonempty(), + enableDiscountField: z + .boolean({ + description: 'Enable discount field for the product in the checkout.', + }) + .optional(), highlighted: z .boolean({ description: 'Highlight this product. Displayed to the user.', diff --git a/packages/billing/core/src/schema/create-billing-checkout.schema.ts b/packages/billing/core/src/schema/create-billing-checkout.schema.ts index 2fca5c856..7d1f527bd 100644 --- a/packages/billing/core/src/schema/create-billing-checkout.schema.ts +++ b/packages/billing/core/src/schema/create-billing-checkout.schema.ts @@ -9,6 +9,7 @@ export const CreateBillingCheckoutSchema = z.object({ trialDays: z.number().optional(), customerId: z.string().optional(), customerEmail: z.string().email().optional(), + enableDiscountField: z.boolean().optional(), variantQuantities: z.array( z.object({ variantId: z.string().min(1), diff --git a/packages/billing/gateway/src/components/plan-picker.tsx b/packages/billing/gateway/src/components/plan-picker.tsx index 52a2fe89c..58841c222 100644 --- a/packages/billing/gateway/src/components/plan-picker.tsx +++ b/packages/billing/gateway/src/components/plan-picker.tsx @@ -153,6 +153,10 @@ export function PlanPicker( id={interval} value={interval} onClick={() => { + form.setValue('interval', interval, { + shouldValidate: true, + }); + if (selectedProduct) { const plan = selectedProduct.plans.find( (item) => item.interval === interval, @@ -160,12 +164,10 @@ export function PlanPicker( form.setValue('planId', plan?.id ?? '', { shouldValidate: true, + shouldDirty: true, + shouldTouch: true, }); } - - form.setValue('interval', interval, { - shouldValidate: true, - }); }} /> @@ -202,7 +204,7 @@ export function PlanPicker( - + {props.config.products.map((product) => { const plan = product.plans.find((item) => { if (item.paymentType === 'one-time') { @@ -216,9 +218,12 @@ export function PlanPicker( return null; } + const planId = plan.id; + const selected = field.value === planId; + const primaryLineItem = getPrimaryLineItem( props.config, - plan.id, + planId, ); if (!primaryLineItem) { @@ -227,14 +232,19 @@ export function PlanPicker( return ( { - form.setValue('planId', plan.id, { + if (selected) { + return; + } + + form.setValue('planId', planId, { shouldValidate: true, }); diff --git a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts index 5b2b46296..52616986e 100644 --- a/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts +++ b/packages/billing/lemon-squeezy/src/services/create-lemon-squeezy-checkout.ts @@ -52,6 +52,7 @@ export async function createLemonSqueezyCheckout( embed: true, media: true, logo: true, + discount: params.enableDiscountField ?? false, }, checkoutData: { email: customerEmail, diff --git a/packages/billing/stripe/src/services/create-stripe-checkout.ts b/packages/billing/stripe/src/services/create-stripe-checkout.ts index aed1d4e12..dbd79025e 100644 --- a/packages/billing/stripe/src/services/create-stripe-checkout.ts +++ b/packages/billing/stripe/src/services/create-stripe-checkout.ts @@ -76,6 +76,7 @@ export async function createStripeCheckout( return stripe.checkout.sessions.create({ mode, + allow_promotion_codes: params.enableDiscountField, ui_mode: uiMode, line_items: lineItems, client_reference_id: clientReferenceId, diff --git a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts index 682eb4c74..7c2f18f84 100644 --- a/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts +++ b/packages/billing/stripe/src/services/stripe-billing-strategy.service.ts @@ -21,44 +21,31 @@ import { createStripeClient } from './stripe-sdk'; export class StripeBillingStrategyService implements BillingStrategyProviderService { + private readonly namespace = 'billing.stripe'; + async createCheckoutSession( params: z.infer, ) { const stripe = await this.stripeProvider(); const logger = await getLogger(); - logger.info( - { - name: 'billing.stripe', - customerId: params.customerId, - accountId: params.accountId, - }, - 'Creating checkout session...', - ); + const ctx = { + name: this.namespace, + customerId: params.customerId, + accountId: params.accountId, + }; + + logger.info(ctx, 'Creating checkout session...'); const { client_secret } = await createStripeCheckout(stripe, params); if (!client_secret) { - logger.error( - { - name: 'billing.stripe', - customerId: params.customerId, - accountId: params.accountId, - }, - 'Failed to create checkout session', - ); + logger.error(ctx, 'Failed to create checkout session'); throw new Error('Failed to create checkout session'); } - logger.info( - { - name: 'billing.stripe', - customerId: params.customerId, - accountId: params.accountId, - }, - 'Checkout session created successfully', - ); + logger.info(ctx, 'Checkout session created successfully'); return { checkoutToken: client_secret }; } @@ -69,32 +56,19 @@ export class StripeBillingStrategyService const stripe = await this.stripeProvider(); const logger = await getLogger(); - logger.info( - { - name: 'billing.stripe', - customerId: params.customerId, - }, - 'Creating billing portal session...', - ); + const ctx = { + name: this.namespace, + customerId: params.customerId, + }; + + logger.info(ctx, 'Creating billing portal session...'); const session = await createStripeBillingPortalSession(stripe, params); if (!session?.url) { - logger.error( - { - name: 'billing.stripe', - customerId: params.customerId, - }, - 'Failed to create billing portal session', - ); + logger.error(ctx, 'Failed to create billing portal session'); } else { - logger.info( - { - name: 'billing.stripe', - customerId: params.customerId, - }, - 'Billing portal session created successfully', - ); + logger.info(ctx, 'Billing portal session created successfully'); } return session; @@ -106,34 +80,26 @@ export class StripeBillingStrategyService const stripe = await this.stripeProvider(); const logger = await getLogger(); - logger.info( - { - name: 'billing.stripe', - subscriptionId: params.subscriptionId, - }, - 'Cancelling subscription...', - ); + const ctx = { + name: this.namespace, + subscriptionId: params.subscriptionId, + }; + + logger.info(ctx, 'Cancelling subscription...'); try { await stripe.subscriptions.cancel(params.subscriptionId, { invoice_now: params.invoiceNow ?? true, }); - logger.info( - { - name: 'billing.stripe', - subscriptionId: params.subscriptionId, - }, - 'Subscription cancelled successfully', - ); + logger.info(ctx, 'Subscription cancelled successfully'); return { success: true }; - } catch (e) { + } catch (error) { logger.error( { - name: 'billing.stripe', - subscriptionId: params.subscriptionId, - error: e, + ...ctx, + error, }, 'Failed to cancel subscription', ); @@ -148,25 +114,18 @@ export class StripeBillingStrategyService const stripe = await this.stripeProvider(); const logger = await getLogger(); - logger.info( - { - name: 'billing.stripe', - sessionId: params.sessionId, - }, - 'Retrieving checkout session...', - ); + const ctx = { + name: this.namespace, + sessionId: params.sessionId, + }; + + logger.info(ctx, 'Retrieving checkout session...'); try { const session = await stripe.checkout.sessions.retrieve(params.sessionId); const isSessionOpen = session.status === 'open'; - logger.info( - { - name: 'billing.stripe', - sessionId: params.sessionId, - }, - 'Checkout session retrieved successfully', - ); + logger.info(ctx, 'Checkout session retrieved successfully'); return { checkoutToken: session.client_secret, @@ -179,8 +138,7 @@ export class StripeBillingStrategyService } catch (error) { logger.error( { - name: 'billing.stripe', - sessionId: params.sessionId, + ...ctx, error, }, 'Failed to retrieve checkout session', @@ -192,14 +150,35 @@ export class StripeBillingStrategyService async reportUsage(params: z.infer) { const stripe = await this.stripeProvider(); + const logger = await getLogger(); - await stripe.subscriptionItems.createUsageRecord( - params.subscriptionItemId, - { - quantity: params.usage.quantity, - action: params.usage.action, - }, - ); + const ctx = { + name: this.namespace, + subscriptionItemId: params.subscriptionItemId, + usage: params.usage, + }; + + logger.info(ctx, 'Reporting usage...'); + + try { + await stripe.subscriptionItems.createUsageRecord( + params.subscriptionItemId, + { + quantity: params.usage.quantity, + action: params.usage.action, + }, + ); + } catch (error) { + logger.error( + { + ...ctx, + error, + }, + 'Failed to report usage', + ); + + throw new Error('Failed to report usage'); + } return { success: true }; } @@ -210,13 +189,14 @@ export class StripeBillingStrategyService const stripe = await this.stripeProvider(); const logger = await getLogger(); - logger.info( - { - name: 'billing.stripe', - ...params, - }, - 'Updating subscription...', - ); + const ctx = { + name: this.namespace, + subscriptionId: params.subscriptionId, + subscriptionItemId: params.subscriptionItemId, + quantity: params.quantity, + }; + + logger.info(ctx, 'Updating subscription...'); try { await stripe.subscriptions.update(params.subscriptionId, { @@ -228,24 +208,11 @@ export class StripeBillingStrategyService ], }); - logger.info( - { - name: 'billing.stripe', - ...params, - }, - 'Subscription updated successfully', - ); + logger.info(ctx, 'Subscription updated successfully'); return { success: true }; - } catch (e) { - logger.error( - { - name: 'billing.stripe', - ...params, - error: e, - }, - 'Failed to update subscription', - ); + } catch (error) { + logger.error({ ...ctx, error }, 'Failed to update subscription'); throw new Error('Failed to update subscription'); } diff --git a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx index 949160eed..7addddb71 100644 --- a/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx +++ b/packages/features/accounts/src/components/personal-account-settings/mfa/multi-factor-auth-list.tsx @@ -85,7 +85,7 @@ export function MultiFactorAuthFactorsList() { if (!allFactors.length) { return (
- +