packages/fxa-auth-server/lib/routes/subscriptions/stripe.ts (1,216 lines of code) (raw):
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
import { ServerRoute } from '@hapi/hapi';
import isA from 'joi';
import * as Sentry from '@sentry/node';
import { SeverityLevel } from '@sentry/core';
import {
PaymentsCustomerError,
PromotionCodeManager,
} from '@fxa/payments/customer';
import { getAccountCustomerByUid } from 'fxa-shared/db/models/auth';
import {
AbbrevPlan,
SubscriptionChangeEligibility,
SubscriptionEligibilityResult,
SubscriptionUpdateEligibility,
} from 'fxa-shared/subscriptions/types';
import * as invoiceDTO from 'fxa-shared/dto/auth/payments/invoice';
import * as couponDTO from 'fxa-shared/dto/auth/payments/coupon';
import {
ACTIVE_SUBSCRIPTION_STATUSES,
DeepPartial,
filterCustomer,
filterIntent,
filterInvoice,
filterSubscription,
singlePlan,
} from 'fxa-shared/subscriptions/stripe';
import omitBy from 'lodash/omitBy';
import { Logger } from 'mozlog';
import { Stripe } from 'stripe';
import { ConfigType } from '../../../config';
import error from '../../error';
import {
COUNTRIES_LONG_NAME_TO_SHORT_NAME_MAP,
StripeHelper,
} from '../../payments/stripe';
import {
stripeInvoiceToFirstInvoicePreviewDTO,
stripeInvoicesToSubsequentInvoicePreviewsDTO,
} from '../../payments/stripe-formatter';
import { AuthLogger, AuthRequest } from '../../types';
import { sendFinishSetupEmailForStubAccount } from '../subscriptions/account';
import validators from '../validators';
import { buildTaxAddress, handleAuth } from './utils';
import { generateIdempotencyKey } from '../../payments/utils';
import { deleteAccountIfUnverified } from '../utils/account';
import SUBSCRIPTIONS_DOCS from '../../../docs/swagger/subscriptions-api';
import DESCRIPTIONS from '../../../docs/swagger/shared/descriptions';
import { CapabilityService } from '../../payments/capability';
import Container from 'typedi';
import { reportSentryMessage, reportSentryError } from '../../sentry';
// List of countries for which we need to look up the province/state of the
// customer.
const addressLookupCountries = Object.values(
COUNTRIES_LONG_NAME_TO_SHORT_NAME_MAP
);
const METRICS_CONTEXT_SCHEMA = require('../../metrics/context').schema;
/**
* Delete any metadata keys prefixed by `capabilities:` and promotion codes
* before sending response. We don't need to reveal those.
* https://github.com/mozilla/fxa/issues/3273#issuecomment-552637420
* https://github.com/mozilla/fxa/issues/12181
*/
export function sanitizePlans(plans: AbbrevPlan[]) {
return plans.map((planIn) => {
// Try not to mutate the original in case we cache plans in memory.
const plan = { ...planIn };
const isCapabilityKey = (value: string, key: string) =>
key.startsWith('capabilities');
const isPromotionCodes = (value: string, key: string) =>
key.toLowerCase() === 'promotioncodes';
const isOmittable = (value: string, key: string) =>
isCapabilityKey(value, key) || isPromotionCodes(value, key);
plan.plan_metadata = omitBy(plan.plan_metadata, isOmittable);
plan.product_metadata = omitBy(plan.product_metadata, isOmittable);
return plan;
});
}
export class StripeHandler {
subscriptionAccountReminders: any;
capabilityService: CapabilityService;
promotionCodeManager: PromotionCodeManager;
unsupportedLocations: string[];
constructor(
// FIXME: For some reason Logger methods were not being detected in
// inheriting classes thus this interface join.
protected log: AuthLogger & Logger,
protected db: any,
protected config: ConfigType,
protected customs: any,
protected push: any,
protected mailer: any,
protected profile: any,
protected stripeHelper: StripeHelper
) {
this.subscriptionAccountReminders =
require('../../subscription-account-reminders')(log, config);
this.capabilityService = Container.get(CapabilityService);
this.promotionCodeManager = Container.get(PromotionCodeManager);
this.unsupportedLocations =
(config.subscriptions.unsupportedLocations as string[]) || [];
}
/**
* Extracts a promotion code from the request, while verifying its
* validity for this priceId and returns a promotionCode if valid, or
* throws an invalidPromoCode error if not.
*/
protected async extractPromotionCode(
promotionCodeFromRequest: string,
priceId: string
) {
let promotionCode: Stripe.PromotionCode | undefined;
if (promotionCodeFromRequest) {
promotionCode = await this.stripeHelper.findValidPromoCode(
promotionCodeFromRequest,
priceId
);
if (!promotionCode) {
throw error.invalidPromoCode(promotionCode);
}
}
return promotionCode;
}
/**
* Reload the customer data to reflect a change.
*/
async customerChanged(request: AuthRequest, uid: string, email: string) {
const [devices] = await Promise.all([
request.app.devices,
this.profile.deleteCache(uid),
]);
await this.push.notifyProfileUpdated(uid, devices);
this.log.notifyAttachedServices('profileDataChange', request, {
uid,
});
}
async getClients(request: AuthRequest) {
this.log.begin('subscriptions.getClients', request);
return this.capabilityService.getClients();
}
async deleteSubscription(request: AuthRequest) {
this.log.begin('subscriptions.deleteSubscription', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'deleteSubscription');
const subscriptionId = request.params.subscriptionId;
await this.stripeHelper.cancelSubscriptionForCustomer(
uid,
email,
subscriptionId
);
await this.customerChanged(request, uid, email);
this.log.info('subscriptions.deleteSubscription.success', {
uid,
subscriptionId,
});
return { subscriptionId };
}
async reactivateSubscription(request: AuthRequest) {
this.log.begin('subscriptions.reactivateSubscription', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'reactivateSubscription');
const { subscriptionId } = request.payload as Record<string, string>;
await this.stripeHelper.reactivateSubscriptionForCustomer(
uid,
email,
subscriptionId
);
await this.customerChanged(request, uid, email);
this.log.info('subscriptions.reactivateSubscription.success', {
uid,
subscriptionId,
});
return {};
}
async updateSubscription(request: AuthRequest) {
this.log.begin('subscriptions.updateSubscription', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'updateSubscription');
const { subscriptionId } = request.params;
const { planId } = request.payload as Record<string, string>;
const subscription = await this.stripeHelper.subscriptionForCustomer(
uid,
email,
subscriptionId
);
if (!subscription) {
throw error.unknownSubscription();
}
const currentPlan = singlePlan(subscription);
if (!currentPlan) {
throw error.internalValidationError(
'updateSubscription',
{ subscription: subscription.id },
'Subscriptions with multiple plans not supported.'
);
}
const result: SubscriptionChangeEligibility =
await this.capabilityService.getPlanEligibility(uid, planId);
const eligibleForUpgrade =
result.subscriptionEligibilityResult ===
SubscriptionEligibilityResult.UPGRADE;
const isUpgradeForCurrentPlan =
result.eligibleSourcePlan?.plan_id === currentPlan.id;
if (!eligibleForUpgrade || !isUpgradeForCurrentPlan) {
throw error.invalidPlanUpdate();
}
// Verify the new plan currency and customer currency are compatible.
// Stripe does not allow customers to change currency after a currency is set, which
// occurs on initial subscription. (https://stripe.com/docs/billing/customer#payment)
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
const { currency: planCurrency } =
await this.stripeHelper.findAbbrevPlanById(planId);
if (customer && customer.currency !== planCurrency) {
throw error.currencyCurrencyMismatch(customer.currency, planCurrency);
}
// Update the plan
await this.stripeHelper.changeSubscriptionPlan(subscription, planId);
try {
for (const redundantOverlap of result.redundantOverlaps || []) {
if (!customer) continue;
const redundantSubscription = (customer.subscriptions?.data || []).find(
(s) =>
s.items.data.at(0)?.plan.id ===
redundantOverlap.eligibleSourcePlan?.plan_id
);
if (!redundantSubscription) {
this.log.error(
'subscriptions.updateSubscription no redundant overlapping subscription found',
{
uid,
redundantOverlap,
subscriptionId,
}
);
reportSentryMessage(
`Redundant overlap subscription not found for customer`,
{
stripeCustomerId: customer.id,
planId: redundantOverlap.eligibleSourcePlan?.plan_id,
sp2: true,
}
);
continue;
}
await this.stripeHelper.updateSubscriptionAndBackfill(
redundantSubscription,
{
metadata: {
redundantCancellation: 'true',
autoCancelledRedundantFor: subscription.id,
cancelled_for_customer_at: Math.floor(Date.now() / 1000),
},
}
);
await this.stripeHelper.stripe.subscriptions.cancel(
redundantSubscription.id,
{
prorate: true,
}
);
}
} catch (err) {
this.log.error('subscriptions.updateSubscription', {
err,
uid,
});
reportSentryError(err, {
uid,
subscriptionId,
planId,
});
}
await this.customerChanged(request, uid, email);
return { subscriptionId };
}
async listPlans(request: AuthRequest) {
this.log.begin('subscriptions.listPlans', request);
const plans = await this.stripeHelper.allAbbrevPlans(
request?.headers?.['accept-language']
);
return sanitizePlans(plans);
}
async getProductName(request: AuthRequest) {
this.log.begin('subscriptions.getProductName', request);
const { productId } = request.query as Record<string, string>;
const plans = await this.stripeHelper.allAbbrevPlans();
const planForProduct = plans.find((plan) => plan.product_id === productId);
if (!planForProduct) {
throw error.unknownSubscriptionPlan();
}
this.log.info('subscriptions.getProductName', {
productId,
});
return { product_name: planForProduct.product_name };
}
async listActive(request: AuthRequest) {
this.log.begin('subscriptions.listActive', request);
const { uid } = await handleAuth(this.db, request.auth, true);
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
const activeSubscriptions = new Array<{
uid: string;
productId: string | Stripe.Product | Stripe.DeletedProduct | null;
subscriptionId: string;
createdAt: number;
cancelledAt: number | null;
}>();
if (customer && customer.subscriptions) {
for (const subscription of customer.subscriptions.data) {
const plan = singlePlan(subscription);
if (!plan) {
throw error.internalValidationError(
'listActive',
{ subscription: subscription.id },
'Subscriptions with multiple plans not supported.'
);
}
const productId = plan.product;
const { id: subscriptionId, created, canceled_at } = subscription;
if (ACTIVE_SUBSCRIPTION_STATUSES.includes(subscription.status)) {
activeSubscriptions.push({
uid,
subscriptionId,
productId,
createdAt: created * 1000,
cancelledAt: canceled_at ? canceled_at * 1000 : null,
});
}
}
}
return activeSubscriptions;
}
/**
* Create a customer.
*/
async createCustomer(
request: AuthRequest
): Promise<DeepPartial<Stripe.Customer>> {
this.log.begin('subscriptions.createCustomer', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'createCustomer');
let customer = await this.stripeHelper.fetchCustomer(uid);
if (customer) {
return filterCustomer(customer);
}
const { displayName } = request.payload as Record<string, string>;
const taxAddress = buildTaxAddress(
this.log,
request.app.clientAddress,
request.app.geo.location
);
const idempotencyKey = generateIdempotencyKey([uid]);
customer = await this.stripeHelper.createPlainCustomer({
uid,
email,
displayName,
idempotencyKey,
taxAddress,
});
return filterCustomer(customer);
}
/**
* Retry an invoice by attaching a new payment method id for use.
*/
async retryInvoice(request: AuthRequest) {
this.log.begin('subscriptions.retryInvoice', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'retryInvoice');
const { stripeCustomerId } = (await getAccountCustomerByUid(uid)) || {};
if (!stripeCustomerId) {
throw error.unknownCustomer(uid);
}
const { invoiceId, paymentMethodId, idempotencyKey } =
request.payload as Record<string, string>;
const retryIdempotencyKey = `${idempotencyKey}-retryInvoice`;
const invoice = await this.stripeHelper.retryInvoiceWithPaymentId(
stripeCustomerId,
invoiceId,
paymentMethodId,
retryIdempotencyKey
);
await this.customerChanged(request, uid, email);
return filterInvoice(invoice);
}
/**
* Preview an invoice for a new plan.
*/
async previewInvoice(
request: AuthRequest
): Promise<invoiceDTO.firstInvoicePreviewSchema> {
this.log.begin('subscriptions.previewInvoice', request);
const { promotionCode, priceId } = request.payload as Record<
string,
string
>;
let customer: Stripe.Customer | void = undefined;
if (request.auth.credentials) {
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'previewInvoice');
try {
customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
'tax',
]);
} catch (e: any) {
this.log.error('previewInvoice.fetchCustomer', { error: e, uid });
}
} else {
await this.customs.checkIpOnly(request, 'previewInvoice');
}
const taxAddress = buildTaxAddress(
this.log,
request.app.clientAddress,
request.app.geo.location
);
const countryCode = taxAddress?.countryCode;
if (countryCode && this.unsupportedLocations.includes(countryCode)) {
throw error.unsupportedLocation(countryCode);
}
try {
let isUpgrade = false,
sourcePlan;
if (customer) {
const result = await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
);
isUpgrade =
result.subscriptionEligibilityResult ===
SubscriptionUpdateEligibility.UPGRADE;
sourcePlan = result.eligibleSourcePlan;
}
const previewInvoice = await this.stripeHelper.previewInvoice({
customer: customer || undefined,
promotionCode,
priceId,
taxAddress,
isUpgrade,
sourcePlan,
});
return stripeInvoiceToFirstInvoicePreviewDTO(previewInvoice);
} catch (err: any) {
//TODO - this is part of FXA-7664, we can remove this one we uncover the underlying error
Sentry.withScope((scope) => {
scope.setContext('previewInvoice', {
error: err,
msg: err.message,
});
reportSentryMessage(`Invoice Preview Error.`, 'error' as SeverityLevel);
});
this.log.error('subscriptions.previewInvoice', err);
if (err.type === 'StripeInvalidRequestError') {
throw error.invalidInvoicePreviewRequest(
err,
err.message,
priceId,
customer?.id
);
} else {
throw err;
}
}
}
/**
* Preview invoices for an array of subscriptionIds
*/
async subsequentInvoicePreviews(
request: AuthRequest
): Promise<invoiceDTO.subsequentInvoicePreviewsSchema> {
this.log.begin('subscriptions.subsequentInvoicePreview', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'subsequentInvoicePreviews');
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
if (!customer || !customer.subscriptions?.data.length) {
return [];
}
const subsequentInvoicePreviews = await Promise.all(
customer.subscriptions.data.map((sub) => {
return this.stripeHelper.previewInvoiceBySubscriptionId({
subscriptionId: sub.id,
includeCanceled: !!sub.canceled_at,
});
})
);
return stripeInvoicesToSubsequentInvoicePreviewsDTO(
subsequentInvoicePreviews
);
}
async retrieveCouponDetails(
request: AuthRequest
): Promise<couponDTO.couponDetailsSchema> {
this.log.begin('subscriptions.retrieveCouponDetails', request);
const { promotionCode, priceId } = request.payload as Record<
string,
string
>;
if (request.auth.credentials) {
const { email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'retrieveCouponDetails');
} else {
await this.customs.checkIpOnly(request, 'retrieveCouponDetails');
}
const taxAddress = buildTaxAddress(
this.log,
request.app.clientAddress,
request.app.geo.location
);
const couponDetails = this.stripeHelper.retrieveCouponDetails({
priceId,
promotionCode,
taxAddress,
});
return couponDetails;
}
/**
* Updates customer subscription with promotion code
*/
async applyPromotionCodeToSubscription(request: AuthRequest) {
this.log.begin('subscriptions.applyPromotionCodeToSubscription', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(
request,
email,
'applyPromotionCodeToSubscription'
);
const customer = await this.stripeHelper.fetchCustomer(uid);
if (!customer) {
throw error.unknownCustomer(uid);
}
const { promotionId, subscriptionId } = request.payload as Record<
string,
string
>;
try {
const updatedSubscription =
await this.promotionCodeManager.applyPromoCodeToSubscription(
customer.id,
subscriptionId,
promotionId
);
return updatedSubscription;
} catch (err) {
this.log.error('subscriptions.applyPromotionCodeToSubscription', {
err,
uid,
});
if (err instanceof PaymentsCustomerError) {
throw error.subscriptionPromotionCodeNotApplied(err, err.message);
} else {
throw err;
}
}
}
/**
* Create a subscription for a user.
*/
async createSubscriptionWithPMI(request: AuthRequest): Promise<{
sourceCountry: string | null;
subscription: DeepPartial<Stripe.Subscription>;
}> {
this.log.begin('subscriptions.createSubscriptionWithPMI', request);
const { uid, email, account } = await handleAuth(
this.db,
request.auth,
true
);
await this.customs.check(request, email, 'createSubscriptionWithPMI');
const taxAddress = buildTaxAddress(
this.log,
request.app.clientAddress,
request.app.geo.location
);
const countryCode = taxAddress?.countryCode;
if (countryCode && this.unsupportedLocations.includes(countryCode)) {
throw error.unsupportedLocation(countryCode);
}
try {
const customer = await this.stripeHelper.fetchCustomer(uid, ['tax']);
if (!customer) {
throw error.unknownCustomer(uid);
}
const {
priceId,
paymentMethodId,
promotionCode: promotionCodeFromRequest,
metricsContext,
} = request.payload as Record<string, string>;
// Make sure to clean up any subscriptions that may be hanging with no payment
const existingSubscription =
this.stripeHelper.findCustomerSubscriptionByPlanId(customer, priceId);
if (existingSubscription?.status === 'incomplete') {
await this.stripeHelper.cancelSubscription(existingSubscription.id);
}
// Validate that the user doesn't have conflicting subscriptions, for instance via IAP
const { subscriptionEligibilityResult } =
await this.capabilityService.getPlanEligibility(
customer.metadata.userid,
priceId
);
if (
subscriptionEligibilityResult !== SubscriptionEligibilityResult.CREATE
) {
throw error.userAlreadySubscribedToProduct();
}
const promotionCode: Stripe.PromotionCode | undefined =
await this.extractPromotionCode(promotionCodeFromRequest, priceId);
let paymentMethod: Stripe.PaymentMethod | undefined;
const planCurrency = (await this.stripeHelper.findAbbrevPlanById(priceId))
.currency;
const automaticTax =
this.stripeHelper.isCustomerTaxableWithSubscriptionCurrency(
customer,
planCurrency
);
// Skip the payment source check if there's no payment method id.
if (paymentMethodId) {
paymentMethod =
await this.stripeHelper.getPaymentMethod(paymentMethodId);
const paymentMethodCountry = paymentMethod.card?.country;
if (
!this.stripeHelper.currencyHelper.isCurrencyCompatibleWithCountry(
planCurrency,
paymentMethodCountry
)
) {
throw error.currencyCountryMismatch(
planCurrency,
paymentMethodCountry
);
}
if (!this.stripeHelper.customerTaxId(customer)) {
await this.stripeHelper.addTaxIdToCustomer(customer, planCurrency);
}
}
const subscription: any =
await this.stripeHelper.createSubscriptionWithPMI({
customerId: customer.id,
priceId,
paymentMethodId,
promotionCode: promotionCode,
automaticTax,
});
if (!automaticTax) {
this.log.warn(
'subscriptions.createSubscriptionWithPMI.automatic_tax_failed',
{
uid,
automatic_tax: customer.tax?.automatic_tax,
}
);
}
const sourceCountry =
this.stripeHelper.extractSourceCountryFromSubscription(subscription);
await this.customerChanged(request, uid, email);
this.log.info('subscriptions.createSubscriptionWithPMI.success', {
uid,
subscriptionId: subscription.id,
});
await sendFinishSetupEmailForStubAccount({
uid,
account,
subscription,
stripeHelper: this.stripeHelper,
mailer: this.mailer,
subscriptionAccountReminders: this.subscriptionAccountReminders,
metricsContext,
});
return {
sourceCountry,
subscription: filterSubscription(subscription),
};
} catch (err) {
try {
if (account.verifierSetAt <= 0) {
await deleteAccountIfUnverified(
this.db,
this.stripeHelper,
this.log,
request,
email
);
}
} catch (deleteAccountError) {
if (
deleteAccountError.errno !== error.ERRNO.ACCOUNT_EXISTS &&
deleteAccountError.errno !==
error.ERRNO.VERIFIED_SECONDARY_EMAIL_EXISTS
) {
throw deleteAccountError;
}
}
throw err;
}
}
/**
* Create a new SetupIntent that will be attached to the current customer
* after it succeeds.
*/
async createSetupIntent(request: AuthRequest) {
this.log.begin('subscriptions.createSetupIntent', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'createSetupIntent');
const { stripeCustomerId } = (await getAccountCustomerByUid(uid)) || {};
if (!stripeCustomerId) {
throw error.unknownCustomer(uid);
}
const setupIntent =
await this.stripeHelper.createSetupIntent(stripeCustomerId);
return filterIntent(setupIntent);
}
/**
* Updates what payment method is used by default on new invoices for the
* customer.
*/
async updateDefaultPaymentMethod(request: AuthRequest) {
this.log.begin('subscriptions.updateDefaultPaymentMethod', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'updateDefaultPaymentMethod');
let customer = await this.stripeHelper.fetchCustomer(uid);
if (!customer) {
throw error.unknownCustomer(uid);
}
const { paymentMethodId } = request.payload as Record<string, string>;
const paymentMethod =
await this.stripeHelper.getPaymentMethod(paymentMethodId);
const paymentMethodCountry = paymentMethod.card?.country;
if (
!this.stripeHelper.currencyHelper.isCurrencyCompatibleWithCountry(
customer.currency,
paymentMethodCountry
)
) {
throw error.currencyCountryMismatch(
customer.currency,
paymentMethodCountry
);
}
customer = await this.stripeHelper.updateDefaultPaymentMethod(
customer.id,
paymentMethodId
);
if (
paymentMethodCountry &&
addressLookupCountries.includes(paymentMethodCountry)
) {
if (paymentMethod?.billing_details?.address?.postal_code) {
this.stripeHelper.setCustomerLocation({
customerId: customer.id,
postalCode: paymentMethod.billing_details.address.postal_code,
country: paymentMethodCountry,
});
} else {
Sentry.withScope((scope) => {
scope.setContext('updateDefaultPaymentMethod', {
customerId: customer?.id,
paymentMethodId: paymentMethod?.id,
});
reportSentryMessage(
`Cannot find a postal code or country for customer.`,
'error' as SeverityLevel
);
});
}
}
await this.stripeHelper.removeSources(customer.id);
return filterCustomer(customer);
}
/**
* Detach a payment method from a customer _without_ any subscriptions.
* This prevents a potentially problematic card for being used for the
* customer's _first_ subscription.
*/
async detachFailedPaymentMethod(request: AuthRequest) {
this.log.begin('subscriptions.detachFailedPaymentMethod', request);
const { uid, email } = await handleAuth(this.db, request.auth, true);
await this.customs.check(request, email, 'detachFailedPaymentMethod');
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
if (!customer) {
throw error.unknownCustomer(uid);
}
const { paymentMethodId } = request.payload as Record<string, string>;
// We are handling a very specific scenario here, one in which the customer
// has not been ever able to successfully subscribe with the attempted
// payment method.
if (
customer.subscriptions?.data.length &&
customer.subscriptions.data.every((s) => s.status === 'incomplete')
) {
return await this.stripeHelper.detachPaymentMethod(paymentMethodId);
}
// Do nothing. There's no course correction action to take.
return { id: paymentMethodId };
}
/**
* Get a list of subscriptions for support agents
*/
async getSubscriptionsForSupport(request: AuthRequest) {
this.log.begin('subscriptions.getSubscriptionsForSupport', request);
const { uid } = request.query as Record<string, string>;
// We know that a user has to be a customer to create a support ticket
const customer = await this.stripeHelper.fetchCustomer(uid, [
'subscriptions',
]);
if (!customer || !customer.subscriptions) {
throw error.internalValidationError(
'getSubscriptionsForSupport',
{
customerId: customer?.id,
uid,
},
'No customer object or no subscriptions object present for customer.'
);
}
const response = await this.stripeHelper.formatSubscriptionsForSupport(
customer.subscriptions
);
return response;
}
}
export const stripeRoutes = (
log: AuthLogger,
db: any,
config: ConfigType,
customs: any,
push: any,
mailer: any,
profile: any,
stripeHelper: StripeHelper
): ServerRoute[] => {
const stripeHandler = new StripeHandler(
log,
db,
config,
customs,
push,
mailer,
profile,
stripeHelper
);
// FIXME: All of these need to be wrapped in Stripe error handling
// FIXME: Many of these stripe calls need retries with careful thought about
// overall request deadline. Stripe retries must include a idempotency_key.
return [
{
method: 'GET',
path: '/oauth/subscriptions/clients',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_CLIENTS_GET,
auth: {
payload: false,
strategy: 'subscriptionsSecret',
},
response: {
schema: isA.array().items(
isA.object().keys({
clientId: isA.string().description(DESCRIPTIONS.clientId),
capabilities: isA
.array()
.items(isA.string())
.description(DESCRIPTIONS.capabilities),
})
) as any,
},
},
handler: (request: AuthRequest) => stripeHandler.getClients(request),
},
{
method: 'GET',
path: '/oauth/subscriptions/plans',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_PLANS_GET,
response: {
schema: isA
.array()
.items(
validators.subscriptionsPlanWithProductConfigValidator,
validators.subscriptionsPlanWithMetaDataValidator
) as any,
},
},
handler: (request: AuthRequest) => stripeHandler.listPlans(request),
},
{
method: 'GET',
path: '/oauth/subscriptions/active',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_ACTIVE_GET,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: isA
.array()
.items(validators.activeSubscriptionValidator) as any,
},
},
handler: (request: AuthRequest) => stripeHandler.listActive(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/customer',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_CUSTOMER_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: validators.subscriptionsStripeCustomerValidator as any,
},
validate: {
payload: {
displayName: isA.string().optional(),
},
},
},
handler: (request: AuthRequest) => stripeHandler.createCustomer(request),
},
{
method: 'POST',
// Avoid conflict with existing, this can be updated to remove `/new` at the
// same time the old routes are removed when the client is updated.
path: '/oauth/subscriptions/active/new',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_ACTIVE_NEW_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: isA.object().keys({
subscription: validators.subscriptionsStripeSubscriptionValidator,
sourceCountry: validators.subscriptionPaymentCountryCode.required(),
}) as any,
},
validate: {
payload: {
priceId: isA.string().required().description(DESCRIPTIONS.priceId),
paymentMethodId: validators.stripePaymentMethodId
.optional()
.description(DESCRIPTIONS.paymentMethodId),
promotionCode: isA
.string()
.optional()
.description(DESCRIPTIONS.promotionCode),
metricsContext: METRICS_CONTEXT_SCHEMA,
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.createSubscriptionWithPMI(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/invoice/retry',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_INVOICE_RETRY_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: validators.subscriptionsStripeInvoiceValidator as any,
},
validate: {
payload: {
invoiceId: isA
.string()
.required()
.description(DESCRIPTIONS.invoiceId),
paymentMethodId: validators.stripePaymentMethodId
.required()
.description(DESCRIPTIONS.paymentMethodId),
idempotencyKey: isA
.string()
.required()
.description(DESCRIPTIONS.idempotencyKey),
},
},
},
handler: (request: AuthRequest) => stripeHandler.retryInvoice(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/invoice/preview',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_INVOICE_PREVIEW_POST,
auth: {
payload: false,
strategy: 'oauthToken',
mode: 'try',
},
response: {
schema: invoiceDTO.firstInvoicePreviewSchema as any,
},
validate: {
payload: {
priceId: validators.subscriptionsPlanId
.required()
.description(DESCRIPTIONS.priceId),
promotionCode: isA
.string()
.optional()
.description(DESCRIPTIONS.promotionCode),
},
},
},
handler: (request: AuthRequest) => stripeHandler.previewInvoice(request),
},
{
method: 'GET',
path: '/oauth/subscriptions/invoice/preview-subsequent',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_INVOICE_PREVIEW_SUBSEQUENT_GET,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: invoiceDTO.subsequentInvoicePreviewsSchema as any,
},
},
handler: (request: AuthRequest) =>
stripeHandler.subsequentInvoicePreviews(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/coupon',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_COUPON_POST,
auth: {
payload: false,
strategy: 'oauthToken',
mode: 'try',
},
response: {
schema: couponDTO.couponDetailsSchema as any,
},
validate: {
payload: {
priceId: validators.subscriptionsPlanId
.required()
.description(DESCRIPTIONS.priceId),
promotionCode: isA
.string()
.required()
.description(DESCRIPTIONS.promotionCode),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.retrieveCouponDetails(request),
},
{
method: 'PUT',
path: '/oauth/subscriptions/coupon/apply',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_COUPON_APPLY_PUT,
auth: {
payload: false,
strategy: 'oauthToken',
},
validate: {
payload: {
promotionId: isA
.string()
.required()
.description(DESCRIPTIONS.promotionId),
subscriptionId: validators.subscriptionsSubscriptionId
.required()
.description(DESCRIPTIONS.subscriptionId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.applyPromotionCodeToSubscription(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/setupintent/create',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_SETUPINTENT_CREATE_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: validators.subscriptionsStripeIntentValidator as any,
},
},
handler: (request: AuthRequest) =>
stripeHandler.createSetupIntent(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/paymentmethod/default',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_PAYMENTMETHOD_DEFAULT_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: validators.subscriptionsStripeCustomerValidator as any,
},
validate: {
payload: {
paymentMethodId: validators.stripePaymentMethodId
.required()
.description(DESCRIPTIONS.paymentMethodId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.updateDefaultPaymentMethod(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/paymentmethod/failed/detach',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_PAYMENTMETHOD_FAILED_DETACH_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: isA
.object({
id: validators.stripePaymentMethodId
.required()
.description(DESCRIPTIONS.paymentMethodId),
})
.unknown(true) as any,
},
validate: {
payload: {
paymentMethodId: validators.stripePaymentMethodId
.required()
.description(DESCRIPTIONS.paymentMethodId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.detachFailedPaymentMethod(request),
},
{
method: 'GET',
path: '/oauth/subscriptions/productname',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_PRODUCTNAME_GET,
response: {
schema: isA.object({
product_name: isA
.string()
.required()
.description(DESCRIPTIONS.productName),
}) as any,
},
validate: {
query: {
productId: isA
.string()
.required()
.description(DESCRIPTIONS.productId),
},
},
},
handler: (request: AuthRequest) => stripeHandler.getProductName(request),
},
{
method: 'PUT',
path: '/oauth/subscriptions/active/{subscriptionId}',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_ACTIVE_SUBSCRIPTIONID_PUT,
auth: {
payload: false,
strategy: 'oauthToken',
},
response: {
schema: isA.object().keys({
subscriptionId: isA.string(),
}) as any,
},
validate: {
params: {
subscriptionId: validators.subscriptionsSubscriptionId
.required()
.description(DESCRIPTIONS.subscriptionId),
},
payload: {
planId: validators.subscriptionsPlanId
.required()
.description(DESCRIPTIONS.planId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.updateSubscription(request),
},
{
method: 'DELETE',
path: '/oauth/subscriptions/active/{subscriptionId}',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_ACTIVE_SUBSCRIPTIONID_DELETE,
auth: {
payload: false,
strategy: 'oauthToken',
},
validate: {
params: {
subscriptionId: validators.subscriptionsSubscriptionId
.required()
.description(DESCRIPTIONS.subscriptionId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.deleteSubscription(request),
},
{
method: 'POST',
path: '/oauth/subscriptions/reactivate',
options: {
...SUBSCRIPTIONS_DOCS.OAUTH_SUBSCRIPTIONS_REACTIVATE_POST,
auth: {
payload: false,
strategy: 'oauthToken',
},
validate: {
payload: {
subscriptionId: validators.subscriptionsSubscriptionId
.required()
.description(DESCRIPTIONS.subscriptionId),
},
},
},
handler: (request: AuthRequest) =>
stripeHandler.reactivateSubscription(request),
},
];
};