From 5d5b8f05e05671852eaceba98bf058fd9341aca0 Mon Sep 17 00:00:00 2001 From: Meghan Sardesai Date: Tue, 26 Mar 2024 13:26:59 -0400 Subject: [PATCH] feat(payments-paypal): create PaypalService createBillingAgreement method Because: * Part of M3a. * We want `createBillingAgreement` added to PaypalService. This commit: * Adds `createBillingAgreement` in `PaypalService`. * Adds `CurrencyManager` library. * Adds `status` to `createBillingAgreement` in `PaypalRepository`. * Adds/updates all applicable tests. Closes FXA-8935 --- libs/payments/currency/.eslintrc.json | 18 + libs/payments/currency/README.md | 11 + libs/payments/currency/jest.config.ts | 11 + libs/payments/currency/package.json | 4 + libs/payments/currency/project.json | 34 ++ .../src/index.ts} | 10 +- .../currency/src/lib/currency.constants.ts | 432 ++++++++++++++++++ .../currency/src/lib/currency.error.ts | 40 ++ .../currency/src/lib/currency.manager.spec.ts | 70 +++ .../currency/src/lib/currency.manager.ts | 55 +++ libs/payments/currency/tsconfig.json | 16 + libs/payments/currency/tsconfig.lib.json | 10 + libs/payments/currency/tsconfig.spec.json | 14 + libs/payments/paypal/src/index.ts | 1 - libs/payments/paypal/src/lib/factories.ts | 9 + libs/payments/paypal/src/lib/paypal.error.ts | 10 + ...ager.in.spec.ts => paypal.manager.spec.ts} | 356 +++++++++------ .../payments/paypal/src/lib/paypal.manager.ts | 68 ++- .../paypalCustomer.factories.ts | 17 + libs/shared/db/mysql/account/src/index.ts | 5 +- tsconfig.base.json | 1 + 21 files changed, 1013 insertions(+), 179 deletions(-) create mode 100644 libs/payments/currency/.eslintrc.json create mode 100644 libs/payments/currency/README.md create mode 100644 libs/payments/currency/jest.config.ts create mode 100644 libs/payments/currency/package.json create mode 100644 libs/payments/currency/project.json rename libs/payments/{paypal/src/lib/paypal.service.ts => currency/src/index.ts} (51%) create mode 100644 libs/payments/currency/src/lib/currency.constants.ts create mode 100644 libs/payments/currency/src/lib/currency.error.ts create mode 100644 libs/payments/currency/src/lib/currency.manager.spec.ts create mode 100644 libs/payments/currency/src/lib/currency.manager.ts create mode 100644 libs/payments/currency/tsconfig.json create mode 100644 libs/payments/currency/tsconfig.lib.json create mode 100644 libs/payments/currency/tsconfig.spec.json rename libs/payments/paypal/src/lib/{paypal.manager.in.spec.ts => paypal.manager.spec.ts} (55%) diff --git a/libs/payments/currency/.eslintrc.json b/libs/payments/currency/.eslintrc.json new file mode 100644 index 0000000000..3456be9b90 --- /dev/null +++ b/libs/payments/currency/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/libs/payments/currency/README.md b/libs/payments/currency/README.md new file mode 100644 index 0000000000..7ed696eece --- /dev/null +++ b/libs/payments/currency/README.md @@ -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). diff --git a/libs/payments/currency/jest.config.ts b/libs/payments/currency/jest.config.ts new file mode 100644 index 0000000000..12c47f1b68 --- /dev/null +++ b/libs/payments/currency/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'payments-currency', + preset: '../../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../../coverage/libs/payments/currency', +}; diff --git a/libs/payments/currency/package.json b/libs/payments/currency/package.json new file mode 100644 index 0000000000..ceb622b936 --- /dev/null +++ b/libs/payments/currency/package.json @@ -0,0 +1,4 @@ +{ + "name": "@fxa/payments/currency", + "version": "0.0.1" +} diff --git a/libs/payments/currency/project.json b/libs/payments/currency/project.json new file mode 100644 index 0000000000..a05a6c9101 --- /dev/null +++ b/libs/payments/currency/project.json @@ -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"] +} diff --git a/libs/payments/paypal/src/lib/paypal.service.ts b/libs/payments/currency/src/index.ts similarity index 51% rename from libs/payments/paypal/src/lib/paypal.service.ts rename to libs/payments/currency/src/index.ts index f749a403e4..085ce71977 100644 --- a/libs/payments/paypal/src/lib/paypal.service.ts +++ b/libs/payments/currency/src/index.ts @@ -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'; diff --git a/libs/payments/currency/src/lib/currency.constants.ts b/libs/payments/currency/src/lib/currency.constants.ts new file mode 100644 index 0000000000..2dc6284363 --- /dev/null +++ b/libs/payments/currency/src/lib/currency.constants.ts @@ -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', +]; diff --git a/libs/payments/currency/src/lib/currency.error.ts b/libs/payments/currency/src/lib/currency.error.ts new file mode 100644 index 0000000000..2c450b5de0 --- /dev/null +++ b/libs/payments/currency/src/lib/currency.error.ts @@ -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, 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, + }); + } +} diff --git a/libs/payments/currency/src/lib/currency.manager.spec.ts b/libs/payments/currency/src/lib/currency.manager.spec.ts new file mode 100644 index 0000000000..888b60734b --- /dev/null +++ b/libs/payments/currency/src/lib/currency.manager.spec.ts @@ -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); + }); + + 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); + }); + }); +}); diff --git a/libs/payments/currency/src/lib/currency.manager.ts b/libs/payments/currency/src/lib/currency.manager.ts new file mode 100644 index 0000000000..639280e792 --- /dev/null +++ b/libs/payments/currency/src/lib/currency.manager.ts @@ -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); + } + } +} diff --git a/libs/payments/currency/tsconfig.json b/libs/payments/currency/tsconfig.json new file mode 100644 index 0000000000..25f7201d87 --- /dev/null +++ b/libs/payments/currency/tsconfig.json @@ -0,0 +1,16 @@ +{ + "extends": "../../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs" + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/libs/payments/currency/tsconfig.lib.json b/libs/payments/currency/tsconfig.lib.json new file mode 100644 index 0000000000..877611e5c9 --- /dev/null +++ b/libs/payments/currency/tsconfig.lib.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"] +} diff --git a/libs/payments/currency/tsconfig.spec.json b/libs/payments/currency/tsconfig.spec.json new file mode 100644 index 0000000000..69a251f328 --- /dev/null +++ b/libs/payments/currency/tsconfig.spec.json @@ -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" + ] +} diff --git a/libs/payments/paypal/src/index.ts b/libs/payments/paypal/src/index.ts index 14bb4d20cc..2d99e45cc5 100644 --- a/libs/payments/paypal/src/index.ts +++ b/libs/payments/paypal/src/index.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'; diff --git a/libs/payments/paypal/src/lib/factories.ts b/libs/payments/paypal/src/lib/factories.ts index 22d2cf38c4..fa07a5fc82 100644 --- a/libs/payments/paypal/src/lib/factories.ts +++ b/libs/payments/paypal/src/lib/factories.ts @@ -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 => ({ + ...NVPResponseFactory(), + BILLINGAGREEMENTID: faker.string.alphanumeric(), + ...override, +}); + export const NVPDoReferenceTransactionResponseFactory = ( override?: Partial ): NVPDoReferenceTransactionResponse => ({ diff --git a/libs/payments/paypal/src/lib/paypal.error.ts b/libs/payments/paypal/src/lib/paypal.error.ts index 0b37081e4e..c6e4f62df1 100644 --- a/libs/payments/paypal/src/lib/paypal.error.ts +++ b/libs/payments/paypal/src/lib/paypal.error.ts @@ -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 + } + }); + } +} diff --git a/libs/payments/paypal/src/lib/paypal.manager.in.spec.ts b/libs/payments/paypal/src/lib/paypal.manager.spec.ts similarity index 55% rename from libs/payments/paypal/src/lib/paypal.manager.in.spec.ts rename to libs/payments/paypal/src/lib/paypal.manager.spec.ts index 5a7d84fb7d..ddc3cc0c42 100644 --- a/libs/payments/paypal/src/lib/paypal.manager.in.spec.ts +++ b/libs/payments/paypal/src/lib/paypal.manager.spec.ts @@ -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; - 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); }); }); }); diff --git a/libs/payments/paypal/src/lib/paypal.manager.ts b/libs/payments/paypal/src/lib/paypal.manager.ts index 962acbe730..87c602dfce 100644 --- a/libs/payments/paypal/src/lib/paypal.manager.ts +++ b/libs/payments/paypal/src/lib/paypal.manager.ts @@ -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 { - 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}`; } } diff --git a/libs/payments/paypal/src/lib/paypalCustomer/paypalCustomer.factories.ts b/libs/payments/paypal/src/lib/paypalCustomer/paypalCustomer.factories.ts index 4d27cd7444..f3c9633700 100644 --- a/libs/payments/paypal/src/lib/paypalCustomer/paypalCustomer.factories.ts +++ b/libs/payments/paypal/src/lib/paypalCustomer/paypalCustomer.factories.ts @@ -8,6 +8,8 @@ import { UpdatePaypalCustomer, } from './paypalCustomer.types'; +import { BillingAgreement, BillingAgreementStatus } from '../paypal.types'; + export const ResultPaypalCustomerFactory = ( override?: Partial ): ResultPaypalCustomer => ({ @@ -54,3 +56,18 @@ export const UpdatePaypalCustomerFactory = ( endedAt: null, ...override, }); + +export const BillingAgreementFactory = ( + override?: Partial +): 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, +}); diff --git a/libs/shared/db/mysql/account/src/index.ts b/libs/shared/db/mysql/account/src/index.ts index e0ff994eaa..75eebfdc48 100644 --- a/libs/shared/db/mysql/account/src/index.ts +++ b/libs/shared/db/mysql/account/src/index.ts @@ -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'; diff --git a/tsconfig.base.json b/tsconfig.base.json index 7112a36e67..b2f684616a 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -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"],