Merge pull request #16630 from mozilla/FXA-8935

feat(payments-paypal): create PaypalService createBillingAgreement method
This commit is contained in:
Meghan Sardesai 2024-04-24 15:19:36 -04:00 коммит произвёл GitHub
Родитель f40da97c81 5d5b8f05e0
Коммит 0ddaf1b4d2
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: B5690EEEBB952194
21 изменённых файлов: 1013 добавлений и 179 удалений

Просмотреть файл

@ -0,0 +1,18 @@
{
"extends": ["../../../.eslintrc.json"],
"ignorePatterns": ["!**/*"],
"overrides": [
{
"files": ["*.ts", "*.tsx", "*.js", "*.jsx"],
"rules": {}
},
{
"files": ["*.ts", "*.tsx"],
"rules": {}
},
{
"files": ["*.js", "*.jsx"],
"rules": {}
}
]
}

Просмотреть файл

@ -0,0 +1,11 @@
# payments-currency
This library was generated with [Nx](https://nx.dev).
## Building
Run `nx build payments-currency` to build the library.
## Running unit tests
Run `nx test-unit payments-currency` to execute the unit tests via [Jest](https://jestjs.io).

Просмотреть файл

@ -0,0 +1,11 @@
/* eslint-disable */
export default {
displayName: 'payments-currency',
preset: '../../../jest.preset.js',
testEnvironment: 'node',
transform: {
'^.+\\.[tj]s$': ['ts-jest', { tsconfig: '<rootDir>/tsconfig.spec.json' }],
},
moduleFileExtensions: ['ts', 'js', 'html'],
coverageDirectory: '../../../coverage/libs/payments/currency',
};

Просмотреть файл

@ -0,0 +1,4 @@
{
"name": "@fxa/payments/currency",
"version": "0.0.1"
}

Просмотреть файл

@ -0,0 +1,34 @@
{
"name": "payments-currency",
"$schema": "../../../node_modules/nx/schemas/project-schema.json",
"sourceRoot": "libs/payments/currency/src",
"projectType": "library",
"targets": {
"build": {
"executor": "@nx/js:tsc",
"outputs": ["{options.outputPath}"],
"options": {
"outputPath": "dist/libs/payments/currency",
"tsConfig": "libs/payments/currency/tsconfig.lib.json",
"packageJson": "libs/payments/currency/package.json",
"main": "libs/payments/currency/src/index.ts",
"assets": ["libs/payments/currency/*.md"]
}
},
"lint": {
"executor": "@nx/linter:eslint",
"outputs": ["{options.outputFile}"],
"options": {
"lintFilePatterns": ["libs/payments/currency/**/*.ts"]
}
},
"test-unit": {
"executor": "@nx/jest:jest",
"outputs": ["{workspaceRoot}/coverage/{projectRoot}"],
"options": {
"jestConfig": "libs/payments/currency/jest.config.ts"
}
}
},
"tags": ["scope:shared:lib:payments"]
}

Просмотреть файл

@ -2,10 +2,6 @@
* 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 { Injectable } from '@nestjs/common';
import { PayPalManager } from './paypal.manager';
@Injectable()
export class PayPalService {
constructor(private paypalManager: PayPalManager) {}
}
export * from './lib/currency.constants';
export * from './lib/currency.error';
export * from './lib/currency.manager';

Просмотреть файл

@ -0,0 +1,432 @@
/* 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/. */
/*
* Mapping from ISO 4217 three-letter currency codes to list of ISO 3166-1 alpha-2
* two-letter country codes: {"EUR": ["DE", "FR"], "USD": ["CA", "GB", "US" ]}
* Requirement for only one currency per country. Tested at runtime. Must be uppercased.
*/
export const CURRENCIES_TO_COUNTRIES = {
USD: ['US', 'GB', 'NZ', 'MY', 'SG', 'CA', 'AS', 'GU', 'MP', 'PR', 'VI'],
EUR: ['FR', 'DE'],
};
/*
* PayPal has specific restrictions on how currencies are handled.
* The general documentation for the AMT field is here: https://developer.paypal.com/docs/nvp-soap-api/do-reference-transaction-nvp/#payment-details-fields
* The documentation for currency codes and the various restrictions is here: https://developer.paypal.com/docs/nvp-soap-api/currency-codes/
*
* Restrictions include
* - whether decimal point is or isn't allowed.
* - whether there's a transaction limit for the country
*
* As a result, we hard code the supported PayPal currencies and additional currencies
* can be added here as necessary, once the PayPal docs have been reviewed to ensure we've
* handled any restrictions.
*
*/
export const SUPPORTED_PAYPAL_CURRENCIES = [
'USD',
'EUR',
'CHF',
'CZK',
'DKK',
'PLN',
];
/*
* List of valid country codes taken from https://en.wikipedia.org/wiki/ISO_3166-1_alpha-2
* Stripe docs: https://stripe.com/docs/radar/rules/reference#country-attributes
*/
export const VALID_COUNTRY_CODES = [
'AD',
'AE',
'AF',
'AG',
'AI',
'AL',
'AM',
'AO',
'AQ',
'AR',
'AS',
'AT',
'AU',
'AW',
'AX',
'AZ',
'BA',
'BB',
'BD',
'BE',
'BF',
'BG',
'BH',
'BI',
'BJ',
'BL',
'BM',
'BN',
'BO',
'BQ',
'BR',
'BS',
'BT',
'BV',
'BW',
'BY',
'BZ',
'CA',
'CC',
'CD',
'CF',
'CG',
'CH',
'CI',
'CK',
'CL',
'CM',
'CN',
'CO',
'CR',
'CU',
'CV',
'CW',
'CX',
'CY',
'CZ',
'DE',
'DJ',
'DK',
'DM',
'DO',
'DZ',
'EC',
'EE',
'EG',
'EH',
'ER',
'ES',
'ET',
'FI',
'FJ',
'FK',
'FM',
'FO',
'FR',
'GA',
'GB',
'GD',
'GE',
'GF',
'GG',
'GH',
'GI',
'GL',
'GM',
'GN',
'GP',
'GQ',
'GR',
'GS',
'GT',
'GU',
'GW',
'GY',
'HK',
'HM',
'HN',
'HR',
'HT',
'HU',
'ID',
'IE',
'IL',
'IM',
'IN',
'IO',
'IQ',
'IR',
'IS',
'IT',
'JE',
'JM',
'JO',
'JP',
'KE',
'KG',
'KH',
'KI',
'KM',
'KN',
'KP',
'KR',
'KW',
'KY',
'KZ',
'LA',
'LB',
'LC',
'LI',
'LK',
'LR',
'LS',
'LT',
'LU',
'LV',
'LY',
'MA',
'MC',
'MD',
'ME',
'MF',
'MG',
'MH',
'MK',
'ML',
'MM',
'MN',
'MO',
'MP',
'MQ',
'MR',
'MS',
'MT',
'MU',
'MV',
'MW',
'MX',
'MY',
'MZ',
'NA',
'NC',
'NE',
'NF',
'NG',
'NI',
'NL',
'NO',
'NP',
'NR',
'NU',
'NZ',
'OM',
'PA',
'PE',
'PF',
'PG',
'PH',
'PK',
'PL',
'PM',
'PN',
'PR',
'PS',
'PT',
'PW',
'PY',
'QA',
'RE',
'RO',
'RS',
'RU',
'RW',
'SA',
'SB',
'SC',
'SD',
'SE',
'SG',
'SH',
'SI',
'SJ',
'SK',
'SL',
'SM',
'SN',
'SO',
'SR',
'SS',
'ST',
'SV',
'SX',
'SY',
'SZ',
'TC',
'TD',
'TF',
'TG',
'TH',
'TJ',
'TK',
'TL',
'TM',
'TN',
'TO',
'TR',
'TT',
'TV',
'TW',
'TZ',
'UA',
'UG',
'UM',
'US',
'UY',
'UZ',
'VA',
'VC',
'VE',
'VG',
'VI',
'VN',
'VU',
'WF',
'WS',
'YE',
'YT',
'ZA',
'ZM',
'ZW',
];
/*
* List of valid currency codes taken from https://stripe.com/docs/currencies
*/
export const VALID_CURRENCY_CODES = [
'USD',
'AED',
'AFN',
'ALL',
'AMD',
'ANG',
'AOA',
'ARS',
'AUD',
'AWG',
'AZN',
'BAM',
'BBD',
'BDT',
'BGN',
'BIF',
'BMD',
'BND',
'BOB',
'BRL',
'BSD',
'BWP',
'BZD',
'CAD',
'CDF',
'CHF',
'CLP',
'CNY',
'COP',
'CRC',
'CVE',
'CZK',
'DJF',
'DKK',
'DOP',
'DZD',
'EGP',
'ETB',
'EUR',
'FJD',
'FKP',
'GBP',
'GEL',
'GIP',
'GMD',
'GNF',
'GTQ',
'GYD',
'HKD',
'HNL',
'HRK',
'HTG',
'HUF',
'IDR',
'ILS',
'INR',
'ISK',
'JMD',
'JPY',
'KES',
'KGS',
'KHR',
'KMF',
'KRW',
'KYD',
'KZT',
'LAK',
'LBP',
'LKR',
'LRD',
'LSL',
'MAD',
'MDL',
'MGA',
'MKD',
'MMK',
'MNT',
'MOP',
'MRO',
'MUR',
'MVR',
'MWK',
'MXN',
'MYR',
'MZN',
'NAD',
'NGN',
'NIO',
'NOK',
'NPR',
'NZD',
'PAB',
'PEN',
'PGK',
'PHP',
'PKR',
'PLN',
'PYG',
'QAR',
'RON',
'RUB',
'RWF',
'SAR',
'SBD',
'SCR',
'SEK',
'SGD',
'SHP',
'SLL',
'SOS',
'SRD',
'STD',
'SZL',
'THB',
'TJS',
'TOP',
'TRY',
'TTD',
'TWD',
'TZS',
'UAH',
'UGX',
'UYU',
'UZS',
'VND',
'VUV',
'WST',
'XAF',
'XCD',
'XOF',
'XPF',
'YER',
'ZAR',
'ZMW',
];

Просмотреть файл

@ -0,0 +1,40 @@
/* 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 { BaseError } from '@fxa/shared/error';
export class CurrencyError extends BaseError {
constructor(message: string, info: Record<string, any>, cause?: Error) {
super(message, {
name: 'CurrencyError',
cause,
info,
});
}
}
export class CurrencyCodeInvalidError extends CurrencyError {
constructor(currency: string | null | undefined) {
super('Invalid currency code', {
currency,
});
}
}
export class CountryCodeInvalidError extends CurrencyError {
constructor(country: string | null | undefined) {
super('Invalid country code', {
country,
});
}
}
export class CurrencyCountryMismatchError extends CurrencyError {
constructor(currency: string, country: string) {
super('Funding source country does not match plan currency', {
currency,
country,
});
}
}

Просмотреть файл

@ -0,0 +1,70 @@
/* 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 { Test, TestingModule } from '@nestjs/testing';
import { faker } from '@faker-js/faker';
import { CurrencyManager } from './currency.manager';
import {
CurrencyCodeInvalidError,
CountryCodeInvalidError,
CurrencyCountryMismatchError,
} from './currency.error';
import { CURRENCIES_TO_COUNTRIES } from './currency.constants';
describe('CurrencyManager', () => {
let mockCurrencyManager: CurrencyManager;
beforeEach(async () => {
const module: TestingModule = await Test.createTestingModule({
providers: [CurrencyManager],
}).compile();
mockCurrencyManager = module.get<CurrencyManager>(CurrencyManager);
});
describe('assertCurrencyCompatibleWithCountry', () => {
const validCountry = faker.helpers.arrayElement(
CURRENCIES_TO_COUNTRIES.USD
);
const validCurrency = 'USD';
it('asserts when currency to country is valid', () => {
mockCurrencyManager.assertCurrencyCompatibleWithCountry(
validCurrency,
validCountry
);
});
it('throws an error when currency is invalid', () => {
expect(() =>
mockCurrencyManager.assertCurrencyCompatibleWithCountry(
'',
validCountry
)
).toThrow(CurrencyCodeInvalidError);
});
it('throws an error when country is invalid', () => {
const countryCode = faker.location.countryCode('alpha-3');
expect(() =>
mockCurrencyManager.assertCurrencyCompatibleWithCountry(
validCurrency,
countryCode
)
).toThrow(CountryCodeInvalidError);
});
it('throws an error when currency to country do not match', () => {
const currencyCode = 'EUR';
expect(() =>
mockCurrencyManager.assertCurrencyCompatibleWithCountry(
currencyCode,
validCountry
)
).toThrow(CurrencyCountryMismatchError);
});
});
});

Просмотреть файл

@ -0,0 +1,55 @@
/* 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 { Injectable } from '@nestjs/common';
import {
CURRENCIES_TO_COUNTRIES,
SUPPORTED_PAYPAL_CURRENCIES,
VALID_COUNTRY_CODES,
VALID_CURRENCY_CODES,
} from './currency.constants';
import {
CurrencyCodeInvalidError,
CountryCodeInvalidError,
CurrencyCountryMismatchError,
} from './currency.error';
@Injectable()
export class CurrencyManager {
constructor() {}
/**
* Verify that provided source country and plan currency are compatible with
* valid currencies and countries as listed in constants
*
* @param currency Currency of customer
* @param country Country of customer
* @returns True if currency is compatible with country, else throws error
*/
assertCurrencyCompatibleWithCountry(currency: string, country: string): void {
if (!currency) throw new CurrencyCodeInvalidError(currency);
if (!country) throw new CountryCodeInvalidError(country);
if (
!VALID_CURRENCY_CODES.includes(currency) ||
!SUPPORTED_PAYPAL_CURRENCIES.includes(currency) ||
!CURRENCIES_TO_COUNTRIES.hasOwnProperty(currency)
) {
throw new CurrencyCodeInvalidError(currency);
}
if (!VALID_COUNTRY_CODES.includes(country)) {
throw new CountryCodeInvalidError(country);
}
if (
currency in CURRENCIES_TO_COUNTRIES &&
!CURRENCIES_TO_COUNTRIES[
currency as keyof typeof CURRENCIES_TO_COUNTRIES
].includes(country)
) {
throw new CurrencyCountryMismatchError(currency, country);
}
}
}

Просмотреть файл

@ -0,0 +1,16 @@
{
"extends": "../../../tsconfig.base.json",
"compilerOptions": {
"module": "commonjs"
},
"files": [],
"include": [],
"references": [
{
"path": "./tsconfig.lib.json"
},
{
"path": "./tsconfig.spec.json"
}
]
}

Просмотреть файл

@ -0,0 +1,10 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"declaration": true,
"types": ["node"]
},
"exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"],
"include": ["src/**/*.ts"]
}

