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