зеркало из https://github.com/mozilla/fxa.git
Merge pull request #16630 from mozilla/FXA-8935
feat(payments-paypal): create PaypalService createBillingAgreement method
This commit is contained in:
Коммит
0ddaf1b4d2
|
@ -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(),
|
||||
paypalManager = moduleRef.get(PayPalManager);
|
||||
paypalClient = moduleRef.get(PayPalClient);
|
||||
stripeManager = moduleRef.get(StripeManager);
|
||||
paypalCustomerManager = moduleRef.get(PaypalCustomerManager);
|
||||
});
|
||||
|
||||
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
|
||||
);
|
||||
});
|
||||
|
||||
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,13 +353,12 @@ 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,
|
||||
]);
|
||||
const mockSubscriptionList = StripeApiListFactory([mockPayPalSubscription]);
|
||||
|
||||
stripeManager.getSubscriptions = jest
|
||||
.fn()
|
||||
|
@ -345,7 +369,6 @@ describe('PaypalManager', () => {
|
|||
);
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCheckoutToken', () => {
|
||||
it('returns token and calls setExpressCheckout with passed options', async () => {
|
||||
|
@ -387,36 +410,67 @@ describe('PaypalManager', () => {
|
|||
|
||||
describe('processInvoice', () => {
|
||||
it('calls processZeroInvoice when amount is less than minimum amount', async () => {
|
||||
const mockInvoice = StripeInvoiceFactory({
|
||||
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 {
|
||||
await this.processZeroInvoice(invoice.id);
|
||||
return;
|
||||
}
|
||||
|
||||
const customer = await this.stripeManager.fetchActiveCustomer(
|
||||
invoice.customer
|
||||
);
|
||||
return await this.processNonZeroInvoice(customer, invoice);
|
||||
}
|
||||
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"],
|
||||
|
|
Загрузка…
Ссылка в новой задаче