packages/fxa-auth-server/lib/routes/subscriptions/stripe-webhook.ts (917 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { ServerRoute } from '@hapi/hapi'; import * as Sentry from '@sentry/node'; import { SeverityLevel } from '@sentry/core'; import { Account, getUidAndEmailByStripeCustomerId, } from 'fxa-shared/db/models/auth'; import { ACTIVE_SUBSCRIPTION_STATUSES } from 'fxa-shared/subscriptions/stripe'; import isA from 'joi'; import { Stripe } from 'stripe'; import Container from 'typedi'; import { ConfigType } from '../../../config'; import SUBSCRIPTIONS_DOCS from '../../../docs/swagger/subscriptions-api'; import { formatMetadataValidationErrorMessage, reportSentryError, reportSentryMessage, reportValidationError, } from '../../../lib/sentry'; import error from '../../error'; import { PayPalHelper, RefusedError } from '../../payments/paypal'; import { RefundType } from '@fxa/payments/paypal'; import { CUSTOMER_RESOURCE, FormattedSubscriptionForEmail, INVOICES_RESOURCE, PAYMENT_METHOD_RESOURCE, STRIPE_OBJECT_TYPE_TO_RESOURCE, StripeHelper, SUBSCRIPTION_UPDATE_TYPES, VALID_RESOURCE_TYPES, } from '../../payments/stripe'; import { AuthLogger, AuthRequest } from '../../types'; import { subscriptionProductMetadataValidator } from '../validators'; import { StripeHandler } from './stripe'; import { FirestoreStripeErrorBuilder } from 'fxa-shared/payments/stripe-firestore'; // ALLOWED_EXPAND_RESOURCE_TYPES is a map of "types" of Stripe objects that we // will fetch the latest of for the webhook event, _instead of_ using the // object in the request payload. Products and plans are excluded because // `expandResource` uses the cached lists for products and plans, but the // cached lists themselves are updated through webhooks. // // 'price' is included in this list of exclusion because the Prices API // replaced Plans, but since it is backwards compatible, we haven't needed to // update our plans handling code. Prices should be treated in the same // fashion as plans, so it's on this list. const BYPASS_LATEST_FETCH_TYPES = ['plan', 'price', 'product']; const BYPASS_LATEST_FETCH_EVENTS = [ 'invoice.upcoming', 'payment_method.detached', ]; const ALLOWED_EXPAND_RESOURCE_TYPES = Object.fromEntries( Object.entries(STRIPE_OBJECT_TYPE_TO_RESOURCE).filter( ([k, _]) => !BYPASS_LATEST_FETCH_TYPES.includes(k) ) ); const IGNORABLE_STRIPE_WEBHOOK_ERRNOS = [ error.ERRNO.UNKNOWN_SUBSCRIPTION_FOR_SOURCE, error.ERRNO.BOUNCE_HARD, error.ERRNO.BOUNCE_COMPLAINT, ]; export class StripeWebhookHandler extends StripeHandler { protected paypalHelper?: PayPalHelper; constructor( log: AuthLogger, db: any, config: ConfigType, customs: any, push: any, mailer: any, profile: any, stripeHelper: StripeHelper ) { super(log, db, config, customs, push, mailer, profile, stripeHelper); if (config.subscriptions.paypalNvpSigCredentials.enabled) { this.paypalHelper = Container.get(PayPalHelper); } } private async checkIfAccountExists(customerId: string) { const result = await getUidAndEmailByStripeCustomerId(customerId); return !!result.uid; } /** * Process an event to Firestore so that we have the latest copy in cache. This * updates the event object in place to have the latest data. */ private async processEventToFirestore(event: Stripe.Event) { // This must run before expansion below to ensure the types that Firestore // can store are updated first to prevent multiple fetches from Stripe. const firestoreHandled = await this.stripeHelper.processWebhookEventToFirestore(event); // Ensure the object is the latest version. const stripeObject = event.data.object as Record<string, any>; const resourceType = ALLOWED_EXPAND_RESOURCE_TYPES[stripeObject.object as string]; if (resourceType) { if (!BYPASS_LATEST_FETCH_EVENTS.includes(event.type)) { // Replace the object with the latest version if we support this object. event.data.object = await this.stripeHelper.expandResource( stripeObject.id, resourceType as (typeof VALID_RESOURCE_TYPES)[number] ); } } else if (stripeObject.object === 'card' && stripeObject.customer) { // Cards are not expandable using `expandResource` and are handled separately. event.data.object = await this.stripeHelper.getCard( stripeObject.customer, stripeObject.id ); } else if ( !BYPASS_LATEST_FETCH_TYPES.includes(stripeObject.object as string) ) { // We shouldn't be handling events that we can't fetch the latest version // of with expandResource. If we have a handler below for this type, then // we should have it included as a resource type to expand above. Sentry.withScope((scope) => { scope.setContext('stripeEvent', { event: { id: event.id, type: event.type, objectType: (event.data.object as any).object, }, }); reportSentryMessage( 'Event being handled that is not using latest object from Stripe.', 'info' as SeverityLevel ); }); } return firestoreHandled; } /** * Dispatch an event to the appropriate handler. */ private async dispatchEventToHandler( request: AuthRequest, event: Stripe.Event, firestoreHandled: boolean ) { switch (event.type as Stripe.WebhookEndpointUpdateParams.EnabledEvent) { case 'credit_note.created': if (this.paypalHelper) { await this.handleCreditNoteEvent(request, event); } break; case 'coupon.created': case 'coupon.updated': await this.handleCouponEvent(request, event); break; case 'customer.created': // We don't need to setup the local customer if it happened via API // because we already set this up during creation. if (event.request?.id) { break; } await this.handleCustomerCreatedEvent(request, event); break; case 'customer.subscription.created': await this.handleSubscriptionCreatedEvent(request, event); break; case 'customer.subscription.updated': await this.handleSubscriptionUpdatedEvent(request, event); break; case 'customer.subscription.deleted': await this.handleSubscriptionDeletedEvent(request, event); break; case 'customer.source.expiring': await this.handleCustomerSourceExpiringEvent(request, event); break; case 'customer.updated': await this.handleCustomerUpdatedEvent(request, event); break; case 'invoice.created': await this.handleInvoiceCreatedEvent(request, event); break; case 'invoice.paid': await this.handleInvoicePaidEvent(request, event); break; case 'invoice.payment_failed': await this.handleInvoicePaymentFailedEvent(request, event); break; case 'invoice.upcoming': await this.handleInvoiceUpcomingEvent(request, event); break; case 'product.created': case 'product.updated': case 'product.deleted': await this.handleProductWebhookEvent(request, event); break; case 'plan.created': case 'plan.updated': await this.handlePlanCreatedOrUpdatedEvent(request, event); break; case 'plan.deleted': await this.handlePlanDeletedEvent(request, event); break; case 'tax_rate.created': case 'tax_rate.updated': await this.handleTaxRateCreatedOrUpdatedEvent(request, event); break; default: if (!firestoreHandled) { Sentry.withScope((scope) => { scope.setContext('stripeEvent', { event: { id: event.id, type: event.type }, }); reportSentryMessage( 'Unhandled Stripe event received.', 'info' as SeverityLevel ); }); } break; } } /** * Handle webhook events from Stripe by pre-processing the incoming * event and dispatching to the appropriate sub-handler. Log an info * message for events we don't yet handle. */ async handleWebhookEvent(request: AuthRequest) { try { const event = this.stripeHelper.constructWebhookEvent( request.payload, request.headers['stripe-signature'] ); const firestoreHandled = await this.processEventToFirestore(event); await this.dispatchEventToHandler(request, event, firestoreHandled); } catch (error) { if (error instanceof FirestoreStripeErrorBuilder && error?.customerId) { // Check if the Account record for this customerId still exists // If the Account record does not exist, it can be assumed that related // Firestore and Stripe records no longer exist either, and the // error can be ignored const accountExists = await this.checkIfAccountExists(error.customerId); if (accountExists) throw error; } else if (!IGNORABLE_STRIPE_WEBHOOK_ERRNOS.includes(error.errno)) { // Error is not ignorable, so re-throw. throw error; } // Caught an ignorable error, so let's log but continue to 200 OK response this.log.error('subscriptions.handleWebhookEvent.failure', { error }); } return {}; } /** * Handle `credit_note.created` Stripe webhook events. */ async handleCreditNoteEvent(request: AuthRequest, event: Stripe.Event) { // Type-guard to require paypalHelper. if (!this.paypalHelper) { return; } const creditNote = event.data.object as Stripe.CreditNote; const invoice = await this.stripeHelper.expandResource( creditNote.invoice, 'invoices' ); if (invoice.collection_method === 'charge_automatically') { // This is a Stripe charge, report if needed and return as Stripe handles // refunding the customer. if ( creditNote.customer_balance_transaction || creditNote.out_of_band_amount ) { // We should be informed if it was applied to the account balance as they // should be refunded to the card or if it was applied out of band. reportSentryError( new Error( `Credit note issued for account balance or out of band: ${creditNote.id}` ), request ); } return; } // We can't issue a refund if there's no paypal transaction to refund. const transactionId = this.stripeHelper.getInvoicePaypalTransactionId(invoice); if (!transactionId) { this.log.error('handleCreditNoteEvent', { invoiceId: invoice.id, message: 'Credit note issued on invoice without a PayPal transaction id.', }); return; } // If the amount doesn't match the invoice we can't reverse it. if ( creditNote.out_of_band_amount && creditNote.out_of_band_amount > invoice.amount_due ) { this.log.error('handleCreditNoteEvent', { invoiceId: invoice.id, message: 'Credit note exceeds invoice amount.', }); reportSentryError( new Error(`Credit amount exceeds invoice: ${invoice.id}.`), request ); return; } try { const fullRefund = creditNote.out_of_band_amount === invoice.amount_due; const refundType: RefundType = fullRefund ? RefundType.Full : RefundType.Partial; // eslint-disable-next-line @typescript-eslint/no-non-null-assertion const amount = fullRefund ? undefined : creditNote.out_of_band_amount!; await this.paypalHelper.issueRefund( invoice, transactionId, refundType, amount ); } catch (error) { if (error instanceof RefusedError) { await this.stripeHelper.updateInvoiceWithPaypalRefundReason( invoice, error.longMessage ); this.log.error('handleCreditNoteEvent', { invoiceId: invoice.id, message: 'Paypal refund refused.', }); const sentryError = Object.assign(error, { output: { payload: { invoiceId: invoice.id, }, }, }); reportSentryError(sentryError, request); } else { throw error; } } return; } /** * Handle `coupon.created` and `coupon.updated` Stripe webhook events. * * Verify that the coupon conforms to our requirements, currently that it: * - Does not have a product ID requirement. */ async handleCouponEvent(request: AuthRequest, event: Stripe.Event) { const eventCoupon = event.data.object as Stripe.Coupon; const coupon = await this.stripeHelper.getCoupon(eventCoupon.id); if (coupon.applies_to?.products && coupon.applies_to?.products.length > 0) { reportSentryError( new Error(`Coupon has a product requirement: ${coupon.id}.`), request ); return; } } /** * Handle `customer.created` Stripe webhook events. */ async handleCustomerCreatedEvent(_: AuthRequest, event: Stripe.Event) { const customer = event.data.object as Stripe.Customer; const account = await this.db.accountRecord(customer.email); await this.stripeHelper.createLocalCustomer(account.uid, customer); } /** * Handle `subscription.created` Stripe webhook events. * * Only subscriptions that are active/trialing are valid. Emit an event for * those conditions only. */ async handleSubscriptionCreatedEvent( request: AuthRequest, event: Stripe.Event ) { const sub = event.data.object as Stripe.Subscription; if (ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)) { return this.capabilityService.stripeUpdate({ sub }); } return; } /** * Handle `subscription.updated` Stripe webhook events. * * The only time this requires us to emit a subscription event is when an * existing incomplete subscription has now been completed. Unpaid renewals and * subscriptions that are cancelled result in a `subscription.deleted` event. */ async handleSubscriptionUpdatedEvent( request: AuthRequest, event: Stripe.Event ) { const sub = event.data.object as Stripe.Subscription; let uid; try { ({ uid } = await this.sendSubscriptionUpdatedEmail(event)); } catch (err) { // It's unexpected that we don't know about the customer or an error happens. if (err.output && typeof err.output.payload === 'object') { err.output.payload = { ...err.output.payload, eventId: event.id }; } // If the customer has been deleted, then there's insufficient information to // send the email. We can't do anything about it, so we'll ignore the error. if (err.errno !== error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER) { reportSentryError(err, request); } return; } // Always send subscription changes to the capability service. return this.capabilityService.stripeUpdate({ sub, uid }); } /** * Handle `subscription.deleted` Stripe wehbook events. */ async handleSubscriptionDeletedEvent( request: AuthRequest, event: Stripe.Event ) { try { const subscription = event.data.object as Stripe.Subscription; const customer = await this.stripeHelper.expandResource( subscription.customer, CUSTOMER_RESOURCE ); if (!customer || customer.deleted) { throw error.unknownCustomer(subscription.customer); } const uid = customer.metadata.userid; const account = await Account.findByUid(uid, { include: ['emails'] }); if ( // When SubPlat cannot collect a PayPal customer's first payment while // attempting to subscribe to a product, the subscription is canceled. // (At the time of writing, this is happening in // `_createPaypalBillingAgreementAndSubscription` of the // `PayPalHandler` route handler.) We should not send an email in that // case. // // If we can retreive the subscription and customer, but the account record // cannot be retrieved from the db, the user has deleted their Mozilla // account which subsequently deletes their subscription from stripe. !account || !( subscription.collection_method === 'send_invoice' && account.verifierSetAt <= 0 ) ) { await this.sendSubscriptionDeletedEmail(subscription); } await this.capabilityService.stripeUpdate({ sub: subscription, uid }); if (this.paypalHelper) { await this.paypalHelper.conditionallyRemoveBillingAgreement(customer); } const eventDetails = await this.getSubscriptionEndedEventDetails( uid, event.id, customer, subscription ); if ( account && subscription.metadata['redundantCancellation'] === 'true' ) { const subscriptionDetails = await this.stripeHelper.extractSubscriptionDeletedEventDetailsForEmail( subscription ); await this.mailer.sendSubscriptionReplacedEmail( account.emails, account, { acceptLanguage: account.locale, ...subscriptionDetails, } ); } await request.emitMetricsEvent('subscription.ended', eventDetails); } catch (err) { // FIXME: If the customer was deleted, we don't send an email that their subscription // was cancelled. This is because the email requires a bunch of details that // only exist on non-deleted customer. A more robust solution is needed. if (err.errno === error.ERRNO.UNKNOWN_SUBSCRIPTION_CUSTOMER) { return; } reportSentryError(err, request); } } /** * Handle `customer.updated` events, this ensures metadata changes to the * customer are reflected correctly in our cached copy. */ async handleCustomerUpdatedEvent(request: AuthRequest, event: Stripe.Event) { const customer = event.data.object as Stripe.Customer; const uid = customer.metadata?.userid; if (!uid || customer.deleted) { // There's nothing to do if this event is for a customer being deleted. return; } const account = await Account.findByUid(uid, { include: ['emails'] }); // If the request has a request id, it means that our API triggered this so // we can safely ignore the account not existing as this is typically due to // us deleting the account in FxA. if (!account && !event.request?.id) { reportSentryError( new Error(`Cannot load account for customerId: ${customer.id}`), request ); return; } } /** * Handle `invoice.created` events, if the subscription is for an invoice * customer (PayPal), set auto_advance false and finalize. */ async handleInvoiceCreatedEvent(request: AuthRequest, event: Stripe.Event) { // Type-guard to require paypalHelper. if (!this.paypalHelper) { return; } const invoice = event.data.object as Stripe.Invoice; const paypalInvoice = await this.stripeHelper.invoicePayableWithPaypal(invoice); if (!paypalInvoice || invoice.status !== 'draft') { return; } // Fetch the customer to determine if we need to set their name. // This is needed because the original PayPal implementation did not copy // the name to the Stripe customer object. This is a workaround for that // issue, with a metric to track the impact and indicate when this can be // removed. const customer = await this.stripeHelper.expandResource( invoice.customer, CUSTOMER_RESOURCE ); if ( paypalInvoice && customer && customer.deleted !== true && !customer.name ) { const billingAgreementId = this.stripeHelper.getCustomerPaypalAgreement(customer); // The customer needs to be updated for their name. if (billingAgreementId) { try { await this.paypalHelper.updateStripeNameFromBA( customer, billingAgreementId ); } catch (err) { if (err.errno === error.ERRNO.INTERNAL_VALIDATION_ERROR) { this.log.error( `handleInvoiceCreatedEvent - Billing agreement (id: ${billingAgreementId}) was cancelled.`, { request, customer, } ); } else { throw err; } } } } try { // Duplicate calls to finalizeInvoice can be made due to race conditions. Failures from re-finalizing an invoice can be ignored. return this.stripeHelper.finalizeInvoice(invoice); } catch (err) { // This is Stripe's only unique way of identifying this error. Remove as part of FXA-11460 if ( err?.raw?.message !== "This invoice is already finalized, you can't re-finalize a non-draft invoice." ) { throw err; } } return invoice; } /** * Handle `invoice.paid` Stripe wehbook events. * * This handler sends out our invoice emails to customers as well as verifying * that we are not accepting payments for users that may have been deleted or * have a Stripe customer account with subscription that has become unlinked * from a FxA account. We have observed this can happen in some edge cases during * checkout in the past, and want to capture sufficient information for Support * to manually handle. */ async handleInvoicePaidEvent(request: AuthRequest, event: Stripe.Event) { const invoice = event.data.object as Stripe.Invoice; const customer = await this.stripeHelper.expandResource( invoice.customer, CUSTOMER_RESOURCE ); let invalidCustomer = false; // Store as much relevant user data as we can lookup in case they were // deleted or not linked to a FxA account. const deletedData: Record<string, any> = { customerId: invoice.customer, invoiceId: invoice.id, }; if (!customer || customer?.deleted) { invalidCustomer = true; deletedData.reason = 'customer_deleted'; } else { const uid = customer.metadata?.userid; if (!uid) { invalidCustomer = true; deletedData.reason = 'no_userid'; } else { deletedData.userId = uid; deletedData.reason = 'fxa_deleted'; const account = await Account.findByUid(uid); invalidCustomer = !account; } } if (invalidCustomer) { const err = Object.assign( new Error('Invoice paid on invalid customer.'), deletedData ); reportSentryError(err, request); return; } try { await this.sendSubscriptionInvoiceEmail(invoice); } catch (err) { reportSentryError(err, request); return; } } /** * Handle `invoice.payment_failed` Stripe wehbook events. */ async handleInvoicePaymentFailedEvent( request: AuthRequest, event: Stripe.Event ) { const invoice = event.data.object as Stripe.Invoice; if (invoice.billing_reason !== 'subscription_cycle') { // Send payment failure emails only when processing a subscription renewal. return; } await this.sendSubscriptionPaymentFailedEmail(invoice); } /** * Handle `invoice.upcoming` Stripe wehbook events. */ async handleInvoiceUpcomingEvent(request: AuthRequest, event: Stripe.Event) { const invoice = event.data.object as Stripe.Invoice; const customer = await this.stripeHelper.expandResource( invoice.customer, CUSTOMER_RESOURCE ); if ( customer?.deleted || !customer?.invoice_settings?.default_payment_method ) { return; } const { card } = await this.stripeHelper.expandResource<Stripe.PaymentMethod>( customer?.invoice_settings.default_payment_method, PAYMENT_METHOD_RESOURCE ); if (card?.exp_month && card.exp_year) { // If card expiry month/year is greater than current month/year, return without further action. if ( new Date(card.exp_year, card.exp_month - 1, 1).getTime() > Date.now() ) { return; } } else { reportSentryError( new Error(`Could not find card for customerId: ${customer.id}`), request ); return; } const subscriptions = await this.stripeHelper.formatSubscriptionsForEmails(customer); if (!subscriptions.length) { reportSentryError( new Error( `Could not find subscriptions for customerId: ${customer.id}` ), request ); return; } const sourceDetails = { uid: customer.metadata.userid, email: customer.email, subscriptions, }; await this.sendSubscriptionPaymentExpiredEmail(sourceDetails); } /** * Handle `customer.source.expiring` Stripe wehbook events. */ async handleCustomerSourceExpiringEvent( request: AuthRequest, event: Stripe.Event ) { const source = event.data.object as Stripe.Source; const sourceDetails = await this.stripeHelper.extractSourceDetailsForEmail(source); await this.sendSubscriptionPaymentExpiredEmail(sourceDetails); } /** * Validate plan metadata and update cached plans. */ async handlePlanCreatedOrUpdatedEvent( request: AuthRequest, event: Stripe.Event ) { const plan = event.data.object as Stripe.Plan; const product = await this.stripeHelper.fetchProductById( plan.product as string ); const allPlans = await this.stripeHelper.allPlans(); // remove the plan until we have validated its metadata const updatedList = allPlans.filter((p) => p.id !== plan.id); if (!product || product.deleted) { const msg = `handlePlanCreatedOrUpdatedEvent - product ${plan.product} appear to have been deleted`; this.log.error(msg, { plan }); Sentry.withScope((scope) => { scope.setContext('planUpdatedEvent', { plan }); reportSentryMessage(msg, 'error' as SeverityLevel); }); this.stripeHelper.updateAllPlans(updatedList); return; } // We'll keep validating the metadata, but if the Firestore config docs // feature flag is on, we add the plan to the list regardless of the // validation result. // // TODO remove metadata validation once we've successfully moved to // Firestore based product configs const { error } = await subscriptionProductMetadataValidator.validateAsync({ ...product.metadata, ...plan.metadata, }); if (error) { const msg = formatMetadataValidationErrorMessage(plan.id, error as any); this.log.error(`handlePlanCreatedOrUpdatedEvent: ${msg}`, { error, plan, }); reportValidationError(msg, error as any); if (!this.config.subscriptions.productConfigsFirestore.enabled) { this.stripeHelper.updateAllPlans(updatedList); return; } } // The original plans list has the product expanded so we attach the // product object here. const updatedPlan = { ...plan, product }; updatedList.push(updatedPlan); this.stripeHelper.updateAllPlans(updatedList); } async handlePlanDeletedEvent(request: AuthRequest, event: Stripe.Event) { const plan = event.data.object as Stripe.Plan; const allPlans = await this.stripeHelper.allPlans(); this.stripeHelper.updateAllPlans(allPlans.filter((p) => p.id !== plan.id)); } async handleTaxRateCreatedOrUpdatedEvent( request: AuthRequest, event: Stripe.Event ) { const taxRate = event.data.object as Stripe.TaxRate; const allTaxRates = await this.stripeHelper.allTaxRates(); const updatedList = allTaxRates.filter((tr) => tr.id !== taxRate.id); updatedList.push(taxRate); this.stripeHelper.updateAllTaxRates(updatedList); } /** * Update products cache and validate metadata. */ async handleProductWebhookEvent(request: AuthRequest, event: Stripe.Event) { const allProducts = await this.stripeHelper.allProducts(); const product = event.data.object as Stripe.Product; const updatedList = allProducts.filter((p) => p.id !== product.id); updatedList.push(product); await this.stripeHelper.updateAllProducts(updatedList); const cachedPlans = await this.stripeHelper.fetchPlansByProductId( product.id ); const latestStripePlans = await this.stripeHelper.fetchAllPlans(); const updatedPlans = latestStripePlans.filter( (plan) => (plan.product as Stripe.Product).id !== product.id ); const latestStripePlansForProduct = latestStripePlans.filter( (plan) => (plan.product as Stripe.Product).id === product.id ); if (event.type !== 'product.deleted') { for (const plan of cachedPlans) { const { error } = await subscriptionProductMetadataValidator.validateAsync({ ...product.metadata, ...plan.metadata, }); if (error) { const msg = formatMetadataValidationErrorMessage( plan.id, error as any ); this.log.error(`handleProductWebhookEvent: ${msg}`, { error, product, }); reportValidationError(msg, error as any); } // We'll keep validating the metadata, but if the Firestore config docs // feature flag is on, we add the plan to the list regardless of the // validation result. // // TODO remove metadata validation once we've successfully moved to // Firestore based product configs if ( this.config.subscriptions.productConfigsFirestore.enabled || !error ) { updatedPlans.push({ ...plan, product, }); } } // Add any valid plans found in Stripe that are missing from the cache. // This is possible e.g. if a plan was created before a product's metadata // was valid and was never updated afterward. for (const plan of latestStripePlansForProduct) { const cachedPlan = updatedPlans.find((p) => p.id === plan.id); if (!cachedPlan) { updatedPlans.push({ ...plan, product, }); } } } this.stripeHelper.updateAllPlans(updatedPlans); } /** * Send out an email on payment expiration. */ async sendSubscriptionPaymentExpiredEmail(sourceDetails: { uid: string; email: string | null; subscriptions: FormattedSubscriptionForEmail[]; }) { const { uid } = sourceDetails; const account = await this.db.account(uid); return this.mailer.sendSubscriptionPaymentExpiredEmail( account.emails, account, { acceptLanguage: account.locale, ...sourceDetails, email: sourceDetails.email || account.email, } ); } /** * Send out an email on payment failure. */ async sendSubscriptionPaymentFailedEmail(invoice: Stripe.Invoice) { const invoiceDetails = await this.stripeHelper.extractInvoiceDetailsForEmail(invoice); const { uid } = invoiceDetails; const account = await this.db.account(uid); await this.mailer.sendSubscriptionPaymentFailedEmail( account.emails, account, { acceptLanguage: account.locale, ...invoiceDetails, } ); await this.stripeHelper.updateEmailSent(invoice, 'paymentFailed'); return invoiceDetails; } /** * Send out the appropriate invoice email, depending on whether it's the * initial or a subsequent invoice. */ async sendSubscriptionInvoiceEmail(invoice: Stripe.Invoice) { const invoiceDetails = await this.stripeHelper.extractInvoiceDetailsForEmail(invoice); const { uid } = invoiceDetails; const account = await this.db.account(uid); const mailParams = [ account.emails, account, { acceptLanguage: account.locale, ...invoiceDetails, }, ]; switch (invoice.billing_reason) { case 'subscription_create': await this.mailer.sendSubscriptionFirstInvoiceEmail(...mailParams); // To not overwhelm users with emails, we only send download subscription email // for existing accounts. Passwordless accounts get their own email. if (account.verifierSetAt > 0) { await this.mailer.sendDownloadSubscriptionEmail(...mailParams); } break; default: // Other billing reasons should be covered in subsequent invoice email // https://stripe.com/docs/api/invoices/object#invoice_object-billing_reason await this.mailer.sendSubscriptionSubsequentInvoiceEmail(...mailParams); break; } return invoiceDetails; } /** * Send out the appropriate email on subscription update, depending on * whether the change was a subscription upgrade or downgrade. */ async sendSubscriptionUpdatedEmail(event: Stripe.Event) { const eventDetails = await this.stripeHelper.extractSubscriptionUpdateEventDetailsForEmail( event ); const { uid, email } = eventDetails; const account = await this.db.account(uid); const mailParams = [ account.emails, account, { acceptLanguage: account.locale, ...eventDetails, }, ]; switch (eventDetails.updateType) { case SUBSCRIPTION_UPDATE_TYPES.UPGRADE: await this.mailer.sendSubscriptionUpgradeEmail(...mailParams); break; case SUBSCRIPTION_UPDATE_TYPES.DOWNGRADE: await this.mailer.sendSubscriptionDowngradeEmail(...mailParams); break; case SUBSCRIPTION_UPDATE_TYPES.REACTIVATION: await this.mailer.sendSubscriptionReactivationEmail(...mailParams); break; case SUBSCRIPTION_UPDATE_TYPES.CANCELLATION: await this.mailer.sendSubscriptionCancellationEmail(...mailParams); break; } return { uid, email }; } /** * Send out the appropriate email on subscription deletion, depending on * whether the user still has an account. * * We receive a subscription deleted event for the following: * 1. A user canceled subscription. * 2. Subscription canceled after multiple Stripe attempts to pay an invoice. * 3. PayPal processor canceled the subscription after failed attempts. * 4. A user deleted their account. * 5. An admin or support agent cancelled the subscription. * * (1) and (5) are handled at the time of cancellation, so we do not send an additional * email here. */ async sendSubscriptionDeletedEmail(subscription: Stripe.Subscription) { if (typeof subscription.latest_invoice !== 'string') { throw error.internalValidationError( 'sendSubscriptionDeletedEmail', { subscriptionId: subscription.id, subscriptionInvoiceType: typeof subscription.latest_invoice, }, 'Subscription latest_invoice was not a string.' ); } const invoice = await this.stripeHelper.expandResource<Stripe.Invoice>( subscription.latest_invoice, INVOICES_RESOURCE ); const invoiceDetails = await this.stripeHelper.extractInvoiceDetailsForEmail(invoice); if (subscription.metadata?.cancelled_for_customer_at) { // Subscription already cancelled, should have triggered an email earlier return invoiceDetails; } const { uid, email, invoiceStatus } = invoiceDetails; let account; try { // If the user's account has not been deleted, we should have already // sent email at subscription update when cancel_at_period_end = true, // _or_ the cancellation is from Stripe due to failed retries or the // PayPal processor, or the subscription was cancelled immediately // which we'll handle here. account = await this.db.account(uid); if (this.stripeHelper.checkSubscriptionPastDue(subscription)) { await this.mailer.sendSubscriptionFailedPaymentsCancellationEmail( account.emails, account, { acceptLanguage: account.locale, ...invoiceDetails, } ); } else if (!subscription.cancel_at_period_end) { // If invoice is open or draft, assume there is an oustanding balance const showOutstandingBalance = invoiceStatus && ['open', 'draft'].includes(invoiceStatus); await this.mailer.sendSubscriptionCancellationEmail( account.emails, account, { acceptLanguage: account.locale, ...invoiceDetails, showOutstandingBalance, cancelAtEnd: subscription.cancel_at_period_end, } ); } } catch (err) { // Has the user's account been deleted? if (err.errno === error.ERRNO.ACCOUNT_UNKNOWN) { // HACK: Minimal account-like object that mailer should accept. // see also: lib/senders/index.js senders.email wrappedMailer account = { email, uid, emails: [{ email, isPrimary: true }] }; await this.mailer.sendSubscriptionAccountDeletionEmail( account.emails, account, { // TODO: How do we retain account.locale on deletion? Save in customer metadata? // acceptLanguage: account.locale, ...invoiceDetails, } ); } } return invoiceDetails; } async getSubscriptionEndedEventDetails( userId: string, eventId: string, customer: Stripe.Customer, subscription: Stripe.Subscription ) { // We can't use the getPaymentProvider helper, since the subscription is no longer active const paymentProvider: 'paypal' | 'stripe' | 'not_chosen' = subscription.collection_method === 'send_invoice' ? 'paypal' : subscription.collection_method === 'charge_automatically' ? 'stripe' : 'not_chosen'; const countryCode: string | null | undefined = customer.shipping?.address?.country; const { id: planId, product: productId } = subscription.items.data[0].plan; // It is considered a voluntary cancellation when the customer cancels their subscription // via Subscription Management, when a FxA account is deleted, or when Support cancels a // subscription immediately. // It is an involuntary cancellation when payment has failed or the latest invoice // is marked as uncollectible. // Voluntary cancellation is undefined if the payment provider is undefined. let voluntaryCancellation: boolean | undefined; if (paymentProvider === 'stripe') { voluntaryCancellation = subscription.cancellation_details?.reason === 'cancellation_requested'; } else if (paymentProvider === 'paypal') { // Unfortunately when we cancel a PayPal subscription due to failed payment, it has // a cancellation_details.reason of 'cancellation_requested'. // Check if the latest invoice was marked as uncollectible; if so, we know the PayPal // subscription was canceled due to failed payment. const latestInvoiceId = subscription.latest_invoice; if (typeof latestInvoiceId === 'string') { const latestInvoice = await this.stripeHelper.expandResource<Stripe.Invoice>( latestInvoiceId, INVOICES_RESOURCE ); voluntaryCancellation = latestInvoice.status !== 'uncollectible'; } } return { country_code: countryCode, payment_provider: paymentProvider, plan_id: planId, product_id: productId, provider_event_id: eventId, subscription_id: subscription.id, uid: userId, voluntary_cancellation: voluntaryCancellation, }; } } export const stripeWebhookRoutes = ( log: AuthLogger, db: any, config: ConfigType, customs: any, push: any, mailer: any, profile: any, stripeHelper: StripeHelper ): ServerRoute[] => { const stripeWebhookHandler = new StripeWebhookHandler( log, db, config, customs, push, mailer, profile, stripeHelper ); // FIXME: All of these need to be wrapped in Stripe error handling // FIXME: Many of these stripe calls need retries with careful thought about // overall request deadline. Stripe retries must include a idempotency_key. return [ { method: 'POST', path: '/oauth/subscriptions/stripe/event', options: { ...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_STRIPE_EVENT_POST, // We'll use the official Stripe library to authenticate the payload, // and it will also return an event. auth: false, // The raw payload is needed for authentication. payload: { output: 'data', parse: false, // Stripe event bodies can be pretty large, the server defaults to 1MB. maxBytes: config.subscriptions.stripeWebhookPayloadLimit, }, validate: { headers: { 'stripe-signature': isA.string().required() }, }, }, handler: (request: AuthRequest) => stripeWebhookHandler.handleWebhookEvent(request), }, ]; };