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