in packages/fxa-auth-server/lib/payments/stripe.ts [2940:3108]
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;
}