From 19cd163b30f2ae021e743b3f5d1dd95bc939c290 Mon Sep 17 00:00:00 2001 From: Filip Stybel Date: Sat, 4 Mar 2023 01:40:49 +0100 Subject: [PATCH] Backend integration with Nordigen - account sync (#74) * Add nordigen integration * Move normalizatoin of accounts to the backend side * Remove .idea from git * Move normalization of transactions to the backend side * Fix some edgecases * Move nordigen to separate directory * Partial refactor of nordigen and e2e test * WIP refactor * Refactoring * Refactoring * Add more tests * Update get accounts path * Rm not needed import * Fix after merge * Fix AnimatedLoading * Fix coverage, jest config, linter * Code review changes * Upgrade to ESM nordigen * Upgrade to ESM nordigen * Remove e2e tests and cleanup packages * Move env vars to config * Rollback prettierrc config * Move nordigen app behind to src * Revert supertest lib * Fixing specs * fixes linter * Fix build errors * Fix linter * Update nordigen-node lib * remove snapshot * remove babel * Fix spec * fix linter * Revert "remove babel" This reverts commit 07ce9fc46043a425f6e83b0b5ce15789fd07e12e. * Fix coverage * Add supertest * Add sortByBookingDate as default sort option for integration bank * Add comment with explanation of client const --------- Co-authored-by: Filip Stybel --- .gitignore | 5 + babel.config.json | 3 + jest.config.json | 14 +- package.json | 4 +- src/app-nordigen/README.md | 61 ++ src/app-nordigen/app-nordigen.js | 166 +++++ src/app-nordigen/bank-factory.js | 9 + src/app-nordigen/banks/bank.interface.ts | 28 + src/app-nordigen/banks/ing-pl-ingbplpw.js | 45 ++ src/app-nordigen/banks/integration-bank.js | 38 ++ .../banks/mbank-retail-brexplpw.js | 41 ++ .../banks/sandboxfinance-sfin0000.js | 44 ++ .../banks/tests/ing-pl-ingbplpw.spec.js | 199 ++++++ .../banks/tests/integration-bank.spec.js | 155 +++++ .../banks/tests/mbank-retail-brexplpw.spec.js | 168 ++++++ .../tests/sandboxfinance-sfin0000.spec.js | 180 ++++++ src/app-nordigen/errors.js | 84 +++ src/app-nordigen/nordigen-node.types.ts | 472 +++++++++++++++ src/app-nordigen/nordigen.types.ts | 82 +++ src/app-nordigen/services/nordigen-service.js | 440 ++++++++++++++ src/app-nordigen/services/tests/fixtures.js | 180 ++++++ .../services/tests/nordigen-service.spec.js | 569 ++++++++++++++++++ src/app-nordigen/tests/bank-factory.spec.js | 35 ++ src/app-nordigen/tests/utils.spec.js | 37 ++ src/app-nordigen/util/handle-error.js | 9 + src/app-nordigen/utils.js | 14 + src/app.js | 2 + src/config-types.ts | 6 +- tsconfig.json | 3 +- yarn.lock | 83 +++ 30 files changed, 3171 insertions(+), 5 deletions(-) create mode 100644 babel.config.json create mode 100644 src/app-nordigen/README.md create mode 100644 src/app-nordigen/app-nordigen.js create mode 100644 src/app-nordigen/bank-factory.js create mode 100644 src/app-nordigen/banks/bank.interface.ts create mode 100644 src/app-nordigen/banks/ing-pl-ingbplpw.js create mode 100644 src/app-nordigen/banks/integration-bank.js create mode 100644 src/app-nordigen/banks/mbank-retail-brexplpw.js create mode 100644 src/app-nordigen/banks/sandboxfinance-sfin0000.js create mode 100644 src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js create mode 100644 src/app-nordigen/banks/tests/integration-bank.spec.js create mode 100644 src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js create mode 100644 src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js create mode 100644 src/app-nordigen/errors.js create mode 100644 src/app-nordigen/nordigen-node.types.ts create mode 100644 src/app-nordigen/nordigen.types.ts create mode 100644 src/app-nordigen/services/nordigen-service.js create mode 100644 src/app-nordigen/services/tests/fixtures.js create mode 100644 src/app-nordigen/services/tests/nordigen-service.spec.js create mode 100644 src/app-nordigen/tests/bank-factory.spec.js create mode 100644 src/app-nordigen/tests/utils.spec.js create mode 100644 src/app-nordigen/util/handle-error.js create mode 100644 src/app-nordigen/utils.js diff --git a/.gitignore b/.gitignore index b76a64a96..b1d1b52a5 100644 --- a/.gitignore +++ b/.gitignore @@ -23,3 +23,8 @@ build/ !.yarn/releases !.yarn/sdks !.yarn/versions + +dist +.idea +/coverage +/coverage-e2e diff --git a/babel.config.json b/babel.config.json new file mode 100644 index 000000000..64363362b --- /dev/null +++ b/babel.config.json @@ -0,0 +1,3 @@ +{ + "presets": ["@babel/preset-flow"] +} diff --git a/jest.config.json b/jest.config.json index b1ea03d77..396e05b0d 100644 --- a/jest.config.json +++ b/jest.config.json @@ -1,4 +1,14 @@ { - "setupFiles": ["./jest.setup.js"], - "testPathIgnorePatterns": ["/node_modules/", "/build/"] + "setupFiles": ["./jest.setup.js"], + "testPathIgnorePatterns": ["dist", "/node_modules/", "/build/"], + "roots": [""], + "testMatch": ["/**/*.spec.js"], + "moduleFileExtensions": ["ts", "js", "json"], + "testEnvironment": "node", + "collectCoverage": true, + "collectCoverageFrom": ["**/*.{js,ts,tsx}"], + "coveragePathIgnorePatterns": ["dist", "/node_modules/", "/build/"], + "coverageReporters": ["html", "lcov", "text", "text-summary"], + "resetMocks": true, + "restoreMocks": true } diff --git a/package.json b/package.json index 6d84cb643..1bbd64e87 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ "start": "node app", "lint": "eslint .", "build": "tsc", - "test": "NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest", + "test": "NODE_ENV=test NODE_OPTIONS='--experimental-vm-modules --trace-warnings' jest --coverage", "types": "tsc --noEmit --incremental", "verify": "yarn -s lint && yarn types" }, @@ -22,9 +22,11 @@ "express": "4.18.2", "express-actuator": "1.8.4", "express-response-size": "^0.0.3", + "nordigen-node": "^1.2.3", "uuid": "^9.0.0" }, "devDependencies": { + "@babel/preset-flow": "^7.18.6", "@types/bcrypt": "^5.0.0", "@types/better-sqlite3": "^7.5.0", "@types/cors": "^2.8.13", diff --git a/src/app-nordigen/README.md b/src/app-nordigen/README.md new file mode 100644 index 000000000..74f11c3bd --- /dev/null +++ b/src/app-nordigen/README.md @@ -0,0 +1,61 @@ +# Integration new bank + +Find in [doc](https://docs.google.com/spreadsheets/d/1ogpzydzotOltbssrc3IQ8rhBLlIZbQgm5QCiiNJrkyA/edit#gid=489769432) what is id of bank which you want to integrate + +Add the `institution_id` and your name to list of possible options in the frontend +project `actual/packages/loot-design/src/components/modals/NordigenExternalMsg.js` + +```jsx +Choose your banks: + { + let user = await validateUser(req, res); + if (!user) { + return; + } + next(); +}); + +app.post( + '/create-web-token', + handleError(async (req, res) => { + const { accessValidForDays, institutionId } = req.body; + const { origin } = req.headers; + + const { link, requisitionId } = await nordigenService.createRequisition({ + accessValidForDays, + institutionId, + host: origin + }); + + res.send({ + status: 'ok', + data: { + link, + requisitionId + } + }); + }) +); + +app.post( + '/get-accounts', + handleError(async (req, res) => { + const { requisitionId } = req.body; + + try { + const { requisition, accounts } = + await nordigenService.getRequisitionWithAccounts(requisitionId); + + res.send({ + status: 'ok', + data: { + ...requisition, + accounts + } + }); + } catch (error) { + if (error instanceof RequisitionNotLinked) { + res.send({ + status: 'ok', + requisitionStatus: error.details.requisitionStatus + }); + } else { + throw error; + } + } + }) +); + +app.post( + '/remove-account', + handleError(async (req, res) => { + let { requisitionId } = req.body; + + const data = await nordigenService.deleteRequisition(requisitionId); + if (data.summary === 'Requisition deleted') { + res.send({ + status: 'ok', + data + }); + } else { + res.send({ + status: 'error', + data: { + data, + reason: 'Can not delete requisition' + } + }); + } + }) +); + +app.post( + '/transactions', + handleError(async (req, res) => { + const { requisitionId, startDate, endDate, accountId } = req.body; + + try { + const { + balances, + institutionId, + startingBalance, + transactions: { booked, pending } + } = await nordigenService.getTransactionsWithBalance( + requisitionId, + accountId, + startDate, + endDate + ); + + res.send({ + status: 'ok', + data: { + balances, + institutionId, + startingBalance, + transactions: { + booked, + pending + } + } + }); + } catch (error) { + const sendErrorResponse = (data) => + res.send({ status: 'ok', data: { ...data, details: error.details } }); + + switch (true) { + case error instanceof RequisitionNotLinked: + sendErrorResponse({ + error_type: 'ITEM_ERROR', + error_code: 'ITEM_LOGIN_REQUIRED', + status: 'expired', + reason: 'Access to account has expired as set in End User Agreement' + }); + break; + case error instanceof AccountNotLinedToRequisition: + sendErrorResponse({ + error_type: 'INVALID_INPUT', + error_code: 'INVALID_ACCESS_TOKEN', + status: 'rejected', + reason: 'Account not linked with this requisition' + }); + break; + case error instanceof GenericNordigenError: + console.log({ message: 'Something went wrong', error }); + sendErrorResponse({ + error_type: 'SYNC_ERROR', + error_code: 'NORDIGEN_ERROR' + }); + break; + default: + console.log({ message: 'Something went wrong', error }); + sendErrorResponse({ + error_type: 'UNKNOWN', + error_code: 'UNKNOWN', + reason: 'Something went wrong' + }); + break; + } + } + }) +); diff --git a/src/app-nordigen/bank-factory.js b/src/app-nordigen/bank-factory.js new file mode 100644 index 000000000..fb902d9d7 --- /dev/null +++ b/src/app-nordigen/bank-factory.js @@ -0,0 +1,9 @@ +import IngPlIngbplpw from './banks/ing-pl-ingbplpw.js'; +import IntegrationBank from './banks/integration-bank.js'; +import MbankRetailBrexplpw from './banks/mbank-retail-brexplpw.js'; +import SandboxfinanceSfin0000 from './banks/sandboxfinance-sfin0000.js'; + +const banks = [MbankRetailBrexplpw, SandboxfinanceSfin0000, IngPlIngbplpw]; + +export default (institutionId) => + banks.find((b) => b.institutionId === institutionId) || IntegrationBank; diff --git a/src/app-nordigen/banks/bank.interface.ts b/src/app-nordigen/banks/bank.interface.ts new file mode 100644 index 000000000..788709e43 --- /dev/null +++ b/src/app-nordigen/banks/bank.interface.ts @@ -0,0 +1,28 @@ +import { + DetailedAccountWithInstitution, + NormalizedAccountDetails +} from '../nordigen.types.js'; +import { Transaction, Balance } from '../nordigen-node.types.js'; + +export interface IBank { + institutionId: string; + /** + * Returns normalized object with required data for the frontend + */ + normalizeAccount: ( + account: DetailedAccountWithInstitution + ) => NormalizedAccountDetails; + + /** + * Function sorts an array of transactions from newest to oldest + */ + sortTransactions: (transactions: Transaction[]) => Transaction[]; + + /** + * Calculates account balance before which was before transactions provided in sortedTransactions param + */ + calculateStartingBalance: ( + sortedTransactions: Transaction[], + balances: Balance[] + ) => number; +} diff --git a/src/app-nordigen/banks/ing-pl-ingbplpw.js b/src/app-nordigen/banks/ing-pl-ingbplpw.js new file mode 100644 index 000000000..1a90ae4fa --- /dev/null +++ b/src/app-nordigen/banks/ing-pl-ingbplpw.js @@ -0,0 +1,45 @@ +import { printIban, amountToInteger } from '../utils.js'; + +/** @type {import('./bank.interface.js').IBank} */ +export default { + institutionId: 'ING_PL_INGBPLPW', + + normalizeAccount(account) { + return { + account_id: account.id, + institution: account.institution, + mask: account.iban.slice(-4), + name: [account.product, printIban(account)].join(' ').trim(), + official_name: account.product, + type: 'checking' + }; + }, + + sortTransactions(transactions = []) { + return transactions.sort((a, b) => { + return ( + Number(b.transactionId.substr(2)) - Number(a.transactionId.substr(2)) + ); + }); + }, + + calculateStartingBalance(sortedTransactions = [], balances = []) { + if (sortedTransactions.length) { + const oldestTransaction = + sortedTransactions[sortedTransactions.length - 1]; + const oldestKnownBalance = amountToInteger( + oldestTransaction.balanceAfterTransaction.balanceAmount.amount + ); + const oldestTransactionAmount = amountToInteger( + oldestTransaction.transactionAmount.amount + ); + + return oldestKnownBalance - oldestTransactionAmount; + } else { + return amountToInteger( + balances.find((balance) => 'interimBooked' === balance.balanceType) + .balanceAmount.amount + ); + } + } +}; diff --git a/src/app-nordigen/banks/integration-bank.js b/src/app-nordigen/banks/integration-bank.js new file mode 100644 index 000000000..c42108242 --- /dev/null +++ b/src/app-nordigen/banks/integration-bank.js @@ -0,0 +1,38 @@ +import { sortByBookingDate } from '../utils.js'; + +/** @type {import('./bank.interface.js').IBank} */ +export default { + institutionId: 'IntegrationBank', + normalizeAccount(account) { + console.log( + 'Available account properties for new institution integration', + { account: JSON.stringify(account) } + ); + + return { + account_id: account.id, + institution: account.institution, + mask: (account?.iban || '0000').slice(-4), + name: `integration-${account.institution_id}`, + official_name: `integration-${account.institution_id}`, + type: 'checking' + }; + }, + sortTransactions(transactions = []) { + console.log( + 'Available (first 10) transactions properties for new integration of institution in sortTransactions function', + { top10Transactions: JSON.stringify(transactions.slice(0, 10)) } + ); + return sortByBookingDate(transactions); + }, + calculateStartingBalance(sortedTransactions = [], balances = []) { + console.log( + 'Available (first 10) transactions properties for new integration of institution in calculateStartingBalance function', + { + balances: JSON.stringify(balances), + top10SortedTransactions: JSON.stringify(sortedTransactions.slice(0, 10)) + } + ); + return 0; + } +}; diff --git a/src/app-nordigen/banks/mbank-retail-brexplpw.js b/src/app-nordigen/banks/mbank-retail-brexplpw.js new file mode 100644 index 000000000..af54f3d65 --- /dev/null +++ b/src/app-nordigen/banks/mbank-retail-brexplpw.js @@ -0,0 +1,41 @@ +import { printIban, amountToInteger } from '../utils.js'; + +/** @type {import('./bank.interface.js').IBank} */ +export default { + institutionId: 'MBANK_RETAIL_BREXPLPW', + + normalizeAccount(account) { + return { + account_id: account.id, + institution: account.institution, + mask: account.iban.slice(-4), + name: [account.displayName, printIban(account)].join(' '), + official_name: account.product, + type: 'checking' + }; + }, + + sortTransactions(transactions = []) { + return transactions.sort( + (a, b) => Number(b.transactionId) - Number(a.transactionId) + ); + }, + + /** + * For MBANK_RETAIL_BREXPLPW we don't know what balance was + * after each transaction so we have to calculate it by getting + * current balance from the account and subtract all the transactions + * + * As a current balance we use `interimBooked` balance type because + * it includes transaction placed during current day + */ + calculateStartingBalance(sortedTransactions = [], balances = []) { + const currentBalance = balances.find( + (balance) => 'interimBooked' === balance.balanceType + ); + + return sortedTransactions.reduce((total, trans) => { + return total - amountToInteger(trans.transactionAmount.amount); + }, amountToInteger(currentBalance.balanceAmount.amount)); + } +}; diff --git a/src/app-nordigen/banks/sandboxfinance-sfin0000.js b/src/app-nordigen/banks/sandboxfinance-sfin0000.js new file mode 100644 index 000000000..253e6de71 --- /dev/null +++ b/src/app-nordigen/banks/sandboxfinance-sfin0000.js @@ -0,0 +1,44 @@ +import { printIban, amountToInteger } from '../utils.js'; + +/** @type {import('./bank.interface.js').IBank} */ +export default { + institutionId: 'SANDBOXFINANCE_SFIN0000', + + normalizeAccount(account) { + return { + account_id: account.id, + institution: account.institution, + mask: account.iban.slice(-4), + name: [account.name, printIban(account)].join(' '), + official_name: account.product, + type: 'checking' + }; + }, + + sortTransactions(transactions = []) { + return transactions.sort((a, b) => { + const [aTime, aSeq] = a.transactionId.split('-'); + const [bTime, bSeq] = b.transactionId.split('-'); + + return Number(bTime) - Number(aTime) || Number(bSeq) - Number(aSeq); + }); + }, + + /** + * For SANDBOXFINANCE_SFIN0000 we don't know what balance was + * after each transaction so we have to calculate it by getting + * current balance from the account and subtract all the transactions + * + * As a current balance we use `interimBooked` balance type because + * it includes transaction placed during current day + */ + calculateStartingBalance(sortedTransactions = [], balances = []) { + const currentBalance = balances.find( + (balance) => 'interimAvailable' === balance.balanceType + ); + + return sortedTransactions.reduce((total, trans) => { + return total - amountToInteger(trans.transactionAmount.amount); + }, amountToInteger(currentBalance.balanceAmount.amount)); + } +}; diff --git a/src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js b/src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js new file mode 100644 index 000000000..9a724582f --- /dev/null +++ b/src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js @@ -0,0 +1,199 @@ +import IngPlIngbplpw from '../ing-pl-ingbplpw.js'; +import { mockTransactionAmount } from '../../services/tests/fixtures.js'; + +describe('IngPlIngbplpw', () => { + describe('#normalizeAccount', () => { + /** @type {import('../../nordigen.types.js').DetailedAccountWithInstitution} */ + const accountRaw = { + resourceId: 'PL00000000000000000987654321', + iban: 'PL00000000000000000987654321', + currency: 'PLN', + ownerName: 'John Example', + product: 'Current Account for Individuals (Retail)', + bic: 'INGBPLPW', + ownerAddressUnstructured: [ + 'UL. EXAMPLE STREET 10 M.1', + '00-000 WARSZAWA' + ], + id: 'd3eccc94-9536-48d3-98be-813f79199ee3', + created: '2022-07-24T20:45:47.929582Z', + last_accessed: '2023-01-24T22:12:00.193558Z', + institution_id: 'ING_PL_INGBPLPW', + status: 'READY', + owner_name: '', + institution: { + id: 'ING_PL_INGBPLPW', + name: 'ING', + bic: 'INGBPLPW', + transaction_total_days: '365', + countries: ['PL'], + logo: 'https://cdn.nordigen.com/ais/ING_PL_INGBPLPW.png', + supported_payments: {}, + supported_features: [ + 'access_scopes', + 'business_accounts', + 'card_accounts', + 'corporate_accounts', + 'pending_transactions', + 'private_accounts' + ] + } + }; + + it('returns normalized account data returned to Frontend', () => { + const normalizedAccount = IngPlIngbplpw.normalizeAccount(accountRaw); + expect(normalizedAccount).toMatchInlineSnapshot(` + { + "account_id": "d3eccc94-9536-48d3-98be-813f79199ee3", + "institution": { + "bic": "INGBPLPW", + "countries": [ + "PL", + ], + "id": "ING_PL_INGBPLPW", + "logo": "https://cdn.nordigen.com/ais/ING_PL_INGBPLPW.png", + "name": "ING", + "supported_features": [ + "access_scopes", + "business_accounts", + "card_accounts", + "corporate_accounts", + "pending_transactions", + "private_accounts", + ], + "supported_payments": {}, + "transaction_total_days": "365", + }, + "mask": "4321", + "name": "Current Account for Individuals (Retail) (XXX 4321)", + "official_name": "Current Account for Individuals (Retail)", + "type": "checking", + } + `); + }); + }); + + describe('#sortTransactions', () => { + it('sorts transactions by time and sequence from newest to oldest', () => { + const transactions = [ + { + transactionId: 'D202301180000003', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301180000004', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301230000001', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301180000002', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301200000001', + transactionAmount: mockTransactionAmount + } + ]; + const sortedTransactions = IngPlIngbplpw.sortTransactions(transactions); + expect(sortedTransactions).toEqual([ + { + transactionId: 'D202301230000001', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301200000001', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301180000004', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301180000003', + transactionAmount: mockTransactionAmount + }, + { + transactionId: 'D202301180000002', + transactionAmount: mockTransactionAmount + } + ]); + }); + + it('handles empty arrays', () => { + const transactions = []; + const sortedTransactions = IngPlIngbplpw.sortTransactions(transactions); + expect(sortedTransactions).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + const sortedTransactions = IngPlIngbplpw.sortTransactions(undefined); + expect(sortedTransactions).toEqual([]); + }); + }); + + describe('#countStartingBalance', () => { + it('should calculate the starting balance correctly', () => { + /** @type {import('../../nordigen-node.types.js').Transaction[]} */ + const sortedTransactions = [ + { + transactionAmount: { amount: '-100.00', currency: 'USD' }, + balanceAfterTransaction: { + balanceAmount: { amount: '400.00', currency: 'USD' }, + balanceType: 'interimBooked' + } + }, + { + transactionAmount: { amount: '50.00', currency: 'USD' }, + balanceAfterTransaction: { + balanceAmount: { amount: '450.00', currency: 'USD' }, + balanceType: 'interimBooked' + } + }, + { + transactionAmount: { amount: '-25.00', currency: 'USD' }, + balanceAfterTransaction: { + balanceAmount: { amount: '475.00', currency: 'USD' }, + balanceType: 'interimBooked' + } + } + ]; + + /** @type {import('../../nordigen-node.types.js').Balance[]} */ + const balances = [ + { + balanceType: 'interimBooked', + balanceAmount: { amount: '500.00', currency: 'USD' } + }, + { + balanceType: 'closingBooked', + balanceAmount: { amount: '600.00', currency: 'USD' } + } + ]; + + const startingBalance = IngPlIngbplpw.calculateStartingBalance( + sortedTransactions, + balances + ); + + expect(startingBalance).toEqual(50000); + }); + + it('returns the same balance amount when no transactions', () => { + const transactions = []; + + /** @type {import('../../nordigen-node.types.js').Balance[]} */ + const balances = [ + { + balanceType: 'interimBooked', + balanceAmount: { amount: '500.00', currency: 'USD' } + } + ]; + expect( + IngPlIngbplpw.calculateStartingBalance(transactions, balances) + ).toEqual(50000); + }); + }); +}); diff --git a/src/app-nordigen/banks/tests/integration-bank.spec.js b/src/app-nordigen/banks/tests/integration-bank.spec.js new file mode 100644 index 000000000..6506217d0 --- /dev/null +++ b/src/app-nordigen/banks/tests/integration-bank.spec.js @@ -0,0 +1,155 @@ +import { jest } from '@jest/globals'; +import IntegrationBank from '../integration-bank.js'; +import { + mockExtendAccountsAboutInstitutions, + mockInstitution +} from '../../services/tests/fixtures.js'; + +describe('IntegrationBank', () => { + let consoleSpy; + + beforeEach(() => { + consoleSpy = jest.spyOn(console, 'log'); + }); + + describe('normalizeAccount', () => { + const account = mockExtendAccountsAboutInstitutions[0]; + + it('should return a normalized account object', () => { + const normalizedAccount = IntegrationBank.normalizeAccount(account); + expect(normalizedAccount).toEqual({ + account_id: account.id, + institution: mockInstitution, + mask: '4321', + name: 'integration-SANDBOXFINANCE_SFIN0000', + official_name: 'integration-SANDBOXFINANCE_SFIN0000', + type: 'checking' + }); + }); + + it('should return a normalized account object with masked value "0000" when no iban property is provided', () => { + const normalizedAccount = IntegrationBank.normalizeAccount({ + ...account, + iban: undefined + }); + expect(normalizedAccount).toEqual({ + account_id: account.id, + institution: mockInstitution, + mask: '0000', + name: 'integration-SANDBOXFINANCE_SFIN0000', + official_name: 'integration-SANDBOXFINANCE_SFIN0000', + type: 'checking' + }); + }); + + it('normalizeAccount logs available account properties', () => { + IntegrationBank.normalizeAccount(account); + expect(consoleSpy).toHaveBeenCalledWith( + 'Available account properties for new institution integration', + { + account: JSON.stringify(account) + } + ); + }); + }); + + describe('sortTransactions', () => { + const transactions = [ + { + date: '2022-01-01', + bookingDate: '2022-01-01', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + date: '2022-01-03', + bookingDate: '2022-01-03', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + date: '2022-01-02', + bookingDate: '2022-01-02', + transactionAmount: { amount: '100', currency: 'EUR' } + } + ]; + const sortedTransactions = [ + { + date: '2022-01-03', + bookingDate: '2022-01-03', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + date: '2022-01-02', + bookingDate: '2022-01-02', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + date: '2022-01-01', + bookingDate: '2022-01-01', + transactionAmount: { amount: '100', currency: 'EUR' } + } + ]; + + it('should return transactions sorted by bookingDate', () => { + const sortedTransactions = IntegrationBank.sortTransactions(transactions); + expect(sortedTransactions).toEqual(sortedTransactions); + }); + + it('sortTransactions logs available transactions properties', () => { + IntegrationBank.sortTransactions(transactions); + expect(consoleSpy).toHaveBeenCalledWith( + 'Available (first 10) transactions properties for new integration of institution in sortTransactions function', + { top10Transactions: JSON.stringify(sortedTransactions.slice(0, 10)) } + ); + }); + }); + + describe('calculateStartingBalance', () => { + /** @type {import('../../nordigen-node.types.js').Transaction[]} */ + const transactions = [ + { + bookingDate: '2022-01-01', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + bookingDate: '2022-02-01', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + bookingDate: '2022-03-01', + transactionAmount: { amount: '100', currency: 'EUR' } + } + ]; + + /** @type {import('../../nordigen-node.types.js').Balance[]} */ + const balances = [ + { + balanceAmount: { amount: '1000.00', currency: 'EUR' }, + balanceType: 'interimBooked' + } + ]; + + it('should return 0 when no transactions or balances are provided', () => { + const startingBalance = IntegrationBank.calculateStartingBalance([], []); + expect(startingBalance).toEqual(0); + }); + + it('should return 0 when transactions and balances are provided', () => { + const startingBalance = IntegrationBank.calculateStartingBalance( + transactions, + balances + ); + expect(startingBalance).toEqual(0); + }); + + it('logs available transactions and balances properties', () => { + IntegrationBank.calculateStartingBalance(transactions, balances); + expect(consoleSpy).toHaveBeenCalledWith( + 'Available (first 10) transactions properties for new integration of institution in calculateStartingBalance function', + { + balances: JSON.stringify(balances), + top10SortedTransactions: JSON.stringify(transactions.slice(0, 10)) + } + ); + }); + }); +}); diff --git a/src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js b/src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js new file mode 100644 index 000000000..54229745b --- /dev/null +++ b/src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js @@ -0,0 +1,168 @@ +import MbankRetailBrexplpw from '../mbank-retail-brexplpw.js'; + +describe('MbankRetailBrexplpw', () => { + describe('#normalizeAccount', () => { + /** @type {import('../../nordigen.types.js').DetailedAccountWithInstitution} */ + const accountRaw = { + iban: 'PL00000000000000000987654321', + currency: 'PLN', + ownerName: 'John Example', + displayName: 'EKONTO', + product: 'RACHUNEK BIEŻĄCY', + usage: 'PRIV', + ownerAddressUnstructured: [ + 'POL', + 'UL. EXAMPLE STREET 10 M.1', + '00-000 WARSZAWA' + ], + id: 'd3eccc94-9536-48d3-98be-813f79199ee3', + created: '2023-01-18T13:24:55.879512Z', + last_accessed: null, + institution_id: 'MBANK_RETAIL_BREXPLPW', + status: 'READY', + owner_name: '', + institution: { + id: 'MBANK_RETAIL_BREXPLPW', + name: 'mBank Retail', + bic: 'BREXPLPW', + transaction_total_days: '90', + countries: ['PL'], + logo: 'https://cdn.nordigen.com/ais/MBANK_RETAIL_BREXCZPP.png', + supported_payments: {}, + supported_features: [ + 'access_scopes', + 'business_accounts', + 'card_accounts', + 'corporate_accounts', + 'pending_transactions', + 'private_accounts' + ] + } + }; + it('returns normalized account data returned to Frontend', () => { + expect(MbankRetailBrexplpw.normalizeAccount(accountRaw)) + .toMatchInlineSnapshot(` + { + "account_id": "d3eccc94-9536-48d3-98be-813f79199ee3", + "institution": { + "bic": "BREXPLPW", + "countries": [ + "PL", + ], + "id": "MBANK_RETAIL_BREXPLPW", + "logo": "https://cdn.nordigen.com/ais/MBANK_RETAIL_BREXCZPP.png", + "name": "mBank Retail", + "supported_features": [ + "access_scopes", + "business_accounts", + "card_accounts", + "corporate_accounts", + "pending_transactions", + "private_accounts", + ], + "supported_payments": {}, + "transaction_total_days": "90", + }, + "mask": "4321", + "name": "EKONTO (XXX 4321)", + "official_name": "RACHUNEK BIEŻĄCY", + "type": "checking", + } + `); + }); + }); + + describe('#sortTransactions', () => { + it('returns transactions from newest to oldest', () => { + const sortedTransactions = MbankRetailBrexplpw.sortTransactions([ + { + transactionId: '202212300001', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300003', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300002', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300000', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202112300001', + transactionAmount: { amount: '100', currency: 'EUR' } + } + ]); + + expect(sortedTransactions).toEqual([ + { + transactionId: '202212300003', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300002', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300001', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202212300000', + transactionAmount: { amount: '100', currency: 'EUR' } + }, + { + transactionId: '202112300001', + transactionAmount: { amount: '100', currency: 'EUR' } + } + ]); + }); + + it('returns empty array for empty input', () => { + const sortedTransactions = MbankRetailBrexplpw.sortTransactions([]); + expect(sortedTransactions).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + const sortedTransactions = + MbankRetailBrexplpw.sortTransactions(undefined); + expect(sortedTransactions).toEqual([]); + }); + }); + + describe('#countStartingBalance', () => { + /** @type {import('../../nordigen-node.types.js').Balance[]} */ + const balances = [ + { + balanceAmount: { amount: '1000.00', currency: 'PLN' }, + balanceType: 'interimBooked' + } + ]; + + it('returns the same balance amount when no transactions', () => { + const transactions = []; + + expect( + MbankRetailBrexplpw.calculateStartingBalance(transactions, balances) + ).toEqual(100000); + }); + + it('returns the balance minus the available transactions', () => { + const transactions = [ + { + transactionAmount: { amount: '200.00', currency: 'PLN' } + }, + { + transactionAmount: { amount: '300.50', currency: 'PLN' } + } + ]; + + expect( + MbankRetailBrexplpw.calculateStartingBalance(transactions, balances) + ).toEqual(49950); + }); + }); +}); diff --git a/src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js b/src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js new file mode 100644 index 000000000..c1626a56c --- /dev/null +++ b/src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js @@ -0,0 +1,180 @@ +import SandboxfinanceSfin0000 from '../sandboxfinance-sfin0000.js'; +import { mockTransactionAmount } from '../../services/tests/fixtures.js'; + +describe('SandboxfinanceSfin0000', () => { + describe('#normalizeAccount', () => { + /** @type {import('../../nordigen.types.js').DetailedAccountWithInstitution} */ + const accountRaw = { + resourceId: '01F3NS5ASCNMVCTEJDT0G215YE', + iban: 'GL0865354374424724', + currency: 'EUR', + ownerName: 'Jane Doe', + name: 'Main Account', + product: 'Checkings', + cashAccountType: 'CACC', + id: '99a0bfe2-0bef-46df-bff2-e9ae0c6c5838', + created: '2022-02-21T13:43:55.608911Z', + last_accessed: '2023-01-25T16:50:15.078264Z', + institution_id: 'SANDBOXFINANCE_SFIN0000', + status: 'READY', + owner_name: 'Jane Doe', + institution: { + id: 'SANDBOXFINANCE_SFIN0000', + name: 'Sandbox Finance', + bic: 'SFIN0000', + transaction_total_days: '90', + countries: ['XX'], + logo: 'https://cdn.nordigen.com/ais/SANDBOXFINANCE_SFIN0000.png', + supported_payments: {}, + supported_features: [] + } + }; + + it('returns normalized account data returned to Frontend', () => { + expect(SandboxfinanceSfin0000.normalizeAccount(accountRaw)) + .toMatchInlineSnapshot(` + { + "account_id": "99a0bfe2-0bef-46df-bff2-e9ae0c6c5838", + "institution": { + "bic": "SFIN0000", + "countries": [ + "XX", + ], + "id": "SANDBOXFINANCE_SFIN0000", + "logo": "https://cdn.nordigen.com/ais/SANDBOXFINANCE_SFIN0000.png", + "name": "Sandbox Finance", + "supported_features": [], + "supported_payments": {}, + "transaction_total_days": "90", + }, + "mask": "4724", + "name": "Main Account (XXX 4724)", + "official_name": "Checkings", + "type": "checking", + } + `); + }); + }); + + describe('#sortTransactions', () => { + it('sorts transactions by time and sequence from newest to oldest', () => { + const transactions = [ + { + transactionId: '2023012301927902-2', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927902-1', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-2', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-1', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-3', + transactionAmount: mockTransactionAmount + } + ]; + const sortedTransactions = + SandboxfinanceSfin0000.sortTransactions(transactions); + expect(sortedTransactions).toEqual([ + { + transactionId: '2023012301927902-2', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927902-1', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-3', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-2', + transactionAmount: mockTransactionAmount + }, + { + transactionId: '2023012301927900-1', + transactionAmount: mockTransactionAmount + } + ]); + }); + + it('handles empty arrays', () => { + const transactions = []; + const sortedTransactions = + SandboxfinanceSfin0000.sortTransactions(transactions); + expect(sortedTransactions).toEqual([]); + }); + + it('returns empty array for undefined input', () => { + const sortedTransactions = + SandboxfinanceSfin0000.sortTransactions(undefined); + expect(sortedTransactions).toEqual([]); + }); + }); + + describe('#countStartingBalance', () => { + /** @type {import('../../nordigen-node.types.js').Balance[]} */ + const balances = [ + { + balanceAmount: { amount: '1000.00', currency: 'PLN' }, + balanceType: 'interimAvailable' + } + ]; + + it('should calculate the starting balance correctly', () => { + const sortedTransactions = [ + { + transactionId: '2022-01-01-1', + transactionAmount: { amount: '-100.00', currency: 'USD' } + }, + { + transactionId: '2022-01-01-2', + transactionAmount: { amount: '50.00', currency: 'USD' } + }, + { + transactionId: '2022-01-01-3', + transactionAmount: { amount: '-25.00', currency: 'USD' } + } + ]; + + const startingBalance = SandboxfinanceSfin0000.calculateStartingBalance( + sortedTransactions, + balances + ); + + expect(startingBalance).toEqual(107500); + }); + + it('returns the same balance amount when no transactions', () => { + const transactions = []; + + expect( + SandboxfinanceSfin0000.calculateStartingBalance(transactions, balances) + ).toEqual(100000); + }); + + it('returns the balance minus the available transactions', () => { + /** @type {import('../../nordigen-node.types.js').Transaction[]} */ + const transactions = [ + { + transactionAmount: { amount: '200.00', currency: 'PLN' } + }, + { + transactionAmount: { amount: '300.50', currency: 'PLN' } + } + ]; + + expect( + SandboxfinanceSfin0000.calculateStartingBalance(transactions, balances) + ).toEqual(49950); + }); + }); +}); diff --git a/src/app-nordigen/errors.js b/src/app-nordigen/errors.js new file mode 100644 index 000000000..d3f9cc4ab --- /dev/null +++ b/src/app-nordigen/errors.js @@ -0,0 +1,84 @@ +export class RequisitionNotLinked extends Error { + constructor(params = {}) { + super('Requisition not linked yet'); + this.details = params; + } +} + +export class AccountNotLinedToRequisition extends Error { + constructor(accountId, requisitionId) { + super('Provided account id is not linked to given requisition'); + this.details = { + accountId, + requisitionId + }; + } +} + +export class GenericNordigenError extends Error { + constructor(data = {}) { + super('Nordigen returned error'); + this.details = data; + } +} + +export class NordigenClientError extends Error { + constructor(message, details) { + super(message); + this.details = details; + } +} + +export class InvalidInputDataError extends NordigenClientError { + constructor(response) { + super('Invalid provided parameters', response); + } +} + +export class InvalidNordigenTokenError extends NordigenClientError { + constructor(response) { + super('Token is invalid or expired', response); + } +} + +export class AccessDeniedError extends NordigenClientError { + constructor(response) { + super('IP address access denied', response); + } +} + +export class NotFoundError extends NordigenClientError { + constructor(response) { + super('Resource not found', response); + } +} + +export class ResourceSuspended extends NordigenClientError { + constructor(response) { + super( + 'Resource was suspended due to numerous errors that occurred while accessing it', + response + ); + } +} + +export class RateLimitError extends NordigenClientError { + constructor(response) { + super( + 'Daily request limit set by the Institution has been exceeded', + response + ); + } +} + +export class UnknownError extends NordigenClientError { + constructor(response) { + super('Request to Institution returned an error', response); + } +} + +export class ServiceError extends NordigenClientError { + constructor(response) { + super('Institution service unavailable', response); + } +} diff --git a/src/app-nordigen/nordigen-node.types.ts b/src/app-nordigen/nordigen-node.types.ts new file mode 100644 index 000000000..518748428 --- /dev/null +++ b/src/app-nordigen/nordigen-node.types.ts @@ -0,0 +1,472 @@ +type RequisitionStatus = + | 'CR' + | 'ID' + | 'LN' + | 'RJ' + | 'ER' + | 'SU' + | 'EX' + | 'GC' + | 'UA' + | 'GA' + | 'SA'; + +export type Requisition = { + /** + * option to enable account selection view for the end user + */ + account_selection: boolean; + + /** + * array of account IDs retrieved within a scope of this requisition + */ + accounts: string[]; + + /** + * EUA associated with this requisition + */ + agreement: string; + + /** + * The date & time at which the requisition was created. + */ + created: string; + + /** + * The unique ID of the requisition + */ + id: string; + + /** + * an Institution ID for this Requisition + */ + institution_id: string; + + /** + * link to initiate authorization with Institution + */ + link: string; + + /** + * redirect URL to your application after end-user authorization with ASPSP + */ + redirect: string; + + /** + * enable redirect back to the client after account list received + */ + redirect_immediate: boolean; + + /** + * additional ID to identify the end user + */ + reference: string; + + /** + * optional SSN field to verify ownership of the account + */ + ssn: string; + + /** + * status of this requisition + */ + status: RequisitionStatus; + + /** + * A two-letter country code (ISO 639-1) + */ + user_language: string; +}; + +/** + * Object representing Nordigen account details + * Account details will be returned in Berlin Group PSD2 format. + */ +export type NordigenAccountDetails = { + /** + * Resource id of the account + */ + resourceId?: string; + + /** + * BBAN of the account. This data element is used for payment accounts which have no IBAN + */ + bban?: string; + + /** + * BIC associated to the account + */ + bic?: string; + + /** + * External Cash Account Type 1 Code from ISO 20022 + */ + cashAccountType?: string; + + /** + * Currency of the account + */ + currency: string; + + /** + * Specifications that might be provided by the financial institution, including + * - Characteristics of the account + * - Characteristics of the relevant card + */ + details?: string; + + /** + * Name of the account as defined by the end user within online channels + */ + displayName?: string; + + /** + * IBAN of the account + */ + iban?: string; + + /** + * This data attribute is a field where a financial institution can name a cash account associated with pending card transactions + */ + linkedAccounts?: string; + + /** + * Alias to a payment account via a registered mobile phone number + */ + msisdn?: string; + + /** + * Name of the account, as assigned by the financial institution + */ + name?: string; + + /** + * Address of the legal account owner + */ + ownerAddressUnstructured?: string[]; + + /** + * Name of the legal account owner. If there is more than one owner, then two names might be noted here. For a corporate account, the corporate name is used for this attribute. + */ + ownerName?: string; + + /** + * Product Name of the Bank for this account, proprietary definition + */ + product?: string; + + /** + * Account status. The value is one of the following: + * - "enabled": account is available + * - "deleted": account is terminated + * - "blocked": account is blocked, e.g. for legal reasons + * + * If this field is not used, then the account is considered available according to the specification. + */ + status?: 'enabled' | 'deleted' | 'blocked'; + + /** + * Specifies the usage of the account: + * - PRIV: private personal account + * - ORGA: professional account + */ + usage?: 'PRIV' | 'ORGA'; +}; + +/** + * Representation of the Nordigen account metadata + */ +export type NordigenAccountMetadata = { + /** + * ID of the Nordigen account metadata + */ + id: string; + /** + * Date when the Nordigen account metadata was created + */ + created: string; + /** + * Date of the last access to the Nordigen account metadata + */ + last_accessed: string; + /** + * IBAN of the Nordigen account metadata + */ + iban: string; + /** + * ID of the institution associated with the Nordigen account metadata + */ + institution_id: string; + /** + * Status of the Nordigen account + * DISCOVERED: User has successfully authenticated and account is discovered + * PROCESSING: Account is being processed by the Institution + * ERROR: An error was encountered when processing account + * EXPIRED: Access to account has expired as set in End User Agreement + * READY: Account has been successfully processed + * SUSPENDED: Account has been suspended (more than 10 consecutive failed attempts to access the account) + */ + status: + | 'DISCOVERED' + | 'PROCESSING' + | 'ERROR' + | 'EXPIRED' + | 'READY' + | 'SUSPENDED'; + /** + * Name of the owner of the Nordigen account metadata + */ + owner_name: string; +}; + +/** + * Information about the Institution + */ +export type Institution = { + /** + * The id of the institution, for example "N26_NTSBDEB1" + */ + id: string; + + /** + * The name of the institution, for example "N26 Bank" + */ + name: string; + + /** + * The BIC of the institution, for example "NTSBDEB1" + */ + bic: string; + + /** + * The total number of days of transactions available, for example "90" + */ + transaction_total_days: string; + + /** + * The countries where the institution operates, for example `["PL"]` + */ + countries: string[]; + + /** + * The logo URL of the institution, for example "https://cdn.nordigen.com/ais/N26_SANDBOX_NTSBDEB1.png" + */ + logo: string; + + supported_payments?: object; + supported_features?: string[]; +}; + +/** + * An object containing information about a balance + */ +export type Balance = { + /** + * An object containing the balance amount and currency + */ + balanceAmount: Amount; + /** + * The type of balance + */ + balanceType: + | 'closingBooked' + | 'expected' + | 'forwardAvailable' + | 'interimAvailable' + | 'interimBooked' + | 'nonInvoiced' + | 'openingBooked'; + /** + * A flag indicating if the credit limit of the corresponding account is included in the calculation of the balance (if applicable) + */ + creditLimitIncluded?: boolean; + /** + * The date and time of the last change to the balance + */ + lastChangeDateTime?: string; + /** + * The reference of the last committed transaction to support the TPP in identifying whether all end users transactions are already known + */ + lastCommittedTransaction?: string; + /** + * The date of the balance + */ + referenceDate?: string; +}; + +/** + * An object representing the amount of a transaction + */ +export type Amount = { + /** + * The amount of the transaction + */ + amount: string; + + /** + * The currency of the transaction + */ + currency: string; +}; + +/** + * An object representing a financial transaction + */ +export type Transaction = { + /** + * Might be used by the financial institution to transport additional transaction-related information. + */ + additionalInformation?: string; + + /** + * Is used if and only if the bookingStatus entry equals "information". + */ + bookingStatus?: string; + + /** + * The balance after this transaction. Recommended balance type is interimBooked. + */ + balanceAfterTransaction?: Pick; + + /** + * Bank transaction code as used by the financial institution and using the sub elements of this structured code defined by ISO20022. For standing order reports the following codes are applicable: + * "PMNT-ICDT-STDO" for credit transfers, + * "PMNT-IRCT-STDO" for instant credit transfers, + * "PMNT-ICDT-XBST" for cross-border credit transfers, + * "PMNT-IRCT-XBST" for cross-border real-time credit transfers, + * "PMNT-MCOP-OTHR" for specific standing orders which have a dynamic amount to move left funds e.g. on month end to a saving account + */ + bankTransactionCode?: string; + + /** + * The date when an entry is posted to an account on the financial institution's books. + */ + bookingDate?: string; + + /** + * The date and time when an entry is posted to an account on the financial institution's books. + */ + bookingDateTime?: string; + + /** + * Identification of a cheque + */ + checkId?: string; + + /** + * Account reference, conditional + */ + creditorAccount?: string; + + /** + * BICFI + */ + creditorAgent?: string; + + /** + * Identification of creditors, e.g. a SEPA Creditor ID + */ + creditorId?: string; + + /** + * Name of the creditor if a "debited" transaction + */ + creditorName?: string; + + /** + * Array of report exchange rates + */ + currencyExchange?: string[]; + + /** + * Account reference, conditional + */ + debtorAccount?: { + iban: string; + }; + + /** + * BICFI + */ + debtorAgent?: string; + + /** + * Name of the debtor if a "credited" transaction + */ + debtorName?: string; + + /** + * Unique end-to-end ID + */ + endToEndId?: string; + + /** + * The identification of the transaction as used for reference given by the financial institution. + */ + entryReference?: string; + + /** + * Transaction identifier given by Nordigen + */ + internalTransactionId?: string; + + /** + * Identification of Mandates, e.g. a SEPA Mandate ID + */ + mandateId?: string; + + /** + * Merchant category code as defined by card issuer + */ + merchantCategoryCode?: string; + + /** + * Proprietary bank transaction code as used within a community or within an financial institution + */ + proprietaryBank?: string; + + /** + * Conditional + */ + purposeCode?: string; + + /** + * Reference as contained in the structured remittance reference structure + */ + remittanceInformation?: string; + + /** + * The amount of the transaction as billed to the account + */ + transactionAmount: Amount; + + /** + * Unique transaction identifier given by financial institution + */ + transactionId?: string; + + /** + * + */ + ultimateCreditor?: string; + + /** + * + */ + ultimateDebtor?: string; + + /** + * The Date at which assets become available to the account owner in case of a credit + */ + valueDate?: string; + + /** + * The date and time at which assets become available to the account owner in case of a credit + */ + valueDateTime?: string; +}; + +export type Transactions = { + booked: Transaction[]; + pending: Transaction[]; +}; diff --git a/src/app-nordigen/nordigen.types.ts b/src/app-nordigen/nordigen.types.ts new file mode 100644 index 000000000..70b07e632 --- /dev/null +++ b/src/app-nordigen/nordigen.types.ts @@ -0,0 +1,82 @@ +import { + NordigenAccountMetadata, + NordigenAccountDetails, + Institution, + Transactions, + Balance +} from './nordigen-node.types.js'; + +export type DetailedAccount = Omit & + NordigenAccountMetadata; +export type DetailedAccountWithInstitution = DetailedAccount & { + institution: Institution; +}; + +export type NormalizedAccountDetails = { + /** + * Id of the account + */ + account_id: string; + + /** + * Institution of account + */ + institution: Institution; + + /** + * last 4 digits from the account iban + */ + mask: string; + + /** + * Name displayed on the UI of Actual app + */ + name: string; + + /** + * name of the product in the institution + */ + official_name: string; + + /** + * type of account + */ + type: string; +}; + +export type GetTransactionsParams = { + /** + * Id of account from the nordigen app + */ + accountId: string; + + /** + * Begin date of the period from which we want to download transactions + */ + startDate: string; + + /** + * End date of the period from which we want to download transactions + */ + endDate: string; +}; + +export type GetTransactionsResponse = { + status_code?: number; + detail?: string; + transactions: Transactions; +}; + +export type CreateRequisitionParams = { + institutionId: string; + accessValidForDays: number; + + /** + * Host of your frontend app - on this host you will be redirected after linking with bank + */ + host: string; +}; + +export type GetBalances = { + balances: Balance[]; +}; diff --git a/src/app-nordigen/services/nordigen-service.js b/src/app-nordigen/services/nordigen-service.js new file mode 100644 index 000000000..b81b9097c --- /dev/null +++ b/src/app-nordigen/services/nordigen-service.js @@ -0,0 +1,440 @@ +import BankFactory from '../bank-factory.js'; +import { + RequisitionNotLinked, + AccountNotLinedToRequisition, + InvalidInputDataError, + InvalidNordigenTokenError, + AccessDeniedError, + NotFoundError, + ResourceSuspended, + RateLimitError, + UnknownError, + ServiceError +} from '../errors.js'; +import * as nordigenNode from 'nordigen-node'; +import * as uuid from 'uuid'; +import config from '../../load-config.js'; +import { sortByBookingDate } from '../utils.js'; + +const NordigenClient = nordigenNode.default; +const nordigenClient = new NordigenClient({ + secretId: config.nordigen_secret_id, + secretKey: config.nordigen_secret_key +}); + +export const handleNordigenError = (response) => { + switch (response.status_code) { + case 400: + throw new InvalidInputDataError(response); + case 401: + throw new InvalidNordigenTokenError(response); + case 403: + throw new AccessDeniedError(response); + case 404: + throw new NotFoundError(response); + case 409: + throw new ResourceSuspended(response); + case 429: + throw new RateLimitError(response); + case 500: + throw new UnknownError(response); + case 503: + throw new ServiceError(response); + default: + return; + } +}; + +export const nordigenService = { + /** + * + * @returns {Promise} + */ + setToken: async () => { + if (!nordigenClient.token) { + const tokenData = await client.generateToken(); + handleNordigenError(tokenData); + nordigenClient.token = tokenData.access; + } + }, + + /** + * + * @param requisitionId + * @throws {RequisitionNotLinked} Will throw an error if requisition is not in Linked + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise} + */ + getLinkedRequisition: async (requisitionId) => { + const requisition = await nordigenService.getRequisition(requisitionId); + + const { status } = requisition; + + // Continue only if status of requisition is "LN" what does + // mean that account has been successfully linked to requisition + if (status !== 'LN') { + throw new RequisitionNotLinked({ requisitionStatus: status }); + } + + return requisition; + }, + + /** + * Returns requisition and all linked accounts in their Bank format. + * Each account object is extended about details of the institution + * @param requisitionId + * @throws {RequisitionNotLinked} Will throw an error if requisition is not in Linked + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise<{requisition: import('../nordigen-node.types.js').Requisition, accounts: Array}>} + */ + getRequisitionWithAccounts: async (requisitionId) => { + const requisition = await nordigenService.getLinkedRequisition( + requisitionId + ); + + let institutionIdSet = new Set(); + const detailedAccounts = await Promise.all( + requisition.accounts.map(async (accountId) => { + const account = await nordigenService.getDetailedAccount(accountId); + institutionIdSet.add(account.institution_id); + return account; + }) + ); + + const institutions = await Promise.all( + Array.from(institutionIdSet).map(async (institutionId) => { + return await nordigenService.getInstitution(institutionId); + }) + ); + + const extendedAccounts = + await nordigenService.extendAccountsAboutInstitutions({ + accounts: detailedAccounts, + institutions + }); + + const normalizedAccounts = extendedAccounts.map((account) => { + const bankAccount = BankFactory(account.institution_id); + return bankAccount.normalizeAccount(account); + }); + + return { requisition, accounts: normalizedAccounts }; + }, + + /** + * + * @param requisitionId + * @param accountId + * @param startDate + * @param endDate + * @throws {AccountNotLinedToRequisition} Will throw an error if requisition not includes provided account id + * @throws {RequisitionNotLinked} Will throw an error if requisition is not in Linked + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise<{balances: Array, institutionId: string, transactions: {booked: Array, pending: Array}, startingBalance: number}>} + */ + getTransactionsWithBalance: async ( + requisitionId, + accountId, + startDate, + endDate + ) => { + const { institution_id, accounts: accountIds } = + await nordigenService.getLinkedRequisition(requisitionId); + + if (!accountIds.includes(accountId)) { + throw new AccountNotLinedToRequisition(accountId, requisitionId); + } + + const [transactions, accountBalance] = await Promise.all([ + nordigenService.getTransactions({ + accountId, + startDate, + endDate + }), + nordigenService.getBalances(accountId) + ]); + + const bank = BankFactory(institution_id); + const sortedBookedTransactions = bank.sortTransactions( + transactions.transactions?.booked + ); + const sortedPendingTransactions = bank.sortTransactions( + transactions.transactions?.pending + ); + + const startingBalance = bank.calculateStartingBalance( + sortedBookedTransactions, + accountBalance.balances + ); + + return { + balances: accountBalance.balances, + institutionId: institution_id, + startingBalance, + transactions: { + booked: sortedBookedTransactions, + pending: sortedPendingTransactions + } + }; + }, + + /** + * + * @param {import('../nordigen.types.js').CreateRequisitionParams} params + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise<{requisitionId, link}>} + */ + createRequisition: async ({ institutionId, accessValidForDays, host }) => { + await nordigenService.setToken(); + + const response = await client.initSession({ + redirectUrl: host + '/nordigen/link', + institutionId, + referenceId: uuid.v4(), + accessValidForDays, + maxHistoricalDays: 90, + userLanguage: 'en', + ssn: null, + redirectImmediate: false, + accountSelection: false + }); + + handleNordigenError(response); + + const { link, id: requisitionId } = response; + + return { + link, + requisitionId + }; + }, + + /** + * Deletes requisition by provided ID + * @param requisitionId + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise<{summary: string, detail: string}>} + */ + deleteRequisition: async (requisitionId) => { + await nordigenService.getRequisition(requisitionId); + const response = client.deleteRequisition(requisitionId); + + handleNordigenError(response); + return response; + }, + + /** + * Retrieve a requisition by ID + * https://nordigen.com/en/docs/account-information/integration/parameters-and-responses/#/requisitions/requisition%20by%20id + * @param { string } requisitionId + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns { Promise } + */ + getRequisition: async (requisitionId) => { + await nordigenService.setToken(); + + const response = client.getRequisitionById(requisitionId); + + handleNordigenError(response); + + return response; + }, + + /** + * Retrieve an detailed account by account id + * @param accountId + * @returns {Promise} + */ + getDetailedAccount: async (accountId) => { + const [detailedAccount, metadataAccount] = await Promise.all([ + client.getDetails(accountId), + client.getMetadata(accountId) + ]); + + handleNordigenError(detailedAccount); + handleNordigenError(metadataAccount); + + return { + ...detailedAccount.account, + ...metadataAccount + }; + }, + + /** + * Retrieve details about a specific Institution + * @param institutionId + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise} + */ + getInstitution: async (institutionId) => { + const response = await client.getInstitutionById(institutionId); + + handleNordigenError(response); + + return response; + }, + + /** + * Extends provided accounts about details of their institution + * @param {{accounts: Array, institutions: Array}} params + * @returns {Promise>} + */ + extendAccountsAboutInstitutions: async ({ accounts, institutions }) => { + const institutionsById = institutions.reduce((acc, institution) => { + acc[institution.id] = institution; + return acc; + }, {}); + + return accounts.map((account) => { + const institution = institutionsById[account.institution_id] || null; + return { + ...account, + institution + }; + }); + }, + + /** + * Returns account transaction in provided dates + * @param {import('../nordigen.types.js').GetTransactionsParams} params + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise} + */ + getTransactions: async ({ accountId, startDate, endDate }) => { + const response = await client.getTransactions({ + accountId, + dateFrom: startDate, + dateTo: endDate + }); + + handleNordigenError(response); + + return response; + }, + + /** + * Returns account available balances + * @param accountId + * @throws {InvalidInputDataError} + * @throws {InvalidNordigenTokenError} + * @throws {AccessDeniedError} + * @throws {NotFoundError} + * @throws {ResourceSuspended} + * @throws {RateLimitError} + * @throws {UnknownError} + * @throws {ServiceError} + * @returns {Promise} + */ + getBalances: async (accountId) => { + const response = await client.getBalances(accountId); + + handleNordigenError(response); + + return response; + } +}; + +/** + * All executions of nordigenClient should be here for testing purposes, + * as the nordigen-node library is not written in a way that is conducive to testing. + * In that way we can mock the `client` const instead of nordigen library + */ +export const client = { + getBalances: async (accountId) => + await nordigenClient.account(accountId).getBalances(), + getTransactions: async ({ accountId, dateFrom, dateTo }) => + await nordigenClient.account(accountId).getTransactions({ + dateFrom, + dateTo, + country: undefined + }), + getInstitutionById: async (institutionId) => + await nordigenClient.institution.getInstitutionById(institutionId), + getDetails: async (accountId) => + await nordigenClient.account(accountId).getDetails(), + getMetadata: async (accountId) => + await nordigenClient.account(accountId).getMetadata(), + getRequisitionById: async (requisitionId) => + await nordigenClient.requisition.getRequisitionById(requisitionId), + deleteRequisition: async (requisitionId) => + await nordigenClient.requisition.deleteRequisition(requisitionId), + initSession: async ({ + redirectUrl, + institutionId, + referenceId, + accessValidForDays, + maxHistoricalDays, + userLanguage, + ssn, + redirectImmediate, + accountSelection + }) => + await nordigenClient.initSession({ + redirectUrl, + institutionId, + referenceId, + accessValidForDays, + maxHistoricalDays, + userLanguage, + ssn, + redirectImmediate, + accountSelection + }), + generateToken: async () => await nordigenClient.generateToken() +}; diff --git a/src/app-nordigen/services/tests/fixtures.js b/src/app-nordigen/services/tests/fixtures.js new file mode 100644 index 000000000..e876039f6 --- /dev/null +++ b/src/app-nordigen/services/tests/fixtures.js @@ -0,0 +1,180 @@ +/** @type {{balances: import('../../nordigen-node.types.js').Balance[]}} */ +export const mockedBalances = { + balances: [ + { + balanceAmount: { + amount: '657.49', + currency: 'string' + }, + balanceType: 'interimAvailable', + referenceDate: '2021-11-22' + }, + { + balanceAmount: { + amount: '185.67', + currency: 'string' + }, + balanceType: 'interimAvailable', + referenceDate: '2021-11-19' + } + ] +}; + +/** @type {{transactions: import('../../nordigen-node.types.js').Transactions}} */ +export const mockTransactions = { + transactions: { + booked: [ + { + transactionId: 'string', + debtorName: 'string', + debtorAccount: { + iban: 'string' + }, + transactionAmount: { + currency: 'EUR', + amount: '328.18' + }, + bankTransactionCode: 'string', + bookingDate: 'date', + valueDate: 'date' + }, + { + transactionId: 'string', + transactionAmount: { + currency: 'EUR', + amount: '947.26' + }, + bankTransactionCode: 'string', + bookingDate: 'date', + valueDate: 'date' + } + ], + pending: [ + { + transactionAmount: { + currency: 'EUR', + amount: '947.26' + }, + valueDate: 'date' + } + ] + } +}; + +export const mockUnknownError = { + summary: "Couldn't update account balances", + detail: 'Request to Institution returned an error', + type: 'UnknownRequestError', + status_code: 500 +}; + +/** @type {{account: import('../../nordigen-node.types.js').NordigenAccountDetails}} */ +export const mockAccountDetails = { + account: { + resourceId: 'PL00000000000000000987654321', + iban: 'PL00000000000000000987654321', + currency: 'PLN', + ownerName: 'JOHN EXAMPLE', + product: 'Savings Account for Individuals (Retail)', + bic: 'INGBPLPW', + ownerAddressUnstructured: ['EXAMPLE STREET 100/001', '00-000 EXAMPLE CITY'] + } +}; + +/** @type {import('../../nordigen-node.types.js').NordigenAccountMetadata} */ +export const mockAccountMetaData = { + id: 'f0e49aa6-f6db-48fc-94ca-4a62372fadf4', + created: '2022-07-24T20:45:47.847062Z', + last_accessed: '2023-01-25T22:12:27.814618Z', + iban: 'PL00000000000000000987654321', + institution_id: 'SANDBOXFINANCE_SFIN0000', + status: 'READY', + owner_name: 'JOHN EXAMPLE' +}; + +/** @type {import('../../nordigen.types.js').DetailedAccount} */ +export const mockDetailedAccount = { + ...mockAccountDetails.account, + ...mockAccountMetaData +}; + +/** @type {import('../../nordigen-node.types.js').Institution} */ +export const mockInstitution = { + id: 'N26_NTSBDEB1', + name: 'N26 Bank', + bic: 'NTSBDEB1', + transaction_total_days: '90', + countries: ['GB', 'NO', 'SE'], + logo: 'https://cdn.nordigen.com/ais/N26_SANDBOX_NTSBDEB1.png' +}; + +/** @type {import('../../nordigen-node.types.js').Requisition} */ +export const mockRequisition = { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + created: '2023-01-31T18:15:50.172Z', + redirect: 'string', + status: 'LN', + institution_id: 'string', + agreement: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + reference: 'string', + accounts: ['f0e49aa6-f6db-48fc-94ca-4a62372fadf4'], + user_language: 'string', + link: 'https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/{$INSTITUTION_ID}', + ssn: 'string', + account_selection: false, + redirect_immediate: false +}; + +export const mockDeleteRequisition = { + summary: 'Requisition deleted', + detail: + "Requisition '$REQUISITION_ID' deleted with all its End User Agreements" +}; + +export const mockCreateRequisition = { + id: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + created: '2023-02-01T15:53:29.481Z', + redirect: 'string', + status: 'CR', + institution_id: 'string', + agreement: '3fa85f64-5717-4562-b3fc-2c963f66afa6', + reference: 'string', + accounts: [], + user_language: 'string', + link: 'https://ob.nordigen.com/psd2/start/3fa85f64-5717-4562-b3fc-2c963f66afa6/{$INSTITUTION_ID}', + ssn: 'string', + account_selection: false, + redirect_immediate: false +}; + +/** @type {import('../../nordigen.types.js').DetailedAccount} */ +export const mockDetailedAccountExample1 = { + ...mockDetailedAccount, + name: 'account-example-one' +}; + +/** @type {import('../../nordigen.types.js').DetailedAccount} */ +export const mockDetailedAccountExample2 = { + ...mockDetailedAccount, + name: 'account-example-two' +}; + +/** @type {import('../../nordigen.types.js').DetailedAccountWithInstitution[]} */ +export const mockExtendAccountsAboutInstitutions = [ + { + ...mockDetailedAccountExample1, + institution: mockInstitution + }, + { + ...mockDetailedAccountExample2, + institution: mockInstitution + } +]; + +export const mockRequisitionWithExampleAccounts = { + ...mockRequisition, + + accounts: [mockDetailedAccountExample1.id, mockDetailedAccountExample2.id] +}; + +export const mockTransactionAmount = { amount: '100', currency: 'EUR' }; diff --git a/src/app-nordigen/services/tests/nordigen-service.spec.js b/src/app-nordigen/services/tests/nordigen-service.spec.js new file mode 100644 index 000000000..fa098d452 --- /dev/null +++ b/src/app-nordigen/services/tests/nordigen-service.spec.js @@ -0,0 +1,569 @@ +import { jest } from '@jest/globals'; +import { + InvalidInputDataError, + InvalidNordigenTokenError, + AccessDeniedError, + NotFoundError, + ResourceSuspended, + RateLimitError, + UnknownError, + ServiceError, + RequisitionNotLinked, + AccountNotLinedToRequisition +} from '../../errors.js'; + +import { + mockedBalances, + mockUnknownError, + mockTransactions, + mockDetailedAccount, + mockInstitution, + mockAccountMetaData, + mockAccountDetails, + mockRequisition, + mockDeleteRequisition, + mockCreateRequisition, + mockRequisitionWithExampleAccounts, + mockDetailedAccountExample1, + mockDetailedAccountExample2, + mockExtendAccountsAboutInstitutions +} from './fixtures.js'; + +import { + nordigenService, + handleNordigenError, + client +} from '../nordigen-service.js'; + +describe('nordigenService', () => { + const accountId = mockAccountMetaData.id; + const requisitionId = mockRequisition.id; + + let getBalancesSpy; + let getTransactionsSpy; + let getDetailsSpy; + let getMetadataSpy; + let getInstitutionSpy; + let getRequisitionsSpy; + let deleteRequisitionsSpy; + let createRequisitionSpy; + let setTokenSpy; + + beforeEach(() => { + getInstitutionSpy = jest.spyOn(client, 'getInstitutionById'); + getRequisitionsSpy = jest.spyOn(client, 'getRequisitionById'); + deleteRequisitionsSpy = jest.spyOn(client, 'deleteRequisition'); + createRequisitionSpy = jest.spyOn(client, 'initSession'); + getBalancesSpy = jest.spyOn(client, 'getBalances'); + getTransactionsSpy = jest.spyOn(client, 'getTransactions'); + getDetailsSpy = jest.spyOn(client, 'getDetails'); + getMetadataSpy = jest.spyOn(client, 'getMetadata'); + setTokenSpy = jest.spyOn(nordigenService, 'setToken'); + }); + + afterEach(() => { + jest.resetAllMocks(); + }); + + describe('#getLinkedRequisition', () => { + it('returns requisition', async () => { + setTokenSpy.mockResolvedValue(); + + jest + .spyOn(nordigenService, 'getRequisition') + .mockResolvedValue(mockRequisition); + + expect(await nordigenService.getLinkedRequisition(requisitionId)).toEqual( + mockRequisition + ); + }); + + it('throws RequisitionNotLinked error if requisition status is different than LN', async () => { + setTokenSpy.mockResolvedValue(); + + jest + .spyOn(nordigenService, 'getRequisition') + .mockResolvedValue({ ...mockRequisition, status: 'ER' }); + + await expect(() => + nordigenService.getLinkedRequisition(requisitionId) + ).rejects.toThrow(RequisitionNotLinked); + }); + }); + + describe('#getRequisitionWithAccounts', () => { + it('returns combined data', async () => { + jest + .spyOn(nordigenService, 'getRequisition') + .mockResolvedValue(mockRequisitionWithExampleAccounts); + jest + .spyOn(nordigenService, 'getDetailedAccount') + .mockResolvedValueOnce(mockDetailedAccountExample1); + jest + .spyOn(nordigenService, 'getDetailedAccount') + .mockResolvedValueOnce(mockDetailedAccountExample2); + jest + .spyOn(nordigenService, 'getInstitution') + .mockResolvedValue(mockInstitution); + jest + .spyOn(nordigenService, 'extendAccountsAboutInstitutions') + .mockResolvedValue([ + { + ...mockExtendAccountsAboutInstitutions[0], + institution_id: 'NEWONE' + }, + { + ...mockExtendAccountsAboutInstitutions[1], + institution_id: 'NEWONE' + } + ]); + + const response = await nordigenService.getRequisitionWithAccounts( + mockRequisitionWithExampleAccounts.id + ); + + expect(response.accounts.length).toEqual(2); + expect(response.accounts).toMatchObject( + expect.arrayContaining([ + expect.objectContaining({ + account_id: mockDetailedAccountExample1.id, + institution: mockInstitution, + official_name: expect.stringContaining('integration-') // It comes from IntegrationBank + }), + expect.objectContaining({ + account_id: mockDetailedAccountExample2.id, + institution: mockInstitution, + official_name: expect.stringContaining('integration-') // It comes from IntegrationBank + }) + ]) + ); + expect(response.requisition).toEqual(mockRequisitionWithExampleAccounts); + }); + }); + + describe('#getTransactionsWithBalance', () => { + const requisitionId = mockRequisition.id; + it('returns transaction with starting balance', async () => { + jest + .spyOn(nordigenService, 'getLinkedRequisition') + .mockResolvedValue(mockRequisition); + jest + .spyOn(nordigenService, 'getTransactions') + .mockResolvedValue(mockTransactions); + jest + .spyOn(nordigenService, 'getBalances') + .mockResolvedValue(mockedBalances); + + expect( + await nordigenService.getTransactionsWithBalance( + requisitionId, + accountId, + undefined, + undefined + ) + ).toEqual( + expect.objectContaining({ + balances: mockedBalances.balances, + institutionId: mockRequisition.institution_id, + startingBalance: 0, + transactions: { + booked: expect.arrayContaining([ + expect.objectContaining({ + bookingDate: expect.any(String), + transactionAmount: { + amount: expect.any(String), + currency: 'EUR' + }, + transactionId: expect.any(String), + valueDate: expect.any(String) + }) + ]), + pending: expect.arrayContaining([ + expect.objectContaining({ + transactionAmount: { + amount: expect.any(String), + currency: 'EUR' + }, + valueDate: expect.any(String) + }) + ]) + } + }) + ); + }); + + it('throws AccountNotLinedToRequisition error if requisition accounts not includes requested account', async () => { + jest + .spyOn(nordigenService, 'getLinkedRequisition') + .mockResolvedValue(mockRequisition); + + await expect(() => + nordigenService.getTransactionsWithBalance({ + requisitionId, + accountId: 'some-unknown-account-id', + startDate: undefined, + endDate: undefined + }) + ).rejects.toThrow(AccountNotLinedToRequisition); + }); + }); + + describe('#createRequisition', () => { + const institutionId = 'some-institution-id'; + const params = { + host: 'https://exemple.com', + institutionId, + accessValidForDays: 90 + }; + + it('calls nordigenClient and delete requisition', async () => { + setTokenSpy.mockResolvedValue(); + + createRequisitionSpy.mockResolvedValue(mockCreateRequisition); + + expect(await nordigenService.createRequisition(params)).toEqual({ + link: expect.any(String), + requisitionId: expect.any(String) + }); + + expect(createRequisitionSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + setTokenSpy.mockResolvedValue(); + + createRequisitionSpy.mockResolvedValue(mockUnknownError); + + await expect(() => + nordigenService.createRequisition(params) + ).rejects.toThrow(UnknownError); + }); + }); + + describe('#deleteRequisition', () => { + const requisitionId = 'some-requisition-id'; + + it('calls nordigenClient and delete requisition', async () => { + setTokenSpy.mockResolvedValue(); + + getRequisitionsSpy.mockResolvedValue(mockRequisition); + deleteRequisitionsSpy.mockResolvedValue(mockDeleteRequisition); + + expect(await nordigenService.deleteRequisition(requisitionId)).toEqual( + mockDeleteRequisition + ); + + expect(getRequisitionsSpy).toBeCalledTimes(1); + expect(deleteRequisitionsSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + setTokenSpy.mockResolvedValue(); + + getRequisitionsSpy.mockResolvedValue(mockRequisition); + deleteRequisitionsSpy.mockReturnValue(mockUnknownError); + + await expect(() => + nordigenService.deleteRequisition(requisitionId) + ).rejects.toThrow(UnknownError); + }); + }); + + describe('#getRequisition', () => { + const requisitionId = 'some-requisition-id'; + + it('calls nordigenClient and fetch requisition', async () => { + setTokenSpy.mockResolvedValue(); + getRequisitionsSpy.mockResolvedValue(mockRequisition); + + expect(await nordigenService.getRequisition(requisitionId)).toEqual( + mockRequisition + ); + + expect(setTokenSpy).toBeCalledTimes(1); + expect(getRequisitionsSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + setTokenSpy.mockResolvedValue(); + + getRequisitionsSpy.mockReturnValue(mockUnknownError); + + await expect(() => + nordigenService.getRequisition(requisitionId) + ).rejects.toThrow(UnknownError); + }); + }); + + describe('#getDetailedAccount', () => { + it('returns merged object', async () => { + getDetailsSpy.mockResolvedValue(mockAccountDetails); + getMetadataSpy.mockResolvedValue(mockAccountMetaData); + + expect(await nordigenService.getDetailedAccount(accountId)).toEqual({ + ...mockAccountMetaData, + ...mockAccountDetails.account + }); + expect(getDetailsSpy).toBeCalledTimes(1); + expect(getMetadataSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the detailedAccount response', async () => { + getDetailsSpy.mockResolvedValue(mockUnknownError); + getMetadataSpy.mockResolvedValue(mockAccountMetaData); + + await expect(() => + nordigenService.getDetailedAccount(accountId) + ).rejects.toThrow(UnknownError); + + expect(getDetailsSpy).toBeCalledTimes(1); + expect(getMetadataSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the metadataAccount response', async () => { + getDetailsSpy.mockResolvedValue(mockAccountDetails); + getMetadataSpy.mockResolvedValue(mockUnknownError); + + await expect(() => + nordigenService.getDetailedAccount(accountId) + ).rejects.toThrow(UnknownError); + + expect(getDetailsSpy).toBeCalledTimes(1); + expect(getMetadataSpy).toBeCalledTimes(1); + }); + }); + + describe('#getInstitution', () => { + const institutionId = 'fake-institution-id'; + it('calls nordigenClient and fetch institution details', async () => { + getInstitutionSpy.mockResolvedValue(mockInstitution); + + expect(await nordigenService.getInstitution(institutionId)).toEqual( + mockInstitution + ); + expect(getInstitutionSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + getInstitutionSpy.mockResolvedValue(mockUnknownError); + + await expect(() => + nordigenService.getInstitution(institutionId) + ).rejects.toThrow(UnknownError); + }); + }); + + describe('#extendAccountsAboutInstitutions', () => { + it('extends accounts with the corresponding institution', async () => { + const institutionA = { ...mockInstitution, id: 'INSTITUTION_A' }; + const institutionB = { ...mockInstitution, id: 'INSTITUTION_B' }; + const accountAA = { + ...mockDetailedAccount, + id: 'AA', + institution_id: 'INSTITUTION_A' + }; + const accountBB = { + ...mockDetailedAccount, + id: 'BB', + institution_id: 'INSTITUTION_B' + }; + + const accounts = [accountAA, accountBB]; + const institutions = [institutionA, institutionB]; + + const expected = [ + { + ...accountAA, + institution: institutionA + }, + { + ...accountBB, + institution: institutionB + } + ]; + + const result = await nordigenService.extendAccountsAboutInstitutions({ + accounts, + institutions + }); + + expect(result).toEqual(expected); + }); + + it('returns accounts with missing institutions as null', async () => { + const accountAA = { + ...mockDetailedAccount, + id: 'AA', + institution_id: 'INSTITUTION_A' + }; + const accountBB = { + ...mockDetailedAccount, + id: 'BB', + institution_id: 'INSTITUTION_B' + }; + + const accounts = [accountAA, accountBB]; + + const institutionA = { ...mockInstitution, id: 'INSTITUTION_A' }; + const institutions = [institutionA]; + + const expected = [ + { + ...accountAA, + institution: institutionA + }, + { + ...accountBB, + institution: null + } + ]; + + const result = await nordigenService.extendAccountsAboutInstitutions({ + accounts, + institutions + }); + + expect(result).toEqual(expected); + }); + }); + + describe('#getTransactions', () => { + it('calls nordigenClient and fetch transactions for provided accountId', async () => { + getTransactionsSpy.mockResolvedValue(mockTransactions); + + expect( + await nordigenService.getTransactions({ + accountId, + startDate: '', + endDate: '' + }) + ).toMatchInlineSnapshot(` + { + "transactions": { + "booked": [ + { + "bankTransactionCode": "string", + "bookingDate": "date", + "debtorAccount": { + "iban": "string", + }, + "debtorName": "string", + "transactionAmount": { + "amount": "328.18", + "currency": "EUR", + }, + "transactionId": "string", + "valueDate": "date", + }, + { + "bankTransactionCode": "string", + "bookingDate": "date", + "transactionAmount": { + "amount": "947.26", + "currency": "EUR", + }, + "transactionId": "string", + "valueDate": "date", + }, + ], + "pending": [ + { + "transactionAmount": { + "amount": "947.26", + "currency": "EUR", + }, + "valueDate": "date", + }, + ], + }, + } + `); + expect(getTransactionsSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + getTransactionsSpy.mockResolvedValue(mockUnknownError); + + await expect(() => + nordigenService.getTransactions({ + accountId, + startDate: '', + endDate: '' + }) + ).rejects.toThrow(UnknownError); + }); + }); + + describe('#getBalances', () => { + it('calls nordigenClient and fetch balances for provided accountId', async () => { + getBalancesSpy.mockResolvedValue(mockedBalances); + + expect(await nordigenService.getBalances(accountId)).toEqual( + mockedBalances + ); + expect(getBalancesSpy).toBeCalledTimes(1); + }); + + it('handle error if status_code present in the response', async () => { + getBalancesSpy.mockResolvedValue(mockUnknownError); + + await expect(() => + nordigenService.getBalances(accountId) + ).rejects.toThrow(UnknownError); + }); + }); +}); + +describe('#handleNordigenError', () => { + it('throws InvalidInputDataError for status code 400', () => { + const response = { status_code: 400 }; + expect(() => handleNordigenError(response)).toThrow(InvalidInputDataError); + }); + + it('throws InvalidNordigenTokenError for status code 401', () => { + const response = { status_code: 401 }; + expect(() => handleNordigenError(response)).toThrow( + InvalidNordigenTokenError + ); + }); + + it('throws AccessDeniedError for status code 403', () => { + const response = { status_code: 403 }; + expect(() => handleNordigenError(response)).toThrow(AccessDeniedError); + }); + + it('throws NotFoundError for status code 404', () => { + const response = { status_code: 404 }; + expect(() => handleNordigenError(response)).toThrow(NotFoundError); + }); + + it('throws ResourceSuspended for status code 409', () => { + const response = { status_code: 409 }; + expect(() => handleNordigenError(response)).toThrow(ResourceSuspended); + }); + + it('throws RateLimitError for status code 429', () => { + const response = { status_code: 429 }; + expect(() => handleNordigenError(response)).toThrow(RateLimitError); + }); + + it('throws UnknownError for status code 500', () => { + const response = { status_code: 500 }; + expect(() => handleNordigenError(response)).toThrow(UnknownError); + }); + + it('throws ServiceError for status code 503', () => { + const response = { status_code: 503 }; + expect(() => handleNordigenError(response)).toThrow(ServiceError); + }); + + it('does not throw an error for status code 200', () => { + const response = { status_code: 200 }; + expect(() => handleNordigenError(response)).not.toThrow(); + }); + + it('does not throw an error when status code is not present', () => { + const response = { foo: 'bar' }; + expect(() => handleNordigenError(response)).not.toThrow(); + }); +}); diff --git a/src/app-nordigen/tests/bank-factory.spec.js b/src/app-nordigen/tests/bank-factory.spec.js new file mode 100644 index 000000000..3205eb192 --- /dev/null +++ b/src/app-nordigen/tests/bank-factory.spec.js @@ -0,0 +1,35 @@ +import BankFactory from '../bank-factory.js'; +import MbankRetailBrexplpw from '../banks/mbank-retail-brexplpw.js'; +import SandboxfinanceSfin0000 from '../banks/sandboxfinance-sfin0000.js'; +import IngPlIngbplpw from '../banks/ing-pl-ingbplpw.js'; +import IntegrationBank from '../banks/integration-bank.js'; + +describe('BankFactory', () => { + it('should return MbankRetailBrexplpw when institutionId is mbank-retail-brexplpw', () => { + const institutionId = MbankRetailBrexplpw.institutionId; + const result = BankFactory(institutionId); + + expect(result.institutionId).toBe(institutionId); + }); + + it('should return SandboxfinanceSfin0000 when institutionId is sandboxfinance-sfin0000', () => { + const institutionId = SandboxfinanceSfin0000.institutionId; + const result = BankFactory(institutionId); + + expect(result.institutionId).toBe(institutionId); + }); + + it('should return IngPlIngbplpw when institutionId is ing-pl-ingbplpw', () => { + const institutionId = IngPlIngbplpw.institutionId; + const result = BankFactory(institutionId); + + expect(result.institutionId).toBe(institutionId); + }); + + it('should return IntegrationBank when institutionId is not found', () => { + const institutionId = IntegrationBank.institutionId; + const result = BankFactory(institutionId); + + expect(result.institutionId).toBe(institutionId); + }); +}); diff --git a/src/app-nordigen/tests/utils.spec.js b/src/app-nordigen/tests/utils.spec.js new file mode 100644 index 000000000..439b8610a --- /dev/null +++ b/src/app-nordigen/tests/utils.spec.js @@ -0,0 +1,37 @@ +import { mockTransactionAmount } from '../services/tests/fixtures.js'; +import { sortByBookingDate } from '../utils.js'; + +describe('utils', () => { + describe('#sortByBookingDate', () => { + it('sorts transactions by bookingDate field from newest to oldest', () => { + const transactions = [ + { + bookingDate: '2023-01-01', + transactionAmount: mockTransactionAmount + }, + { + bookingDate: '2023-01-20', + transactionAmount: mockTransactionAmount + }, + { + bookingDate: '2023-01-10', + transactionAmount: mockTransactionAmount + } + ]; + expect(sortByBookingDate(transactions)).toEqual([ + { + bookingDate: '2023-01-20', + transactionAmount: mockTransactionAmount + }, + { + bookingDate: '2023-01-10', + transactionAmount: mockTransactionAmount + }, + { + bookingDate: '2023-01-01', + transactionAmount: mockTransactionAmount + } + ]); + }); + }); +}); diff --git a/src/app-nordigen/util/handle-error.js b/src/app-nordigen/util/handle-error.js new file mode 100644 index 000000000..0c91914a0 --- /dev/null +++ b/src/app-nordigen/util/handle-error.js @@ -0,0 +1,9 @@ +export function handleError(func) { + return (req, res) => { + func(req, res).catch((err) => { + console.log('Error', req.originalUrl, err); + res.status(500); + res.send({ status: 'error', reason: 'internal-error' }); + }); + }; +} diff --git a/src/app-nordigen/utils.js b/src/app-nordigen/utils.js new file mode 100644 index 000000000..541482eaa --- /dev/null +++ b/src/app-nordigen/utils.js @@ -0,0 +1,14 @@ +export const printIban = (account) => { + if (account.iban) { + return '(XXX ' + account.iban.slice(-4) + ')'; + } else { + return ''; + } +}; + +export const sortByBookingDate = (transactions = []) => + transactions.sort( + (a, b) => +new Date(b.bookingDate) - +new Date(a.bookingDate) + ); + +export const amountToInteger = (n) => Math.round(n * 100); diff --git a/src/app.js b/src/app.js index a354895a1..b07036527 100644 --- a/src/app.js +++ b/src/app.js @@ -7,6 +7,7 @@ import config from './load-config.js'; import * as accountApp from './app-account.js'; import * as syncApp from './app-sync.js'; +import * as nordigenApp from './app-nordigen/app-nordigen.js'; const app = express(); @@ -21,6 +22,7 @@ app.use(bodyParser.raw({ type: 'application/encrypted-file', limit: '50mb' })); app.use('/sync', syncApp.handlers); app.use('/account', accountApp.handlers); +app.use('/nordigen', nordigenApp.handlers); app.get('/mode', (req, res) => { res.send(config.mode); diff --git a/src/config-types.ts b/src/config-types.ts index 41eb18865..2a8fd6664 100644 --- a/src/config-types.ts +++ b/src/config-types.ts @@ -1,3 +1,5 @@ +import { ServerOptions } from 'https'; + export interface Config { mode: 'test' | 'development'; port: number; @@ -8,5 +10,7 @@ export interface Config { https?: { key: string; cert: string; - } & Parameters[0]; + } & ServerOptions; + nordigen_secret_id?: string; + nordigen_secret_key?: string; } diff --git a/tsconfig.json b/tsconfig.json index 804772f20..2a3511698 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,6 +8,7 @@ "experimentalDecorators": true, "resolveJsonModule": true, "downlevelIteration": true, + "skipLibCheck": true, "jsx": "preserve", // Check JS files too "allowJs": true, @@ -16,5 +17,5 @@ "module": "node16", "outDir": "build" }, - "exclude": ["node_modules", "build", "./app-plaid.js"] + "exclude": ["node_modules", "build", "./app-plaid.js", "coverage"], } diff --git a/yarn.lock b/yarn.lock index 0f6f94836..9c658e52e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -259,6 +259,17 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-syntax-flow@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/plugin-syntax-flow@npm:7.18.6" + dependencies: + "@babel/helper-plugin-utils": ^7.18.6 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: abe82062b3eef14de7d2b3c0e4fecf80a3e796ca497e9df616d12dd250968abf71495ee85a955b43a6c827137203f0c409450cf792732ed0d6907c806580ea71 + languageName: node + linkType: hard + "@babel/plugin-syntax-import-meta@npm:^7.8.3": version: 7.10.4 resolution: "@babel/plugin-syntax-import-meta@npm:7.10.4" @@ -380,6 +391,31 @@ __metadata: languageName: node linkType: hard +"@babel/plugin-transform-flow-strip-types@npm:^7.18.6": + version: 7.19.0 + resolution: "@babel/plugin-transform-flow-strip-types@npm:7.19.0" + dependencies: + "@babel/helper-plugin-utils": ^7.19.0 + "@babel/plugin-syntax-flow": ^7.18.6 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: c35339bf80c2a2b9abb9e2ce0382e1d9cc3ef7db2af127f4ec3d184bad2aec3269f3fcac5fdcd565439732803acad72eb9e7d5a18e439221526fdc041c9e8e1e + languageName: node + linkType: hard + +"@babel/preset-flow@npm:^7.18.6": + version: 7.18.6 + resolution: "@babel/preset-flow@npm:7.18.6" + dependencies: + "@babel/helper-plugin-utils": ^7.18.6 + "@babel/helper-validator-option": ^7.18.6 + "@babel/plugin-transform-flow-strip-types": ^7.18.6 + peerDependencies: + "@babel/core": ^7.0.0-0 + checksum: 9100d4eab3402e6601e361a5b235e46d90cfd389c12db19e2a071e1082ca2a00c04bd47eb185ce68d8979e7c8f3e548cd5d61b86dcd701135468fb929c3aecb6 + languageName: node + linkType: hard + "@babel/template@npm:^7.18.10, @babel/template@npm:^7.20.7, @babel/template@npm:^7.3.3": version: 7.20.7 resolution: "@babel/template@npm:7.20.7" @@ -1323,6 +1359,7 @@ __metadata: dependencies: "@actual-app/api": 4.1.6 "@actual-app/web": 23.2.9 + "@babel/preset-flow": ^7.18.6 "@types/bcrypt": ^5.0.0 "@types/better-sqlite3": ^7.5.0 "@types/cors": ^2.8.13 @@ -1344,6 +1381,7 @@ __metadata: express-actuator: 1.8.4 express-response-size: ^0.0.3 jest: ^29.3.1 + nordigen-node: ^1.2.3 prettier: ^2.8.3 supertest: ^6.3.1 typescript: ^4.9.5 @@ -1546,6 +1584,17 @@ __metadata: languageName: node linkType: hard +"axios@npm:^1.2.1": + version: 1.3.2 + resolution: "axios@npm:1.3.2" + dependencies: + follow-redirects: ^1.15.0 + form-data: ^4.0.0 + proxy-from-env: ^1.1.0 + checksum: 9791af75a6df137b15ef45d13ad11eb357b3860d2496347ee18778db9d0abc2320362a4452f1e070e3160f1dbcc518fcefdc9e005be097e7db39acb22cf608e5 + languageName: node + linkType: hard + "babel-jest@npm:^29.4.1": version: 29.4.1 resolution: "babel-jest@npm:29.4.1" @@ -2242,6 +2291,13 @@ __metadata: languageName: node linkType: hard +"dotenv@npm:^10.0.0": + version: 10.0.0 + resolution: "dotenv@npm:10.0.0" + checksum: f412c5fe8c24fbe313d302d2500e247ba8a1946492db405a4de4d30dd0eb186a88a43f13c958c5a7de303938949c4231c56994f97d05c4bc1f22478d631b4005 + languageName: node + linkType: hard + "ee-first@npm:1.1.1": version: 1.1.1 resolution: "ee-first@npm:1.1.1" @@ -2781,6 +2837,16 @@ __metadata: languageName: node linkType: hard +"follow-redirects@npm:^1.15.0": + version: 1.15.2 + resolution: "follow-redirects@npm:1.15.2" + peerDependenciesMeta: + debug: + optional: true + checksum: faa66059b66358ba65c234c2f2a37fcec029dc22775f35d9ad6abac56003268baf41e55f9ee645957b32c7d9f62baf1f0b906e68267276f54ec4b4c597c2b190 + languageName: node + linkType: hard + "form-data@npm:^4.0.0": version: 4.0.0 resolution: "form-data@npm:4.0.0" @@ -4460,6 +4526,16 @@ __metadata: languageName: node linkType: hard +"nordigen-node@npm:^1.2.3": + version: 1.2.3 + resolution: "nordigen-node@npm:1.2.3" + dependencies: + axios: ^1.2.1 + dotenv: ^10.0.0 + checksum: 721b1b87e750ddde72e97de6b77791da71b0f7206397b485ceb8c271121d26d0e76613b95896b762f9b88eb32fd9cf83202c638a3aeade956910c6971639146b + languageName: node + linkType: hard + "normalize-path@npm:^3.0.0": version: 3.0.0 resolution: "normalize-path@npm:3.0.0" @@ -4845,6 +4921,13 @@ __metadata: languageName: node linkType: hard +"proxy-from-env@npm:^1.1.0": + version: 1.1.0 + resolution: "proxy-from-env@npm:1.1.0" + checksum: ed7fcc2ba0a33404958e34d95d18638249a68c430e30fcb6c478497d72739ba64ce9810a24f53a7d921d0c065e5b78e3822759800698167256b04659366ca4d4 + languageName: node + linkType: hard + "pump@npm:^3.0.0": version: 3.0.0 resolution: "pump@npm:3.0.0"