From bca44ec3639d5ffb198224ce230c1be4e2e47945 Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Mon, 9 Feb 2026 14:56:10 -0800 Subject: [PATCH] chore(payments-next): Create proof of concept for free trials Because: * We need the ability to support free trials for our customers This commit: * Adds in the code for a proof-of-concept that only applies to the monthly 123DonePro plan in dev Closes #PAY-3499 --- .../checkout/[cartId]/success/page.tsx | 230 +++++++++++------- libs/payments/cart/src/lib/cart.types.ts | 1 + .../payments/cart/src/lib/checkout.service.ts | 99 ++++++-- .../events/src/lib/emitter.service.ts | 1 + libs/payments/events/src/lib/emitter.types.ts | 9 + .../stripe/src/lib/factories/price.factory.ts | 2 +- .../lib/client/components/StripeWrapper.tsx | 8 +- .../src/lib/subscription-handler.service.ts | 37 +++ 8 files changed, 280 insertions(+), 107 deletions(-) diff --git a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx index ad25b7204dc..c84a1ceb78b 100644 --- a/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx +++ b/apps/payments/next/app/[locale]/[offeringId]/[interval]/checkout/[cartId]/success/page.tsx @@ -91,6 +91,8 @@ export default async function CheckoutSuccess({ const { successActionButtonUrl, successActionButtonLabel } = cms.commonContent.localizations.at(0) || cms.commonContent; + const isTrial = cart.trialDays && cart.trialDays > 0; + return (
- {l10n.getString( - 'next-payment-confirmation-thanks-heading-account-exists', - 'Thanks, now check your email!' - )} + {isTrial + ? `Your ${cart.trialDays}-day free trial has started!` + : l10n.getString( + 'next-payment-confirmation-thanks-heading-account-exists', + 'Thanks, now check your email!' + )}

- {l10n.getString( - 'payment-confirmation-thanks-subheading-account-exists-2', - { - email: session?.user?.email || '', - }, - `You’ll receive an email at ${session?.user?.email} with instructions about your subscription, as well as your payment details.` - )} + {isTrial + ? `You won't be charged until your trial ends. Cancel anytime before then to avoid charges. You'll receive an email at ${session?.user?.email} with details about your trial.` + : l10n.getString( + 'payment-confirmation-thanks-subheading-account-exists-2', + { + email: session?.user?.email || '', + }, + `You’ll receive an email at ${session?.user?.email} with instructions about your subscription, as well as your payment details.` + )}

