From a3e6192e9894e0fdeba057e62ab7aa3b0d357dce Mon Sep 17 00:00:00 2001 From: Davey Alvarez Date: Fri, 31 Jan 2025 14:23:52 -0800 Subject: [PATCH] feat(paypal): Send country code with paypal transactions Because: * Accounting needs a queriable country code on paypal transactions This commit: * uses the ip-located country when available (or the currency default country when not), and sends that to paypal's COUNTRYCODE NVP field. Closes #FXA-10948 --- .../cart/src/lib/checkout.service.spec.ts | 8 +++-- libs/payments/currency/src/index.ts | 1 + .../currency/src/lib/currency.manager.ts | 10 ++++++ .../customer/src/lib/invoice.manager.spec.ts | 34 +++++++++++++++++-- .../customer/src/lib/invoice.manager.ts | 16 ++++++++- libs/payments/paypal/src/lib/factories.ts | 1 + .../paypal/src/lib/paypal.client.spec.ts | 4 +++ libs/payments/paypal/src/lib/paypal.client.ts | 2 ++ .../paypal/src/lib/paypal.client.types.ts | 1 + libs/payments/paypal/src/lib/paypal.types.ts | 1 + libs/payments/stripe/src/index.ts | 1 + .../src/lib/factories/address.factory.ts | 16 +++++++++ .../stripe/src/lib/stripe.client.types.ts | 1 + packages/fxa-admin-server/tsconfig.build.json | 1 + .../lib/payments/paypal/helper.ts | 15 ++++++++ .../test/local/payments/paypal.js | 10 ++++++ 16 files changed, 116 insertions(+), 6 deletions(-) create mode 100644 libs/payments/stripe/src/lib/factories/address.factory.ts diff --git a/libs/payments/cart/src/lib/checkout.service.spec.ts b/libs/payments/cart/src/lib/checkout.service.spec.ts index 3a9fe921670..2da66dfa37d 100644 --- a/libs/payments/cart/src/lib/checkout.service.spec.ts +++ b/libs/payments/cart/src/lib/checkout.service.spec.ts @@ -688,7 +688,9 @@ describe('CheckoutService', () => { ); const mockPaypalCustomer = ResultPaypalCustomerFactory(); const mockInvoice = StripeResponseFactory( - StripeInvoiceFactory({ status: 'paid' }) + StripeInvoiceFactory({ + status: 'paid', + }) ); const mockPrice = StripePriceFactory(); const mockPrePayStepsResult = PrePayStepsResultFactory({ @@ -852,7 +854,9 @@ describe('CheckoutService', () => { ); const mockPaypalCustomer = ResultPaypalCustomerFactory(); const mockInvoice = StripeResponseFactory( - StripeInvoiceFactory({ status: 'uncollectible' }) + StripeInvoiceFactory({ + status: 'uncollectible', + }) ); const mockPrice = StripePriceFactory(); const mockPrePayStepsResult = PrePayStepsResultFactory({ diff --git a/libs/payments/currency/src/index.ts b/libs/payments/currency/src/index.ts index 085ce719778..1f6ef6aba1a 100644 --- a/libs/payments/currency/src/index.ts +++ b/libs/payments/currency/src/index.ts @@ -5,3 +5,4 @@ export * from './lib/currency.constants'; export * from './lib/currency.error'; export * from './lib/currency.manager'; +export * from './lib/currency.config'; diff --git a/libs/payments/currency/src/lib/currency.manager.ts b/libs/payments/currency/src/lib/currency.manager.ts index 91bce67ae9a..8cb06f25f51 100644 --- a/libs/payments/currency/src/lib/currency.manager.ts +++ b/libs/payments/currency/src/lib/currency.manager.ts @@ -72,4 +72,14 @@ export class CurrencyManager { return undefined; } + + getDefaultCountryForCurrency(currency: string) { + if ( + currency in Object.getOwnPropertyNames(this.config.currenciesToCountries) + ) { + return this.config.currenciesToCountries[currency][0]; + } else { + return undefined; + } + } } diff --git a/libs/payments/customer/src/lib/invoice.manager.spec.ts b/libs/payments/customer/src/lib/invoice.manager.spec.ts index 705d2b3711c..85cfc7f45cb 100644 --- a/libs/payments/customer/src/lib/invoice.manager.spec.ts +++ b/libs/payments/customer/src/lib/invoice.manager.spec.ts @@ -15,6 +15,7 @@ import { StripePromotionCodeFactory, StripeResponseFactory, StripeUpcomingInvoiceFactory, + StripeAddressFactory, } from '@fxa/payments/stripe'; import { TaxAddressFactory } from './factories/tax-address.factory'; import { InvoicePreviewFactory } from './invoice.factories'; @@ -26,6 +27,10 @@ import { PayPalClient, PaypalClientConfig, } from '@fxa/payments/paypal'; +import { + CurrencyManager, + MockCurrencyConfigProvider, +} from '@fxa/payments/currency'; import { STRIPE_CUSTOMER_METADATA, STRIPE_INVOICE_METADATA } from './types'; jest.mock('../lib/util/stripeInvoiceToFirstInvoicePreviewDTO'); @@ -49,6 +54,8 @@ describe('InvoiceManager', () => { StripeClient, PayPalClient, PaypalClientConfig, + CurrencyManager, + MockCurrencyConfigProvider, MockStripeConfigProvider, InvoiceManager, ], @@ -99,6 +106,7 @@ describe('InvoiceManager', () => { const result = await invoiceManager.previewUpcoming({ priceId: mockPrice.id, + currency: mockPrice.currency, customer: mockCustomer, taxAddress: mockTaxAddress, }); @@ -135,6 +143,7 @@ describe('InvoiceManager', () => { const result = await invoiceManager.previewUpcoming({ priceId: mockPrice.id, + currency: mockPrice.currency, customer: mockCustomer, taxAddress: mockTaxAddress, couponCode: mockPromotionCode.code, @@ -188,6 +197,7 @@ describe('InvoiceManager', () => { const mockInvoice = StripeInvoiceFactory({ amount_due: 50, currency: 'usd', + customer_shipping: { address: StripeAddressFactory() }, }); mockedGetMinimumChargeAmountForCurrency.mockReturnValue(10); @@ -249,6 +259,7 @@ describe('InvoiceManager', () => { mockPaymentAttemptCount ), }, + customer_shipping: { address: StripeAddressFactory() }, }) ); const mockPayPalCharge = ChargeResponseFactory({ @@ -280,6 +291,7 @@ describe('InvoiceManager', () => { mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement], invoiceNumber: mockInvoice.id, currencyCode: mockInvoice.currency, + countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, taxAmountInCents: mockInvoice.tax, }); @@ -312,7 +324,11 @@ describe('InvoiceManager', () => { const mockCustomer = StripeResponseFactory( StripeCustomerFactory({ metadata: {} }) ); - const mockInvoice = StripeResponseFactory(StripeInvoiceFactory()); + const mockInvoice = StripeResponseFactory( + StripeInvoiceFactory({ + customer_shipping: { address: StripeAddressFactory() }, + }) + ); await expect( invoiceManager.processPayPalNonZeroInvoice(mockCustomer, mockInvoice) @@ -321,7 +337,10 @@ describe('InvoiceManager', () => { it('throws an error for an already-paid invoice', async () => { const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockInvoice = StripeResponseFactory( - StripeInvoiceFactory({ status: 'paid' }) + StripeInvoiceFactory({ + status: 'paid', + customer_shipping: { address: StripeAddressFactory() }, + }) ); await expect( @@ -331,7 +350,10 @@ describe('InvoiceManager', () => { it('throws an error for an uncollectible invoice', async () => { const mockCustomer = StripeResponseFactory(StripeCustomerFactory()); const mockInvoice = StripeResponseFactory( - StripeInvoiceFactory({ status: 'uncollectible' }) + StripeInvoiceFactory({ + status: 'uncollectible', + customer_shipping: { address: StripeAddressFactory() }, + }) ); await expect( @@ -356,6 +378,7 @@ describe('InvoiceManager', () => { mockPaymentAttemptCount ), }, + customer_shipping: { address: StripeAddressFactory() }, }) ); const mockPayPalCharge = ChargeResponseFactory({ @@ -384,6 +407,7 @@ describe('InvoiceManager', () => { mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement], invoiceNumber: mockInvoice.id, currencyCode: mockInvoice.currency, + countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, taxAmountInCents: mockInvoice.tax, }); @@ -421,6 +445,7 @@ describe('InvoiceManager', () => { mockPaymentAttemptCount ), }, + customer_shipping: { address: StripeAddressFactory() }, }) ); const mockPayPalCharge = ChargeResponseFactory({ @@ -446,6 +471,7 @@ describe('InvoiceManager', () => { mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement], invoiceNumber: mockInvoice.id, currencyCode: mockInvoice.currency, + countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, taxAmountInCents: mockInvoice.tax, }); @@ -484,6 +510,7 @@ describe('InvoiceManager', () => { ), }, tax: 0, + customer_shipping: { address: StripeAddressFactory() }, }) ); const mockPayPalCharge = ChargeResponseFactory({ @@ -515,6 +542,7 @@ describe('InvoiceManager', () => { mockCustomer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement], invoiceNumber: mockInvoice.id, currencyCode: mockInvoice.currency, + countryCode: mockInvoice.customer_shipping?.address?.country, idempotencyKey: `${mockInvoice.id}-${mockPaymentAttemptCount}`, taxAmountInCents: mockInvoice.tax, }); diff --git a/libs/payments/customer/src/lib/invoice.manager.ts b/libs/payments/customer/src/lib/invoice.manager.ts index add6d88695e..8bb5daee3f8 100644 --- a/libs/payments/customer/src/lib/invoice.manager.ts +++ b/libs/payments/customer/src/lib/invoice.manager.ts @@ -17,6 +17,7 @@ import { PayPalClient, PayPalClientError, } from '@fxa/payments/paypal'; +import { CurrencyManager } from '@fxa/payments/currency'; import { InvoicePreview, STRIPE_CUSTOMER_METADATA, @@ -36,7 +37,8 @@ import { export class InvoiceManager { constructor( private stripeClient: StripeClient, - private paypalClient: PayPalClient + private paypalClient: PayPalClient, + private currencyManager: CurrencyManager ) {} async finalizeWithoutAutoAdvance(invoiceId: string) { @@ -142,6 +144,17 @@ export class InvoiceManager { ); } + 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' @@ -155,6 +168,7 @@ export class InvoiceManager { customer.metadata[STRIPE_CUSTOMER_METADATA.PaypalAgreement], invoiceNumber: invoice.id, currencyCode: invoice.currency, + countryCode, idempotencyKey, ...(ipaddress && { ipaddress }), ...(invoice.tax !== null && { taxAmountInCents: invoice.tax }), diff --git a/libs/payments/paypal/src/lib/factories.ts b/libs/payments/paypal/src/lib/factories.ts index c3d79272d37..0a174c3d852 100644 --- a/libs/payments/paypal/src/lib/factories.ts +++ b/libs/payments/paypal/src/lib/factories.ts @@ -180,6 +180,7 @@ export const ChargeOptionsFactory = ( amountInCents: faker.number.int({ max: 100000000 }), billingAgreementId: faker.string.uuid(), currencyCode: faker.finance.currencyCode(), + countryCode: faker.finance.currencyCode(), idempotencyKey: faker.string.uuid(), invoiceNumber: faker.string.uuid(), taxAmountInCents: faker.number.int({ max: 100000000 }), diff --git a/libs/payments/paypal/src/lib/paypal.client.spec.ts b/libs/payments/paypal/src/lib/paypal.client.spec.ts index 7818c45ce86..8863b5491fa 100644 --- a/libs/payments/paypal/src/lib/paypal.client.spec.ts +++ b/libs/payments/paypal/src/lib/paypal.client.spec.ts @@ -148,6 +148,7 @@ describe('PayPalClient', () => { const options = { amount: faker.finance.amount(), currencyCode: faker.finance.currencyCode(), + countryCode: faker.location.countryCode(), idempotencyKey: faker.string.sample(), ipaddress: faker.internet.ipv4(), billingAgreementId: faker.string.sample(), @@ -161,6 +162,7 @@ describe('PayPalClient', () => { PAYMENTTYPE: 'instant', AMT: options.amount, CURRENCYCODE: options.currencyCode, + COUNTRYCODE: options.countryCode, CUSTOM: options.idempotencyKey, INVNUM: options.invoiceNumber, IPADDRESS: options.ipaddress, @@ -184,6 +186,7 @@ describe('PayPalClient', () => { const options = { amount: faker.finance.amount(), currencyCode: faker.finance.currencyCode(), + countryCode: faker.location.countryCode(), idempotencyKey: faker.string.sample(), ipaddress: faker.internet.ipv4(), billingAgreementId: faker.string.sample(), @@ -198,6 +201,7 @@ describe('PayPalClient', () => { PAYMENTTYPE: 'instant', AMT: options.amount, CURRENCYCODE: options.currencyCode, + COUNTRYCODE: options.countryCode, CUSTOM: options.idempotencyKey, INVNUM: options.invoiceNumber, IPADDRESS: options.ipaddress, diff --git a/libs/payments/paypal/src/lib/paypal.client.ts b/libs/payments/paypal/src/lib/paypal.client.ts index 43daeaa38f3..1f6dbf7cec2 100644 --- a/libs/payments/paypal/src/lib/paypal.client.ts +++ b/libs/payments/paypal/src/lib/paypal.client.ts @@ -231,6 +231,7 @@ export class PayPalClient { const data = { AMT: options.amount, CURRENCYCODE: options.currencyCode.toUpperCase(), + COUNTRYCODE: options.countryCode.toUpperCase(), CUSTOM: options.idempotencyKey, INVNUM: options.invoiceNumber, ...(options.ipaddress && { IPADDRESS: options.ipaddress }), @@ -342,6 +343,7 @@ export class PayPalClient { ), billingAgreementId: options.billingAgreementId, currencyCode: options.currencyCode, + countryCode: options.countryCode, idempotencyKey: options.idempotencyKey, invoiceNumber: options.invoiceNumber, ...(options.ipaddress && { ipaddress: options.ipaddress }), diff --git a/libs/payments/paypal/src/lib/paypal.client.types.ts b/libs/payments/paypal/src/lib/paypal.client.types.ts index 6fa7d7953b1..2680206f90d 100644 --- a/libs/payments/paypal/src/lib/paypal.client.types.ts +++ b/libs/payments/paypal/src/lib/paypal.client.types.ts @@ -145,6 +145,7 @@ export interface DoReferenceTransactionOptions { invoiceNumber: string; idempotencyKey: string; currencyCode: string; + countryCode: string; taxAmount?: string; ipaddress?: string; } diff --git a/libs/payments/paypal/src/lib/paypal.types.ts b/libs/payments/paypal/src/lib/paypal.types.ts index 12c8ccf35af..d51062b9742 100644 --- a/libs/payments/paypal/src/lib/paypal.types.ts +++ b/libs/payments/paypal/src/lib/paypal.types.ts @@ -23,6 +23,7 @@ export interface ChargeOptions { amountInCents: number; billingAgreementId: string; currencyCode: string; + countryCode: string; idempotencyKey: string; invoiceNumber: string; ipaddress?: string; diff --git a/libs/payments/stripe/src/index.ts b/libs/payments/stripe/src/index.ts index cf1d362c3a8..0033bb87d78 100644 --- a/libs/payments/stripe/src/index.ts +++ b/libs/payments/stripe/src/index.ts @@ -5,6 +5,7 @@ export * from './lib/accountCustomer/accountCustomer.error'; export * from './lib/accountCustomer/accountCustomer.factories'; export * from './lib/accountCustomer/accountCustomer.manager'; +export { StripeAddressFactory } from './lib/factories/address.factory'; export { StripeApiListFactory, StripeResponseFactory, diff --git a/libs/payments/stripe/src/lib/factories/address.factory.ts b/libs/payments/stripe/src/lib/factories/address.factory.ts new file mode 100644 index 00000000000..4f43bb66195 --- /dev/null +++ b/libs/payments/stripe/src/lib/factories/address.factory.ts @@ -0,0 +1,16 @@ +/* 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 { faker } from '@faker-js/faker'; +import { StripeAddress } from '../stripe.client.types'; + +export const StripeAddressFactory = (override?: Partial) => ({ + city: '', + line1: '', + line2: '', + state: '', + postal_code: faker.location.zipCode(), + country: faker.location.countryCode(), + ...override, +}); diff --git a/libs/payments/stripe/src/lib/stripe.client.types.ts b/libs/payments/stripe/src/lib/stripe.client.types.ts index bd01ac9d37a..a14ee9352e4 100644 --- a/libs/payments/stripe/src/lib/stripe.client.types.ts +++ b/libs/payments/stripe/src/lib/stripe.client.types.ts @@ -348,6 +348,7 @@ export type StripePaymentMethod = NegotiateExpanded< 'customer' >; +export type StripeAddress = Stripe.Address; export type StripeApiList = Stripe.ApiList; export type StripeApiListPromise = Stripe.ApiListPromise; export type StripeResponse = Stripe.Response; diff --git a/packages/fxa-admin-server/tsconfig.build.json b/packages/fxa-admin-server/tsconfig.build.json index 56c68c4762e..9684d79c959 100644 --- a/packages/fxa-admin-server/tsconfig.build.json +++ b/packages/fxa-admin-server/tsconfig.build.json @@ -7,6 +7,7 @@ "@fxa/payments/stripe": ["libs/payments/stripe/src/index"], "@fxa/payments/paypal": ["libs/payments/paypal/src/index"], "@fxa/payments/customer": ["libs/payments/customer/src/index"], + "@fxa/payments/currency": ["libs/payments/currency/src/index"], "@fxa/shared/cms": ["libs/shared/cms/src/index"], "@fxa/shared/cloud-tasks": ["libs/shared/cloud-tasks/src/index"], "@fxa/shared/db/firestore": ["libs/shared/db/firestore/src/index"], diff --git a/packages/fxa-auth-server/lib/payments/paypal/helper.ts b/packages/fxa-auth-server/lib/payments/paypal/helper.ts index 5b532bd338e..363a1b60480 100644 --- a/packages/fxa-auth-server/lib/payments/paypal/helper.ts +++ b/packages/fxa-auth-server/lib/payments/paypal/helper.ts @@ -52,6 +52,7 @@ export type ChargeCustomerOptions = { amountInCents: number; billingAgreementId: string; currencyCode: string; + countryCode: string; idempotencyKey: string; invoiceNumber: string; ipaddress?: string; @@ -277,6 +278,7 @@ export class PayPalHelper { ), billingAgreementId: options.billingAgreementId, currencyCode: options.currencyCode, + countryCode: options.countryCode, idempotencyKey: options.idempotencyKey, invoiceNumber: options.invoiceNumber, ...(options.ipaddress && { ipaddress: options.ipaddress }), @@ -483,6 +485,18 @@ export class PayPalHelper { paymentAttempt ); + const countryCode: string = + invoice.customer_shipping?.address?.country ?? + this.currencyHelper.currencyToCountryMap[ + invoice.currency.toUpperCase() + ][0]; + + if (!countryCode) { + throw error.internalValidationError('processInvoice', { + message: 'Invalid country code', + }); + } + const promises: Promise[] = [ this.chargeCustomer({ amountInCents: invoice.amount_due, @@ -490,6 +504,7 @@ export class PayPalHelper { // eslint-disable-next-line @typescript-eslint/no-non-null-assertion invoiceNumber: invoice.id!, currencyCode: invoice.currency, + countryCode, idempotencyKey, ...(ipaddress && { ipaddress }), ...(invoice.tax && { taxAmountInCents: invoice.tax }), diff --git a/packages/fxa-auth-server/test/local/payments/paypal.js b/packages/fxa-auth-server/test/local/payments/paypal.js index bf9f7c75696..e6678c9fe32 100644 --- a/packages/fxa-auth-server/test/local/payments/paypal.js +++ b/packages/fxa-auth-server/test/local/payments/paypal.js @@ -52,6 +52,7 @@ describe('PayPalHelper', () => { total: 1234, currency: 'usd', period_end: 1587426018, + customer_shipping: { address: { country: 'US' } }, lines: { data: [ { @@ -229,6 +230,7 @@ describe('PayPalHelper', () => { amountInCents: 1099, billingAgreementId: 'B-12345', currencyCode: 'usd', + countryCode: 'US', invoiceNumber: 'in_asdf', idempotencyKey: ' in_asdf-0', }; @@ -247,6 +249,7 @@ describe('PayPalHelper', () => { invoiceNumber: validOptions.invoiceNumber, idempotencyKey: validOptions.idempotencyKey, currencyCode: validOptions.currencyCode, + countryCode: validOptions.countryCode, }; assert.ok( paypalHelper.client.doReferenceTransaction.calledOnceWith( @@ -292,6 +295,7 @@ describe('PayPalHelper', () => { invoiceNumber: options.invoiceNumber, idempotencyKey: options.idempotencyKey, currencyCode: options.currencyCode, + countryCode: options.countryCode, taxAmount: paypalHelper.currencyHelper.getPayPalAmountStringFromAmountInCents( options.taxAmountInCents @@ -982,6 +986,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id, @@ -1019,6 +1024,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id, @@ -1055,6 +1061,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id, @@ -1104,6 +1111,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id, @@ -1147,6 +1155,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id, @@ -1201,6 +1210,7 @@ describe('PayPalHelper', () => { amountInCents: validInvoice.amount_due, billingAgreementId: agreementId, currencyCode: validInvoice.currency, + countryCode: validInvoice.customer_shipping.address.country, invoiceNumber: validInvoice.id, idempotencyKey: paypalHelper.generateIdempotencyKey( validInvoice.id,