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 <filip.stybel@ynd.co>
This commit is contained in:
Filip Stybel
2023-03-04 01:40:49 +01:00
committed by GitHub
parent 06b687e899
commit 19cd163b30
30 changed files with 3171 additions and 5 deletions

5
.gitignore vendored
View File

@@ -23,3 +23,8 @@ build/
!.yarn/releases
!.yarn/sdks
!.yarn/versions
dist
.idea
/coverage
/coverage-e2e

3
babel.config.json Normal file
View File

@@ -0,0 +1,3 @@
{
"presets": ["@babel/preset-flow"]
}

View File

@@ -1,4 +1,14 @@
{
"setupFiles": ["./jest.setup.js"],
"testPathIgnorePatterns": ["/node_modules/", "/build/"]
"setupFiles": ["./jest.setup.js"],
"testPathIgnorePatterns": ["dist", "/node_modules/", "/build/"],
"roots": ["<rootDir>"],
"testMatch": ["<rootDir>/**/*.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
}

View File

@@ -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",

View File

@@ -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
<Strong>Choose your banks:</Strong>
<CustomSelect
options={[
['default', 'Choose your bank'],
['ING_PL_INGBPLPW', 'ING PL'],
['MBANK_RETAIL_BREXPLPW', 'MBANK'],
['SANDBOXFINANCE_SFIN0000', 'DEMO - TEST']
]}
```
Launch frontend and backend server
Create new linked account selecting the institution which you added recently.
In the server logs you can find all required information to create class for
your bank.
Create new a bank class based on `app-nordigen/banks/sandboxfinance-sfin0000.js`. Name of the file and class should be
created based on the ID of the integrated institution.
Fill the logic of `normalizeAccount`, `sortTransactions`, and `calculateStartingBalance` functions.
You should do it based on the data which you found in the logs.
Example logs which help you to fill:
- `normalizeAccount` function:
```log
Available account properties for new institution integration {
account: '{"iban":"PL00000000000000000987654321","currency":"PLN","ownerName":"John Example","displayName":"Product name","product":"Daily account","usage":"PRIV","ownerAddressUnstructured":["POL","UL. Example 1","00-000 Warsaw"],"id":"XXXXXXXX-XXXX-XXXXX-XXXXXX-XXXXXXXXX","created":"2023-01-18T12:15:16.502446Z","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"]}}'
}
```
- `sortTransactions` function:
```log
Available (first 10) transactions properties for new integration of institution in sortTransactions function {
top10SortedTransactions: '[{"transactionId":"20220101001","bookingDate":"2022-01-01","valueDate":"2022-01-01","transactionAmount":{"amount":"5.01","currency":"EUR"},"creditorName":"JOHN EXAMPLE","creditorAccount":{"iban":"PL00000000000000000987654321"},"debtorName":"CHRIS EXAMPLE","debtorAccount":{"iban":"PL12345000000000000987654321"},"remittanceInformationUnstructured":"TEST BANK TRANSFER","remittanceInformationUnstructuredArray":["TEST BANK TRANSFER"],"balanceAfterTransaction":{"balanceAmount":{"amount":"448.52","currency":"EUR"},"balanceType":"interimBooked"},"internalTransactionId":"casfib7720c2a02c0331cw2"}]'
}
```
- `calculateStartingBalance` function:
```log
Available (first 10) transactions properties for new integration of institution in calculateStartingBalance function {
balances: '[{"balanceAmount":{"amount":"448.52","currency":"EUR"},"balanceType":"forwardAvailable"},{"balanceAmount":{"amount":"448.52","currency":"EUR"},"balanceType":"interimBooked"}]',
top10SortedTransactions: '[{"transactionId":"20220101001","bookingDate":"2022-01-01","valueDate":"2022-01-01","transactionAmount":{"amount":"5.01","currency":"EUR"},"creditorName":"JOHN EXAMPLE","creditorAccount":{"iban":"PL00000000000000000987654321"},"debtorName":"CHRIS EXAMPLE","debtorAccount":{"iban":"PL12345000000000000987654321"},"remittanceInformationUnstructured":"TEST BANK TRANSFER","remittanceInformationUnstructuredArray":["TEST BANK TRANSFER"],"balanceAfterTransaction":{"balanceAmount":{"amount":"448.52","currency":"EUR"},"balanceType":"interimBooked"},"internalTransactionId":"casfib7720c2a02c0331cw2"}]'
}
```
Add new bank integration to `BankFactory` class in file `actual-server/app-nordigen/bank-factory.js`
Remember to add tests for new bank integration in

View File

@@ -0,0 +1,166 @@
import express from 'express';
import { nordigenService } from './services/nordigen-service.js';
import {
RequisitionNotLinked,
AccountNotLinedToRequisition,
GenericNordigenError
} from './errors.js';
import { handleError } from './util/handle-error.js';
import validateUser from '../util/validate-user.js';
const app = express();
export { app as handlers };
app.use(express.json());
app.use(async (req, res, next) => {
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;
}
}
})
);

View File

@@ -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;

View File

@@ -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;
}

View File

@@ -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
);
}
}
};

View File

@@ -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;
}
};

View File

@@ -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));
}
};

View File

@@ -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));
}
};

View File

@@ -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);
});
});
});

View File

@@ -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))
}
);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
});
});
});

View File

@@ -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);
}
}

View File

@@ -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<Balance, 'balanceType' | 'balanceAmount'>;
/**
* 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[];
};

View File

@@ -0,0 +1,82 @@
import {
NordigenAccountMetadata,
NordigenAccountDetails,
Institution,
Transactions,
Balance
} from './nordigen-node.types.js';
export type DetailedAccount = Omit<NordigenAccountDetails, 'status'> &
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[];
};

View File

@@ -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<void>}
*/
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<import('../nordigen-node.types.js').Requisition>}
*/
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<import('../nordigen.types.js').NormalizedAccountDetails>}>}
*/
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<import('../nordigen-node.types.js').Balance>, institutionId: string, transactions: {booked: Array<import('../nordigen-node.types.js').Transaction>, pending: Array<import('../nordigen-node.types.js').Transaction>}, 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<import('../nordigen-node.types.js').Requisition> }
*/
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<import('../nordigen.types.js').DetailedAccount>}
*/
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<import('../nordigen-node.types.js').Institution>}
*/
getInstitution: async (institutionId) => {
const response = await client.getInstitutionById(institutionId);
handleNordigenError(response);
return response;
},
/**
* Extends provided accounts about details of their institution
* @param {{accounts: Array<import('../nordigen.types.js').DetailedAccount>, institutions: Array<import('../nordigen-node.types.js').Institution>}} params
* @returns {Promise<Array<import('../nordigen.types.js').DetailedAccount&{institution: import('../nordigen-node.types.js').Institution}>>}
*/
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<import('../nordigen.types.js').GetTransactionsResponse>}
*/
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<import('../nordigen.types.js').GetBalances>}
*/
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()
};

View File

@@ -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' };

View File

@@ -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();
});
});

View File

@@ -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);
});
});

View File

@@ -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
}
]);
});
});
});

View File

@@ -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' });
});
};
}

14
src/app-nordigen/utils.js Normal file
View File

@@ -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);

View File

@@ -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);

View File

@@ -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<typeof import('node:https')['createServer']>[0];
} & ServerOptions;
nordigen_secret_id?: string;
nordigen_secret_key?: string;
}

View File

@@ -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"],
}

View File

@@ -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"