packages/fxa-auth-server/lib/payments/stripe.ts (2,950 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 { Firestore } from '@google-cloud/firestore';
import * as Sentry from '@sentry/node';
import { SeverityLevel } from '@sentry/core';
import {
Cacheable,
CacheClearStrategy,
CacheClearStrategyContext,
CacheUpdate,
} from '@type-cacheable/core';
import {
createAccountCustomer,
deleteAccountCustomer,
getAccountCustomerByUid,
getUidAndEmailByStripeCustomerId,
updatePayPalBA,
} from 'fxa-shared/db/models/auth';
import * as Coupon from 'fxa-shared/dto/auth/payments/coupon';
import {
getIapPurchaseType,
isAppStoreSubscriptionPurchase,
isPlayStoreSubscriptionPurchase,
} from 'fxa-shared/payments/iap/util';
import {
CHARGES_RESOURCE,
CUSTOMER_RESOURCE,
INVOICES_RESOURCE,
PAYMENT_METHOD_RESOURCE,
PLAN_RESOURCE,
PRODUCT_RESOURCE,
STRIPE_PLANS_CACHE_KEY,
STRIPE_PRICE_METADATA,
STRIPE_PRODUCTS_CACHE_KEY,
StripeHelper as StripeHelperBase,
SUBSCRIPTIONS_RESOURCE,
} from 'fxa-shared/payments/stripe';
import { PlanConfig } from 'fxa-shared/subscriptions/configuration/plan';
import {
ACTIVE_SUBSCRIPTION_STATUSES,
getMinimumAmount,
singlePlan,
} from 'fxa-shared/subscriptions/stripe';
import {
AbbrevPlan,
AbbrevProduct,
MozillaSubscriptionTypes,
PAYPAL_PAYMENT_ERROR_FUNDING_SOURCE,
PAYPAL_PAYMENT_ERROR_MISSING_AGREEMENT,
PaypalPaymentError,
WebSubscription,
InvoicePreview,
} from 'fxa-shared/subscriptions/types';
import { StatsD } from 'hot-shots';
import ioredis from 'ioredis';
import moment from 'moment';
import { Logger } from 'mozlog';
import { Stripe } from 'stripe';
import { Container } from 'typedi';
import { ConfigType } from '../../config';
import error from '../error';
import { GoogleMapsService } from '../google-maps-services';
import Redis from '../redis';
import { subscriptionProductMetadataValidator } from '../routes/validators';
import {
formatMetadataValidationErrorMessage,
reportValidationError,
} from 'fxa-shared/sentry/report-validation-error';
import { AppConfig, AuthFirestore, AuthLogger, TaxAddress } from '../types';
import { PaymentConfigManager } from './configuration/manager';
import { CurrencyHelper } from './currencies';
import { AppStoreSubscriptionPurchase } from './iap/apple-app-store/subscription-purchase';
import { PlayStoreSubscriptionPurchase } from './iap/google-play/subscription-purchase';
import {
StripeFirestoreMultiError,
FirestoreStripeError,
StripeFirestore,
} from './stripe-firestore';
import { stripeInvoiceToLatestInvoiceItemsDTO } from './stripe-formatter';
import { generateIdempotencyKey, roundTime } from './utils';
import { ProductConfigurationManager } from '@fxa/shared/cms';
import { reportSentryError, reportSentryMessage } from '../sentry';
import { StripeMapperService } from '@fxa/payments/legacy';
// Maintains backwards compatibility. Some type defs hoisted to fxa-shared/payments/stripe
export * from 'fxa-shared/payments/stripe';
export const MOZILLA_TAX_ID = 'Tax ID';
export const STRIPE_TAX_RATES_CACHE_KEY = 'listStripeTaxRates';
export const SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY = 'appliedPromotionCode';
export enum STRIPE_CUSTOMER_METADATA {
PAYPAL_AGREEMENT = 'paypalAgreementId',
}
export enum STRIPE_PRODUCT_METADATA {
PROMOTION_CODES = 'promotionCodes',
}
export enum STRIPE_INVOICE_METADATA {
PAYPAL_TRANSACTION_ID = 'paypalTransactionId',
PAYPAL_REFUND_TRANSACTION_ID = 'paypalRefundTransactionId',
PAYPAL_REFUND_REASON = 'paypalRefundRefused',
EMAIL_SENT = 'emailSent',
RETRY_ATTEMPTS = 'paymentAttempts',
}
export const SUBSCRIPTION_UPDATE_TYPES = {
UPGRADE: 'upgrade',
DOWNGRADE: 'downgrade',
REACTIVATION: 'reactivation',
CANCELLATION: 'cancellation',
};
export type FormattedSubscriptionForEmail = {
productId: string;
productName: string;
planId: string;
planName: string | null;
planEmailIconURL: string;
planSuccessActionButtonURL: string;
planConfig: Partial<PlanConfig>;
productMetadata: Stripe.Metadata;
};
export type BillingAddressOptions = {
city: string;
country: string;
line1: string;
line2: string;
postalCode: string;
state: string;
};
export type PaymentBillingDetails = Awaited<
ReturnType<StripeHelper['extractBillingDetails']> // eslint-disable-line no-use-before-define
> & {
paypal_payment_error?: PaypalPaymentError;
billing_agreement_id?: string;
};
// The countries we need region data for
export const COUNTRIES_LONG_NAME_TO_SHORT_NAME_MAP = {
// The long name is used in the BigQuery metrics logs; the short name is used
// in the Stripe customer billing address. The long names are also used to
// index into the country to states maps.
'United States': 'US',
Canada: 'CA',
} as { [key: string]: string };
/**
* The CacheUpdate decorator has an _optional_ property in its options
* parameter named `cacheKeysToClear`. However, if you do not pass in a value
* for cacheKeysToClear, type-cacheable will compute one from CacheUpdate's
* context! In our case, that leads to a cannot covert a circular reference
* to JSON error. Since that default behavior is not well documented, the error
* can be very confusing.
*
* As a result, a value of 'noop' is passed in for cacheKeysToClear. And to
* prevent an actual 'DEL noop' on redis, the below CacheClearStrategy is used.
*/
class NoopCacheClearStrategy implements CacheClearStrategy {
async handle(context: CacheClearStrategyContext): Promise<any> {}
}
const noopCacheClearStrategy = new NoopCacheClearStrategy();
export class StripeHelper extends StripeHelperBase {
// Base class requirements
public override readonly stripe: Stripe;
protected override readonly stripeFirestore: StripeFirestore;
protected override readonly paymentConfigManager?: PaymentConfigManager;
protected override readonly redis?: ioredis.Redis;
protected override readonly productConfigurationManager?:
| ProductConfigurationManager
| undefined;
protected override readonly stripeMapperService?: StripeMapperService;
// Note that this isn't quite accurate, as the auth-server logger has some extras
// attached to it in Hapi.
private plansAndProductsCacheTtlSeconds: number;
private stripeTaxRatesCacheTtlSeconds: number;
private webhookSecret: string;
private taxIds: { [key: string]: string };
private firestore: Firestore;
readonly googleMapsService: GoogleMapsService;
public currencyHelper: CurrencyHelper;
/**
* Create a Stripe Helper with built-in caching.
*/
constructor(log: Logger, config: ConfigType, statsd: StatsD) {
super(config, statsd, log);
// TODO (FXA-949 / issue #3922): The TTL setting here is serving double-duty for
// both TTL and whether caching should be enabled at all. We should
// introduce a second setting for cache enable / disable.
this.redis = config.subhub.plansCacheTtlSeconds
? Redis(
{
...config.redis,
...config.redis.subhub,
},
log
)?.redis
: undefined;
this.stripe = new Stripe(config.subscriptions.stripeApiKey, {
apiVersion: '2024-11-20.acacia',
maxNetworkRetries: 3,
});
// Set the app config and logger for any downstream dependencies that
// expect them to exist.
if (!Container.has(AppConfig)) {
Container.set(AppConfig, config);
}
if (!Container.has(AuthLogger)) {
Container.set(AuthLogger, log);
}
this.firestore = Container.get(AuthFirestore);
const firestore_prefix = `${config.authFirestore.prefix}stripe-`;
const customerCollectionDbRef = this.firestore.collection(
`${firestore_prefix}customers`
);
this.stripeFirestore = new StripeFirestore(
this.firestore,
customerCollectionDbRef,
this.stripe,
firestore_prefix
);
if (config.subscriptions.productConfigsFirestore.enabled === true) {
this.paymentConfigManager = Container.get(PaymentConfigManager);
}
this.googleMapsService = Container.get(GoogleMapsService);
this.plansAndProductsCacheTtlSeconds = config.subhub.plansCacheTtlSeconds;
this.stripeTaxRatesCacheTtlSeconds =
config.subhub.stripeTaxRatesCacheTtlSeconds;
this.webhookSecret = config.subscriptions.stripeWebhookSecret;
this.taxIds = config.subscriptions.taxIds;
this.currencyHelper = Container.get(CurrencyHelper);
if (Container.has(ProductConfigurationManager)) {
this.productConfigurationManager = Container.get(
ProductConfigurationManager
);
this.stripeMapperService = new StripeMapperService(
this.productConfigurationManager,
{ ttl: this.config.cms.legacyMapper.mapperCacheTTL }
);
}
// Initializes caching
this.initRedis();
// Listens to stripe events
this.initStripe();
}
async checkStripeAPIKey() {
try {
await this.stripe.customers.list({ limit: 1 });
} catch (error) {
if (error.type === 'StripeAuthenticationError') {
this.log.error('checkStripeAPIKey', {
error,
rawMesage: error.raw.message,
});
if (['dev', 'development'].includes(this.config.env)) {
console.error(`
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
!!!-----------------------------------!!!
!!!-----------------------------------!!!
!!!--- Stripe Authentication Error ---!!!
!!!---- Check your Stripe API Key ----!!!
!!!-----------------------------------!!!
!!!-----------------------------------!!!
!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!
`);
}
}
}
}
/**
* Validates state of stripe plan
* @param plan
* @returns true if plan is valid
*/
protected override async validatePlan(plan: Stripe.Plan): Promise<boolean> {
const { error } = await subscriptionProductMetadataValidator.validateAsync({
...plan.metadata,
...(plan.product as Stripe.Product)?.metadata,
});
if (error) {
const msg = formatMetadataValidationErrorMessage(plan.id, error as any);
this.log.error(`fetchAllPlans: ${msg}`, { error, plan: plan });
reportValidationError(msg, error as any);
return false;
}
return true;
}
/**
* Extract an AbbrevProduct from Stripe Product
*/
abbrevProductFromStripeProduct(product: Stripe.Product): AbbrevProduct {
return {
product_id: product.id,
product_name: product.name,
product_metadata: product.metadata,
};
}
@CacheUpdate({
cacheKey: STRIPE_PRODUCTS_CACHE_KEY,
ttlSeconds: (args, context) => context.plansAndProductsCacheTtlSeconds,
cacheKeysToClear: 'noop',
clearStrategy: noopCacheClearStrategy,
})
async updateAllProducts(allProducts: Stripe.Product[]) {
return allProducts;
}
async allAbbrevProducts(): Promise<AbbrevProduct[]> {
const products = await this.allProducts();
return products.map(this.abbrevProductFromStripeProduct);
}
/**
* Fetch all active tax rates.
*/
async fetchAllTaxRates() {
const taxRates = new Array<Stripe.TaxRate>();
for await (const taxRate of this.stripe.taxRates.list({ active: true })) {
taxRates.push(taxRate);
}
return taxRates;
}
/**
* Fetches all active tax rates from stripe and returns them.
*
* Uses Redis caching if configured.
*/
@Cacheable({
cacheKey: STRIPE_TAX_RATES_CACHE_KEY,
ttlSeconds: (args, context) => context.stripeTaxRatesCacheTtlSeconds,
})
async allTaxRates() {
return this.fetchAllTaxRates();
}
@CacheUpdate({
cacheKey: STRIPE_TAX_RATES_CACHE_KEY,
ttlSeconds: (args, context) => context.stripeTaxRatesCacheTtlSeconds,
cacheKeysToClear: 'noop',
clearStrategy: noopCacheClearStrategy,
})
async updateAllTaxRates(allTaxRates: Stripe.TaxRate[]) {
return allTaxRates;
}
/**
* Locates a tax rate by the country code and returns it.
*
* @param countryCode Two letter country code.
*/
async taxRateByCountryCode(countryCode: string) {
const taxRates = await this.allTaxRates();
const lcCountryCode = countryCode.toLowerCase();
return taxRates.find((tr) => tr.country?.toLowerCase() === lcCountryCode);
}
/**
* Create a stripe customer.
*/
async createPlainCustomer(args: {
uid: string;
email: string;
displayName: string;
idempotencyKey: string;
taxAddress?: TaxAddress;
}): Promise<Stripe.Customer> {
const { uid, email, displayName, idempotencyKey, taxAddress } = args;
const shipping = taxAddress
? {
name: email,
address: {
country: taxAddress.countryCode,
postal_code: taxAddress.postalCode,
},
}
: undefined;
const stripeCustomer = await this.stripe.customers.create(
{
email,
name: displayName,
description: uid,
metadata: {
userid: uid,
geoip_date: new Date().toString(),
},
shipping,
},
{
idempotencyKey,
}
);
await Promise.all([
createAccountCustomer(uid, stripeCustomer.id),
this.stripeFirestore.insertCustomerRecord(uid, stripeCustomer),
]);
return stripeCustomer;
}
/**
* Insert a local db record for a customer that already exist on Stripe.
*/
async createLocalCustomer(uid: string, stripeCustomer: Stripe.Customer) {
return createAccountCustomer(uid, stripeCustomer.id);
}
/**
* Update an existing customer to use a new payment method id.
*/
async retryInvoiceWithPaymentId(
customerId: string,
invoiceId: string,
paymentMethodId: string,
idempotencyKey: string
) {
try {
const paymentMethod = await this.stripe.paymentMethods.attach(
paymentMethodId,
{
customer: customerId,
},
{ idempotencyKey }
);
const customer = await this.stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
customer
);
await this.stripeFirestore.insertPaymentMethodRecord(paymentMethod);
// Try paying now instead of waiting for Stripe since this could block a
// customer from finishing a payment
const invoice = await this.stripe.invoices.pay(invoiceId, {
expand: ['payment_intent'],
});
await this.stripeFirestore.insertInvoiceRecord(invoice);
return invoice;
} catch (err) {
if (err.type === 'StripeCardError') {
throw error.rejectedSubscriptionPaymentToken(err.message, err);
}
throw err;
}
}
/**
* Create a subscription for the provided customer.
*/
async createSubscriptionWithPMI(opts: {
customerId: string;
priceId: string;
paymentMethodId?: string;
promotionCode?: Stripe.PromotionCode;
automaticTax: boolean;
}) {
const {
customerId,
priceId,
paymentMethodId,
promotionCode,
automaticTax,
} = opts;
let paymentMethod;
if (paymentMethodId) {
try {
paymentMethod = await this.stripe.paymentMethods.attach(
paymentMethodId,
{
customer: customerId,
}
// At the moment the frontend creates a new paymentMethod before every call to this method.
// If we were to reuse the same idempotencyKey we'd get the idempotency_error from Stripe.
// https://stripe.com/docs/api/errors
// As a potential alternative approach, we could compare paymentMethod fingerprints before
// attaching it to paymentMethods.
// { idempotencyKey: `pma-${subIdempotencyKey}` }
);
} catch (err) {
if (err.type === 'StripeCardError') {
throw error.rejectedSubscriptionPaymentToken(err.message, err);
}
throw err;
}
const customer = await this.stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
customer
);
await this.stripeFirestore.insertPaymentMethodRecord(paymentMethod);
}
this.statsd.increment('stripe_subscription', {
payment_provider: 'stripe',
});
const subIdempotencyKey = generateIdempotencyKey([
customerId,
priceId,
paymentMethod?.card?.fingerprint || '',
roundTime(),
]);
const createParams: Stripe.SubscriptionCreateParams = {
customer: customerId,
items: [{ price: priceId }],
expand: ['latest_invoice.payment_intent.latest_charge'],
promotion_code: promotionCode?.id,
automatic_tax: {
enabled: automaticTax,
},
};
const subscription = await this.stripe.subscriptions.create(createParams, {
idempotencyKey: `ssc-${subIdempotencyKey}`,
});
const paymentIntent = (subscription.latest_invoice as Stripe.Invoice)
.payment_intent as Stripe.PaymentIntent;
if (paymentIntent?.last_payment_error) {
await this.cancelSubscription(subscription.id);
throw error.rejectedSubscriptionPaymentToken(
paymentIntent.last_payment_error.code,
new Error(
`Subscription creation failed with error code ${paymentIntent.last_payment_error.code}`
)
);
}
const updatedSubscription = await this.postSubscriptionCreationUpdates({
subscription,
promotionCode,
});
return updatedSubscription;
}
/**
* Create a subscription for the provided customer using PayPal.
*
* A subscription will be created for out-of-band payment with the
* collection_method set to send_invoice.
*
* If an active/past_due subscription exists in this state for this
* priceId, then it will be returned instead of creating a new one.
*
*/
async createSubscriptionWithPaypal(opts: {
customer: Stripe.Customer;
priceId: string;
promotionCode?: Stripe.PromotionCode;
subIdempotencyKey: string;
automaticTax: boolean;
}) {
const {
customer,
priceId,
promotionCode,
subIdempotencyKey,
automaticTax,
} = opts;
const sub = this.findCustomerSubscriptionByPlanId(customer, priceId);
if (sub && ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)) {
if (sub.collection_method === 'send_invoice') {
sub.latest_invoice = await this.expandResource(
sub.latest_invoice,
INVOICES_RESOURCE
);
return sub;
}
throw error.subscriptionAlreadyExists();
} else if (sub && sub.status === 'incomplete') {
// Sub has never been active or charged, delete it.
this.stripe.subscriptions.cancel(sub.id);
}
this.statsd.increment('stripe_subscription', {
payment_provider: 'paypal',
});
const createParams: Stripe.SubscriptionCreateParams = {
customer: customer.id,
items: [{ price: priceId }],
expand: ['latest_invoice'],
collection_method: 'send_invoice',
days_until_due: 1,
promotion_code: promotionCode?.id,
automatic_tax: {
enabled: automaticTax,
},
};
const subscription = await this.stripe.subscriptions.create(createParams, {
idempotencyKey: `ssc-${subIdempotencyKey}`,
});
const updatedSubscription = await this.postSubscriptionCreationUpdates({
subscription,
promotionCode,
});
return updatedSubscription;
}
private async postSubscriptionCreationUpdates({
subscription,
promotionCode,
}: {
subscription: Stripe.Response<Stripe.Subscription>;
promotionCode?: Stripe.PromotionCode;
}) {
// Save the promotion code into the subscription's metadata now that the
// subscription has been successfully created.
if (
promotionCode &&
(subscription.latest_invoice as Stripe.Invoice).discount
) {
const subscriptionMetadata = {
...subscription.metadata,
[SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY]: promotionCode.code,
};
subscription.metadata = subscriptionMetadata;
await this.stripe.subscriptions.update(subscription.id, {
metadata: subscriptionMetadata,
});
}
await this.stripeFirestore.insertSubscriptionRecordWithBackfill({
...subscription,
latest_invoice: subscription.latest_invoice
? (subscription.latest_invoice as Stripe.Invoice).id
: null,
});
return subscription;
}
/**
* Previews an invoice for a customer in the provided country with a
* subscription of the given priceId and a possible discount applied.
*
* The discount parameter is optional and can be either a coupon id or
* a promotion code.
*/
async previewInvoice({
customer,
priceId,
promotionCode,
taxAddress,
isUpgrade,
sourcePlan,
}: {
customer?: Stripe.Customer;
priceId: string;
promotionCode?: string;
taxAddress?: TaxAddress;
isUpgrade?: boolean;
sourcePlan?: AbbrevPlan;
}): Promise<InvoicePreview> {
const params: Stripe.InvoiceRetrieveUpcomingParams = {};
const { currency: planCurrency } = await this.findAbbrevPlanById(priceId);
if (promotionCode) {
const stripePromotionCode = await this.findValidPromoCode(
promotionCode,
priceId
);
if (stripePromotionCode) {
params['coupon'] = stripePromotionCode.coupon.id;
}
}
const automaticTax = !!(
(customer &&
this.isCustomerTaxableWithSubscriptionCurrency(
customer,
planCurrency
)) ||
(!customer &&
taxAddress &&
this.currencyHelper.isCurrencyCompatibleWithCountry(
planCurrency,
taxAddress.countryCode
))
);
const shipping =
!customer && taxAddress
? {
name: '',
address: {
country: taxAddress.countryCode,
postal_code: taxAddress.postalCode,
},
}
: undefined;
const requestObject: Stripe.InvoiceRetrieveUpcomingParams = {
customer: customer?.id,
automatic_tax: {
enabled: automaticTax,
},
customer_details: {
tax_exempt: 'none', // Param required when shipping address not present
shipping,
},
subscription_items: [{ price: priceId }],
expand: ['total_tax_amounts.tax_rate'],
...params,
};
try {
const firstInvoice =
await this.stripe.invoices.retrieveUpcoming(requestObject);
let proratedInvoice;
if (isUpgrade && requestObject.subscription_items?.length) {
try {
requestObject.subscription_proration_behavior = 'always_invoice';
requestObject.subscription_proration_date = Math.floor(
Date.now() / 1000
);
const subscriptionItem = customer?.subscriptions?.data
.flatMap((sub) => sub.items.data)
?.find((sub) => sub.plan.id === sourcePlan?.plan_id);
requestObject.subscription_items[0].id = subscriptionItem?.id;
requestObject.subscription = subscriptionItem?.subscription;
proratedInvoice =
await this.stripe.invoices.retrieveUpcoming(requestObject);
} catch (error: any) {
Sentry.withScope((scope) => {
scope.setContext('previewInvoice.proratedInvoice', {
error: error,
msg: error.message,
});
reportSentryMessage(
`Invoice Preview Error: Prorated Invoice Preview`,
'error' as SeverityLevel
);
});
this.log.error('subscriptions.previewInvoice.proratedInvoice', error);
}
}
return [firstInvoice, proratedInvoice];
} catch (e: any) {
this.log.warn('stripe.previewInvoice.automatic_tax', {
postalCode: taxAddress?.postalCode,
countryCode: taxAddress?.countryCode,
priceId,
promotionCode,
});
throw e;
}
}
/**
* Previews the subsequent invoice for a specific subscription
*/
async previewInvoiceBySubscriptionId({
subscriptionId,
includeCanceled,
}: {
subscriptionId: string;
includeCanceled?: boolean;
}) {
return this.stripe.invoices.retrieveUpcoming({
subscription: subscriptionId,
...(includeCanceled && { subscription_cancel_at_period_end: false }),
});
}
/** Fetch a coupon with `applies_to` expanded. */
async getCoupon(couponId: string) {
return this.stripe.coupons.retrieve(couponId, {
expand: ['applies_to'],
});
}
/**
* Determines whether a given promotion code is
* a valid code in the system for the given price, and if it hasn't
* expired.
*
* Note that this does not check whether the coupon has been redeemed to
* many times, whether its valid for a first time customer, or any of the
* other conditions that may apply to its use.
*/
async findValidPromoCode(
code: string,
priceId: string
): Promise<Stripe.PromotionCode | undefined> {
const nowSecs = Date.now() / 1000;
// Determine if code exists, is active, and has not expired.
const promotionCode = await this.findPromoCodeByCode(code, true);
if (
!promotionCode ||
(promotionCode.expires_at && promotionCode.expires_at < nowSecs)
) {
return;
}
// Is the coupon valid given redemptions/expiration and product restrictions?
if (!promotionCode.coupon.valid) {
return;
}
// Is the coupon valid for this price?
const planContainsPromo = await this.checkPromotionCodeForPlan(
code,
priceId
);
if (!planContainsPromo) {
return;
}
return promotionCode;
}
/**
* Check various properties in Promotion Code or Coupon and if they are valid.
* If the properties are invalid, then return what's making it invalid.
* Current invalid states include:
* - Expired
* - Maximally redeemed
*/
checkPromotionAndCouponProperties({
valid,
redeem_by: redeemBy,
max_redemptions: maxRedemptions,
times_redeemed: timesRedeemed,
}: {
valid: boolean;
redeem_by: number | null;
max_redemptions: number | null;
times_redeemed: number;
}) {
let expired = false;
let maximallyRedeemed = false;
if (!valid) {
if (redeemBy) {
const expiry = new Date(redeemBy * 1000);
const now = new Date();
expired = now > expiry;
} else {
expired = false;
}
if (maxRedemptions) {
maximallyRedeemed = timesRedeemed >= maxRedemptions;
} else {
maximallyRedeemed = false;
}
}
return {
valid,
expired,
maximallyRedeemed,
};
}
/**
* Verify that the Promotion Code and Coupon are valid.
* If either are not valid, then return what's making it invalid.
* Current invalid states include:
* - Expired
* - Maximally redeemed
*/
async verifyPromotionAndCoupon(
priceId: string,
promotionCode: Stripe.PromotionCode
) {
const { coupon } = promotionCode;
const verifyCoupon = this.checkPromotionAndCouponProperties(coupon);
const verifyPromotionCode = this.checkPromotionAndCouponProperties({
...promotionCode,
valid: promotionCode.active,
redeem_by: promotionCode.expires_at,
});
const validCouponDuration = await this.validateCouponDurationForPlan(
priceId,
promotionCode.code,
coupon
);
return {
valid:
verifyCoupon.valid && verifyPromotionCode.valid && validCouponDuration,
expired: verifyCoupon.expired || verifyPromotionCode.expired,
maximallyRedeemed:
verifyCoupon.maximallyRedeemed || verifyPromotionCode.maximallyRedeemed,
};
}
/**
* Validate that the Coupon Duration is valid for a plan interval.
* Currently checking if the coupon duration is applied for the entire
* plan interval.
*/
async validateCouponDurationForPlan(
priceId: string,
promotionCode: string,
coupon: Stripe.Coupon
) {
const {
duration: couponDuration,
duration_in_months: couponDurationInMonths,
} = coupon;
// If the coupon duration is repeating, check if the duration months will be
// applied for a whole plan interval. Currently we do not want to support
// coupons being applied for part of the plan interval.
if (couponDuration === 'repeating') {
const { interval: priceInterval, interval_count: priceIntervalCount } =
await this.findAbbrevPlanById(priceId);
// Currently we only support coupons for year and month plan intervals.
if (['month', 'year'].includes(priceInterval) && couponDurationInMonths) {
const multiplier = priceInterval === 'year' ? 12 : 1;
if (!(couponDurationInMonths % (priceIntervalCount * multiplier))) {
return true;
} else {
Sentry.withScope((scope) => {
scope.setContext('validateCouponDurationForPlan', {
promotionCode,
priceId,
couponDuration,
couponDurationInMonths,
priceInterval,
priceIntervalCount,
});
reportSentryMessage(
'Coupon duration does not apply for entire plan interval',
'error' as SeverityLevel
);
});
return false;
}
} else {
return false;
}
} else {
return true;
}
}
/**
* Retrieve details about a coupon for a given priceId and possible
* promotion code for a customer in the provided country. Will also
* provide the discount amount for the subscription via
* previewInvoice return value. Coupon details are returned
* regardless of current validity (expiry, redeemability).
*
* Throws invalidPromoCode error if the promotion code does not
* exist for the provided priceId.
*/
async retrieveCouponDetails({
priceId,
promotionCode,
taxAddress,
}: {
priceId: string;
promotionCode: string;
taxAddress?: TaxAddress;
}): Promise<Coupon.couponDetailsSchema> {
const stripePromotionCode = await this.retrievePromotionCodeForPlan(
promotionCode,
priceId
);
if (stripePromotionCode?.coupon.id) {
const stripeCoupon: Stripe.Coupon = stripePromotionCode.coupon;
const couponDetails: Coupon.couponDetailsSchema = {
promotionCode: promotionCode,
type: stripeCoupon.duration,
durationInMonths: stripeCoupon.duration_in_months,
valid: false,
maximallyRedeemed: false,
expired: false,
};
const verifiedPromotionAndCoupon = await this.verifyPromotionAndCoupon(
priceId,
stripePromotionCode
);
if (verifiedPromotionAndCoupon.valid) {
try {
const invoicePreview = (
await this.previewInvoice({
priceId,
promotionCode,
taxAddress,
})
)[0];
const { currency, discount, total, total_discount_amounts } =
invoicePreview;
const minAmount = getMinimumAmount(currency);
if (total !== 0 && minAmount && total < minAmount) {
throw error.invalidPromoCode(promotionCode);
}
if (discount && total_discount_amounts) {
couponDetails.discountAmount = total_discount_amounts[0].amount;
}
} catch (err) {
if (
err instanceof error &&
err.errno === error.ERRNO.INVALID_PROMOTION_CODE
) {
throw err;
} else {
verifiedPromotionAndCoupon.valid = false;
Sentry.withScope((scope) => {
scope.setContext('retrieveCouponDetails', {
priceId,
promotionCode,
});
reportSentryError(err);
});
}
}
}
return {
...couponDetails,
...verifiedPromotionAndCoupon,
};
} else {
throw error.invalidPromoCode(promotionCode);
}
}
/**
* Retrieves the stripe promotionCode object for a plan regardless of current validity.
*/
async retrievePromotionCodeForPlan(
code: string,
priceId: string
): Promise<Stripe.PromotionCode | undefined> {
const promotionCode = await this.findPromoCodeByCode(code, undefined);
if (!promotionCode) {
return;
}
const planContainsPromo = await this.checkPromotionCodeForPlan(
code,
priceId
);
if (!planContainsPromo) {
return;
}
return promotionCode;
}
/**
* Checks plan meta-data to see if promotion code applies.
*/
async checkPromotionCodeForPlan(
code: string,
priceId: string
): Promise<boolean> {
const price = await this.findAbbrevPlanById(priceId);
const validPromotionCodes: string[] = [];
if (
price.plan_metadata &&
price.plan_metadata[STRIPE_PRICE_METADATA.PROMOTION_CODES]
) {
validPromotionCodes.push(
...price.plan_metadata[STRIPE_PRICE_METADATA.PROMOTION_CODES]
.split(',')
.map((c) => c.trim())
);
}
if (
price.product_metadata &&
price.product_metadata[STRIPE_PRODUCT_METADATA.PROMOTION_CODES]
) {
validPromotionCodes.push(
...price.product_metadata[STRIPE_PRODUCT_METADATA.PROMOTION_CODES]
.split(',')
.map((c) => c.trim())
);
}
// promotion codes are possibily staying in Stripe metadata instead of
// moving into Firestore configuration docs, but we can just check all three
// places...
// the abbrev plans do not have the promotion codes in them since they
// are for the front end
const planConfigs: Partial<PlanConfig> = await this.maybeGetPlanConfig(
price.plan_id
);
validPromotionCodes.push(...(planConfigs.promotionCodes || []));
return validPromotionCodes.includes(code);
}
/**
* Queries Stripe for promotion codes and returns a matching one if
* found.
*/
async findPromoCodeByCode(
code: string,
active?: boolean
): Promise<Stripe.PromotionCode | undefined> {
const promoCodes = await this.stripe.promotionCodes.list({
active,
code,
});
return promoCodes.data.find((c) => c.code === code);
}
async invoicePayableWithPaypal(invoice: Stripe.Invoice): Promise<boolean> {
if (invoice.billing_reason === 'subscription_create') {
// We only work with non-creation invoices, initial invoices are resolved by
// checkout code.
return false;
}
const subscription = await this.expandResource(
invoice.subscription,
SUBSCRIPTIONS_RESOURCE
);
if (subscription?.collection_method !== 'send_invoice') {
// Not a PayPal funded subscription.
return false;
}
return true;
}
/**
* Get Card for a customer.
*/
async getCard(customerId: string, cardId: string): Promise<Stripe.Card> {
return this.stripe.customers.retrieveSource(
customerId,
cardId
) as Promise<Stripe.Card>;
}
/**
* Get Invoice object based on invoice Id
*/
async getInvoice(id: string): Promise<Stripe.Invoice> {
return this.expandResource<Stripe.Invoice>(id, INVOICES_RESOURCE);
}
/*
* Expand the discounts property of an invoice
* TODO: We may be able to remove this method in the future if we want to add logic
* to expandResource to check if the discounts property is expanded.
*/
getInvoiceWithDiscount(invoiceId: string) {
return this.stripe.invoices.retrieve(invoiceId, { expand: ['discounts'] });
}
/**
* Finalizes an invoice and marks auto_advance as false.
*/
async finalizeInvoice(invoice: Stripe.Invoice) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.finalizeInvoice(invoice.id, {
auto_advance: false,
});
}
/**
*
* @param paymentIntentId
* @param reason
* @returns
*/
async refundPayment(
paymentIntentId: string,
reason: Stripe.RefundCreateParams.Reason
) {
return await this.stripe.refunds.create({
payment_intent: paymentIntentId,
reason,
});
}
/**
* Attempts to refund all of the invoices passed, provided they're created via Stripe
* This will invisibly do nothing if the invoice is not billed through Stripe, so be mindful
* if using it elsewhere and need confirmation of a refund.
*/
async refundInvoices(invoices: Stripe.Invoice[]) {
const stripeInvoices = invoices.filter(
(invoice) => invoice.collection_method === 'charge_automatically'
);
for (const invoice of stripeInvoices) {
const chargeId =
typeof invoice.charge === 'string'
? invoice.charge
: invoice.charge?.id;
if (!chargeId) continue;
const charge = await this.stripe.charges.retrieve(chargeId);
if (charge.refunded) continue;
try {
await this.stripe.refunds.create({
charge: chargeId,
});
this.log.info('refundInvoices', {
invoiceId: invoice.id,
priceId: this.getPriceIdFromInvoice(invoice),
total: invoice.total,
currency: invoice.currency,
});
} catch (error) {
this.log.error('StripeHelper.refundInvoices', {
error,
invoiceId: invoice.id,
});
if (
[
'StripeRateLimitError',
'StripeAPIError',
'StripeConnectionError',
'StripeAuthenticationError',
].includes(error.type)
) {
throw error;
}
}
}
return;
}
/**
* Updates invoice metadata with the PayPal Transaction ID.
*/
async updateInvoiceWithPaypalTransactionId(
invoice: Stripe.Invoice,
transactionId: string
) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.update(invoice.id, {
metadata: {
[STRIPE_INVOICE_METADATA.PAYPAL_TRANSACTION_ID]: transactionId,
},
});
}
/**
* Updates invoice metadata with the PayPal Refund Transaction ID.
*/
async updateInvoiceWithPaypalRefundTransactionId(
invoice: Stripe.Invoice,
transactionId: string
) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.update(invoice.id, {
metadata: {
[STRIPE_INVOICE_METADATA.PAYPAL_REFUND_TRANSACTION_ID]: transactionId,
},
});
}
/**
* Updates invoice metadata with the reason the PayPal Refund failed.
*/
async updateInvoiceWithPaypalRefundReason(
invoice: Stripe.Invoice,
reason: string
) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.update(invoice.id, {
metadata: {
[STRIPE_INVOICE_METADATA.PAYPAL_REFUND_REASON]: reason,
},
});
}
/**
* Returns the Paypal transaction id for the invoice if one exists.
*/
getInvoicePaypalRefundTransactionId(invoice: Stripe.Invoice) {
return invoice.metadata?.paypalRefundTransactionId;
}
/**
* Returns the Paypal transaction id for the invoice if one exists.
*/
getInvoicePaypalTransactionId(invoice: Stripe.Invoice) {
return invoice.metadata?.paypalTransactionId;
}
/**
* Retrieve the payment attempts that have been made on this invoice via PayPal.
*
* This variable reflects the amount of payment attempts that have been made. It is
* incremented *after* a payment attempt is made by any code that runs a reference
* transaction. As such, this number could be incremented multiple times at checkout
* or during a payment update on the subscription management page.
*
* The PayPal idempotencyKey has this number affixed to it in the pre-increment state.
*/
getPaymentAttempts(invoice: Stripe.Invoice): number {
return parseInt(
invoice?.metadata?.[STRIPE_INVOICE_METADATA.RETRY_ATTEMPTS] ?? '0'
);
}
/**
* Update the payment attempts on an invoice after attempting via PayPal.
*
* Increments by 1, or sets to the attempts passed in.
*/
async updatePaymentAttempts(invoice: Stripe.Invoice, attempts?: number) {
const setAttempt = attempts ?? this.getPaymentAttempts(invoice) + 1;
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.update(invoice.id, {
metadata: {
[STRIPE_INVOICE_METADATA.RETRY_ATTEMPTS]: setAttempt.toString(),
},
});
}
/**
* Get the email types that have been sent for this invoice.
*/
getEmailTypes(invoice: Stripe.Invoice) {
return (invoice.metadata?.[STRIPE_INVOICE_METADATA.EMAIL_SENT] ?? '')
.split(':')
.filter((a) => a);
}
/**
* Updates the email types sent for this invoice. These types are concatenated
* on the value of a single invoice metadata key and are thus limited to 500
* characters.
*/
async updateEmailSent(invoice: Stripe.Invoice, emailType: string) {
const emailTypes = this.getEmailTypes(invoice);
if (emailTypes.includes(emailType)) {
return;
}
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.update(invoice.id, {
metadata: {
[STRIPE_INVOICE_METADATA.EMAIL_SENT]: [...emailTypes, emailType].join(
':'
),
},
});
}
/**
* Pays an invoice out of band.
*/
async payInvoiceOutOfBand(invoice: Stripe.Invoice) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
try {
return await this.stripe.invoices.pay(invoice.id, {
paid_out_of_band: true,
});
} catch (err) {
if (err.message.includes('Invoice is already paid')) {
// This was already marked paid, we can ignore the error.
return;
}
throw err;
}
}
/**
* Update the customer object to add customer's address.
*/
async updateCustomerBillingAddress({
customerId,
options,
name,
}: {
customerId: string;
options?: BillingAddressOptions;
name?: string;
}): Promise<Stripe.Customer> {
const updates: Stripe.CustomerUpdateParams = { expand: ['tax'] };
if (options) {
updates.address = {
city: options.city,
country: options.country,
line1: options.line1,
line2: options.line2,
postal_code: options.postalCode,
state: options.state,
};
}
if (name) {
updates.name = name;
}
const customer = await this.stripe.customers.update(customerId, updates);
// Pull out tax as we don't want to cache that inconsistently.
const tax = customer.tax;
delete customer.tax;
await this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
customer
);
return { ...customer, tax };
}
/**
* Set the state (code), country (code), and postal code for a customer.
* Returns a boolean indicating success. It does not throw any exceptions as
* this operation should not block any functionality.
*
* This will _overwrite_ any existing customer address.
*/
async setCustomerLocation({
customerId,
postalCode,
country,
}: {
customerId: string;
postalCode: string;
country: string;
}): Promise<boolean> {
try {
const state = await this.googleMapsService.getStateFromZip(
postalCode,
country
);
await this.updateCustomerBillingAddress({
customerId: customerId,
options: {
line1: '',
line2: '',
city: '',
state,
country,
postalCode,
},
});
return true;
} catch (err: unknown) {
Sentry.withScope((scope) => {
scope.setContext('setCustomerLocation', {
customer: { id: customerId },
postalCode,
country,
});
reportSentryError(err);
});
}
return false;
}
/**
* Update the customer object to add a PayPal Billing Agreement ID.
*
* This is a no-op if the billing agreement is already attached to the customer.
*/
async updateCustomerPaypalAgreement(
customer: Stripe.Customer,
agreementId: string
): Promise<Stripe.Customer> {
if (
customer.metadata[STRIPE_CUSTOMER_METADATA.PAYPAL_AGREEMENT] ===
agreementId
) {
return customer;
}
const updatedCustomer = await this.stripe.customers.update(customer.id, {
metadata: { [STRIPE_CUSTOMER_METADATA.PAYPAL_AGREEMENT]: agreementId },
});
await this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
updatedCustomer
);
return updatedCustomer;
}
/**
* Remove the PayPal Billing Agreement ID from a customer.
*/
async removeCustomerPaypalAgreement(
uid: string,
customerId: string,
billingAgreementId: string
) {
const [customer] = await Promise.all([
this.stripe.customers.update(customerId, {
metadata: { [STRIPE_CUSTOMER_METADATA.PAYPAL_AGREEMENT]: null },
}),
updatePayPalBA(uid, billingAgreementId, 'Cancelled', Date.now()),
]);
return this.stripeFirestore.insertCustomerRecordWithBackfill(uid, customer);
}
/**
* Get the PayPal billing agreement id to use for this customer if available.
*/
getCustomerPaypalAgreement(customer: Stripe.Customer): string | undefined {
return customer.metadata[STRIPE_CUSTOMER_METADATA.PAYPAL_AGREEMENT];
}
/**
* Fetch all open invoices for manually invoiced subscriptions that are active.
*
* Note that created times for Stripe are in seconds since epoch and that
* invoices can be open for subscriptions that are cancelled, thus the extra
* subscription check before returning an invoice.
*/
async *fetchOpenInvoices(
created: Stripe.InvoiceListParams['created'],
customerId?: string
): AsyncGenerator<Stripe.Invoice> {
for await (const invoice of this.stripe.invoices.list({
customer: customerId,
limit: 100,
collection_method: 'send_invoice',
status: 'open',
created,
expand: ['data.customer', 'data.subscription'],
})) {
const subscription = invoice.subscription as Stripe.Subscription;
if (
subscription &&
ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status)
) {
yield invoice;
}
}
}
/**
* Updates the invoice to uncollectible
*/
markUncollectible(invoice: Stripe.Invoice) {
if (!invoice.id) throw new Error('Invoice ID must be provided');
return this.stripe.invoices.markUncollectible(invoice.id);
}
/**
* Updates subscription to cancelled status
*/
async cancelSubscription(
subscriptionId: string
): Promise<Stripe.Subscription> {
return this.stripe.subscriptions.cancel(subscriptionId);
}
/**
* Create a SetupIntent for a customer.
*/
async createSetupIntent(customerId: string): Promise<Stripe.SetupIntent> {
return this.stripe.setupIntents.create({ customer: customerId });
}
/**
* Updates the default payment method used for invoices for the customer
*/
async updateDefaultPaymentMethod(
customerId: string,
paymentMethodId: string
): Promise<Stripe.Customer> {
const customer = await this.stripe.customers.update(customerId, {
invoice_settings: { default_payment_method: paymentMethodId },
});
await this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
customer
);
return customer;
}
/**
* Remove all sources from a customer.
*
* For users that are using payment methods, we no longer wish to store
* sources so we remove them all.
*
* Returns the deleted cards.
*/
async removeSources(customerId: string): Promise<Stripe.Card[]> {
const sources = await this.stripe.customers.listSources(customerId, {
object: 'card',
});
if (sources.data.length === 0) {
return [];
}
return Promise.all(
sources.data.map(
(s) =>
this.stripe.customers.deleteSource(
customerId,
s.id
) as unknown as Promise<Stripe.Card>
)
);
}
async getPaymentMethod(
paymentMethodId: string
): Promise<Stripe.PaymentMethod> {
return this.expandResource<Stripe.PaymentMethod>(
paymentMethodId,
PAYMENT_METHOD_RESOURCE
);
}
getPaymentProvider(customer: Stripe.Customer) {
const subscription = customer.subscriptions?.data.find((sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)
);
if (subscription) {
return subscription.collection_method === 'send_invoice'
? 'paypal'
: 'stripe';
}
return 'not_chosen';
}
/**
* Returns whether or not the customer has any active subscriptions that
* are require a payment method on file (not marked to be cancelled).
*/
hasSubscriptionRequiringPaymentMethod(customer: Stripe.Customer) {
const subscription = customer.subscriptions?.data.find(
(sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status) &&
!sub.cancel_at_period_end
);
return !!subscription;
}
/**
* Returns true if the FxA account with uid has an active subscription.
*/
async hasActiveSubscription(uid: string): Promise<boolean> {
const { stripeCustomerId } = (await getAccountCustomerByUid(uid)) || {};
if (!stripeCustomerId) {
return false;
}
const customer = await this.expandResource<Stripe.Customer>(
stripeCustomerId,
CUSTOMER_RESOURCE
);
const subscription = customer.subscriptions?.data.find((sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)
);
return !!subscription;
}
/**
* Fetches all latest invoices for all active subscriptions.
*/
async getLatestInvoicesForActiveSubscriptions(
customer: Stripe.Customer
): Promise<Stripe.Invoice[]> {
const invoices = customer.subscriptions?.data
.filter((sub) => ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status))
.map((sub) => sub.latest_invoice)
.filter(
(invoice): invoice is Stripe.Invoice | 'string' => invoice !== null
);
if (!invoices?.length) {
return [];
}
return Promise.all(
invoices.map((invoice) =>
this.expandResource<Stripe.Invoice>(invoice, INVOICES_RESOURCE)
)
);
}
/**
* Returns whether or not any of the invoices for the customer are open (payment
* has not been processed) and have any payment attempts.
*/
async hasOpenInvoiceWithPaymentAttempts(customer: Stripe.Customer) {
const invoices =
await this.getLatestInvoicesForActiveSubscriptions(customer);
if (!invoices?.length) {
return false;
}
return invoices.some(
(invoice) =>
invoice.status === 'open' && this.getPaymentAttempts(invoice) > 0
);
}
/**
* Adds the appropriate tax id if found to the customer based on passed in
* currency or the customers existing currency.
**/
async addTaxIdToCustomer(customer: Stripe.Customer, currency?: string) {
const taxId =
this.taxIds[
currency?.toUpperCase() ?? customer.currency?.toUpperCase() ?? ''
];
if (taxId) {
const updatedCustomer = await this.stripe.customers.update(customer.id, {
invoice_settings: {
custom_fields: [{ name: MOZILLA_TAX_ID, value: taxId }],
},
});
return this.stripeFirestore.insertCustomerRecordWithBackfill(
customer.metadata.userid,
updatedCustomer
);
}
return;
}
/**
* Returns the correct tax id for a customer.
*/
getTaxIdForCustomer(customer: Stripe.Customer) {
return this.taxIds[customer.currency?.toUpperCase() ?? ''];
}
/**
* Returns the customers tax id if they have one.
**/
customerTaxId(customer: Stripe.Customer) {
return customer.invoice_settings.custom_fields?.find(
(field) => field.name === MOZILLA_TAX_ID
);
}
async detachPaymentMethod(
paymentMethodId: string
): Promise<Stripe.PaymentMethod> {
const paymentMethod =
await this.stripe.paymentMethods.detach(paymentMethodId);
await this.stripeFirestore.removePaymentMethodRecord(paymentMethodId);
return paymentMethod;
}
/**
* Fetch a customer for the record from Stripe based on user id.
*/
async fetchCustomer(
uid: string,
expand?: (
| 'subscriptions'
| 'invoice_settings.default_payment_method'
| 'tax'
)[]
): Promise<Stripe.Customer | void> {
try {
return await super.fetchCustomer(uid, expand);
} catch (err) {
throw error.backendServiceFailure('stripe', 'fetchCustomer', {}, err);
}
}
async fetchInvoicesForActiveSubscriptions(
customerId: string,
status: Stripe.InvoiceListParams.Status,
earliestCreatedDate?: Date
) {
const activeSubscriptionIds = (
await this.stripe.subscriptions.list({
customer: customerId,
status: 'active',
})
).data.map((sub) => sub.id);
if (!activeSubscriptionIds.length) return [];
const invoices = await this.stripe.invoices.list({
customer: customerId,
status,
created: earliestCreatedDate
? { gte: Math.floor(earliestCreatedDate.getTime() / 1000) }
: undefined,
});
return invoices.data.filter((invoice) => {
// The invoice list we fetched did not expand the subscription so these must be strings
if (typeof invoice.subscription !== 'string') return false;
return activeSubscriptionIds.includes(invoice.subscription);
});
}
/**
* On FxA deletion, if the user is a Stripe Customer:
* - delete the stripe customer to delete
* - remove the cache entry
* - optionally update the subscription metadata to record metadata about the deletion
*
* @param updateActiveSubMetadata - Optional metadata to update the active subscriptions with
* before removing the customer.
*/
async removeCustomer(
uid: string,
updateActiveSubMetadata?: Record<string, string>
) {
const accountCustomer = await getAccountCustomerByUid(uid);
if (!accountCustomer || !accountCustomer.stripeCustomerId) return;
const customer = await this.fetchCustomer(accountCustomer.uid, [
'invoice_settings.default_payment_method',
]);
if (customer) {
// detach the customer's payment method so we maybe won't get webhooks about it
if (customer.invoice_settings.default_payment_method)
await this.stripe.paymentMethods.detach(
(
customer.invoice_settings
.default_payment_method as Stripe.PaymentMethod
).id
);
// Only update metadata if we were passed an object with keys. Otherwise
// this would erase existing metadata if it were passed an empty object.
if (
updateActiveSubMetadata &&
Object.keys(updateActiveSubMetadata).length > 0
) {
const activeSubscriptions =
customer.subscriptions?.data.filter((sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)
) || [];
for (const sub of activeSubscriptions) {
await this.stripe.subscriptions.update(sub.id, {
metadata: updateActiveSubMetadata,
});
}
}
await this.stripe.customers.del(accountCustomer.stripeCustomerId);
}
const recordsDeleted = await deleteAccountCustomer(uid);
if (recordsDeleted === 0) {
this.log.error(
`StripeHelper.removeCustomer failed to remove AccountCustomer record for uid ${uid}`,
{}
);
}
}
/**
* Fetch a subscription for a customer from Stripe.
*
* Uses Redis caching if configured.
*
* Note: This method is used in context to only return this
* subscription if it belongs to this user.
*/
async subscriptionForCustomer(
uid: string,
email: string,
subscriptionId: string
): Promise<Stripe.Subscription | void> {
const customer = await this.fetchCustomer(uid, ['subscriptions']);
if (!customer) {
return;
}
return customer.subscriptions?.data.find(
(subscription) => subscription.id === subscriptionId
);
}
/**
* Fetch all price ids that correspond to a list of Play Store or App Store
* SubscriptionPurchases.
*
* Confusingly, the App Store analog to a Stripe planId is the App Store productId.
*/
async iapPurchasesToPriceIds(
purchases: (PlayStoreSubscriptionPurchase | AppStoreSubscriptionPurchase)[]
) {
const prices = await this.allAbbrevPlans();
const purchasedIds = purchases.map((purchase) => {
if (isAppStoreSubscriptionPurchase(purchase)) {
return purchase.productId.toLowerCase();
}
if (isPlayStoreSubscriptionPurchase(purchase)) {
return purchase.sku.toLowerCase();
}
throw new Error(
'Purchase is not recognized as either Google or Apple IAP.'
);
});
const iapType = getIapPurchaseType(purchases[0]);
const purchasedPrices = new Array<string>();
for (const price of prices) {
const purchaseIds = this.priceToIapIdentifiers(price, iapType);
if (purchaseIds.some((id) => purchasedIds.includes(id))) {
purchasedPrices.push(price.plan_id);
}
}
return purchasedPrices;
}
/**
* Find all active subscriptions for the given `planId`. Filter out
* any subscriptions marked as `cancel_at_period_end`.
*
* It is expected that the `customer` is expanded.
*/
async *findActiveSubscriptionsByPlanId(
planId: string,
currentPeriodEnd: Stripe.RangeQueryParam,
limit = 50
) {
const params: Stripe.SubscriptionListParams = {
price: planId,
current_period_end: currentPeriodEnd,
limit,
expand: ['data.customer'],
};
for await (const subscription of this.stripe.subscriptions.list(params)) {
if (
!ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status) ||
subscription.cancel_at_period_end
) {
continue;
}
yield subscription;
}
}
/**
* Find and return a subscription for a customer of the given plan id.
*/
findCustomerSubscriptionByPlanId(
customer: Stripe.Customer,
planId: string
): Stripe.Subscription | undefined {
if (!customer.subscriptions) {
throw error.internalValidationError(
'findCustomerSubscriptionByPlanId',
{
customerId: customer.id,
},
'Expected subscriptions to be loaded.'
);
}
return customer.subscriptions.data.find(
(sub) => sub.items.data.find((item) => item.plan.id === planId) != null
);
}
/**
* Fetches all plans that are attached to a product from the cached products
*/
async fetchPlansByProductId(productId: string): Promise<Stripe.Plan[]> {
const allPlans = await this.allPlans();
return allPlans.filter(
(plan) => (plan.product as Stripe.Product).id === productId
);
}
/**
* Fetches a product by its id from cached products.
*/
async fetchProductById(
productId: string
): Promise<Stripe.Product | undefined> {
const allProducts = await this.allProducts();
return allProducts.find((p) => p.id === productId);
}
@CacheUpdate({
cacheKey: STRIPE_PLANS_CACHE_KEY,
ttlSeconds: (args, context) => context.plansAndProductsCacheTtlSeconds,
cacheKeysToClear: 'noop',
clearStrategy: noopCacheClearStrategy,
})
async updateAllPlans(allPlans: Stripe.Plan[]) {
return allPlans;
}
/**
* Find a plan by id or error if it's not a valid planId.
*/
async findAbbrevPlanById(planId: string): Promise<AbbrevPlan> {
const plans = await this.allAbbrevPlans();
const selectedPlan = plans.find((p) => p.plan_id === planId);
if (!selectedPlan) {
throw error.unknownSubscriptionPlan(planId);
}
return selectedPlan;
}
/**
* Check if customer's automatic tax status indicates that they're eligible for automatic tax.
* Creating a subscription with automatic_tax enabled requires a customer with an address
* that is in a recognized location with an active tax registration.
*/
isCustomerStripeTaxEligible(customer: Stripe.Customer) {
return (
customer.tax?.automatic_tax === 'supported' ||
customer.tax?.automatic_tax === 'not_collecting'
);
}
/**
* Check if we should enable stripe tax for a given customer and subscription currency.
*/
isCustomerTaxableWithSubscriptionCurrency(
customer: Stripe.Customer,
targetCurrency: string
) {
const taxCountry = customer.tax?.location?.country;
if (!taxCountry) {
return false;
}
const isCurrencyCompatibleWithCountry =
this.currencyHelper.isCurrencyCompatibleWithCountry(
targetCurrency,
taxCountry
);
if (!isCurrencyCompatibleWithCountry) {
return false;
}
return this.isCustomerStripeTaxEligible(customer);
}
async updateSubscriptionAndBackfill(
subscription: Stripe.Subscription,
newProps: Stripe.SubscriptionUpdateParams
) {
const updatedSubscription = await this.stripe.subscriptions.update(
subscription.id,
newProps
);
await this.stripeFirestore.insertSubscriptionRecordWithBackfill(
updatedSubscription
);
return updatedSubscription;
}
/**
* Change a subscription to the new plan.
*
* Note that this call does not verify its a valid upgrade, the
* `verifyPlanUpgradeForSubscription` should be done first to
* validate this is an appropriate change for tier use.
*/
async changeSubscriptionPlan(
subscription: Stripe.Subscription,
newPlanId: string
): Promise<Stripe.Subscription> {
const currentPlanId = subscription.items.data[0].plan.id;
if (currentPlanId === newPlanId) {
throw error.subscriptionAlreadyChanged();
}
const updatedMetadata = {
...subscription.metadata,
previous_plan_id: currentPlanId,
plan_change_date: moment().unix(),
};
const updatedSubscription = await this.updateSubscriptionAndBackfill(
subscription,
{
cancel_at_period_end: false,
items: [
{
id: subscription.items.data[0].id,
plan: newPlanId,
},
],
proration_behavior: 'always_invoice',
metadata: updatedMetadata,
}
);
return updatedSubscription;
}
/**
* Cancel a given subscription for a customer
* If the subscription does not belong to the customer, throw an error
*/
async cancelSubscriptionForCustomer(
uid: string,
email: string,
subscriptionId: string
): Promise<void> {
const subscription = await this.subscriptionForCustomer(
uid,
email,
subscriptionId
);
if (!subscription) {
throw error.unknownSubscription();
}
await this.updateSubscriptionAndBackfill(subscription, {
cancel_at_period_end: true,
metadata: {
...(subscription.metadata || {}),
cancelled_for_customer_at: moment().unix(),
},
});
}
/**
* Reactivate a given subscription for a customer
* If a customer has an active subscription that is set to cancel at the period end:
* 1. Update the subscription to remain active at the period end
* 2. Verify that after the update the subscription is still in an active state
* True: return the updated Subscription
* False: throw an error
* If the customer does not own the subscription, throw an error
*/
async reactivateSubscriptionForCustomer(
uid: string,
email: string,
subscriptionId: string
): Promise<Stripe.Subscription> {
const subscription = await this.subscriptionForCustomer(
uid,
email,
subscriptionId
);
if (!subscription) {
throw error.unknownSubscription();
}
if (!ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
const err = new Error(
`Reactivated subscription (${subscriptionId}) is not active/trialing`
);
throw error.backendServiceFailure(
'stripe',
'reactivateSubscription',
{},
err
);
}
const reactivatedSubscription = await this.updateSubscriptionAndBackfill(
subscription,
{
cancel_at_period_end: false,
metadata: {
...(subscription.metadata || {}),
cancelled_for_customer_at: '',
},
}
);
return reactivatedSubscription;
}
/**
* Attempt to pay invoice by invoice id
* Throws payment failed error on failure
*/
async payInvoice(invoiceId: string): Promise<Stripe.Invoice> {
let invoice;
try {
invoice = await this.stripe.invoices.pay(invoiceId, {
expand: ['payment_intent'],
});
} catch (err) {
if (err.code === 'card_declined') {
throw error.paymentFailed();
}
throw err;
}
if (!this.paidInvoice(invoice)) {
throw error.paymentFailed();
}
return invoice;
}
/**
* Verify that the invoice was paid successfully.
*
* Note that the invoice *must have the `payment_intent` expanded*
* or this function will fail.
*/
paidInvoice(invoice: Stripe.Subscription['latest_invoice']): boolean {
if (
!invoice ||
typeof invoice === 'string' ||
!invoice.payment_intent ||
typeof invoice.payment_intent === 'string'
) {
throw error.internalValidationError('paidInvoice', {
invoice: invoice,
});
}
return (
invoice.status === 'paid' && invoice.payment_intent.status === 'succeeded'
);
}
/**
* Retrieve a PaymentIntent from an invoice
*/
async fetchPaymentIntentFromInvoice(
invoice: Stripe.Invoice
): Promise<Stripe.PaymentIntent> {
if (!invoice.payment_intent) {
// We don't have any code working with draft invoices, so
// this should not be hit... yet. PayPal support *will* likely operate
// on draft invoices though.
throw error.internalValidationError(
'fetchPaymentIntentFromInvoice',
invoice,
new Error(`Invoice not finalized: ${invoice.id}`)
);
}
if (typeof invoice.payment_intent !== 'string') {
return invoice.payment_intent;
}
return this.stripe.paymentIntents.retrieve(invoice.payment_intent);
}
/**
* Extract the source country from a subscription payment details.
*
* Requires the `latest_invoice.payment_intent` to be expanded during
* subscription load.
*/
extractSourceCountryFromSubscription(
subscription: Stripe.Subscription
): null | string {
// Eliminate all the optional values and ensure they were expanded such
// that they're not a string.
if (
!subscription.latest_invoice ||
typeof subscription.latest_invoice === 'string' ||
!subscription.latest_invoice.payment_intent ||
typeof subscription.latest_invoice.payment_intent === 'string'
) {
return null;
}
const latestCharge = subscription.latest_invoice.payment_intent
.latest_charge as string | Stripe.Charge | null | undefined;
if (latestCharge && typeof latestCharge !== 'string') {
// Get the country from the payment details.
// However, historically there were (rare) instances where `charges` was
// not found in the object graph, hence the defensive code.
// There's only one charge (the latest), per Stripe's docs.
const paymentMethodDetails: Stripe.Charge.PaymentMethodDetails | null =
latestCharge.payment_method_details;
if (paymentMethodDetails?.card?.country) {
return paymentMethodDetails.card.country;
}
} else {
Sentry.withScope((scope) => {
scope.setContext('stripeSubscription', {
subscription: { id: subscription.id },
});
reportSentryMessage(
'Payment charges not found in subscription payment intent on subscription creation.',
'warning' as SeverityLevel
);
});
}
return null;
}
async getBillingDetailsAndSubscriptions(uid: string) {
const customer = await this.fetchCustomer(uid, [
'invoice_settings.default_payment_method',
]);
if (!customer) {
return null;
}
const billingDetails = await this.extractBillingDetails(customer);
const detailsAndSubs: {
customerId: string;
subscriptions: WebSubscription[];
} & PaymentBillingDetails = {
customerId: customer.id,
subscriptions: [],
...billingDetails,
};
if (detailsAndSubs.payment_provider === 'paypal') {
detailsAndSubs.billing_agreement_id =
this.getCustomerPaypalAgreement(customer);
}
if (
detailsAndSubs.payment_provider === 'paypal' &&
this.hasSubscriptionRequiringPaymentMethod(customer)
) {
if (!this.getCustomerPaypalAgreement(customer)) {
detailsAndSubs.paypal_payment_error =
PAYPAL_PAYMENT_ERROR_MISSING_AGREEMENT;
} else if (await this.hasOpenInvoiceWithPaymentAttempts(customer)) {
detailsAndSubs.paypal_payment_error =
PAYPAL_PAYMENT_ERROR_FUNDING_SOURCE;
}
}
if (customer.subscriptions) {
const activeSubscriptions = customer.subscriptions.data.filter((sub) =>
ACTIVE_SUBSCRIPTION_STATUSES.includes(sub.status)
);
detailsAndSubs.subscriptions = await this.subscriptionsToResponse({
...customer.subscriptions,
data: activeSubscriptions,
});
}
return detailsAndSubs;
}
/**
* Extracts billing details if a customer has a source on file.
*/
async extractBillingDetails(customer: Stripe.Customer) {
const defaultPayment = customer.invoice_settings.default_payment_method;
const paymentProvider = this.getPaymentProvider(customer);
if (defaultPayment) {
if (typeof defaultPayment === 'string') {
// This should always be expanded here.
throw error.backendServiceFailure('stripe', 'paymentExpansion');
}
if (defaultPayment.card) {
return {
billing_name: defaultPayment.billing_details.name,
payment_provider: paymentProvider,
payment_type: defaultPayment.card.funding,
last4: defaultPayment.card.last4,
exp_month: defaultPayment.card.exp_month,
exp_year: defaultPayment.card.exp_year,
brand: defaultPayment.card.brand,
};
}
}
if (customer.default_source) {
const paymentMethod = await this.expandResource<Stripe.PaymentMethod>(
// CustomerSource doesn't quite overlap with PaymentMethod, but in our
// situation, the missing type isn't one we let our customers use.
customer.default_source as unknown as Stripe.PaymentMethod,
PAYMENT_METHOD_RESOURCE
);
if (!paymentMethod.card)
throw new Error('Card must be present on payment method');
const { brand, exp_month, exp_year, funding, last4 } = paymentMethod.card;
return {
billing_name: customer.name,
payment_provider: paymentProvider,
payment_type: funding,
last4,
exp_month,
exp_year,
brand,
};
}
return {
payment_provider: paymentProvider,
};
}
/**
* Check if a subscription is past due
*/
checkSubscriptionPastDue(subscription: Stripe.Subscription) {
return (
subscription.status === 'past_due' &&
subscription.collection_method === 'charge_automatically'
);
}
/**
* Formats Stripe subscriptions for a customer into an appropriate response.
*/
async subscriptionsToResponse(
subscriptions: Stripe.ApiList<Stripe.Subscription>
): Promise<WebSubscription[]> {
const subs: WebSubscription[] = [];
const products = await this.allAbbrevProducts();
for (const sub of subscriptions.data) {
let failure_code, failure_message;
// Don't include incomplete/incomplete_expired subscriptions as we pretend they
// don't exist. When a user tries to sign-up, if an incomplete is found, it will
// then be used correctly.
if (sub.status === 'incomplete' || sub.status === 'incomplete_expired') {
continue;
}
let latestInvoice = sub.latest_invoice;
if (typeof latestInvoice === 'string') {
latestInvoice = await this.expandResource<Stripe.Invoice>(
latestInvoice,
INVOICES_RESOURCE
);
}
if (!latestInvoice) {
throw new Error('Latest invoice for subscription could not be found');
}
if (!latestInvoice.number) {
throw new Error('Invoice number for subscription is required');
}
// If this is a charge-automatically payment that is past_due, attempt
// to get details of why it failed. The caller should expand the last_invoice
// calls by passing ['data.subscriptions.data.latest_invoice'] to `fetchCustomer`
// as the `expand` argument or this will not fetch the failure code/message.
if (
this.checkSubscriptionPastDue(sub) &&
latestInvoice &&
latestInvoice.charge
) {
let charge = latestInvoice.charge;
if (typeof latestInvoice.charge === 'string') {
charge = await this.stripe.charges.retrieve(latestInvoice.charge);
}
if (typeof charge !== 'string') {
failure_code = charge.failure_code;
failure_message = charge.failure_message;
}
}
const { discount } = sub;
// This type inconsistency runs quite deep, but plan does exist on the subscription here
// for all current use-cases.
const plan = (sub as any).plan as Stripe.Plan;
const product = products.find((p) => p.product_id === plan.product);
if (!product)
throw new Error(
`Matching product for subscription ${sub.id} not found`
);
const { product_id, product_name } = product;
// FIXME: Note that the plan is only set if the subscription contains a single
// plan. Multiple product support will require changes here to fetch all
// plans for this subscription.
subs.push({
_subscription_type: MozillaSubscriptionTypes.WEB,
created: sub.created,
current_period_end: sub.current_period_end,
current_period_start: sub.current_period_start,
cancel_at_period_end: sub.cancel_at_period_end,
end_at: sub.ended_at,
latest_invoice: latestInvoice.number,
latest_invoice_items:
stripeInvoiceToLatestInvoiceItemsDTO(latestInvoice),
plan_id: plan.id,
product_name,
product_id,
status: sub.status,
subscription_id: sub.id,
failure_code,
failure_message,
promotion_amount_off: discount?.coupon?.amount_off ?? null,
promotion_code:
sub.metadata[SUBSCRIPTION_PROMOTION_CODE_METADATA_KEY] ?? null,
promotion_duration: (discount?.coupon?.duration as string) ?? null,
promotion_end: discount?.end ?? null,
promotion_name: discount?.coupon?.name ?? null,
promotion_percent_off: discount?.coupon?.percent_off ?? null,
});
}
return subs;
}
/**
* Formats Stripe subscriptions with information needed to provide support.
*/
async formatSubscriptionsForSupport(
subscriptions: Stripe.ApiList<Stripe.Subscription>
) {
const subs = new Array<any>();
for (const sub of subscriptions.data) {
const plan = singlePlan(sub);
if (!plan) {
throw error.internalValidationError(
'formatSubscriptionsForSupport',
sub,
new Error(`Unexpected multiple items for subscription: ${sub.id}`)
);
}
const product = await this.expandResource(plan.product, PRODUCT_RESOURCE);
if (!product || product.deleted) {
throw error.internalValidationError(
'formatSubscriptionsForSupport',
sub,
new Error(`Product invalid for subscription: ${sub.id}`)
);
}
const product_name = product.name;
let previous_product: string | null = null;
let plan_changed: number | null = null;
if (sub.metadata.previous_plan_id !== undefined) {
const previousPlan = await this.findAbbrevPlanById(
sub.metadata.previous_plan_id
);
previous_product = previousPlan.product_name;
plan_changed = Number(sub.metadata.plan_change_date);
}
// FIXME: Note that the plan is only set if the subscription contains a single
// plan. Multiple product support will require changes here to fetch all
// plans for this subscription.
subs.push({
created: sub.created,
current_period_end: sub.current_period_end,
current_period_start: sub.current_period_start,
plan_changed,
previous_product,
product_name,
status: sub.status,
subscription_id: sub.id,
});
}
return subs;
}
/**
* Use the Stripe lib to authenticate and get a webhook event.
*/
constructWebhookEvent(payload: any, signature: string): Stripe.Event {
return this.stripe.webhooks.constructEvent(
payload,
signature,
this.webhookSecret
);
}
/**
* Get PriceId of subscription from invoice
*/
getPriceIdFromInvoice(invoice: Stripe.Invoice) {
return invoice.lines.data.find(
(invoiceLine) => invoiceLine.type === 'subscription'
)?.price?.id;
}
/**
* Extract invoice details for billing emails.
*
* Note that this function throws an error in the following cases:
* - Stripe customer is deleted.
* - No plan in the invoice.
* - No product attached to the plan.
* - No email on the customer object.
* - No subscriptions in the invoice.
*/
async extractInvoiceDetailsForEmail(invoice: Stripe.Invoice) {
const customer = await this.expandResource(
invoice.customer,
CUSTOMER_RESOURCE
);
if (!customer || customer.deleted) {
throw error.unknownCustomer(invoice.customer);
}
// Get the new subscription, ignoring any invoiceitem line items
// that could contain prorations for old subscriptions
const subscriptionLineItem = invoice.lines.data.find(
(line) => line.type === 'subscription'
);
// In certain instances the invoice won't have a 'subscription' line item.
// In those cases, select the 'invoiceitem' without proration_details.credited_items
const invoiceitemLineItem = !subscriptionLineItem
? invoice.lines.data.find(
(line) =>
line.type === 'invoiceitem' &&
!line.proration_details?.credited_items
)
: undefined;
const lineItem = subscriptionLineItem || invoiceitemLineItem;
if (!lineItem) {
// No subscription or invoiceitem is present for the invoice. This should never happen
// since all invoices have a related incoming subscription as one of the line items.
throw error.internalValidationError(
'extractInvoiceDetailsForEmail',
invoice,
new Error(
`No subscription or invoiceitem line items found for invoice: ${invoice.id}`
)
);
}
// Dig up & expand objects in the invoice that usually come as just IDs
const { plan } = lineItem;
if (!plan) {
// No plan is present if this is not a subscription or proration, which
// should never happen as we only have subscriptions.
throw error.internalValidationError(
'extractInvoiceDetailsForEmail',
invoice.lines.data[0],
new Error(`Unexpected line item: ${invoice.lines.data[0].id}`)
);
}
const [abbrevProduct, charge] = await Promise.all([
this.expandAbbrevProductForPlan(plan),
this.expandResource(invoice.charge, CHARGES_RESOURCE),
]);
// if the invoice does not have the deprecated discount property but has a discount ID in discounts
// expand the discount
let discountType: Stripe.Coupon.Duration | null = null;
let discountDuration: number | null = null;
if (invoice.discount) {
discountType = invoice.discount.coupon.duration;
discountDuration = invoice.discount.coupon.duration_in_months;
}
if (
invoice.id &&
!invoice.discount &&
!!invoice.discounts?.length &&
invoice.discounts.length === 1
) {
const invoiceWithDiscount = await this.getInvoiceWithDiscount(invoice.id);
const discount = invoiceWithDiscount.discounts?.pop() as Stripe.Discount;
discountType = discount.coupon.duration;
discountDuration = discount.coupon.duration_in_months;
}
if (!!invoice.discounts?.length && invoice.discounts.length > 1) {
throw error.internalValidationError(
'extractInvoiceDetailsForEmail',
invoice,
new Error(`Invoice has multiple discounts.`)
);
}
if (!abbrevProduct) {
throw error.internalValidationError(
'extractInvoiceDetailsForEmail',
invoice,
new Error(`No product attached to plan ${plan.id}`)
);
}
if (!customer.email) {
throw error.internalValidationError(
'extractInvoiceDetailsForEmail',
{ customerId: customer.id },
'Customer missing email.'
);
}
const {
email,
metadata: { userid: uid },
} = customer;
const { product_id: productId, product_name: productName } = abbrevProduct;
const {
number: invoiceNumber,
created: invoiceDate,
currency: invoiceTotalCurrency,
total: invoiceTotalInCents,
subtotal: invoiceSubtotalInCents,
hosted_invoice_url: invoiceLink,
tax: invoiceTaxAmountInCents,
status: invoiceStatus,
} = invoice;
const nextInvoiceDate = lineItem.period.end;
const invoiceDiscountAmountInCents =
(invoice.total_discount_amounts &&
invoice.total_discount_amounts.length &&
invoice.total_discount_amounts[0].amount) ||
null;
// Only show the Subtotal when there is a Discount
const showSubtotal =
invoiceDiscountAmountInCents || discountType || discountDuration;
const { id: planId, nickname: planName } = plan;
const abbrevPlan = await this.findAbbrevPlanById(planId);
const productMetadata = this.mergeMetadata(
{
...plan,
metadata: abbrevPlan.plan_metadata,
},
abbrevProduct
);
// Use Firestore product configs if that exist
const planConfig: Partial<PlanConfig> =
await this.maybeGetPlanConfig(planId);
const { emailIconURL: planEmailIconURL = '', successActionButtonURL } = {
emailIconURL: planConfig.urls?.emailIcon || productMetadata.emailIconURL,
successActionButtonURL:
planConfig.urls?.successActionButton ||
productMetadata.successActionButtonURL,
};
const planSuccessActionButtonURL = successActionButtonURL || '';
const { lastFour, cardType } = this.extractCardDetails({
charge,
});
const payment_provider = this.getPaymentProvider(customer);
return {
uid,
email,
cardType,
lastFour,
payment_provider,
invoiceLink,
invoiceNumber,
invoiceStatus,
invoiceTotalInCents,
invoiceTotalCurrency,
invoiceSubtotalInCents: showSubtotal ? invoiceSubtotalInCents : null,
invoiceDiscountAmountInCents,
invoiceTaxAmountInCents,
invoiceDate: new Date(invoiceDate * 1000),
nextInvoiceDate: new Date(nextInvoiceDate * 1000),
productId,
productName,
planId,
planName,
planEmailIconURL,
planSuccessActionButtonURL,
planConfig,
productMetadata,
showPaymentMethod: !!invoiceTotalInCents,
showTaxAmount: false, // Currently we do not want to show tax amounts in emails
discountType,
discountDuration,
};
}
async formatSubscriptionForEmail(
subscription: Stripe.Subscription
): Promise<FormattedSubscriptionForEmail> {
const subPlan = singlePlan(subscription);
if (!subPlan) {
throw error.internalValidationError(
'formatSubscription',
subscription,
new Error(
`Multiple items for a subscription not supported: ${subscription.id}`
)
);
}
const plan = await this.expandResource(subPlan, PLAN_RESOURCE);
const abbrevProduct = await this.expandAbbrevProductForPlan(plan);
const planConfig = await this.maybeGetPlanConfig(plan.id);
const { product_id: productId, product_name: productName } = abbrevProduct;
const { id: planId, nickname: planName } = plan;
const abbrevPlan = await this.findAbbrevPlanById(planId);
const productMetadata = this.mergeMetadata(
{
...plan,
metadata: abbrevPlan.plan_metadata,
},
abbrevProduct
);
const { emailIconURL: planEmailIconURL = '', successActionButtonURL } =
productMetadata;
const planSuccessActionButtonURL = successActionButtonURL || '';
return {
productId,
productName,
planId,
planName,
planEmailIconURL,
planSuccessActionButtonURL,
planConfig,
productMetadata,
};
}
async formatSubscriptionsForEmails(
customer: Readonly<Stripe.Customer>
): Promise<FormattedSubscriptionForEmail[]> {
if (!customer.subscriptions) {
return [];
}
const formattedSubscriptions = new Array<FormattedSubscriptionForEmail>();
for (const subscription of customer.subscriptions.data) {
if (ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
const formattedSubscription =
await this.formatSubscriptionForEmail(subscription);
formattedSubscriptions.push(formattedSubscription);
}
}
return formattedSubscriptions;
}
extractCardDetails({ charge }: { charge: Stripe.Charge | null }) {
let lastFour: string | null = null;
let cardType: string | null = null;
if (charge?.payment_method_details?.card) {
({ brand: cardType, last4: lastFour } =
charge.payment_method_details.card);
}
return { lastFour, cardType };
}
/**
* Extract source details for billing emails
*/
async extractSourceDetailsForEmail(source: Stripe.Source | Stripe.Card) {
if (source.object !== 'card') {
// We shouldn't get here - all sources should currently be cards.
throw error.internalValidationError(
'extractSourceDetailsForEmail',
source,
new Error(`Payment source was not card: ${source.id}`)
);
}
if (!source.customer) {
// We shouldn't get here - our sources should be attached to customers.
throw error.internalValidationError(
'extractSourceDetailsForEmail',
source,
new Error(`Customer was not found on source: ${source.id}`)
);
}
const customer = await this.expandResource(
source.customer,
CUSTOMER_RESOURCE
);
if (customer.deleted === true) {
throw error.unknownCustomer(source.customer);
}
if (!customer.subscriptions) {
throw error.internalValidationError(
'extractSourceDetailsForEmail',
customer,
new Error(`No subscriptions found for customer: ${customer.id}`)
);
}
const subscriptions = await this.formatSubscriptionsForEmails(customer);
if (subscriptions.length === 0) {
throw error.missingSubscriptionForSourceError(
'extractSourceDetailsForEmail',
source
);
}
if (!customer.email) {
throw error.internalValidationError(
'extractSourceDetailsForEmail',
{ customerId: customer.id },
'Customer missing email.'
);
}
const {
email,
metadata: { userid: uid },
} = customer;
return {
uid,
email,
subscriptions,
};
}
stripePlanToPaymentCycle(plan: Stripe.Plan) {
if (plan.interval_count === 1) {
return plan.interval;
}
return `${plan.interval_count} ${plan.interval}s`;
}
/**
* Extract subscription update details for billing emails
*/
async extractSubscriptionUpdateEventDetailsForEmail(event: Stripe.Event) {
if (event.type !== 'customer.subscription.updated') {
throw error.internalValidationError(
'extractSubscriptionUpdateEventDetailsForEmail',
event,
new Error('Event was not of type customer.subscription.updated')
);
}
const eventData = event.data;
const subscription = eventData.object as Stripe.Subscription;
const customer = await this.expandResource(
subscription.customer,
'customers'
);
if (customer.deleted === true) {
throw error.unknownCustomer(subscription.customer);
}
let invoice = subscription.latest_invoice;
if (typeof invoice === 'string') {
// if we have to do a fetch, go ahead and ensure we also get the additional needed resource
invoice = await this.stripe.invoices.retrieve(invoice, {
expand: ['charge'],
});
}
const {
email,
metadata: { userid: uid },
} = customer;
const planNew = singlePlan(subscription);
if (!planNew) {
throw error.internalValidationError(
'extractSubscriptionUpdateEventDetailsForEmail',
event,
new Error(
`Multiple items for a subscription not supported: ${subscription.id}`
)
);
}
// Stripe only sends fields that have changed in their previous_attributes field
// Additionally, previous_attributes is a generic field that has no proper typings
// and is used in a flexible manner.
const previousAttributes = eventData.previous_attributes as any;
const planOldDiff = (previousAttributes as any)
.plan as Partial<Stripe.Plan> | null;
const planOld: Stripe.Plan | null = planOldDiff
? {
...planNew,
...planOldDiff,
}
: null;
let invoiceTotalOldInCents: number | undefined;
const previousLatestInvoice = previousAttributes.latest_invoice as
| string
| undefined;
if (previousLatestInvoice) {
const invoiceOld = await this.getInvoice(previousLatestInvoice);
invoiceTotalOldInCents = invoiceOld.total;
}
const planIdNew = planNew.id;
const cancelAtPeriodEndNew = subscription.cancel_at_period_end;
const cancelAtPeriodEndOld = previousAttributes.cancel_at_period_end;
const abbrevProductNew = await this.expandAbbrevProductForPlan(planNew);
const {
amount: paymentAmountNewInCents,
currency: paymentAmountNewCurrency,
} = planNew;
const { product_id: productIdNew, product_name: productNameNew } =
abbrevProductNew;
const abbrevPlanNew = await this.findAbbrevPlanById(planNew.id);
const productNewMetadata = this.mergeMetadata(
{
...planNew,
metadata: abbrevPlanNew.plan_metadata,
},
abbrevProductNew
);
const { emailIconURL: productIconURLNew = '' } = productNewMetadata;
const planConfig = await this.maybeGetPlanConfig(planIdNew);
const productPaymentCycleNew = this.stripePlanToPaymentCycle(planNew);
// During upgrades it's possible that an invoice isn't created when the
// subscription is updated. Instead there will be pending invoice items
// which will be added to next invoice once its generated.
// For more info see https://stripe.com/docs/api/subscriptions/update
let upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined;
try {
const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({
customer: customer.id,
subscription: subscription.id,
});
// Only use upcomingInvoice if there are `invoiceitems`
upcomingInvoiceWithInvoiceItem = upcomingInvoice?.lines.data.some(
(line) => line.type === 'invoiceitem'
)
? upcomingInvoice
: undefined;
} catch (error) {
if (
error.type === 'StripeInvalidRequestError' &&
error.code === 'invoice_upcoming_none'
) {
upcomingInvoiceWithInvoiceItem = undefined;
} else {
throw error;
}
}
const baseDetails = {
uid,
email,
planId: planIdNew,
productId: productIdNew,
productIdNew,
productNameNew,
productIconURLNew,
planIdNew,
paymentAmountNewInCents,
paymentAmountNewCurrency,
productPaymentCycleNew,
closeDate: event.created,
invoiceTotalOldInCents,
productMetadata: productNewMetadata,
planConfig,
};
if (!invoice) {
throw error.internalValidationError(
'extractSubscriptionUpdateEventDetailsForEmail',
event,
new Error(`Invoice expected for subscription: ${subscription.id}`)
);
}
if (!cancelAtPeriodEndOld && cancelAtPeriodEndNew && !planOld) {
return this.extractSubscriptionUpdateCancellationDetailsForEmail(
subscription,
baseDetails,
invoice,
upcomingInvoiceWithInvoiceItem
);
} else if (cancelAtPeriodEndOld && !cancelAtPeriodEndNew && !planOld) {
return this.extractSubscriptionUpdateReactivationDetailsForEmail(
subscription,
baseDetails
);
} else if (!cancelAtPeriodEndNew && planOld) {
return this.extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail(
subscription,
baseDetails,
invoice,
upcomingInvoiceWithInvoiceItem,
planOld
);
}
// unknown update scenario, but let's return some details anyway
return baseDetails;
}
/**
* Helper for extractSubscriptionDeletedEventDetailsForEmail to further
* extract details in redundant case
*/
async extractSubscriptionDeletedEventDetailsForEmail(
subscription: Stripe.Subscription
) {
if (typeof subscription.latest_invoice !== 'string') {
throw error.internalValidationError(
'handleSubscriptionDeletedEvent',
{
subscriptionId: subscription.id,
subscriptionInvoiceType: typeof subscription.latest_invoice,
},
'Subscription latest_invoice was not a string.'
);
}
const invoice = await this.expandResource<Stripe.Invoice>(
subscription.latest_invoice,
INVOICES_RESOURCE
);
return this.extractInvoiceDetailsForEmail(invoice);
}
/**
* Helper for extractSubscriptionUpdateEventDetailsForEmail to further
* extract details in cancellation case
*/
async extractSubscriptionUpdateCancellationDetailsForEmail(
subscription: Stripe.Subscription,
baseDetails: any,
invoice: Stripe.Invoice,
upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined
) {
const { current_period_end: serviceLastActiveDate } = subscription;
const {
uid,
email,
planId,
productId,
productNameNew: productName,
productIconURLNew: planEmailIconURL,
productMetadata,
planConfig,
} = baseDetails;
const {
total: invoiceTotalInCents,
currency: invoiceTotalCurrency,
created: invoiceDate,
} = upcomingInvoiceWithInvoiceItem || invoice;
return {
updateType: SUBSCRIPTION_UPDATE_TYPES.CANCELLATION,
email,
uid,
productId,
planId,
planEmailIconURL,
productName,
invoiceDate: new Date(invoiceDate * 1000),
invoiceTotalInCents,
invoiceTotalCurrency,
serviceLastActiveDate: new Date(serviceLastActiveDate * 1000),
showOutstandingBalance: !!upcomingInvoiceWithInvoiceItem,
productMetadata,
planConfig,
};
}
/**
* Helper for extractSubscriptionUpdateEventDetailsForEmail to further
* extract details in reactivation case
*
* @param {Subscription} subscription
* @param {*} baseDetails
* @param {Invoice} invoice
*/
async extractSubscriptionUpdateReactivationDetailsForEmail(
subscription: Stripe.Subscription,
baseDetails: any
) {
const {
uid,
email,
planId,
productId,
productNameNew: productName,
productIconURLNew: planEmailIconURL,
planConfig,
} = baseDetails;
const { lastFour, cardType } =
await this.extractCustomerDefaultPaymentDetailsByUid(uid);
const upcomingInvoice = await this.stripe.invoices.retrieveUpcoming({
subscription: subscription.id,
});
const {
total: invoiceTotalInCents,
currency: invoiceTotalCurrency,
created: nextInvoiceDate,
} = upcomingInvoice;
return {
updateType: SUBSCRIPTION_UPDATE_TYPES.REACTIVATION,
email,
uid,
productId,
planId,
planEmailIconURL,
productName,
invoiceTotalInCents,
invoiceTotalCurrency,
cardType,
lastFour,
nextInvoiceDate: nextInvoiceDate
? new Date(nextInvoiceDate * 1000)
: null,
planConfig,
};
}
async extractCustomerDefaultPaymentDetailsByUid(uid: string) {
const customer = await this.fetchCustomer(uid, [
'invoice_settings.default_payment_method',
]);
if (!customer) {
throw error.unknownCustomer(uid);
}
return this.extractCustomerDefaultPaymentDetails(customer);
}
async extractCustomerDefaultPaymentDetails(customer: Stripe.Customer) {
let lastFour: string | null = null;
let cardType: string | null = null;
let country: string | null = null;
let postalCode: string | null = null;
if (customer.invoice_settings.default_payment_method) {
// Post-SCA customer with a default PaymentMethod
// default_payment_method *should* be expanded, but just in case...
const paymentMethod = customer.invoice_settings
.default_payment_method as Stripe.PaymentMethod;
if (paymentMethod.card) {
({ last4: lastFour, brand: cardType, country } = paymentMethod.card);
}
if (paymentMethod.billing_details.address)
({ postal_code: postalCode } = paymentMethod.billing_details.address);
// PaymentMethods should all be cards, but email templates should
// already handle undefined lastFour and cardType gracefully
} else if (customer.default_source) {
// Legacy pre-SCA customer still using a Source rather than PaymentMethod
if (typeof customer.default_source !== 'string') {
// We don't expand this resource in cached customer, but it seemed to happen once
({
last4: lastFour,
brand: cardType,
country,
address_zip: postalCode,
} = customer.default_source as Stripe.Card);
} else {
// Sources are available as payment methods, so we can expand them.
const pm = await this.expandResource<Stripe.PaymentMethod>(
customer.default_source,
PAYMENT_METHOD_RESOURCE
);
if (!pm.card) throw new Error('Card must be present on payment method');
({ last4: lastFour, brand: cardType, country } = pm.card);
if (pm.billing_details.address)
({ postal_code: postalCode } = pm.billing_details.address);
}
}
return { lastFour, cardType, country, postalCode };
}
/**
* Helper for extractSubscriptionUpdateEventDetailsForEmail to further
* extract details in upgrade & downgrade cases.
*/
async extractSubscriptionUpdateUpgradeDowngradeDetailsForEmail(
subscription: Stripe.Subscription,
baseDetails: any,
invoice: Stripe.Invoice,
upcomingInvoiceWithInvoiceItem: Stripe.UpcomingInvoice | undefined,
planOld: Stripe.Plan
) {
const {
id: invoiceId,
number: invoiceNumber,
currency: paymentProratedCurrency,
amount_due: invoiceAmountDue,
} = invoice;
// https://github.com/mozilla/subhub/blob/e224feddcdcbafaf0f3cd7d52691d29d94157de5/src/hub/vendor/customer.py#L643
const abbrevProductOld = await this.expandAbbrevProductForPlan(planOld);
const { product_id: productIdOld, product_name: productNameOld } =
abbrevProductOld;
const abbrevPlanOld = await this.findAbbrevPlanById(planOld.id);
const { emailIconURL: productIconURLOld = '' } = this.mergeMetadata(
{
...planOld,
metadata: abbrevPlanOld.plan_metadata,
},
abbrevProductOld
);
const productPaymentCycleOld = this.stripePlanToPaymentCycle(planOld);
// get next invoice details
const nextInvoice = upcomingInvoiceWithInvoiceItem
? upcomingInvoiceWithInvoiceItem
: await this.previewInvoiceBySubscriptionId({
subscriptionId: subscription.id,
});
const { total: nextInvoiceTotal, currency: nextInvoiceCurrency } =
nextInvoice || {};
return {
...baseDetails,
updateType: SUBSCRIPTION_UPDATE_TYPES.UPGRADE,
productIdOld,
productNameOld,
productIconURLOld,
productPaymentCycleOld,
paymentAmountOldInCents: baseDetails.invoiceTotalOldInCents,
paymentAmountOldCurrency: planOld.currency,
paymentAmountNewInCents: nextInvoiceTotal,
paymentAmountNewCurrency: nextInvoiceCurrency,
invoiceNumber,
invoiceId,
paymentProratedInCents: invoiceAmountDue,
paymentProratedCurrency,
};
}
/**
* Process a customer event that needs to be saved to Firestore.
*/
async processCustomerEventToFirestore(event: Stripe.Event) {
const customer = await this.stripe.customers.retrieve(
(event.data.object as Stripe.Customer).id
);
const { uid } = await getUidAndEmailByStripeCustomerId(customer.id);
if (!uid) {
return;
}
// Ensure the customer and its subscriptions exist in Firestore.
// Note that we still insert the object here in case we've already
// fetched the customer previously.
return this.stripeFirestore.insertCustomerRecordWithBackfill(uid, customer);
}
/**
* Process a subscription event that needs to be saved to Firestore.
*/
async processSubscriptionEventToFirestore(event: Stripe.Event) {
const subscription = await this.stripe.subscriptions.retrieve(
(event.data.object as Stripe.Subscription).id
);
// Update the customer if our copy of the customer is missing the currency.
// This could occur in some edge cases where the subscription is created
// before a user paying with paypal has the paypal agreement ID set on the
// user.
const customer = await this.expandResource(
subscription.customer,
CUSTOMER_RESOURCE
);
if (!customer.deleted && !customer.currency) {
await this.stripeFirestore.fetchAndInsertCustomer(
subscription.customer as string
);
return subscription;
}
return this.stripeFirestore.insertSubscriptionRecordWithBackfill(
subscription
);
}
/**
* Process a invoice event that needs to be saved to Firestore.
*/
async processInvoiceEventToFirestore(event: Stripe.Event) {
const invoiceId = (event.data.object as Stripe.Invoice).id;
if (!invoiceId) throw new Error('Invoice ID must be specified');
const invoice = await this.stripe.invoices.retrieve(invoiceId);
try {
await this.stripeFirestore.insertInvoiceRecord(invoice);
} catch (err) {
if (err.name === FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND) {
await this.stripeFirestore.retrieveAndFetchSubscription(
invoice.subscription as string
);
return this.stripeFirestore.insertInvoiceRecord(invoice);
}
throw err;
}
return;
}
/**
* Process a payment method event that needs to be saved to Firestore.
*
* Note that this does not account for previous attributes as payment methods
* only change in their entirety.
*/
async processPaymentMethodEventToFirestore(event: Stripe.Event) {
// If this payment method is not attached, we can't store it in firestore as
// the customer may not exist.
if (!(event.data.object as Stripe.PaymentMethod).customer) {
return;
}
const paymentMethod = await this.stripe.paymentMethods.retrieve(
(event.data.object as Stripe.PaymentMethod).id
);
// If this payment method is not attached, we can't store it in firestore as
// the customer may not exist. It is possible that a payment_method.detached
// event has already been processed, detaching the payment method.
if (!paymentMethod.customer) {
return;
}
try {
await this.stripeFirestore.insertPaymentMethodRecordWithBackfill(
paymentMethod
);
} catch (err) {
if (
!(
err.name === FirestoreStripeError.STRIPE_CUSTOMER_DELETED &&
[
'payment_method.card_automatically_updated',
'payment_method.updated',
].includes(event.type)
)
) {
throw err;
}
}
}
/**
* Process a payment_method.detached event. Remove the payment method from Firestore.
*/
async processPaymentMethodDetachedEventToFirestore(event: Stripe.Event) {
const paymentMethodId = (event.data.object as Stripe.PaymentMethod).id;
await this.stripeFirestore.removePaymentMethodRecord(paymentMethodId);
}
/**
* Process a webhook event from Stripe and if needed, save it to Firestore.
*/
async processWebhookEventToFirestore(event: Stripe.Event) {
const { type } = event;
// Stripe does not include the card_automatically_updated event
// despite this being a valid event for Stripe webhook registration
type StripeEnabledEvent =
| Stripe.WebhookEndpointUpdateParams.EnabledEvent
| 'payment_method.card_automatically_updated';
// Note that we must insert before any event handled by the general
// webhook code to ensure the object is up to date in Firestore before
// our code handles the event.
let handled = true;
try {
switch (type as StripeEnabledEvent) {
case 'invoice.created':
case 'invoice.finalized':
case 'invoice.paid':
case 'invoice.payment_failed':
case 'invoice.updated':
case 'invoice.deleted':
await this.processInvoiceEventToFirestore(event);
break;
case 'customer.created':
case 'customer.updated':
case 'customer.deleted':
await this.processCustomerEventToFirestore(event);
break;
case 'customer.subscription.created':
case 'customer.subscription.updated':
case 'customer.subscription.deleted':
await this.processSubscriptionEventToFirestore(event);
break;
case 'payment_method.attached':
case 'payment_method.card_automatically_updated':
case 'payment_method.updated':
await this.processPaymentMethodEventToFirestore(event);
break;
case 'payment_method.detached':
await this.processPaymentMethodDetachedEventToFirestore(event);
break;
default: {
handled = false;
break;
}
}
} catch (err) {
if (
[
FirestoreStripeError.STRIPE_CUSTOMER_DELETED,
FirestoreStripeError.FIRESTORE_CUSTOMER_NOT_FOUND,
].includes(err.name)
) {
// We cannot back-fill Firestore with records for deleted customers
// as they're missing necessary metadata for us to know which user
// the customer belongs to.
return handled;
}
throw err;
}
return handled;
}
/**
* Accept a Stripe Plan, attempt to expand an AbbrevProduct from cache
* or Stripe fetch
*/
async expandAbbrevProductForPlan(plan: Stripe.Plan): Promise<AbbrevProduct> {
const checkDeletedProduct = (product: Stripe.Product) => {
if ((product.deleted as unknown as boolean) === true) {
throw error.unknownSubscriptionPlan(plan.id);
}
return this.abbrevProductFromStripeProduct(product);
};
// The "plan" argument might not have any product info on it.
const planWithProductId = await this.findAbbrevPlanById(plan.id);
// Next, look for product details in cache
const products = await this.allAbbrevProducts();
const productCached = products.find(
(p) => p.product_id === planWithProductId.product_id
);
if (productCached) {
return productCached;
}
// Finally, do a direct Stripe fetch if none of the above works.
return checkDeletedProduct(
await this.stripe.products.retrieve(planWithProductId.product_id)
);
}
/**
* Metadata consists of product metadata with per-plan overrides.
*
* @param {Plan} plan
* @param {AbbrevProduct} abbrevProduct
*/
mergeMetadata(
plan: Stripe.Plan,
abbrevProduct: AbbrevProduct
): Stripe.Metadata {
return {
...abbrevProduct.product_metadata,
...plan.metadata,
};
}
/**
* TODO This function exists to help the transition from product/plan
* metadata to Firestore doc based configs. Once the Firestore based configs
* have been proven stable in prod, we remove this level of indirection.
*/
async maybeGetPlanConfig(planId: string) {
return this.paymentConfigManager
? (await this.paymentConfigManager.getMergedPlanConfiguration(planId)) ||
{}
: {};
}
async removeFirestoreCustomer(uid: string) {
try {
return await this.stripeFirestore.removeCustomerRecursive(uid);
} catch (error) {
if (error instanceof StripeFirestoreMultiError) {
reportSentryError(error);
}
throw error;
}
}
}
/**
* Create a Stripe Helper with built-in caching.
*/
export function createStripeHelper(log: any, config: any, statsd: StatsD) {
const stripeHelper = new StripeHelper(log, config, statsd);
stripeHelper.checkStripeAPIKey();
return stripeHelper;
}