Просмотреть файл

@ -0,0 +1,14 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"outDir": "../../../dist/out-tsc",
"module": "commonjs",
"types": ["jest", "node"]
},
"include": [
"jest.config.ts",
"src/**/*.test.ts",
"src/**/*.spec.ts",
"src/**/*.d.ts"
]
}

Просмотреть файл

@ -8,6 +8,5 @@ export * from './lib/paypal.client';
export * from './lib/paypal.client.types';
export * from './lib/paypal.error';
export * from './lib/paypal.manager';
export * from './lib/paypal.service';
export * from './lib/paypal.types';
export * from './lib/util';

Просмотреть файл

@ -4,6 +4,7 @@
import { faker } from '@faker-js/faker';
import {
NVPCreateBillingAgreementResponse,
NVPDoReferenceTransactionResponse,
NVPError,
NVPErrorResponse,
@ -62,6 +63,14 @@ export const NVPSetExpressCheckoutResponseFactory = (
...override,
});
export const NVPCreateBillingAgreementResponseFactory = (
override?: Partial<NVPCreateBillingAgreementResponse>
): NVPCreateBillingAgreementResponse => ({
...NVPResponseFactory(),
BILLINGAGREEMENTID: faker.string.alphanumeric(),
...override,
});
export const NVPDoReferenceTransactionResponseFactory = (
override?: Partial<NVPDoReferenceTransactionResponse>
): NVPDoReferenceTransactionResponse => ({

Просмотреть файл

@ -55,3 +55,13 @@ export class PaypalManagerError extends BaseError {
super(...args);
}
}
export class AmountExceedsPayPalCharLimitError extends PaypalManagerError {
constructor(amountInCents: number) {
super('Amount must be less than 10 characters', {
info: {
amountInCents
}
});
}
}

Просмотреть файл

@ -2,91 +2,85 @@
* 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 { Kysely } from 'kysely';
import { Test } from '@nestjs/testing';
import {
StripeApiListFactory,
StripeClient,
StripeConfig,
StripeCustomerFactory,
StripeInvoiceFactory,
StripeManager,
StripeResponseFactory,
StripeSubscription,
StripeSubscriptionFactory,
} from '@fxa/payments/stripe';
import { DB, testAccountDatabaseSetup } from '@fxa/shared/db/mysql/account';
import {
AccountDbProvider,
MockAccountDatabaseNestFactory,
} from '@fxa/shared/db/mysql/account';
import {
NVPCreateBillingAgreementResponseFactory,
NVPBAUpdateTransactionResponseFactory,
NVPSetExpressCheckoutResponseFactory,
} from './factories';
import { PayPalClient } from './paypal.client';
import { PayPalManager } from './paypal.manager';
import { BillingAgreementStatus } from './paypal.types';
import {
AmountExceedsPayPalCharLimitError,
PaypalManagerError,
} from './paypal.error';
import { PaypalCustomerMultipleRecordsError } from './paypalCustomer/paypalCustomer.error';
import { ResultPaypalCustomerFactory } from './paypalCustomer/paypalCustomer.factories';
import { PaypalCustomerManager } from './paypalCustomer/paypalCustomer.manager';
import { PaypalManagerError } from './paypal.error';
describe('PaypalManager', () => {
let kyselyDb: Kysely<DB>;
let paypalClient: PayPalClient;
describe('PayPalManager', () => {
let paypalManager: PayPalManager;
let stripeClient: StripeClient;
let stripeConfig: StripeConfig;
let paypalClient: PayPalClient;
let stripeManager: StripeManager;
let paypalCustomerManager: PaypalCustomerManager;
beforeAll(async () => {
kyselyDb = await testAccountDatabaseSetup([
'paypalCustomers',
'accountCustomers',
]);
beforeEach(async () => {
const moduleRef = await Test.createTestingModule({
providers: [
PayPalManager,
{
provide: AccountDbProvider,
useValue: MockAccountDatabaseNestFactory,
},
PayPalClient,
StripeManager,
PaypalCustomerManager,
],
})
.overrideProvider(PayPalClient)
.useValue({
createBillingAgreement: jest.fn(),
baUpdate: jest.fn(),
setExpressCheckout: jest.fn(),
})
.overrideProvider(StripeManager)
.useValue({
getMinimumAmount: jest.fn(),
getSubscriptions: jest.fn(),
finalizeInvoiceWithoutAutoAdvance: jest.fn(),
fetchActiveCustomer: jest.fn(),
})
.overrideProvider(PaypalCustomerManager)
.useValue({
createPaypalCustomer: jest.fn(),
fetchPaypalCustomersByUid: jest.fn(),
})
.compile();
paypalClient = new PayPalClient({
sandbox: false,
user: faker.string.uuid(),
pwd: faker.string.uuid(),
signature: faker.string.uuid(),
});
stripeConfig = {
apiKey: faker.string.uuid(),
taxIds: { EUR: 'EU1234' },
};
stripeClient = new StripeClient({} as any);
stripeManager = new StripeManager(stripeClient, stripeConfig);
paypalCustomerManager = new PaypalCustomerManager(kyselyDb);
paypalManager = new PayPalManager(
paypalClient,
stripeManager,
paypalCustomerManager
);
paypalManager = moduleRef.get(PayPalManager);
paypalClient = moduleRef.get(PayPalClient);
stripeManager = moduleRef.get(StripeManager);
paypalCustomerManager = moduleRef.get(PaypalCustomerManager);
});
afterAll(async () => {
if (kyselyDb) {
await kyselyDb.destroy();
}
});
describe('createBillingAgreement', () => {
it('creates a billing agreement', async () => {
const token = faker.string.uuid();
const mockBillingAgreement = NVPBAUpdateTransactionResponseFactory();
paypalClient.createBillingAgreement = jest
.fn()
.mockResolvedValueOnce(mockBillingAgreement);
const result = await paypalManager.createBillingAgreement(token);
expect(result).toEqual(mockBillingAgreement.BILLINGAGREEMENTID);
expect(paypalClient.createBillingAgreement).toBeCalledWith({
token,
});
});
afterEach(() => {
jest.restoreAllMocks();
});
describe('getOrCreateBillingAgreementId', () => {
@ -116,25 +110,23 @@ describe('PaypalManager', () => {
it('returns a new billing agreement when no billing agreement exists and token passed', async () => {
const uid = faker.string.uuid();
const token = faker.string.uuid();
const mockNewBillingAgreement = NVPBAUpdateTransactionResponseFactory();
const mockBillingAgreementId = faker.string.uuid();
paypalCustomerManager.fetchPaypalCustomersByUid = jest
paypalManager.getCustomerBillingAgreementId = jest
.fn()
.mockResolvedValueOnce([]);
.mockResolvedValueOnce(undefined);
paypalClient.createBillingAgreement = jest
paypalManager.createBillingAgreement = jest
.fn()
.mockResolvedValueOnce(mockNewBillingAgreement);
.mockResolvedValueOnce(mockBillingAgreementId);
const result = await paypalManager.getOrCreateBillingAgreementId(
uid,
false,
token
);
expect(result).toEqual(mockNewBillingAgreement.BILLINGAGREEMENTID);
expect(paypalClient.createBillingAgreement).toBeCalledWith({
token,
});
expect(result).toEqual(mockBillingAgreementId);
expect(paypalManager.createBillingAgreement).toBeCalledWith(uid, token);
});
it('throws an error if no billing agreement id is present and user has subscriptions', async () => {
@ -179,13 +171,14 @@ describe('PaypalManager', () => {
it('cancels a billing agreement', async () => {
const billingAgreementId = faker.string.sample();
paypalClient.baUpdate = jest
.fn()
jest
.spyOn(paypalClient, 'baUpdate')
.mockResolvedValueOnce(NVPBAUpdateTransactionResponseFactory());
const result = await paypalManager.cancelBillingAgreement(
billingAgreementId
);
expect(result).toBeUndefined();
expect(paypalClient.baUpdate).toBeCalledWith({
billingAgreementId,
@ -194,112 +187,144 @@ describe('PaypalManager', () => {
});
it('throws an error', async () => {
expect(paypalManager.cancelBillingAgreement).rejects.toThrowError();
const billingAgreementId = faker.string.sample();
jest.spyOn(paypalClient, 'baUpdate').mockRejectedValue(new Error('Boom'));
expect(() =>
paypalManager.cancelBillingAgreement(billingAgreementId)
).rejects.toThrowError();
});
});
describe('createBillingAgreement', () => {
it('creates a billing agreement', async () => {
const uid = faker.string.uuid();
const token = faker.string.uuid();
const billingAgreement = NVPCreateBillingAgreementResponseFactory();
const paypalCustomer = ResultPaypalCustomerFactory();
jest
.spyOn(paypalClient, 'createBillingAgreement')
.mockResolvedValue(billingAgreement);
jest
.spyOn(paypalCustomerManager, 'createPaypalCustomer')
.mockResolvedValue(paypalCustomer);
const result = await paypalManager.createBillingAgreement(uid, token);
expect(paypalClient.createBillingAgreement).toHaveBeenCalledWith({
token,
});
expect(paypalCustomerManager.createPaypalCustomer).toHaveBeenCalledWith({
uid: uid,
billingAgreementId: billingAgreement.BILLINGAGREEMENTID,
status: 'active',
endedAt: null,
});
expect(result).toEqual(paypalCustomer.billingAgreementId);
});
it('throws an error', async () => {
expect(paypalManager.createBillingAgreement).rejects.toThrowError();
});
});
describe('getBillingAgreement', () => {
it('returns agreement details (active status)', async () => {
const nvpBillingAgreementMock = NVPBAUpdateTransactionResponseFactory();
const billingAgreementId = faker.string.sample();
const successfulBAUpdateResponse = NVPBAUpdateTransactionResponseFactory({
BILLINGAGREEMENTSTATUS: 'Active',
});
paypalClient.baUpdate = jest
.fn()
.mockResolvedValueOnce(successfulBAUpdateResponse);
const expected = {
city: successfulBAUpdateResponse.CITY,
countryCode: successfulBAUpdateResponse.COUNTRYCODE,
firstName: successfulBAUpdateResponse.FIRSTNAME,
lastName: successfulBAUpdateResponse.LASTNAME,
state: successfulBAUpdateResponse.STATE,
status: BillingAgreementStatus.Active,
street: successfulBAUpdateResponse.STREET,
street2: successfulBAUpdateResponse.STREET2,
zip: successfulBAUpdateResponse.ZIP,
};
const baUpdateMock = jest
.spyOn(paypalClient, 'baUpdate')
.mockResolvedValue(nvpBillingAgreementMock);
const result = await paypalManager.getBillingAgreement(
billingAgreementId
);
expect(result).toEqual(expected);
expect(paypalClient.baUpdate).toBeCalledTimes(1);
expect(paypalClient.baUpdate).toBeCalledWith({ billingAgreementId });
expect(result).toEqual({
city: nvpBillingAgreementMock.CITY,
countryCode: nvpBillingAgreementMock.COUNTRYCODE,
firstName: nvpBillingAgreementMock.FIRSTNAME,
lastName: nvpBillingAgreementMock.LASTNAME,
state: nvpBillingAgreementMock.STATE,
status: BillingAgreementStatus.Active,
street: nvpBillingAgreementMock.STREET,
street2: nvpBillingAgreementMock.STREET2,
zip: nvpBillingAgreementMock.ZIP,
});
expect(baUpdateMock).toBeCalledTimes(1);
expect(baUpdateMock).toBeCalledWith({ billingAgreementId });
});
it('returns agreement details (cancelled status)', async () => {
const billingAgreementId = faker.string.sample();
const successfulBAUpdateResponse = NVPBAUpdateTransactionResponseFactory({
const nvpBillingAgreementMock = NVPBAUpdateTransactionResponseFactory({
BILLINGAGREEMENTSTATUS: 'Canceled',
});
paypalClient.baUpdate = jest
.fn()
.mockResolvedValueOnce(successfulBAUpdateResponse);
const expected = {
city: successfulBAUpdateResponse.CITY,
countryCode: successfulBAUpdateResponse.COUNTRYCODE,
firstName: successfulBAUpdateResponse.FIRSTNAME,
lastName: successfulBAUpdateResponse.LASTNAME,
state: successfulBAUpdateResponse.STATE,
status: BillingAgreementStatus.Cancelled,
street: successfulBAUpdateResponse.STREET,
street2: successfulBAUpdateResponse.STREET2,
zip: successfulBAUpdateResponse.ZIP,
};
const baUpdateMock = jest
.spyOn(paypalClient, 'baUpdate')
.mockResolvedValue(nvpBillingAgreementMock);
const result = await paypalManager.getBillingAgreement(
billingAgreementId
);
expect(result).toEqual(expected);
expect(paypalClient.baUpdate).toBeCalledTimes(1);
expect(paypalClient.baUpdate).toBeCalledWith({ billingAgreementId });
expect(result).toEqual({
city: nvpBillingAgreementMock.CITY,
countryCode: nvpBillingAgreementMock.COUNTRYCODE,
firstName: nvpBillingAgreementMock.FIRSTNAME,
lastName: nvpBillingAgreementMock.LASTNAME,
state: nvpBillingAgreementMock.STATE,
status: BillingAgreementStatus.Cancelled,
street: nvpBillingAgreementMock.STREET,
street2: nvpBillingAgreementMock.STREET2,
zip: nvpBillingAgreementMock.ZIP,
});
expect(baUpdateMock).toBeCalledTimes(1);
expect(baUpdateMock).toBeCalledWith({ billingAgreementId });
});
});
describe('getCustomerBillingAgreementId', () => {
it("returns the customer's current PayPal billing agreement ID", async () => {
const uid = faker.string.uuid();
const mockPayPalCustomer = ResultPaypalCustomerFactory();
const mockStripeCustomer = StripeCustomerFactory();
paypalCustomerManager.fetchPaypalCustomersByUid = jest
.fn()
.mockResolvedValueOnce([mockPayPalCustomer]);
.mockResolvedValue([mockPayPalCustomer]);
const result = await paypalManager.getCustomerBillingAgreementId(
mockStripeCustomer.id
);
const result = await paypalManager.getCustomerBillingAgreementId(uid);
expect(result).toEqual(mockPayPalCustomer.billingAgreementId);
});
it('returns undefined if no PayPal customer record', async () => {
const mockStripeCustomer = StripeCustomerFactory();
const uid = faker.string.uuid();
paypalCustomerManager.fetchPaypalCustomersByUid = jest
.fn()
.mockResolvedValueOnce([]);
.mockResolvedValue([]);
const result = await paypalManager.getCustomerBillingAgreementId(
mockStripeCustomer.id
);
const result = await paypalManager.getCustomerBillingAgreementId(uid);
expect(result).toEqual(undefined);
});
it('throws PaypalCustomerMultipleRecordsError if more than one PayPal customer found', async () => {
const uid = faker.string.uuid();
const mockPayPalCustomer1 = ResultPaypalCustomerFactory();
const mockPayPalCustomer2 = ResultPaypalCustomerFactory();
const mockStripeCustomer = StripeCustomerFactory();
paypalCustomerManager.fetchPaypalCustomersByUid = jest
.fn()
.mockResolvedValueOnce([mockPayPalCustomer1, mockPayPalCustomer2]);
.mockResolvedValue([mockPayPalCustomer1, mockPayPalCustomer2]);
expect.assertions(1);
expect(
paypalManager.getCustomerBillingAgreementId(mockStripeCustomer.id)
paypalManager.getCustomerBillingAgreementId(uid)
).rejects.toBeInstanceOf(PaypalCustomerMultipleRecordsError);
});
});
@ -328,23 +353,21 @@ describe('PaypalManager', () => {
);
expect(result).toEqual(expected);
});
});
it('returns empty array when no subscriptions', async () => {
const mockCustomer = StripeCustomerFactory();
const mockPayPalSubscription = [] as StripeSubscription[];
const mockSubscriptionList = StripeApiListFactory([
mockPayPalSubscription,
]);
it('returns empty array when no subscriptions', async () => {
const mockCustomer = StripeCustomerFactory();
const mockPayPalSubscription = [] as StripeSubscription[];
const mockSubscriptionList = StripeApiListFactory([mockPayPalSubscription]);
stripeManager.getSubscriptions = jest
.fn()
.mockResolvedValueOnce(mockSubscriptionList);
stripeManager.getSubscriptions = jest
.fn()
.mockResolvedValueOnce(mockSubscriptionList);
const result = await paypalManager.getCustomerPayPalSubscriptions(
mockCustomer.id
);
expect(result).toEqual([]);
});
const result = await paypalManager.getCustomerPayPalSubscriptions(
mockCustomer.id
);
expect(result).toEqual([]);
});
describe('getCheckoutToken', () => {
@ -387,36 +410,67 @@ describe('PaypalManager', () => {
describe('processInvoice', () => {
it('calls processZeroInvoice when amount is less than minimum amount', async () => {
const mockInvoice = StripeInvoiceFactory({
amount_due: 0,
currency: 'usd',
});
const mockInvoice = StripeResponseFactory(
StripeInvoiceFactory({
amount_due: 0,
currency: 'usd',
})
);
paypalManager.processZeroInvoice = jest.fn().mockResolvedValueOnce({});
jest.spyOn(stripeManager, 'getMinimumAmount').mockReturnValue(10);
jest
.spyOn(paypalManager, 'processZeroInvoice')
.mockResolvedValue(mockInvoice);
jest.spyOn(paypalManager, 'processNonZeroInvoice').mockResolvedValue();
const result = await paypalManager.processInvoice(mockInvoice);
expect(result).toEqual({});
await paypalManager.processInvoice(mockInvoice);
expect(paypalManager.processZeroInvoice).toBeCalledWith(mockInvoice.id);
expect(paypalManager.processNonZeroInvoice).not.toHaveBeenCalled();
});
it('calls PayPalManager processNonZeroInvoice when amount is greater than minimum amount', async () => {
const mockCustomer = StripeCustomerFactory();
const mockCustomer = StripeResponseFactory(StripeCustomerFactory());
const mockInvoice = StripeInvoiceFactory({
amount_due: 50,
currency: 'usd',
});
stripeManager.fetchActiveCustomer = jest
.fn()
.mockResolvedValueOnce(mockCustomer);
paypalManager.processNonZeroInvoice = jest.fn().mockResolvedValueOnce({});
jest.spyOn(stripeManager, 'getMinimumAmount').mockReturnValue(10);
jest
.spyOn(stripeManager, 'fetchActiveCustomer')
.mockResolvedValue(mockCustomer);
jest
.spyOn(paypalManager, 'processZeroInvoice')
.mockResolvedValue(StripeResponseFactory(mockInvoice));
jest.spyOn(paypalManager, 'processNonZeroInvoice').mockResolvedValue();
await paypalManager.processInvoice(mockInvoice);
const result = await paypalManager.processInvoice(mockInvoice);
expect(result).toEqual({});
expect(paypalManager.processNonZeroInvoice).toBeCalledWith(
mockCustomer,
mockInvoice
);
expect(paypalManager.processZeroInvoice).not.toHaveBeenCalled();
});
});
describe('getPayPalAmountStringFromAmountInCents', () => {
it('returns correctly formatted string', () => {
const amountInCents = 9999999999;
const expectedResult = (amountInCents / 100).toFixed(2);
const result =
paypalManager.getPayPalAmountStringFromAmountInCents(amountInCents);
expect(result).toEqual(expectedResult);
});
it('throws an error if number exceeds digit limit', () => {
const amountInCents = 12345678910;
expect(() => {
paypalManager.getPayPalAmountStringFromAmountInCents(amountInCents);
}).toThrow(AmountExceedsPayPalCharLimitError);
});
});
});

Просмотреть файл

@ -13,6 +13,7 @@ import {
import { PayPalClient } from './paypal.client';
import { BillingAgreement, BillingAgreementStatus } from './paypal.types';
import { PaypalCustomerMultipleRecordsError } from './paypalCustomer/paypalCustomer.error';
import { AmountExceedsPayPalCharLimitError } from './paypal.error';
import { PaypalCustomerManager } from './paypalCustomer/paypalCustomer.manager';
import { PaypalManagerError } from './paypal.error';
@ -24,18 +25,6 @@ export class PayPalManager {
private paypalCustomerManager: PaypalCustomerManager
) {}
/**
* Create billing agreement using the ExpressCheckout token.
*
* If the call to PayPal fails, a PayPalClientError will be thrown.
*/
public async createBillingAgreement(token: string): Promise<string> {
const response = await this.client.createBillingAgreement({
token,
});
return response.BILLINGAGREEMENTID;
}
public async getOrCreateBillingAgreementId(
uid: string,
hasSubscriptions: boolean,
@ -57,7 +46,7 @@ export class PayPalManager {
);
}
const newBillingAgreementId = await this.createBillingAgreement(token);
const newBillingAgreementId = await this.createBillingAgreement(uid, token);
return newBillingAgreementId;
}
@ -69,6 +58,28 @@ export class PayPalManager {
await this.client.baUpdate({ billingAgreementId, cancel: true });
}
/**
* Create and verify a billing agreement is funded from the appropriate
* country given the currency of the billing agreement.
*/
async createBillingAgreement(uid: string, token: string) {
const billingAgreement = await this.client.createBillingAgreement({
token,
});
const billingAgreementId = billingAgreement.BILLINGAGREEMENTID;
const paypalCustomer =
await this.paypalCustomerManager.createPaypalCustomer({
uid: uid,
billingAgreementId: billingAgreementId,
status: 'active',
endedAt: null,
});
return paypalCustomer.billingAgreementId;
}
/**
* Get Billing Agreement details by calling the update Billing Agreement API.
* Parses the API call response for country code and billing agreement status
@ -166,12 +177,31 @@ export class PayPalManager {
const amountInCents = invoice.amount_due;
if (amountInCents < this.stripeManager.getMinimumAmount(invoice.currency)) {
return await this.processZeroInvoice(invoice.id);
} else {
const customer = await this.stripeManager.fetchActiveCustomer(
invoice.customer
);
return await this.processNonZeroInvoice(customer, invoice);
await this.processZeroInvoice(invoice.id);
return;
}
const customer = await this.stripeManager.fetchActiveCustomer(
invoice.customer
);
await this.processNonZeroInvoice(customer, invoice);
}
/*
* Convert amount in cents to paypal AMT string.
* We use Stripe to manage everything and plans are recorded in an AmountInCents.
* PayPal AMT field requires a string of 10 characters or less, as documented here:
* https://developer.paypal.com/docs/nvp-soap-api/do-reference-transaction-nvp/#payment-details-fields
* https://developer.paypal.com/docs/api/payments/v1/#definition-amount
*/
getPayPalAmountStringFromAmountInCents(amountInCents: number): string {
if (amountInCents.toString().length > 10) {
throw new AmountExceedsPayPalCharLimitError(amountInCents);
}
// Left pad with zeros if necessary, so we always get a minimum of 0.01.
const amountAsString = String(amountInCents).padStart(3, '0');
const dollars = amountAsString.slice(0, -2);
const cents = amountAsString.slice(-2);
return `${dollars}.${cents}`;
}
}

Просмотреть файл

@ -8,6 +8,8 @@ import {
UpdatePaypalCustomer,
} from './paypalCustomer.types';
import { BillingAgreement, BillingAgreementStatus } from '../paypal.types';
export const ResultPaypalCustomerFactory = (
override?: Partial<ResultPaypalCustomer>
): ResultPaypalCustomer => ({
@ -54,3 +56,18 @@ export const UpdatePaypalCustomerFactory = (
endedAt: null,
...override,
});
export const BillingAgreementFactory = (
override?: Partial<BillingAgreement>
): BillingAgreement => ({
city: faker.location.city(),
countryCode: faker.location.countryCode(),
firstName: faker.person.firstName(),
lastName: faker.person.lastName(),
state: faker.location.state(),
status: BillingAgreementStatus.Active,
street: faker.location.streetAddress(),
street2: faker.location.streetAddress(),
zip: faker.location.zipCode(),
...override,
});

Просмотреть файл

@ -13,4 +13,7 @@ export { setupAccountDatabase, AccountDbProvider } from './lib/setup';
export { testAccountDatabaseSetup } from './lib/tests';
export type { ACCOUNT_TABLES } from './lib/tests';
export type { AccountDatabase } from './lib/setup';
export { AccountDatabaseNestFactory } from './lib/account.provider';
export {
AccountDatabaseNestFactory,
MockAccountDatabaseNestFactory,
} from './lib/account.provider';

Просмотреть файл

@ -25,6 +25,7 @@
"baseUrl": ".",
"paths": {
"@fxa/payments/capability": ["libs/payments/capability/src/index.ts"],
"@fxa/payments/currency": ["libs/payments/currency/src/index.ts"],
"@fxa/payments/cart": ["libs/payments/cart/src/index.ts"],
"@fxa/payments/eligibility": ["libs/payments/eligibility/src/index.ts"],
"@fxa/payments/legacy": ["libs/payments/legacy/src/index.ts"],