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 (
- +