async extractSubscriptionUpdateEventDetailsForEmail()

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;
  }