Skip to content

Commit

Permalink
Merge pull request #18313 from mozilla/FXA-10948
Browse files Browse the repository at this point in the history
feat(paypal): Send country code with paypal transactions
  • Loading branch information
david1alvarez authored Feb 7, 2025
2 parents 188431b + a3e6192 commit 1463f62
Show file tree
Hide file tree
Showing 16 changed files with 116 additions and 6 deletions.
8 changes: 6 additions & 2 deletions libs/payments/cart/src/lib/checkout.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -688,7 +688,9 @@ describe('CheckoutService', () => {
);
const mockPaypalCustomer = ResultPaypalCustomerFactory();
const mockInvoice = StripeResponseFactory(
StripeInvoiceFactory({ status: 'paid' })
StripeInvoiceFactory({
status: 'paid',
})
);
const mockPrice = StripePriceFactory();
const mockPrePayStepsResult = PrePayStepsResultFactory({
Expand Down Expand Up @@ -852,7 +854,9 @@ describe('CheckoutService', () => {
);
const mockPaypalCustomer = ResultPaypalCustomerFactory();
const mockInvoice = StripeResponseFactory(
StripeInvoiceFactory({ status: 'uncollectible' })
StripeInvoiceFactory({
status: 'uncollectible',
})
);
const mockPrice = StripePriceFactory();
const mockPrePayStepsResult = PrePayStepsResultFactory({
Expand Down
1 change: 1 addition & 0 deletions libs/payments/currency/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@
export * from './lib/currency.constants';
export * from './lib/currency.error';
export * from './lib/currency.manager';
export * from './lib/currency.config';
10 changes: 10 additions & 0 deletions libs/payments/currency/src/lib/currency.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
}
34 changes: 31 additions & 3 deletions libs/payments/customer/src/lib/invoice.manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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');
Expand All @@ -49,6 +54,8 @@ describe('InvoiceManager', () => {
StripeClient,
PayPalClient,
PaypalClientConfig,
CurrencyManager,
MockCurrencyConfigProvider,
MockStripeConfigProvider,
InvoiceManager,
],
Expand Down Expand Up @@ -99,6 +106,7 @@ describe('InvoiceManager', () => {

const result = await invoiceManager.previewUpcoming({
priceId: mockPrice.id,
currency: mockPrice.currency,
customer: mockCustomer,
taxAddress: mockTaxAddress,
});
Expand Down Expand Up @@ -135,6 +143,7 @@ describe('InvoiceManager', () => {

const result = await invoiceManager.previewUpcoming({
priceId: mockPrice.id,
currency: mockPrice.currency,
customer: mockCustomer,
taxAddress: mockTaxAddress,
couponCode: mockPromotionCode.code,
Expand Down Expand Up @@ -188,6 +197,7 @@ describe('InvoiceManager', () => {
const mockInvoice = StripeInvoiceFactory({
amount_due: 50,
currency: 'usd',
customer_shipping: { address: StripeAddressFactory() },
});

mockedGetMinimumChargeAmountForCurrency.mockReturnValue(10);
Expand Down Expand Up @@ -249,6 +259,7 @@ describe('InvoiceManager', () => {
mockPaymentAttemptCount
),
},
customer_shipping: { address: StripeAddressFactory() },
})
);
const mockPayPalCharge = ChargeResponseFactory({
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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)
Expand All @@ -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(
Expand All @@ -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(
Expand All @@ -356,6 +378,7 @@ describe('InvoiceManager', () => {
mockPaymentAttemptCount
),
},
customer_shipping: { address: StripeAddressFactory() },
})
);
const mockPayPalCharge = ChargeResponseFactory({
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -421,6 +445,7 @@ describe('InvoiceManager', () => {
mockPaymentAttemptCount
),
},
customer_shipping: { address: StripeAddressFactory() },
})
);
const mockPayPalCharge = ChargeResponseFactory({
Expand All @@ -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,
});
Expand Down Expand Up @@ -484,6 +510,7 @@ describe('InvoiceManager', () => {
),
},
tax: 0,
customer_shipping: { address: StripeAddressFactory() },
})
);
const mockPayPalCharge = ChargeResponseFactory({
Expand Down Expand Up @@ -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,
});
Expand Down
16 changes: 15 additions & 1 deletion libs/payments/customer/src/lib/invoice.manager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
PayPalClient,
PayPalClientError,
} from '@fxa/payments/paypal';
import { CurrencyManager } from '@fxa/payments/currency';
import {
InvoicePreview,
STRIPE_CUSTOMER_METADATA,
Expand All @@ -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) {
Expand Down Expand Up @@ -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'
Expand All @@ -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 }),
Expand Down
1 change: 1 addition & 0 deletions libs/payments/paypal/src/lib/factories.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down
4 changes: 4 additions & 0 deletions libs/payments/paypal/src/lib/paypal.client.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand All @@ -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,
Expand All @@ -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(),
Expand All @@ -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,
Expand Down
2 changes: 2 additions & 0 deletions libs/payments/paypal/src/lib/paypal.client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 }),
Expand Down Expand Up @@ -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 }),
Expand Down
1 change: 1 addition & 0 deletions libs/payments/paypal/src/lib/paypal.client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,7 @@ export interface DoReferenceTransactionOptions {
invoiceNumber: string;
idempotencyKey: string;
currencyCode: string;
countryCode: string;
taxAmount?: string;
ipaddress?: string;
}
Expand Down
1 change: 1 addition & 0 deletions libs/payments/paypal/src/lib/paypal.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@ export interface ChargeOptions {
amountInCents: number;
billingAgreementId: string;
currencyCode: string;
countryCode: string;
idempotencyKey: string;
invoiceNumber: string;
ipaddress?: string;
Expand Down
1 change: 1 addition & 0 deletions libs/payments/stripe/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
16 changes: 16 additions & 0 deletions libs/payments/stripe/src/lib/factories/address.factory.ts
Original file line number Diff line number Diff line change
@@ -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<StripeAddress>) => ({
city: '',
line1: '',
line2: '',
state: '',
postal_code: faker.location.zipCode(),
country: faker.location.countryCode(),
...override,
});
1 change: 1 addition & 0 deletions libs/payments/stripe/src/lib/stripe.client.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -348,6 +348,7 @@ export type StripePaymentMethod = NegotiateExpanded<
'customer'
>;

export type StripeAddress = Stripe.Address;
export type StripeApiList<T> = Stripe.ApiList<T>;
export type StripeApiListPromise<T> = Stripe.ApiListPromise<T>;
export type StripeResponse<T> = Stripe.Response<T>;
1 change: 1 addition & 0 deletions packages/fxa-admin-server/tsconfig.build.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
Expand Down
15 changes: 15 additions & 0 deletions packages/fxa-auth-server/lib/payments/paypal/helper.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,7 @@ export type ChargeCustomerOptions = {
amountInCents: number;
billingAgreementId: string;
currencyCode: string;
countryCode: string;
idempotencyKey: string;
invoiceNumber: string;
ipaddress?: string;
Expand Down Expand Up @@ -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 }),
Expand Down Expand Up @@ -483,13 +485,26 @@ 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<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,
countryCode,
idempotencyKey,
...(ipaddress && { ipaddress }),
...(invoice.tax && { taxAmountInCents: invoice.tax }),
Expand Down
Loading

0 comments on commit 1463f62

Please sign in to comment.