mirror of
https://github.com/actualbudget/actual.git
synced 2026-03-11 20:44:32 -05:00
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:
5
.gitignore
vendored
5
.gitignore
vendored
@@ -23,3 +23,8 @@ build/
|
||||
!.yarn/releases
|
||||
!.yarn/sdks
|
||||
!.yarn/versions
|
||||
|
||||
dist
|
||||
.idea
|
||||
/coverage
|
||||
/coverage-e2e
|
||||
|
||||
3
babel.config.json
Normal file
3
babel.config.json
Normal file
@@ -0,0 +1,3 @@
|
||||
{
|
||||
"presets": ["@babel/preset-flow"]
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
61
src/app-nordigen/README.md
Normal file
61
src/app-nordigen/README.md
Normal 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
|
||||
166
src/app-nordigen/app-nordigen.js
Normal file
166
src/app-nordigen/app-nordigen.js
Normal 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;
|
||||
}
|
||||
}
|
||||
})
|
||||
);
|
||||
9
src/app-nordigen/bank-factory.js
Normal file
9
src/app-nordigen/bank-factory.js
Normal 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;
|
||||
28
src/app-nordigen/banks/bank.interface.ts
Normal file
28
src/app-nordigen/banks/bank.interface.ts
Normal 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;
|
||||
}
|
||||
45
src/app-nordigen/banks/ing-pl-ingbplpw.js
Normal file
45
src/app-nordigen/banks/ing-pl-ingbplpw.js
Normal 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
|
||||
);
|
||||
}
|
||||
}
|
||||
};
|
||||
38
src/app-nordigen/banks/integration-bank.js
Normal file
38
src/app-nordigen/banks/integration-bank.js
Normal 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;
|
||||
}
|
||||
};
|
||||
41
src/app-nordigen/banks/mbank-retail-brexplpw.js
Normal file
41
src/app-nordigen/banks/mbank-retail-brexplpw.js
Normal 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));
|
||||
}
|
||||
};
|
||||
44
src/app-nordigen/banks/sandboxfinance-sfin0000.js
Normal file
44
src/app-nordigen/banks/sandboxfinance-sfin0000.js
Normal 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));
|
||||
}
|
||||
};
|
||||
199
src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js
Normal file
199
src/app-nordigen/banks/tests/ing-pl-ingbplpw.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
155
src/app-nordigen/banks/tests/integration-bank.spec.js
Normal file
155
src/app-nordigen/banks/tests/integration-bank.spec.js
Normal 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))
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
});
|
||||
168
src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js
Normal file
168
src/app-nordigen/banks/tests/mbank-retail-brexplpw.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
180
src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js
Normal file
180
src/app-nordigen/banks/tests/sandboxfinance-sfin0000.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
});
|
||||
84
src/app-nordigen/errors.js
Normal file
84
src/app-nordigen/errors.js
Normal 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);
|
||||
}
|
||||
}
|
||||
472
src/app-nordigen/nordigen-node.types.ts
Normal file
472
src/app-nordigen/nordigen-node.types.ts
Normal 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[];
|
||||
};
|
||||
82
src/app-nordigen/nordigen.types.ts
Normal file
82
src/app-nordigen/nordigen.types.ts
Normal 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[];
|
||||
};
|
||||
440
src/app-nordigen/services/nordigen-service.js
Normal file
440
src/app-nordigen/services/nordigen-service.js
Normal 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()
|
||||
};
|
||||
180
src/app-nordigen/services/tests/fixtures.js
Normal file
180
src/app-nordigen/services/tests/fixtures.js
Normal 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' };
|
||||
569
src/app-nordigen/services/tests/nordigen-service.spec.js
Normal file
569
src/app-nordigen/services/tests/nordigen-service.spec.js
Normal 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();
|
||||
});
|
||||
});
|
||||
35
src/app-nordigen/tests/bank-factory.spec.js
Normal file
35
src/app-nordigen/tests/bank-factory.spec.js
Normal 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);
|
||||
});
|
||||
});
|
||||
37
src/app-nordigen/tests/utils.spec.js
Normal file
37
src/app-nordigen/tests/utils.spec.js
Normal 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
|
||||
}
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
9
src/app-nordigen/util/handle-error.js
Normal file
9
src/app-nordigen/util/handle-error.js
Normal 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
14
src/app-nordigen/utils.js
Normal 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);
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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"],
|
||||
}
|
||||
|
||||
83
yarn.lock
83
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"
|
||||
|
||||
Reference in New Issue
Block a user