async processPayPalNonZeroInvoice()

in libs/payments/customer/src/lib/invoice.manager.ts [218:313]


  async processPayPalNonZeroInvoice(
    customer: StripeCustomer,
    invoice: StripeInvoice,
    ipaddress?: string
  ) {
    if (!customer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement]) {
      throw new StripePayPalAgreementNotFoundError(customer.id);
    }
    if (invoice.status === 'paid') {
      if (!invoice.metadata?.[STRIPE_INVOICE_METADATA.PaypalTransactionId]) {
        throw new InvalidInvoiceError(
          `Invoice ${invoice.id} is marked paid without a transaction id`
        );
      }
      return invoice;
    } else if (!['draft', 'open'].includes(invoice.status ?? '')) {
      throw new InvalidInvoiceError(
        `Invoice is in ${invoice.status} state, expected draft or open`
      );
    }

    const countryCode =
      invoice.customer_shipping?.address?.country ??
      this.currencyManager.getDefaultCountryForCurrency(
        invoice.currency.toUpperCase()
      );
    if (!countryCode) {
      throw new Error(
        'No valid country code could be found for invoice or currency'
      );
    }

    // PayPal allows for idempotent retries on payment attempts to prevent double charging.
    const paymentAttemptCount = parseInt(
      invoice?.metadata?.[STRIPE_INVOICE_METADATA.RetryAttempts] ?? '0'
    );
    const idempotencyKey = `${invoice.id}-${paymentAttemptCount}`;

    // Charge the customer on PayPal
    const chargeOptions = {
      amountInCents: invoice.amount_due,
      billingAgreementId:
        customer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement],
      invoiceNumber: invoice.id,
      currencyCode: invoice.currency,
      countryCode,
      idempotencyKey,
      ...(ipaddress && { ipaddress }),
      ...(invoice.tax !== null && { taxAmountInCents: invoice.tax }),
    } satisfies ChargeOptions;
    let paypalCharge: ChargeResponse;
    try {
      // Charge the PayPal customer after the invoice is finalized to prevent charges with a failed invoice
      this.safeFinalizeWithoutAutoAdvance(invoice.id);
      paypalCharge = await this.paypalClient.chargeCustomer(chargeOptions);
    } catch (error) {
      if (PayPalClientError.hasPayPalNVPError(error)) {
        PayPalClientError.throwPaypalCodeError(error);
      }
      throw error;
    }

    // update Stripe payment charge attempt count
    const updatedPaymentAttemptCount = paymentAttemptCount + 1;
    let updatedInvoice = await this.stripeClient.invoicesUpdate(invoice.id, {
      metadata: {
        [STRIPE_INVOICE_METADATA.RetryAttempts]: updatedPaymentAttemptCount,
      },
    });

    // Process the transaction by PayPal charge status
    switch (paypalCharge.paymentStatus) {
      case 'Completed':
      case 'Processed':
        [updatedInvoice] = await Promise.all([
          this.stripeClient.invoicesUpdate(invoice.id, {
            metadata: {
              [STRIPE_INVOICE_METADATA.PaypalTransactionId]:
                paypalCharge.transactionId,
            },
          }),
          this.stripeClient.invoicesPay(invoice.id),
        ]);

        return await this.stripeClient.invoicesRetrieve(updatedInvoice.id);
      case 'Pending':
      case 'In-Progress':
        return updatedInvoice;
      case 'Denied':
      case 'Failed':
      case 'Voided':
      case 'Expired':
      default:
        throw new PayPalPaymentFailedError(paypalCharge.paymentStatus);
    }
  }