-
-
- {l10n.getString( - 'next-payment-confirmation-order-heading', - 'Order details' - )} -
-
- - {l10n.getString( - 'next-payment-confirmation-invoice-number', - { - invoiceNumber: cart.latestInvoicePreview?.number ?? '', - }, - `Invoice #${cart.latestInvoicePreview?.number}` - )} - - - {l10n.getString( - 'next-payment-confirmation-invoice-date', - { - invoiceDate: l10n.getLocalizedDate(cart.createdAt / 1000), - }, - l10n.getLocalizedDateString(cart.createdAt / 1000) - )} - + {isTrial && ( +
+
Trial Information
+
+
+ Trial Period: + {cart.trialDays} days +
+
+ Payment Method Saved: + + {cart.paymentInfo.walletType ? ( + {getCardIcon(cart.paymentInfo.walletType, + ) : cart.paymentInfo.type === SubPlatPaymentMethodType.Card && + cart.paymentInfo.brand ? ( + <> + {getCardIcon(cart.paymentInfo.brand, + {`Card ending in ${cart.paymentInfo.last4}`} + + ) : ( + {getCardIcon(cart.paymentInfo.type, + )} + +
+
+ Amount After Trial: + + {l10n.getLocalizedCurrencyString( + cart.offeringPrice, + cart.currency, + locale + )} + +
+
-
+ )} -
-
- {l10n.getString( - 'next-payment-confirmation-details-heading-2', - 'Payment information' - )} -
-
- {l10n.getLocalizedCurrencyString( - cart.latestInvoicePreview?.amountDue, - cart.latestInvoicePreview?.currency, - locale - )} - {cart.paymentInfo.walletType ? ( -
- {getCardIcon(cart.paymentInfo.walletType, + {!isTrial && ( + <> +
+
+ {l10n.getString( + 'next-payment-confirmation-order-heading', + 'Order details' + )} +
+
+ + {l10n.getString( + 'next-payment-confirmation-invoice-number', + { + invoiceNumber: cart.latestInvoicePreview?.number ?? '', + }, + `Invoice #${cart.latestInvoicePreview?.number}` + )} + + + {l10n.getString( + 'next-payment-confirmation-invoice-date', + { + invoiceDate: l10n.getLocalizedDate(cart.createdAt / 1000), + }, + l10n.getLocalizedDateString(cart.createdAt / 1000) + )} +
- ) : cart.paymentInfo.type === SubPlatPaymentMethodType.Card && - cart.paymentInfo.brand ? ( - - {getCardIcon(cart.paymentInfo.brand, +
+ +
+
{l10n.getString( - 'next-payment-confirmation-cc-card-ending-in', - { - last4: cart.paymentInfo.last4 ?? '', - }, - `Card ending in ${cart.paymentInfo.last4}` + 'next-payment-confirmation-details-heading-2', + 'Payment information' )} - - ) : ( -
- {getCardIcon(cart.paymentInfo.type,
- )} -
-
+
+ {l10n.getLocalizedCurrencyString( + cart.latestInvoicePreview?.amountDue, + cart.latestInvoicePreview?.currency, + locale + )} + {cart.paymentInfo.walletType ? ( +
+ {getCardIcon(cart.paymentInfo.walletType, +
+ ) : cart.paymentInfo.type === SubPlatPaymentMethodType.Card && + cart.paymentInfo.brand ? ( + + {getCardIcon(cart.paymentInfo.brand, + {l10n.getString( + 'next-payment-confirmation-cc-card-ending-in', + { + last4: cart.paymentInfo.last4 ?? '', + }, + `Card ending in ${cart.paymentInfo.last4}` + )} + + ) : ( +
+ {getCardIcon(cart.paymentInfo.type, +
+ )} +
+
+ + )} > & { readonly id: string; readonly uid?: string; currency: string; + trialDays?: number; }; export type FromPrice = { diff --git a/libs/payments/cart/src/lib/checkout.service.ts b/libs/payments/cart/src/lib/checkout.service.ts index 706aefb36ca..e394685157b 100644 --- a/libs/payments/cart/src/lib/checkout.service.ts +++ b/libs/payments/cart/src/lib/checkout.service.ts @@ -111,6 +111,27 @@ export class CheckoutService { @Inject(StatsDService) private statsd: StatsD ) {} + private getTrialDays(priceId: string): number | undefined { + if (process.env.NODE_ENV === 'production') { + console.warn('[blocked] Unexpected trial attempt in production', { + priceId, + }); + return undefined; + } + + // TODO: Fetch from Strapi + // current: hard-coded 14 day trial enabled for 123DonePro + const trialDays = ['price_1NSnz3BVqmGyQTMaIkV5wjEc'].includes(priceId) + ? 14 + : undefined; + + if (trialDays) { + console.log('Trial enabled for price', { priceId, trialDays }); + } + + return trialDays; + } + /** * Reload the customer data to reflect a change. * NOTE: This is currently duplicated in subscriptionManagement.service.ts @@ -338,6 +359,8 @@ export class CheckoutService { new PayWithStripeNullCurrencyError(cart.id, price.id) ); + const trialDays = this.getTrialDays(price.id); + const subscription = eligibility.subscriptionEligibilityResult !== EligibilityStatus.UPGRADE ? await this.subscriptionManager.create( @@ -352,7 +375,10 @@ export class CheckoutService { price: price.id, }, ], - payment_behavior: 'default_incomplete', + // Note: payment_behavior and trial_period_days cannot both be set + ...(trialDays + ? { trial_period_days: trialDays } + : { payment_behavior: 'default_incomplete' }), currency: cart.currency ?? undefined, metadata: { // Note: These fields are due to missing Fivetran support on Stripe multi-currency plans @@ -401,27 +427,15 @@ export class CheckoutService { // Get payment/setup intent for subscription let intent: StripePaymentIntent | StripeSetupIntent | undefined; + const isTrial = subscription.status === 'trialing'; + try { - assert( - subscription.latest_invoice, - new PayWithStripeLatestInvoiceNotFoundOnSubscriptionError( - cart.id, - subscription.id - ) - ); - const invoice = await this.invoiceManager.retrieve( - subscription.latest_invoice - ); + if (isTrial) { + console.log('Creating SetupIntent for trial subscription', { + subscription_id: subscription.id, + trial_end: subscription.trial_end, + }); - if (invoice.payment_intent && invoice.amount_due !== 0) { - intent = await this.paymentIntentManager.confirm( - invoice.payment_intent, - { - confirmation_token: confirmationTokenId, - off_session: false, - } - ); - } else { intent = await this.setupIntentManager.createAndConfirm( customer.id, confirmationTokenId @@ -429,7 +443,52 @@ export class CheckoutService { this.statsd.increment('checkout_stripe_payment_setupintent_status', { status: intent.status, + trial: 'true', }); + + // Set payment method as default for trial-end charge + if (intent.status === 'succeeded' && intent.payment_method) { + await this.customerManager.update(customer.id, { + invoice_settings: { + default_payment_method: intent.payment_method, + }, + }); + console.log('Set default payment method for trial', { + customer_id: customer.id, + payment_method: intent.payment_method, + }); + } + } else { + // Standard payment flow + assert( + subscription.latest_invoice, + new PayWithStripeLatestInvoiceNotFoundOnSubscriptionError( + cart.id, + subscription.id + ) + ); + const invoice = await this.invoiceManager.retrieve( + subscription.latest_invoice + ); + + if (invoice.payment_intent && invoice.amount_due !== 0) { + intent = await this.paymentIntentManager.confirm( + invoice.payment_intent, + { + confirmation_token: confirmationTokenId, + off_session: false, + } + ); + } else { + intent = await this.setupIntentManager.createAndConfirm( + customer.id, + confirmationTokenId + ); + + this.statsd.increment('checkout_stripe_payment_setupintent_status', { + status: intent.status, + }); + } } } catch (error) { if (error?.payment_intent) { diff --git a/libs/payments/events/src/lib/emitter.service.ts b/libs/payments/events/src/lib/emitter.service.ts index 17b2bb7ce76..7e02fa3538f 100644 --- a/libs/payments/events/src/lib/emitter.service.ts +++ b/libs/payments/events/src/lib/emitter.service.ts @@ -55,6 +55,7 @@ export class PaymentsEmitterService { 'subscriptionEnded', this.handleSubscriptionEnded.bind(this) ); + this.emitter.on('trialConverted', () => {}); this.emitter.on('sp3Rollout', this.handleSP3Rollout.bind(this)); this.emitter.on('locationView', this.handleLocationView.bind(this)); this.emitter.on('auth', this.handleAuthEvent.bind(this)); diff --git a/libs/payments/events/src/lib/emitter.types.ts b/libs/payments/events/src/lib/emitter.types.ts index 218b74706f2..8c033f1cbd3 100644 --- a/libs/payments/events/src/lib/emitter.types.ts +++ b/libs/payments/events/src/lib/emitter.types.ts @@ -28,6 +28,14 @@ export type SubscriptionEndedEvents = { uid?: string; }; +export type TrialConvertedEvents = { + subscriptionId: string; + customerId: string; + priceId?: string; + trialEnd?: number; + providerEventId: string; +} + export const PaymentsEmitterEventsKeys = [ 'checkoutView', 'checkoutEngage', @@ -57,6 +65,7 @@ export type PaymentsEmitterEvents = { checkoutSuccess: CheckoutPaymentEvents; checkoutFail: CheckoutPaymentEvents; subscriptionEnded: SubscriptionEndedEvents; + trialConverted: TrialConvertedEvents; sp3Rollout: SP3RolloutEvent; locationView: LocationStatus | TaxChangeAllowedStatus; auth: AuthEvents; diff --git a/libs/payments/stripe/src/lib/factories/price.factory.ts b/libs/payments/stripe/src/lib/factories/price.factory.ts index 5d4aa9ee623..ae240c45646 100644 --- a/libs/payments/stripe/src/lib/factories/price.factory.ts +++ b/libs/payments/stripe/src/lib/factories/price.factory.ts @@ -62,7 +62,7 @@ export const StripePriceRecurringFactory = ( meter: null, interval: faker.helpers.arrayElement(['day', 'week', 'month', 'year']), interval_count: 1, - trial_period_days: null, + trial_period_days: override?.trial_period_days ?? null, // is this needed? usage_type: 'licensed', ...override, }); diff --git a/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx b/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx index 52615480741..aceb3d18760 100644 --- a/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx +++ b/libs/payments/ui/src/lib/client/components/StripeWrapper.tsx @@ -118,6 +118,7 @@ interface StripeWrapperProps { walletType?: string; }; hasActiveSubscriptions?: boolean; + trialDays?: number; }; children: React.ReactNode; locale: string; @@ -137,12 +138,15 @@ export function StripeWrapper({ const minCharge = STRIPE_MINIMUM_CHARGE_AMOUNTS[normalizedCurrency]; const isBelowMin = amount < minCharge; + const isTrial = cart.trialDays && cart.trialDays > 0; + const options: StripeElementsOptions = { ...sharedOptions, - mode: isBelowMin ? 'setup' : 'subscription', + mode: isBelowMin || isTrial ? 'setup' : 'subscription', locale: isStripeElementLocale(locale) ? locale : 'auto', - amount: isBelowMin ? undefined : amount, + amount: isBelowMin || isTrial ? undefined : amount, currency: normalizedCurrency, + ...(isTrial && { setupFutureUsage: 'off_session' }), externalPaymentMethodTypes: ['external_paypal'], customerSessionClientSecret: cart.paymentInfo?.customerSessionClientSecret, }; diff --git a/libs/payments/webhooks/src/lib/subscription-handler.service.ts b/libs/payments/webhooks/src/lib/subscription-handler.service.ts index d86376a62d0..6fb61524bd8 100644 --- a/libs/payments/webhooks/src/lib/subscription-handler.service.ts +++ b/libs/payments/webhooks/src/lib/subscription-handler.service.ts @@ -85,4 +85,41 @@ export class SubscriptionEventsService { uid, }); } + + async handleCustomerSubscriptionUpdated( + event: Stripe.Event, + eventSubscription: Stripe.Subscription + ) { + const previousAttributes = (event.data as any).previous_attributes; + + // Detect trial to paid conversion + if ( + previousAttributes?.status === 'trialing' && + eventSubscription.status === 'active' + ) { + // Trial converted to paid subscription + console.log('Trial subscription converted to active', { + subscription_id: eventSubscription.id, + customer_id: + typeof eventSubscription.customer === 'string' + ? eventSubscription.customer + : eventSubscription.customer.id, + price_id: eventSubscription.items.data[0]?.price.id, + trial_end: eventSubscription.trial_end, + event_id: event.id, + }); + + // TODO: Process events, send emails, etc. + this.emitterService.getEmitter().emit('trialConverted', { + subscriptionId: eventSubscription.id, + customerId: + typeof eventSubscription.customer === 'string' + ? eventSubscription.customer + : eventSubscription.customer.id, + priceId: eventSubscription.items.data[0]?.price.id, + trialEnd: eventSubscription.trial_end ?? undefined, + providerEventId: event.id, + }); + } + } }