packages/fxa-auth-server/lib/payments/paypal/helper.ts (528 lines of code) (raw):

/* This Source Code Form is subject to the terms of the Mozilla Public * License, v. 2.0. If a copy of the MPL was not distributed with this * file, You can obtain one at http://mozilla.org/MPL/2.0/. */ import { hasPaypalSubscription } from 'fxa-shared/subscriptions/stripe'; import { StatsD } from 'hot-shots'; import { Logger } from 'mozlog'; import Stripe from 'stripe'; import { Container } from 'typedi'; import { BAUpdateOptions, CreateBillingAgreementOptions, DoReferenceTransactionOptions, IpnMessage, nvpToObject, PayPalClient, PayPalClientError, RefundTransactionOptions, RefundType, SetExpressCheckoutOptions, TransactionSearchOptions, TransactionStatus, } from '@fxa/payments/paypal'; import error from '../../error'; import { CurrencyHelper } from '../currencies'; import { StripeHelper } from '../stripe'; import { RefusedError } from './error'; import { PAYPAL_APP_ERRORS, PAYPAL_BILLING_AGREEMENT_INVALID, PAYPAL_RETRY_ERRORS, PAYPAL_SOURCE_ERRORS, } from './error-codes'; type PaypalHelperOptions = { log: Logger; }; type AgreementDetails = { city: string; countryCode: string; firstName: string; lastName: string; state: string; status: 'active' | 'cancelled'; street: string; street2: string; zip: string; }; export type ChargeCustomerOptions = { amountInCents: number; billingAgreementId: string; currencyCode: string; idempotencyKey: string; invoiceNumber: string; countryCode?: string; ipaddress?: string; taxAmountInCents?: number; }; export type ChargeResponse = { avsCode: string; cvv2Match: string; transactionId: string; parentTransactionId: string | undefined; transactionType: 'cart' | 'express-checkout'; paymentType: string; orderTime: string; amount: string; paymentStatus: | 'None' | 'Canceled-Reversal' | 'Completed' | 'Denied' | 'Expired' | 'Failed' | 'In-Progress' | 'Partially-Refunded' | 'Pending' | 'Refunded' | 'Reversed' | 'Processed' | 'Voided'; pendingReason: | 'none' | 'address' | 'authorization' | 'echeck' | 'intl' | 'multi-currency' | 'order' | 'paymentreview' | 'regulatoryreview' | 'unilateral' | 'verify' | 'other'; reasonCode: | 'none' | 'chargeback' | 'guarantee' | 'buyer-complaint' | 'refund' | 'other'; }; export type TransactionSearchResult = { amount: string; currencyCode: string; email: string; feeAmount: string; name: string; netAmount: string; status: TransactionStatus; timestamp: string; transactionId: string; type: string; }; /** * Translates paypal client errors into fxa-auth-server errors. * * Re-throws the provided error as an auth-server error. * * @param err */ function throwPaypalCodeError(err: PayPalClientError) { const primaryError = err.getPrimaryError(); const code = primaryError.errorCode; if (!code) { throw error.backendServiceFailure( 'paypal', 'transaction', { errData: err.data, message: 'Error with no errorCode is not expected', }, err ); } if ( PAYPAL_SOURCE_ERRORS.includes(code) || code === PAYPAL_BILLING_AGREEMENT_INVALID ) { const rethrowErr = error.paymentFailed(); rethrowErr.jse_cause = err; throw rethrowErr; } if (PAYPAL_APP_ERRORS.includes(code)) { throw error.backendServiceFailure( 'paypal', 'transaction', { errData: err.data, code, }, err ); } if (PAYPAL_RETRY_ERRORS.includes(code)) { throw error.serviceUnavailable(); } throw error.internalValidationError( 'paypalCodeHandler', { code, errData: err.data, }, err ); } export class RefundError extends Error { constructor(message: string) { super(message); this.name = 'RefundError'; } } const MAX_REFUND_DAYS = 180; export class PayPalHelper { private log: Logger; private client: PayPalClient; private metrics: StatsD; private stripeHelper: StripeHelper; public currencyHelper: CurrencyHelper; constructor(options: PaypalHelperOptions) { this.log = options.log; this.client = Container.get(PayPalClient); this.metrics = Container.get(StatsD); this.stripeHelper = Container.get(StripeHelper); this.currencyHelper = Container.get(CurrencyHelper); if (this.metrics) { this.client.on('response', (response) => { this.metrics.timing('paypal_request', response.elapsed, undefined, { method: response.method, error: response.error ? 'true' : 'false', }); }); } } /** * Generate a PayPal idempotency key, used as the MSGSUBID on PayPal NVP * API calls. * * @param invoiceId * @param paymentAttempt */ public generateIdempotencyKey( invoiceId: string, paymentAttempt: number ): string { return `${invoiceId}-${paymentAttempt}`; } /** * Parse the invoice Id and payment attempt out of the idempotency key * * Returns the paymentAttempt with 1 added to reflect the actual payment * attempts made as the original attempt number is incremented *after* the * attempt is made successfully. * * @param idempotencyKey */ public parseIdempotencyKey(idempotencyKey: string): { invoiceId: string; paymentAttempt: number; } { const parsedValue = idempotencyKey.split('-'); return { invoiceId: parsedValue[0], paymentAttempt: parseInt(parsedValue[1]) + 1, }; } /** * Get a token authorizing transaction to move to the next stage. * * If the call to PayPal fails, a PayPalClientError will be thrown. * */ public async getCheckoutToken( options: SetExpressCheckoutOptions ): Promise<string> { const response = await this.client.setExpressCheckout(options); return response.TOKEN; } /** * Create billing agreement using the ExpressCheckout token. * * If the call to PayPal fails, a PayPalClientError will be thrown. * */ public async createBillingAgreement( options: CreateBillingAgreementOptions ): Promise<string> { const response = await this.client.createBillingAgreement(options); return response.BILLINGAGREEMENTID; } /** * Charge customer based on an existing Billing Agreement. * * If the call to PayPal fails, a PayPalClientError will be thrown. * If the call is successful, all PayPal response data is returned. * */ public async chargeCustomer( options: ChargeCustomerOptions ): Promise<ChargeResponse> { const doReferenceTransactionOptions: DoReferenceTransactionOptions = { amount: this.currencyHelper.getPayPalAmountStringFromAmountInCents( options.amountInCents ), billingAgreementId: options.billingAgreementId, currencyCode: options.currencyCode, idempotencyKey: options.idempotencyKey, invoiceNumber: options.invoiceNumber, ...(options.countryCode && { countryCode: options.countryCode }), ...(options.ipaddress && { ipaddress: options.ipaddress }), ...(options.taxAmountInCents && { taxAmount: this.currencyHelper.getPayPalAmountStringFromAmountInCents( options.taxAmountInCents ), }), }; const response = await this.client.doReferenceTransaction( doReferenceTransactionOptions ); return { amount: response.AMT, avsCode: response.AVSCODE, cvv2Match: response.CVV2MATCH, orderTime: response.ORDERTIME, parentTransactionId: response.PARENTTRANSACTIONID, paymentStatus: response.PAYMENTSTATUS as ChargeResponse['paymentStatus'], paymentType: response.PAYMENTTYPE, pendingReason: response.PENDINGREASON as ChargeResponse['pendingReason'], reasonCode: response.REASONCODE as ChargeResponse['reasonCode'], transactionId: response.TRANSACTIONID, transactionType: response.TRANSACTIONTYPE as ChargeResponse['transactionType'], }; } /** * Get Billing Agreement details by calling the update Billing Agreement API. * Parses the API call response for country code and billing agreement status */ public async agreementDetails( options: BAUpdateOptions ): Promise<AgreementDetails> { const response = await this.client.baUpdate(options); return { city: response.CITY, countryCode: response.COUNTRYCODE, firstName: response.FIRSTNAME, lastName: response.LASTNAME, state: response.STATE, status: response.BILLINGAGREEMENTSTATUS.toLowerCase() as AgreementDetails['status'], street: response.STREET, street2: response.STREET2, zip: response.ZIP, }; } /** * Cancel a billing agreement. * * Errors from PayPal canceling the agreement are ignored as they only occur * if the agreement is no longer valid, isn't present anymore, etc. Other errors * processing the request are not ignored. * * @param billingAgreementId */ public async cancelBillingAgreement( billingAgreementId: string ): Promise<null> { try { await this.client.baUpdate({ billingAgreementId, cancel: true }); } catch (err) { if (!PayPalClientError.hasPayPalNVPError(err)) { throw err; } } return null; } /** * Verify whether an IPN message is valid. * * @param message */ public async verifyIpnMessage(message: string): Promise<boolean> { return (await this.client.ipnVerify(message)) === 'VERIFIED'; } /** * Extract an IPN message from a payload. * * @param payload */ public extractIpnMessage(payload: string): IpnMessage { return nvpToObject(payload) as IpnMessage; } public async searchTransactions( options: TransactionSearchOptions ): Promise<TransactionSearchResult[]> { const results = await this.client.transactionSearch(options); if (!(results.L instanceof Array)) { return []; } return results.L.map((r) => ({ amount: r.AMT, currencyCode: r.CURRENCYCODE, email: r.EMAIL, feeAmount: r.FEEAMT, name: r.NAME, netAmount: r.NETAMT, status: r.STATUS, timestamp: r.TIMESTAMP, transactionId: r.TRANSACTIONID, type: r.TYPE, })); } /** * Removes Paypal billing agreements on a customer if they paid with * Paypal but no longer have an active/past_due/trialing subscription. */ async conditionallyRemoveBillingAgreement( customer: Stripe.Customer ): Promise<boolean> { const billingAgreementId = this.stripeHelper.getCustomerPaypalAgreement(customer); if (!billingAgreementId) { return false; } if (hasPaypalSubscription(customer)) { return false; } await this.cancelBillingAgreement(billingAgreementId); await this.stripeHelper.removeCustomerPaypalAgreement( customer.metadata.userid, customer.id, billingAgreementId ); return true; } public async updateStripeNameFromBA( customer: Stripe.Customer, billingAgreementId: string ): Promise<Stripe.Customer> { this.metrics.increment('paypal.updateStripeNameFromBA'); const agreementDetails = await this.agreementDetails({ billingAgreementId, }); if (agreementDetails.status === 'cancelled') { throw error.internalValidationError('updateStripeNameFromBA', { message: 'Billing agreement was cancelled.', }); } const name = `${agreementDetails.firstName} ${agreementDetails.lastName}`; return this.stripeHelper.updateCustomerBillingAddress({ customerId: customer.id, name, }); } /** * Finalize and process a draft invoice that has no amounted owed. * * @param invoice */ public processZeroInvoice(invoice: Stripe.Invoice) { // It appears for subscriptions that do not require payment, the invoice // transitions to paid automatially. // https://stripe.com/docs/billing/invoices/subscription#sub-invoice-lifecycle return this.stripeHelper.finalizeInvoice(invoice); } /** * Process an invoice with a billing agreement that is in draft/open with * the provided billing agreement. * * @param opts */ public async processInvoice(opts: { customer: Stripe.Customer; invoice: Stripe.Invoice; batchProcessing?: boolean; ipaddress?: string; }) { const { customer, invoice, batchProcessing = false, ipaddress } = opts; const agreementId = this.stripeHelper.getCustomerPaypalAgreement(customer); if (!agreementId) { throw error.internalValidationError('processInvoice', { message: 'Agreement ID not found.', }); } if (!['draft', 'open'].includes(invoice.status ?? '')) { throw error.internalValidationError('processInvoice', { message: 'Invoice in invalid state.', }); } const paymentAttempt = this.stripeHelper.getPaymentAttempts(invoice); // PayPal supports an idempotencyKey on transaction charges to avoid repeat // charges. This key is restricted to the invoice and payment // attempt in combination, so that retries can be made if // the prior attempt failed and a retry is desired. const idempotencyKey = this.generateIdempotencyKey( // eslint-disable-next-line @typescript-eslint/no-non-null-assertion invoice.id!, paymentAttempt ); let countryCode: string | undefined = invoice.customer_shipping?.address?.country ?? undefined; if (!countryCode) { const validCountries = this.currencyHelper.currencyToCountryMap.get( invoice.currency.toUpperCase() ); if (validCountries && validCountries.length > 0) { countryCode = validCountries[0]; } else { this.log.error('processInvoice.countryCode', { message: 'No valid country code found for invoice', invoiceId: invoice.id, currency: invoice.currency, }); } } const promises: Promise<any>[] = [ this.chargeCustomer({ amountInCents: invoice.amount_due, billingAgreementId: agreementId, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion invoiceNumber: invoice.id!, currencyCode: invoice.currency, idempotencyKey, ...(countryCode && { countryCode }), ...(ipaddress && { ipaddress }), ...(invoice.tax && { taxAmountInCents: invoice.tax }), }), ]; if (invoice.status === 'draft') { promises.push(this.stripeHelper.finalizeInvoice(invoice)); } let transactionResponse; try { [transactionResponse] = (await Promise.all(promises)) as [ ChargeResponse, any ]; } catch (err) { if (PayPalClientError.hasPayPalNVPError(err) && !batchProcessing) { throwPaypalCodeError(err); } this.log.error('processInvoice', { err, nvpData: err.data, invoiceId: invoice.id, }); throw err; } await this.stripeHelper.updatePaymentAttempts(invoice); switch (transactionResponse.paymentStatus) { case 'Completed': case 'Processed': return Promise.all([ this.stripeHelper.updateInvoiceWithPaypalTransactionId( invoice, transactionResponse.transactionId ), this.stripeHelper.payInvoiceOutOfBand(invoice), ]); case 'Pending': case 'In-Progress': return; case 'Denied': case 'Failed': case 'Voided': case 'Expired': throw error.paymentFailed(); default: // Unexpected response here, log details and throw validation error. this.log.error('processInvoice', { message: 'Unexpected PayPal transaction response.', transactionResponse, }); throw error.internalValidationError('processInvoice', { message: 'Unexpected PayPal transaction response.', transactionResponse: transactionResponse.paymentStatus, }); } } /** * Given the transaction ID, refund the transaction in full. * Use the Stripe Invoice ID as the idempotency key since we * expect one refund per invoice. * * @param options */ public async refundTransaction(options: RefundTransactionOptions) { let response; try { response = await this.client.refundTransaction(options); } catch (err) { if (!PayPalClientError.hasPayPalNVPError(err)) { throw err; } const primaryError = err.getPrimaryError(); if ( primaryError.data.L && primaryError.data.L[0].SHORTMESSAGE === 'Transaction refused' ) { throw new RefusedError( primaryError.data.L[0].SHORTMESSAGE, primaryError.data.L[0].LONGMESSAGE, primaryError.data.L[0].ERRORCODE ); } throw err; } return { pendingReason: response.PENDINGREASON, refundStatus: response.REFUNDSTATUS, refundTransactionId: response.REFUNDTRANSACTIONID, }; } public async issueRefund( invoice: Stripe.Invoice, transactionId: string, refundType: RefundType, amount?: number ) { const refundResponse = await this.refundTransaction({ // eslint-disable-next-line @typescript-eslint/no-non-null-assertion idempotencyKey: invoice.id!, transactionId: transactionId, refundType: refundType, // eslint-disable-next-line @typescript-eslint/no-non-null-assertion amount: amount!, }); const success = ['instant', 'delayed']; if (success.includes(refundResponse.refundStatus.toLowerCase())) { this.stripeHelper.updateInvoiceWithPaypalRefundTransactionId( invoice, refundResponse.refundTransactionId ); return; } this.log.error('issueRefund', { message: 'PayPal refund transaction unsuccessful', invoiceId: invoice.id, transactionId, refundResponse, }); throw error.internalValidationError('issueRefund', { message: 'PayPal refund transaction unsuccessful', }); } /** * Attempts to refund all of the invoices passed, provided they're created via PayPal * This will invisibly do nothing if the invoice is not billed through PayPal, so be mindful * if using it elsewhere and need confirmation of a refund. */ public async refundInvoices(invoices: Stripe.Invoice[]) { this.log.debug('PayPalHelper.refundInvoices', { numberOfInvoices: invoices.length, }); const payPalInvoices = invoices.filter( (invoice) => invoice.collection_method === 'send_invoice' ); for (const invoice of payPalInvoices) { try { await this.refundInvoice(invoice); } catch (error) { if ( !(error instanceof RefusedError) && !(error instanceof RefundError) ) { throw error; } } } return; } /** * Refunds the invoice passed, throwing an error on any issue encountered. */ public async refundInvoice(invoice: Stripe.Invoice) { this.log.debug('PayPalHelper.refundInvoice', { invoiceId: invoice.id, }); const minCreated = Math.floor( new Date().setDate(new Date().getDate() - MAX_REFUND_DAYS) / 1000 ); if (invoice.collection_method !== 'send_invoice') { throw new Error('Invoice is not a Paypal invoice'); } try { if (invoice.created < minCreated) { throw new RefundError( 'Invoice created outside of maximum refund period' ); } const transactionId = this.stripeHelper.getInvoicePaypalTransactionId(invoice); if (!transactionId) { throw new RefundError('Missing transactionId'); } const refundTransactionId = this.stripeHelper.getInvoicePaypalRefundTransactionId(invoice); if (refundTransactionId) { throw new RefundError('Invoice already refunded with PayPal'); } await this.issueRefund(invoice, transactionId, RefundType.Full); this.log.info('refundInvoice', { invoiceId: invoice.id, priceId: this.stripeHelper.getPriceIdFromInvoice(invoice), total: invoice.total, currency: invoice.currency, }); } catch (error) { this.log.error('PayPalHelper.refundInvoice', { error, invoiceId: invoice.id, }); throw error; } return; } }