From aece4c7df3ec2ef277f21af8c8ed32548a1a4308 Mon Sep 17 00:00:00 2001 From: Kevin Leyow Date: Fri, 20 Dec 2024 12:43:59 -0800 Subject: [PATCH] feat!: fx and interscheme implementation (#1058) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * feat: added db migration scripts for fx * feat(3574): update prepare-handler to deal with FX transfers (#988) * feat(3574): updated prepare handler for FX * feat(3574): added fxTransfer related tables; updated validator for FX * feat(3574): added fxTransfer related tables; updated validator for FX * feat(3574): comments/todos * feat(3574): added fx roleTypes; added content.context * feat(3574): added cyril; updated unit-tests * feat(3574): added FX endpoints to seeds (#989) * feat: implement changes in position handler for FX (#986) * chore: updated central service shared lib * feat: added some changes for fx flow * feat: added changes for position prepare handler * chore: refactor cyril functions * feat: position-commit working * feat: upgraded central shared * chore(snapshot): 17.4.0-snapshot.0 * chore(snapshot): 17.4.0-snapshot.1 * chore(snapshot): 17.4.0-snapshot.2 * chore(snapshot): 17.4.0-snapshot.3 * chore(snapshot): 17.4.0-snapshot.4 * chore(snapshot): 17.4.0-snapshot.5 * chore(snapshot): 17.4.0-snapshot.6 * chore(snapshot): 17.4.0-snapshot.7 * chore(snapshot): 17.4.0-snapshot.8 * fix: positions * fix: disable unit tests for snapshot * chore(snapshot): 17.4.0-snapshot.9 * chore: added docs * chore: updated doc * fix: normal fulfil * fix: normal flow * fix: updated fx diagram * chore: dep update * chore(snapshot): 17.4.0-snapshot.10 * feat(mojaloop/#3689): fx quotes changes (#995) * feat: add FX quotes endpointType and kafka topics * chore: upgrade cs-shared * chore: fix audit * chore(snapshot): 17.4.0-snapshot.12 * ci: disable unit tests and test coverage runs for snapshots * chore(snapshot): 17.4.0-snapshot.13 * fix: manual changes from upstream commits * chore(snapshot): 17.7.0-snapshot.0 * chore(mojaloop/#3820): fix current tests and merge in main (#1000) * fix: cluster performance testing issues (#996) * test: fix disconnect errors (#998) * chore(release): 17.6.1 [skip ci] * chore: fix current tests * boolean * chore: add endpoints to test data * fix endpoint import * chore: improve validator coverage * chore: move prepare tests into file to match src structure --------- Co-authored-by: Kalin Krustev Co-authored-by: mojaloopci * test(mojaloop/#3819): harden fx prepare flow (#1002) * chore: more coverage * coverage * test(mojaloop/#3819): prepare handler testing (#1004) * test(mojaloop/#3819): prepare handler testing * dep * audit * reconcile to one file due to producer bug #3067 * address comments * chore: standardise position prepare handler (#1005) * feat: added fx-position-prepare capability to batch handler * chore: reverted fx implementation in non batch mode * chore: added unit tests * chore: dep, audit and lint --------- Co-authored-by: Kevin Leyow * feat(mojaloop/#3844): added corner cases impl. for FX; added unit-tests (#1006) * feat(mojaloop/#3844): added corner cases impl. for FX; added unit-tests * feat(mojaloop/#3844): added corner cases impl. for FX; added unit-tests * feat(mojaloop/#3844): added fxTransferErrorDuplicateCheck table; moved fxFulfilt tests in a separare file * feat(mojaloop/#3844): run tests with output * feat(mojaloop/#3844): fixed unit-test on ci env * feat(mojaloop/#3844): added unit-tests for FxFulfilService; moved duplicateCheckComparator logic to service * feat(mojaloop/#3844): reverted ci test-coverage * feat(mojaloop/#3844): added license * feat(mojaloop/#3844): moved checkErrorPayload to helpers * feat(mojaloop/#3844): moved checkErrorPayload to helpers * feat(mojaloop/#3844): updated from feat/fx-impl * fix: removed fx position prepare integration tests in non batch mode (#1010) * chore: fix int tests, lint and update deps (#1013) * chore: lint and update deps * int test * chore: removed unneeded kafkaHelper; excluded some files from test-coverage check (#1015) * feat(mojaloop/#3844): added integration tests for fxFulfil flow (#1011) * feat(mojaloop/#3844): added corner cases impl. for FX; added unit-tests * feat(mojaloop/#3844): added corner cases impl. for FX; added unit-tests * feat(mojaloop/#3844): added fxTransferErrorDuplicateCheck table; moved fxFulfilt tests in a separare file * feat(mojaloop/#3844): run tests with output * feat(mojaloop/#3844): fixed unit-test on ci env * feat(mojaloop/#3844): added unit-tests for FxFulfilService; moved duplicateCheckComparator logic to service * feat(mojaloop/#3844): reverted ci test-coverage * feat(mojaloop/#3844): added license * feat(mojaloop/#3844): moved checkErrorPayload to helpers * feat(mojaloop/#3844): moved checkErrorPayload to helpers * feat(mojaloop/#3844): updated from feat/fx-impl * feat(mojaloop/#3844): added integration tests for fxFulfil flow * feat(mojaloop/#3844): fixed producer.disconnect() in int-tests * feat(mojaloop/#3844): added test:int:transfers script * feat(mojaloop/#3844): added duplicateCheck int test * feat(mojaloop/#3844): small cleanup * feat(mojaloop/#3844): added duplicate and fulfilment check int-tests * feat(mojaloop/#3844): removed unneeded code * feat(mojaloop/#3844): added testConsumer.clearEvents() for int-tests * feat(mojaloop/#3844): skipped newly added int-test * feat(mojaloop/#3844): updated validateFulfilCondition * feat: unskip int-test feat: unskip int-test * feat(mojaloop/#3844): removed unneeded npm script --------- Co-authored-by: Kevin Leyow * test: added transferFulfilReject.end() (#1027) * feat: fx fulfil position batching (#1019) * feat: implemented fx * fix: unit tests * fix: unit tests * chore: removed fx-fulfil in non batch mode * feat: refactored position fulfil handler for fx * chore: removed fx from non batch position fulfil * chore: removed fx references from non batch position handler * chore: simplified existing tests * chore: added unit tests * fix: prepare position fx * chore(mojaloop/#3819): update functional tests and move fulfil int test (#1009) * chore: update functional tests * version * snap * test * name * func * update * test-function * snap * changes * feat: implemented fx * fix: unit tests * fix: unit tests * chore: removed fx-fulfil in non batch mode * add back functions * feat: refactored position fulfil handler for fx * chore: removed fx from non batch position fulfil * chore: removed fx references from non batch position handler * chore: simplified existing tests * chore: added unit tests * fix: prepare position fx * publish messages to batch topic * update script * move fxfulfil tests to batch tests --------- Co-authored-by: Vijay * chore: add integration tests for pos fulfil fx (#1030) * feat: added integration test for fulfil fx * chore: refined integration tests * feat(mojaloop/#3818): added sequence and ER diagrams for transfer/fxTransfer flows (#1029) * feat(mojaloop/#3818): added sequence and ER diagrams for transfer timeout cron * feat(mojaloop/#3818): added sequence diagram for FX timeout * feat(mojaloop/#3818): added sequence diagram for FX timeout * feat(mojaloop/#3818): added transfer state diagrams * feat(mojaloop/#3818): added transfer state diagrams * feat(mojaloop/#3818): added transfer state diagrams * feat(mojaloop/#3818): added transfer state diagrams * feat(mojaloop/#3818): added transfer state diagrams * feat(mojaloop/#3818): finalize FX timeout diagrams * feat(mojaloop/#3818): added fxTimeout tables * feat(mojaloop/#3903): update interal state on fx fulfil to RECEIVED_FULFIL_DEPENDENT (#1032) * feat(mojaloop/#3903): update interal state on fx fulfil to RECEIVED_FULFIL_DEPENDENT * file * test * chore: update harness (#1031) * feat(mojaloop/#3904): add position event timeout reserved batch handling (#1033) * chore: add integration test for batch * unit * reenable * chore: comments * cleanup function * remove * unskip * fix potential int test failures * reorder * fix replace * Update README.md * feat: implemented timeout handler for fx (#1036) * feat: added timeout handler implementation * fix: queries * fix: int tests * fix: issues * fix: fx timeout * chore: update central services shared * fix: cicd * fix: deps * fix: lint * fix: unit tests * chore: added unit tests * chore: added unit tests * Fix/test (#1039) * dep, audit * fix test * chore: dep update --------- Co-authored-by: Kevin Leyow * feat: enable sending events directly to Kafka (#1037) * chore(snapshot): 17.7.0-snapshot.3 * feat(mojaloop/#3904): add position event fx timeout reserved batch handling (#1035) * chore: add integration test for batch * unit * reenable * chore: comments * feat: added timeout handler implementation * test * cleanup function * test * remove * reorder * fix potential int test failures * fix tests * fix: queries * unit tests * unskip * fix potential int test failures * reorder * fix replace * unit tests * fix: int tests * fix: issues * fix: fx timeout * chore: update central services shared * fix: cicd * fix: deps * fix: lint * fix: unit tests * chore: added unit tests * chore: added unit tests * pull,audit,dep * update tests * update position query logic * rename * add comment * detail --------- Co-authored-by: Vijay * chore(snapshot): 17.7.0-snapshot.4 * audit fix and dep update * audit fix and dep update * chore(snapshot): 17.7.0-snapshot.5 * image scan * chore(snapshot): 17.7.0-snapshot.6 * image scan * chore(snapshot): 17.7.0-snapshot.7 * node version * chore(snapshot): 17.7.0-snapshot.8 * revert pipeline changes to get working snapshot * chore(snapshot): 17.7.0-snapshot.9 * fix typo * chore(snapshot): 17.7.0-snapshot.10 * fix command * chore(snapshot): 17.7.0-snapshot.11 * fix: remove trx.rollback() (#1051) * fix: produce followup messages in parallel (#1052) * chore(snapshot): 17.7.0-snapshot.12 * fix: #3932 participant currency validation for fx (#1041) * fix: validation * fix: unit tests * fix: int tests * fix: some tests * fix: tests * fix: lint * fix: integration tests * chore: added coverage * chore: lint * chore: re-enabled some integration tests * fix: avoid extra db call (#1055) * chore(snapshot): 17.7.0-snapshot.13 * chore(snapshot): 17.7.0-snapshot.14 * feat(csi-164): parameterize switch id (#1057) * fix: migration scripts * chore(snapshot): 17.7.0-snapshot.17 * fix: migration scripts * chore(snapshot): 17.7.0-snapshot.18 * chore(snapshot): 17.7.0-snapshot.19 * chore(snapshot): 17.7.0-snapshot.20 * feat: extend admin api to support proxy participants (#1043) * feat: extend admin api to support proxy participants * image revert * audit * changes * changes * add filter * update swagger * fix: allow isProxy * feat(mojaloop/csi-190): add new state and functionality to handle proxied transfers (#1059) * diff * update diagram * chore: happy path * chore: int tests * chore(snapshot): 17.8.0-snapshot.0 * update tests * chore: add error cases * test: coverage * tests * tests * fix test * update dep --------- Co-authored-by: Kalin Krustev * feat(csi-22): add proxy lib to handlers (#1060) * feat(csi-22): add proxy lib to handlers * diff * add * int tests * fix hanging int tests * fixes? * unit fixes? * coverage * feat: refactor proxy cache integration * feat: restore default * feat: minor optimization * test: update coverage * test: remove try-catch * fix: fix disconnect error * feat: proxy cache update (#1061) * addressed comments --------- Co-authored-by: Steven Oderayi * feat(mojaloop/#3998): proxy obligation tracking for position changes (#1064) * fix: consider HUB_ID when seeding the hub (#1073) * fix: fsp id validation (#1074) * fix: Cannot read properties of undefined * chore(snapshot): 17.8.0-snapshot.1 * fix: name validation * chore(snapshot): 17.8.0-snapshot.2 * fix: isProxy validation (#1075) * feat(csi-22): add prepare participant substitution (#1065) * unit tests * unit tests * int tests * stuff * some int tests * comments * pass object * messy but working * coverage * hanging int test? * fix int tests * clarify naming * comment * fixes? * dep update * feat: fulfil obligation tracking (#1063) * feat(csi-22): add proxy lib to handlers * diff * add * int tests * fix hanging int tests * fixes? * unit fixes? * coverage * feat: add zero adjustment for prepare position batch * feat: refactor proxy cache integration * feat: restore default * feat: minor optimization * test: update coverage * test: remove try-catch * fix: fix disconnect error * feat(prepare-position): add proxy substitution and zero adjustment logic * fix: remove uneeded async * feat: proxy cache update (#1061) * addressed comments * chore: refactor * test: add unit tests * chore: minor refactor * chore: lint * feat: revert prepare hadnler change, update test coverage * feat: update docker compose and default config for docker * chore: remove commented code * test: update test * test: update test * feat: added proxy check in fulfil handler * fix: derive fn * fix: checkSameCreditorDebtorProxy * unit tests * unit tests * int tests * fix: unit tests * chore: added unit tests for proxyCache deriveCurrencyId function * chore: added coverage * stuff * some int tests * comments * pass object * messy but working * coverage * hanging int test? * fix int tests * feat: refactor * clarify naming * comment * feat: added more test coverage * fixes? * dep update * fix: int tests * fix: disable tests around fspiop header validation in fulfil * fix: int tests * chore: disabled a fulfil test due to and issue in position handler * fix: int tests * chore: addressed pr comment * fix: lint * fix: integration tests --------- Co-authored-by: Kevin Leyow Co-authored-by: Steven Oderayi * chore(snapshot): 17.8.0-snapshot.3 * feat(mojaloop/#3885): add migrations for storing fxQuotes (#1076) * initial fxQuote migrations * changes * changes * remove * audit dep * chore(snapshot): 17.8.0-snapshot.4 * feat: impl fx abort (#1077) * feat: added abort batching * fix: tests * fix: bulk abort * chore: cleanup * chore: added int tests * feat: added fx_abort_validation * fix: positions * fix: lint * fix: unit tests * chore: add test coverage * fix: tests * chore: migration fixes (#1078) * chore: migration fixes * chore(snapshot): 17.8.0-snapshot.5 * field * chore(snapshot): 17.8.0-snapshot.6 * notnullable * chore(snapshot): 17.8.0-snapshot.7 * feat: position zero messages (#1079) * feat: initial commit * fix: int tests * fix: int tests * chore: skip coverage check for snapshots * chore(snapshot): 17.8.0-snapshot.8 * fix: proxy cluster * chore: dep update * chore(snapshot): 17.8.0-snapshot.9 * test: add unit tests for zero position change (#1081) --------- Co-authored-by: Steven Oderayi * chore: add logging for admin api, proxy lookup, participant domain (#1080) * feat: initial commit * fix: int tests * fix: int tests * chore: skip coverage check for snapshots * chore(snapshot): 17.8.0-snapshot.8 * fix: proxy cluster * chore: dep update * chore(snapshot): 17.8.0-snapshot.9 * chore: add logging for admin api, proxy lookup, participant domain * fix tests * address comments * ignore --------- Co-authored-by: Vijay * chore(snapshot): 17.8.0-snapshot.10 * fix: fx fulfil header validation (#1084) * fix: fx fulfil * chore(snapshot): 17.8.0-snapshot.10 * chore(snapshot): 17.8.0-snapshot.11 * chore(snapshot): 17.8.0-snapshot.12 * fix: fx fulfil proxy * chore(snapshot): 17.8.0-snapshot.13 * ci: make redis cluster default for integration tests (#1083) * fix: fx fulfil header validation2 (#1085) * fix: added missing fields in query * chore(snapshot): 17.8.0-snapshot.14 * fix: skip validation when payer and payee are represented by proxy (#1086) * fix: test * add unit tests * update saveTransferPrepared * chore: int tests * retry count --------- Co-authored-by: Vijay * chore(snapshot): 17.8.0-snapshot.15 * feat(csi/551): add transfer state change for proxied fxTransfer (#1087) * feat(csi/551): add transfer state change for proxied fxTransfer * remove * add case * dep * unit tests * int tests * chore(snapshot): 17.8.0-snapshot.16 * fix: remove misleading commit and rollback (#1089) * fix: call commit and rollback * test: fix coverage * fix: call commit and rollback * fix: remove unnecessary commit and rollback * fix: remove unnecessary commit and rollback * fix: duplicate fx transfers (#1097) * fix: int tests * fix: int tests * fix: audit and lint fix * fix: spelling * chore: skipped an int test * chore(snapshot): 17.8.0-snapshot.17 * chore(snapshot): 17.8.0-snapshot.18 * chore(snapshot): 17.8.0-snapshot.19 * chore(snapshot): 17.8.0-snapshot.20 * fix: add duplication logic and test for fxTransfers * conversionState * alter int test for message key 0 * alter test --------- Co-authored-by: Kevin Leyow * feat(csi-633): added externalParticipant model; added JSDocs; updated transfer/facade (#1099) * feat(csi-318): added externalParticipants table * refactor(csi-631): added calculateProxyObligation fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): improved logging in transfer facade * chore(csi-632): added migrations to create externalParticipant table * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * feat(csi-633): added externalParticipant model; added JSDocs; updated transfer/facade * feat(csi-633): added externalParticipantId field to fxTransferParticipant table * feat(csi-633): added externalParticipantId field to fxTransferParticipant table * Revert "feat(csi-633): added externalParticipant model; added JSDocs; updated transfer/facade" (#1100) Revert "feat(csi-633): added externalParticipant model; added JSDocs; updated…" This reverts commit eb54f672a7df665e7c66489b087e3eee01def269. * fix: get fx transfer not working (#1098) * fix: int tests * fix: int tests * fix: audit and lint fix * fix: spelling * chore: skipped an int test * chore(snapshot): 17.8.0-snapshot.17 * chore(snapshot): 17.8.0-snapshot.18 * chore(snapshot): 17.8.0-snapshot.19 * chore(snapshot): 17.8.0-snapshot.20 * fix: get fx transfers * fix: refactor * fix: fx fulfilment * fix: unit tests * fix: tests * fix: fx transfer extension (#1102) * fix: retify int tests (#1104) * fix: fix abort callback (#1106) fix: from argument in kafka notification for abort * Revert "fix: fix abort callback" (#1109) Revert "fix: fix abort callback (#1106)" This reverts commit b6e9e2b72f4a4911ba540421bb2a3004cab12929. * chore(snapshot): 17.8.0-snapshot.22 * fix: gp failure fixes for interscheme and fx changes (#1091) * fix: check participant.isActive in prepare * chore(snapshot): 17.8.0-snapshot.16 * chore(snapshot): 17.8.0-snapshot.17 * fix: check position account is active in prepare * chore(snapshot): 17.8.0-snapshot.18 * test: temporarily disable coverage for proxy * chore(snapshot): 17.8.0-snapshot.19 * chore(snapshot): 17.8.0-snapshot.20 * ci: temporarily disable int tests for snapshots * chore(snapshot): 17.8.0-snapshot.21 * fix: fix typos * refactor: reactor getFSPProxy * chore(snapshot): 17.8.0-snapshot.22 * doc: update comment * chore(snapshot): 17.8.0-snapshot.23 * fix(csi-603): fix getTransferParticipant query join * ci: re-enable integration tests for snapshots * chore(snapshot): 17.8.0-snapshot.24 * chore(snapshot): 17.8.0-snapshot.25 * fix: fix query * chore(snapshot): 17.8.0-snapshot.26 * refactor: refactor * refactor: refactor * fix(csi-610): fix hub responding with RESERVED instead of COMMITED for v1.1 reserved fulfil * chore(snapshot): 17.8.0-snapshot.27 --------- Co-authored-by: Vijay * feat(csi/643): add fx-notify publishing on payer init fxTranfer success (#1105) * feat(csi/643): add fx-notify publishing on payer init fxTranfer success * loop * deps * tests * list * chore(snapshot): 17.8.0-snapshot.28 * fix: position changes (#1108) * fix: from argument in kafka notification for abort * fix: position changes * fix: to number * fix: position change in timeout * fix: related fxtransfer check * fix: unit tests * fix: timeout * chore: deps * fix fx-abort tests * fix fx-timeout tests * chore: added a comment * fix more tests * fix: invalid fulfilment * fix: unit test * chore(snapshot): 17.8.0-snapshot.28 * chore(snapshot): 17.8.0-snapshot.29 * fix: lint * chore(snapshot): 17.8.0-snapshot.30 --------- Co-authored-by: Kevin Leyow * feat(csi-318): added externalParticipant table (#1092) * feat(csi-318): added externalParticipants table * refactor(csi-631): added calculateProxyObligation fn (#1093) * refactor(csi-631): added calculateProxyObligation fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): improved logging in transfer facade * chore(csi-632): added migrations to create externalParticipant table (#1094) * refactor(csi-631): added calculateProxyObligation fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): improved logging in transfer facade * chore(csi-632): added migrations to create externalParticipant table * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * feat(csi-633): added externalParticipant model; updated transfer/facade; added JSDocs; (#1101) * refactor(csi-631): added calculateProxyObligation fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): added forwardPrepare fn * refactor(csi-631): improved logging in transfer facade * chore(csi-632): added migrations to create externalParticipant table * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * chore(csi-632): added migration to add externalParticipantId FK to fxTransferParticipant * feat(csi-633): added externalParticipant model; added JSDocs; updated transfer/facade * feat(csi-633): added externalParticipantId field to fxTransferParticipant table * feat(csi-633): added externalParticipantId field to fxTransferParticipant table * feat(csi-633): updated from feat/fx-impl * feat(csi-650): updated transferTimeout handler to take into account externalParticipant (#1107) * feat(csi-650): updated transferTimeout handler to take into account externalParticipant * feat(csi-650): fixed ep1.externalParticipantId field * feat(csi-650): used leftJoin for externalParticipant table * feat(csi-650): added externalPayeeName as source to timeout handler * feat(csi-650): updated fxTimeout logic to take into account externalParticipant info * feat(csi-650): code cleaning up * feat(csi-650): code cleaning up * feat(csi-651): updated fxAbort handling to use externalParticipant info (#1111) * feat(csi-650): updated transferTimeout handler to take into account externalParticipant * feat(csi-650): fixed ep1.externalParticipantId field * feat(csi-650): used leftJoin for externalParticipant table * feat(csi-650): added externalPayeeName as source to timeout handler * feat(csi-650): updated fxTimeout logic to take into account externalParticipant info * feat(csi-650): code cleaning up * feat(csi-650): code cleaning up * feat(csi-651): updated fxAbort handling to use externalParticipant info * feat(csi-651): updated fxValidation handling * feat(csi-651): fixed one leftJoin clause * feat(csi-651): updated getExternalParticipantIdByNameOrCreate * feat(csi-651): updated getExternalParticipantIdByNameOrCreate * feat(csi-651): added externalParticipantCached model * feat(csi-651): fixed prepare-internals tests * feat(csi-651): added more tests * feat(csi-651): reverted changes back to feat/fx-impl * feat(csi-651): reverted unneeded changes back to feat/fx-impl version * feat(csi-651): excluded some files from test coverage check * chore(snapshot): 17.8.0-snapshot.32 * chore(snapshot): 17.8.0-snapshot.33 * feat(csi-634): added mock-knex lib to mock mysql in unit-tests (#1113) * feat: add ULID support (#1114) * Fix code scanning alert no. 9: Missing regular expression anchor Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> * fix: uuid/ulid regex * test: start using mojaloop/build orb (#1115) * fix: sonar security hot spots * Update src/shared/fspiopErrorFactory.js Co-authored-by: shashi165 <33355509+shashi165@users.noreply.github.com> * chore: dep audit * chore: update licenses (#1138) * chore: update licenses * chore: update licenses * chore: deps * chore: address/remove todos * chore: update state diagram * chore: address comments * chore: address comments --------- Co-authored-by: Vijay Co-authored-by: Eugen Klymniuk Co-authored-by: vijayg10 <33152110+vijayg10@users.noreply.github.com> Co-authored-by: Steven Oderayi Co-authored-by: Kalin Krustev Co-authored-by: mojaloopci Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> Co-authored-by: shashi165 <33355509+shashi165@users.noreply.github.com> --- .circleci/config.yml | 1058 +--- .ncurc.yaml | 4 +- .nvmrc | 2 +- .nycrc.yml | 17 +- Dockerfile | 19 +- README.md | 48 +- audit-ci.jsonc | 18 +- config/default.json | 18 +- docker-compose.yml | 92 +- docker/central-ledger/default.json | 9 + .../config-modifier/configs/central-ledger.js | 25 + docker/env.sh | 15 + docker/kafka/scripts/provision.sh | 5 +- docker/ml-api-adapter/default.json | 5 +- documentation/db/erd-transfer-timeout.png | Bin 0 -> 251696 bytes documentation/db/erd-transfer-timeout.txt | 81 + documentation/fx-implementation/README.md | 48 + .../assets/fx-position-movements.drawio.svg | 4 + .../assets/test-scenario.drawio.svg | 4 + .../Handler - FX timeout.plantuml | 123 + .../Handler - FX timeout.png | Bin 0 -> 276688 bytes .../Handler - timeout.plantuml | 81 + .../sequence-diagrams/Handler - timeout.png | Bin 0 -> 134131 bytes .../transfer-ML-spec-states-diagram.png | Bin 0 -> 24596 bytes .../transfer-internal-states-diagram.png | Bin 0 -> 173384 bytes .../transfer-internal-states.plantuml | 74 + .../state-diagrams/transfer-states.plantuml | 13 + ...10204_transferParticipant-participantId.js | 52 + ...antPositionChange-participantCurrencyId.js | 47 + ...310404_participantPositionChange-change.js | 46 + migrations/600010_fxTransferType.js | 43 + migrations/600011_fxTransferType-indexes.js | 38 + .../600012_fxParticipantCurrencyType.js | 43 + ...00013_fxParticipantCurrencyType-indexes.js | 38 + migrations/600100_fxTransferDuplicateCheck.js | 42 + ...600110_fxTransferErrorDuplicateCheck.js.js | 17 + migrations/600200_fxTransfer.js | 51 + migrations/600201_fxTransfer-indexes.js | 40 + migrations/600400_fxTransferStateChange.js | 46 + .../600401_fxTransferStateChange-indexes.js | 40 + migrations/600501_fxWatchList.js | 46 + migrations/600502_fxWatchList-indexes.js | 40 + ...0600_fxTransferFulfilmentDuplicateCheck.js | 43 + ...ransferFulfilmentDuplicateCheck-indexes.js | 38 + migrations/600700_fxTransferFulfilment.js | 47 + .../600701_fxTransferFulfilment-indexes.js | 42 + migrations/600800_fxTransferExtension.js | 47 + migrations/601400_fxTransferTimeout.js | 43 + .../601401_fxTransferTimeout-indexes.js | 37 + migrations/601500_fxTransferError.js | 44 + migrations/601501_fxTransferError-indexes.js | 37 + migrations/610200_fxTransferParticipant.js | 52 + .../610201_fxTransferParticipant-indexes.js | 44 + ...202_fxTransferParticipant-participantId.js | 52 + ...03_participantPositionChange-fxTransfer.js | 46 + migrations/800101_feature-fixSubIdRef.js | 4 +- migrations/910101_feature904DataMigration.js | 98 +- migrations/910102_feature949DataMigration.js | 444 +- ...0103_dropTransferParticipantStateChange.js | 2 +- ...settlementModel-settlementAccountTypeId.js | 35 +- migrations/950108_participantProxy.js | 52 + migrations/950109_fxQuote.js | 53 + migrations/950110_fxQuoteResponse.js | 59 + migrations/950111_fxQuoteError.js | 57 + migrations/950113_fxQuoteDuplicateCheck.js | 52 + .../950114_fxQuoteResponseDuplicateCheck.js | 55 + migrations/950115_fxQuoteConversionTerms.js | 70 + .../950116_fxQuoteConversionTermsExtension.js | 55 + .../950117_fxQuoteResponseConversionTerms.js | 73 + ...fxQuoteResponseConversionTermsExtension.js | 55 + migrations/950119_fxCharge.js | 61 + .../960100_create_externalParticipant.js | 47 + ...icipant__addFiled_externalParticipantId.js | 50 + ...icipant__addFiled_externalParticipantId.js | 50 + package-lock.json | 5373 +++++------------ package.json | 59 +- seeds/endpointType.js | 18 + seeds/fxParticipantCurrencyType.js | 45 + seeds/fxTransferType.js | 45 + seeds/participant.js | 3 +- seeds/transferParticipantRoleType.js | 9 + seeds/transferState.js | 10 + src/api/interface/swagger.json | 53 +- src/api/ledgerAccountTypes/handler.js | 10 +- src/api/ledgerAccountTypes/routes.js | 10 +- src/api/metrics/handler.js | 10 +- src/api/metrics/plugin.js | 6 +- src/api/metrics/routes.js | 10 +- src/api/participants/handler.js | 18 +- src/api/participants/routes.js | 25 +- src/api/root/handler.js | 30 +- src/api/root/routes.js | 6 +- src/api/settlementModels/handler.js | 10 +- src/api/settlementModels/routes.js | 9 +- src/api/transactions/handler.js | 9 +- src/api/transactions/routes.js | 9 +- src/domain/bulkTransfer/index.js | 9 +- src/domain/fx/cyril.js | 462 ++ src/domain/fx/index.js | 81 + src/domain/ledgerAccountTypes/index.js | 9 +- src/domain/participant/index.js | 118 +- src/domain/position/abort.js | 249 + src/domain/position/binProcessor.js | 466 +- src/domain/position/fulfil.js | 398 +- src/domain/position/fx-fulfil.js | 173 + src/domain/position/fx-prepare.js | 315 + src/domain/position/fx-timeout-reserved.js | 194 + src/domain/position/index.js | 7 +- src/domain/position/prepare.js | 153 +- src/domain/position/timeout-reserved.js | 197 + src/domain/settlement/index.js | 9 +- src/domain/timeout/index.js | 37 +- src/domain/transactions/index.js | 9 +- src/domain/transfer/index.js | 36 +- src/domain/transfer/transform.js | 25 +- src/handlers/admin/handler.js | 8 +- src/handlers/api/plugin.js | 6 +- src/handlers/api/routes.js | 6 +- src/handlers/bulk/fulfil/handler.js | 16 +- src/handlers/bulk/get/handler.js | 16 +- src/handlers/bulk/index.js | 6 +- src/handlers/bulk/prepare/handler.js | 24 +- src/handlers/bulk/processing/handler.js | 21 +- src/handlers/bulk/shared/validator.js | 8 +- src/handlers/index.js | 6 +- src/handlers/positions/handler.js | 39 +- src/handlers/positions/handlerBatch.js | 86 +- src/handlers/register.js | 9 +- src/handlers/timeouts/handler.js | 318 +- src/handlers/transfers/FxFulfilService.js | 390 ++ .../transfers/createRemittanceEntity.js | 141 + src/handlers/transfers/dto.js | 53 + src/handlers/transfers/handler.js | 1167 ++-- src/handlers/transfers/prepare.js | 582 ++ src/handlers/transfers/validator.js | 89 +- src/lib/cache.js | 2 +- src/lib/config.js | 9 +- src/lib/db.js | 9 +- src/lib/enum.js | 6 +- src/lib/enumCached.js | 9 +- src/lib/healthCheck/subServiceHealth.js | 23 +- src/lib/proxyCache.js | 166 + src/lib/urlParser.js | 9 +- src/models/bulkTransfer/bulkTransfer.js | 9 +- .../bulkTransfer/bulkTransferAssociation.js | 9 +- .../bulkTransferDuplicateCheck.js | 9 +- .../bulkTransfer/bulkTransferExtension.js | 9 +- .../bulkTransferFulfilmentDuplicateCheck.js | 9 +- .../bulkTransfer/bulkTransferStateChange.js | 9 +- src/models/bulkTransfer/facade.js | 149 +- src/models/bulkTransfer/individualTransfer.js | 9 +- src/models/fxTransfer/duplicateCheck.js | 188 + src/models/fxTransfer/fxTransfer.js | 594 ++ src/models/fxTransfer/fxTransferError.js | 56 + src/models/fxTransfer/fxTransferExtension.js | 44 + src/models/fxTransfer/fxTransferTimeout.js | 71 + src/models/fxTransfer/index.js | 50 + src/models/fxTransfer/stateChange.js | 81 + src/models/fxTransfer/watchList.js | 52 + src/models/ilpPackets/ilpPacket.js | 9 +- .../ledgerAccountType/ledgerAccountType.js | 49 +- src/models/misc/migrationLock.js | 9 +- src/models/misc/segment.js | 10 +- src/models/participant/externalParticipant.js | 98 + .../participant/externalParticipantCached.js | 151 + src/models/participant/facade.js | 323 +- src/models/participant/participant.js | 12 +- src/models/participant/participantCached.js | 9 +- src/models/participant/participantCurrency.js | 11 +- .../participant/participantCurrencyCached.js | 9 +- src/models/participant/participantLimit.js | 9 +- .../participant/participantLimitCached.js | 9 +- src/models/participant/participantPosition.js | 19 +- .../participant/participantPositionChange.js | 9 +- src/models/position/batch.js | 97 +- src/models/position/batchCached.js | 6 +- src/models/position/facade.js | 19 +- src/models/position/participantPosition.js | 9 +- .../position/participantPositionChanges.js | 71 + src/models/settlement/settlementModel.js | 37 +- .../settlement/settlementModelCached.js | 9 +- src/models/transfer/facade.js | 1260 ++-- src/models/transfer/ilpPacket.js | 49 +- src/models/transfer/transfer.js | 9 +- src/models/transfer/transferDuplicateCheck.js | 9 +- src/models/transfer/transferError.js | 9 +- .../transfer/transferErrorDuplicateCheck.js | 9 +- src/models/transfer/transferExtension.js | 9 +- src/models/transfer/transferFulfilment.js | 9 +- .../transferFulfilmentDuplicateCheck.js | 9 +- src/models/transfer/transferParticipant.js | 9 +- src/models/transfer/transferStateChange.js | 9 +- src/models/transfer/transferTimeout.js | 9 +- src/schema/bulkTransfer.js | 6 +- src/shared/constants.js | 52 + src/shared/fspiopErrorFactory.js | 130 + src/shared/logger/index.js | 8 + src/shared/loggingPlugin.js | 43 + src/shared/plugins.js | 6 + src/shared/setup.js | 16 +- test-integration.Dockerfile | 2 +- test.Dockerfile | 2 +- test/fixtures.js | 366 ++ .../handlers/positions/handlerBatch.test.js | 669 +- .../handlers/transfers/fxAbort.test.js | 854 +++ .../handlers/transfers/fxFulfil.test.js | 312 + .../handlers/transfers/fxTimeout.test.js | 875 +++ .../handlers/transfers/handlers.test.js | 2074 ++++++- .../prepare/prepare-internals.test.js | 179 + test/integration-override/lib/proxyCache.js | 220 + .../domain/participant/index.test.js | 54 +- test/integration/handlers/root.test.js | 16 +- .../handlers/transfers/handlers.test.js | 110 +- .../integration/helpers/createTestConsumer.js | 59 + test/integration/helpers/hubAccounts.js | 9 +- test/integration/helpers/ilpPacket.js | 9 +- test/integration/helpers/index.js | 9 +- test/integration/helpers/kafkaHelper.js | 127 - test/integration/helpers/participant.js | 22 +- .../helpers/participantEndpoint.js | 9 +- .../helpers/participantFundsInOut.js | 9 +- test/integration/helpers/participantLimit.js | 9 +- test/integration/helpers/settlementModels.js | 11 +- test/integration/helpers/testConsumer.js | 15 +- test/integration/helpers/testProducer.js | 9 +- test/integration/helpers/transfer.js | 9 +- .../helpers/transferDuplicateCheck.js | 9 +- test/integration/helpers/transferError.js | 9 +- test/integration/helpers/transferExtension.js | 9 +- test/integration/helpers/transferState.js | 9 +- .../helpers/transferStateChange.js | 9 +- .../integration/helpers/transferTestHelper.js | 11 +- .../participant/externalParticipant.test.js | 71 + .../models/transfer/facade.test.js | 12 +- .../models/transfer/ilpPacket.test.js | 12 +- .../models/transfer/transferError.test.js | 12 +- .../models/transfer/transferExtension.test.js | 12 +- .../transfer/transferStateChange.test.js | 12 +- test/scripts/test-functional.sh | 12 +- test/scripts/test-integration.sh | 26 +- test/unit/api/index.test.js | 15 +- .../api/ledgerAccountTypes/handler.test.js | 15 +- .../api/ledgerAccountTypes/routes.test.js | 9 +- test/unit/api/metrics/handler.test.js | 15 +- test/unit/api/metrics/plugin.test.js | 9 +- test/unit/api/participants/handler.test.js | 80 +- test/unit/api/participants/routes.test.js | 9 +- test/unit/api/root/handler.test.js | 60 +- test/unit/api/root/routes.test.js | 29 +- test/unit/api/routes.test.js | 18 +- .../unit/api/settlementModels/handler.test.js | 15 +- test/unit/api/settlementModels/routes.test.js | 9 +- test/unit/api/transactions/handler.test.js | 15 +- test/unit/api/transactions/routes.test.js | 9 +- test/unit/base.js | 9 +- test/unit/domain/fx/cyril.test.js | 1228 ++++ test/unit/domain/fx/index.test.js | 167 + .../domain/ledgerAccountTypes/index.test.js | 9 +- test/unit/domain/participant/index.test.js | 25 +- test/unit/domain/position/abort.test.js | 693 +++ .../unit/domain/position/binProcessor.test.js | 235 +- test/unit/domain/position/fulfil.test.js | 1057 ++-- test/unit/domain/position/fx-fulfil.test.js | 200 + test/unit/domain/position/fx-prepare.test.js | 552 ++ .../position/fx-timeout-reserved.test.js | 324 + test/unit/domain/position/index.test.js | 11 +- test/unit/domain/position/prepare.test.js | 328 +- test/unit/domain/position/sampleBins.js | 156 + .../domain/position/timeout-reserved.test.js | 312 + test/unit/domain/settlement/index.test.js | 9 +- test/unit/domain/timeout/index.test.js | 80 +- test/unit/domain/transactions/index.test.js | 9 +- test/unit/domain/transfer/index.test.js | 39 +- test/unit/domain/transfer/transform.test.js | 12 +- test/unit/handlers/admin/handler.test.js | 10 +- test/unit/handlers/api/handler.test.js | 15 +- test/unit/handlers/bulk/get/handler.test.js | 11 +- .../handlers/bulk/prepare/handler.test.js | 11 +- test/unit/handlers/index.test.js | 8 +- test/unit/handlers/positions/handler.test.js | 11 + .../handlers/positions/handlerBatch.test.js | 104 +- test/unit/handlers/register.test.js | 5 + test/unit/handlers/timeouts/handler.test.js | 139 +- .../transfers/FxFulfilService.test.js | 207 + .../transfers/fxFulfilHandler.test.js | 532 ++ test/unit/handlers/transfers/handler.test.js | 913 +-- test/unit/handlers/transfers/mocks.js | 60 + test/unit/handlers/transfers/prepare.test.js | 1696 ++++++ .../unit/handlers/transfers/validator.test.js | 99 +- test/unit/lib/cachingOfEnums.test.js | 9 +- test/unit/lib/config.test.js | 12 - test/unit/lib/enum.test.js | 9 +- test/unit/lib/enumCached.test.js | 9 +- .../lib/healthCheck/subServiceHealth.test.js | 47 +- test/unit/lib/proxyCache.test.js | 182 + test/unit/lib/requestLogger.test.js | 9 +- test/unit/lib/urlparser.test.js | 9 +- .../models/fxTransfer/duplicateCheck.test.js | 257 + test/unit/models/fxTransfer/watchList.test.js | 77 + test/unit/models/ilpPackets/ilpPacket.test.js | 9 +- .../ledgerAccountType.test.js | 73 +- test/unit/models/misc/migrationLock.test.js | 9 +- test/unit/models/misc/segment.test.js | 9 +- .../participant/externalParticipant.test.js | 125 + .../externalParticipantCached.test.js | 141 + test/unit/models/participant/facade.test.js | 164 +- .../models/participant/participant.test.js | 17 +- .../participant/participantCached.test.js | 9 +- .../participant/participantCurrency.test.js | 9 +- .../participantCurrencyCached.test.js | 9 +- .../participant/participantLimit.test.js | 9 +- .../participantLimitCached.test.js | 9 +- .../participant/participantPosition.test.js | 35 +- .../participantPositionChange.test.js | 9 +- test/unit/models/position/batch.test.js | 48 +- test/unit/models/position/batchCached.test.js | 9 +- test/unit/models/position/facade.test.js | 38 +- .../position/participantPosition.test.js | 9 +- .../participantPositionChanges.test.js | 116 + .../models/settlement/settlementModel.test.js | 9 +- .../settlement/settlementModelCached.test.js | 9 +- .../transfer/facade-withMockKnex.test.js | 103 + test/unit/models/transfer/facade.test.js | 460 +- test/unit/models/transfer/ilpPacket.test.js | 9 +- test/unit/models/transfer/transfer.test.js | 9 +- .../transfer/transferDuplicateCheck.test.js | 10 +- .../models/transfer/transferError.test.js | 9 +- .../transferErrorDuplicateCheck.test.js | 9 +- .../models/transfer/transferExtension.test.js | 9 +- .../transfer/transferFulfilment.test.js | 9 +- .../transferFulfilmentDuplicateCheck.test.js | 9 +- .../transfer/transferParticipant.test.js | 9 +- .../transfer/transferStateChange.test.js | 9 +- .../models/transfer/transferTimeout.test.js | 9 +- test/unit/seeds/amountType.test.js | 9 +- test/unit/seeds/balanceOfPayments.test.js | 9 +- test/unit/seeds/bulkProcessingState.test.js | 9 +- test/unit/seeds/bulkTransferState.test.js | 9 +- test/unit/seeds/currency.test.js | 9 +- test/unit/seeds/endpointType.test.js | 9 +- test/unit/seeds/ledgerAccountType.test.js | 9 +- test/unit/seeds/ledgerEntryType.test.js | 9 +- test/unit/seeds/participant.test.js | 11 +- test/unit/seeds/participantLimitType.test.js | 9 +- test/unit/seeds/partyIdentifierType.test.js | 9 +- test/unit/seeds/partyType.test.js | 9 +- test/unit/seeds/settlementState.test.js | 9 +- .../unit/seeds/settlementWindow1State.test.js | 9 +- test/unit/seeds/settlementWindow2Open.test.js | 9 +- test/unit/seeds/transactionInitiator.test.js | 9 +- .../seeds/transactionInitiatorType.test.js | 9 +- test/unit/seeds/transactionScenario.test.js | 9 +- .../seeds/transferParticipantRoleType.test.js | 9 +- test/unit/seeds/transferState.test.js | 9 +- test/unit/shared/setup.test.js | 71 +- test/util/helpers.js | 36 +- test/util/randomTransfers.js | 9 +- 357 files changed, 28877 insertions(+), 9701 deletions(-) create mode 100755 docker/env.sh create mode 100644 documentation/db/erd-transfer-timeout.png create mode 100644 documentation/db/erd-transfer-timeout.txt create mode 100644 documentation/fx-implementation/README.md create mode 100644 documentation/fx-implementation/assets/fx-position-movements.drawio.svg create mode 100644 documentation/fx-implementation/assets/test-scenario.drawio.svg create mode 100644 documentation/sequence-diagrams/Handler - FX timeout.plantuml create mode 100644 documentation/sequence-diagrams/Handler - FX timeout.png create mode 100644 documentation/sequence-diagrams/Handler - timeout.plantuml create mode 100644 documentation/sequence-diagrams/Handler - timeout.png create mode 100644 documentation/state-diagrams/transfer-ML-spec-states-diagram.png create mode 100644 documentation/state-diagrams/transfer-internal-states-diagram.png create mode 100644 documentation/state-diagrams/transfer-internal-states.plantuml create mode 100644 documentation/state-diagrams/transfer-states.plantuml create mode 100644 migrations/310204_transferParticipant-participantId.js create mode 100644 migrations/310403_participantPositionChange-participantCurrencyId.js create mode 100644 migrations/310404_participantPositionChange-change.js create mode 100644 migrations/600010_fxTransferType.js create mode 100644 migrations/600011_fxTransferType-indexes.js create mode 100644 migrations/600012_fxParticipantCurrencyType.js create mode 100644 migrations/600013_fxParticipantCurrencyType-indexes.js create mode 100644 migrations/600100_fxTransferDuplicateCheck.js create mode 100644 migrations/600110_fxTransferErrorDuplicateCheck.js.js create mode 100644 migrations/600200_fxTransfer.js create mode 100644 migrations/600201_fxTransfer-indexes.js create mode 100644 migrations/600400_fxTransferStateChange.js create mode 100644 migrations/600401_fxTransferStateChange-indexes.js create mode 100644 migrations/600501_fxWatchList.js create mode 100644 migrations/600502_fxWatchList-indexes.js create mode 100644 migrations/600600_fxTransferFulfilmentDuplicateCheck.js create mode 100644 migrations/600601_fxTransferFulfilmentDuplicateCheck-indexes.js create mode 100644 migrations/600700_fxTransferFulfilment.js create mode 100644 migrations/600701_fxTransferFulfilment-indexes.js create mode 100644 migrations/600800_fxTransferExtension.js create mode 100644 migrations/601400_fxTransferTimeout.js create mode 100644 migrations/601401_fxTransferTimeout-indexes.js create mode 100644 migrations/601500_fxTransferError.js create mode 100644 migrations/601501_fxTransferError-indexes.js create mode 100644 migrations/610200_fxTransferParticipant.js create mode 100644 migrations/610201_fxTransferParticipant-indexes.js create mode 100644 migrations/610202_fxTransferParticipant-participantId.js create mode 100644 migrations/610403_participantPositionChange-fxTransfer.js create mode 100644 migrations/950108_participantProxy.js create mode 100644 migrations/950109_fxQuote.js create mode 100644 migrations/950110_fxQuoteResponse.js create mode 100644 migrations/950111_fxQuoteError.js create mode 100644 migrations/950113_fxQuoteDuplicateCheck.js create mode 100644 migrations/950114_fxQuoteResponseDuplicateCheck.js create mode 100644 migrations/950115_fxQuoteConversionTerms.js create mode 100644 migrations/950116_fxQuoteConversionTermsExtension.js create mode 100644 migrations/950117_fxQuoteResponseConversionTerms.js create mode 100644 migrations/950118_fxQuoteResponseConversionTermsExtension.js create mode 100644 migrations/950119_fxCharge.js create mode 100644 migrations/960100_create_externalParticipant.js create mode 100644 migrations/960110_alter_transferParticipant__addFiled_externalParticipantId.js create mode 100644 migrations/960111_alter_fxTransferParticipant__addFiled_externalParticipantId.js create mode 100644 seeds/fxParticipantCurrencyType.js create mode 100644 seeds/fxTransferType.js create mode 100644 src/domain/fx/cyril.js create mode 100644 src/domain/fx/index.js create mode 100644 src/domain/position/abort.js create mode 100644 src/domain/position/fx-fulfil.js create mode 100644 src/domain/position/fx-prepare.js create mode 100644 src/domain/position/fx-timeout-reserved.js create mode 100644 src/domain/position/timeout-reserved.js create mode 100644 src/handlers/transfers/FxFulfilService.js create mode 100644 src/handlers/transfers/createRemittanceEntity.js create mode 100644 src/handlers/transfers/dto.js create mode 100644 src/handlers/transfers/prepare.js create mode 100644 src/lib/proxyCache.js create mode 100644 src/models/fxTransfer/duplicateCheck.js create mode 100644 src/models/fxTransfer/fxTransfer.js create mode 100644 src/models/fxTransfer/fxTransferError.js create mode 100644 src/models/fxTransfer/fxTransferExtension.js create mode 100644 src/models/fxTransfer/fxTransferTimeout.js create mode 100644 src/models/fxTransfer/index.js create mode 100644 src/models/fxTransfer/stateChange.js create mode 100644 src/models/fxTransfer/watchList.js create mode 100644 src/models/participant/externalParticipant.js create mode 100644 src/models/participant/externalParticipantCached.js create mode 100644 src/models/position/participantPositionChanges.js create mode 100644 src/shared/constants.js create mode 100644 src/shared/fspiopErrorFactory.js create mode 100644 src/shared/logger/index.js create mode 100644 src/shared/loggingPlugin.js create mode 100644 test/fixtures.js create mode 100644 test/integration-override/handlers/transfers/fxAbort.test.js create mode 100644 test/integration-override/handlers/transfers/fxFulfil.test.js create mode 100644 test/integration-override/handlers/transfers/fxTimeout.test.js create mode 100644 test/integration-override/handlers/transfers/prepare/prepare-internals.test.js create mode 100644 test/integration-override/lib/proxyCache.js create mode 100644 test/integration/helpers/createTestConsumer.js delete mode 100644 test/integration/helpers/kafkaHelper.js create mode 100644 test/integration/models/participant/externalParticipant.test.js mode change 100644 => 100755 test/scripts/test-functional.sh mode change 100644 => 100755 test/scripts/test-integration.sh create mode 100644 test/unit/domain/fx/cyril.test.js create mode 100644 test/unit/domain/fx/index.test.js create mode 100644 test/unit/domain/position/abort.test.js create mode 100644 test/unit/domain/position/fx-fulfil.test.js create mode 100644 test/unit/domain/position/fx-prepare.test.js create mode 100644 test/unit/domain/position/fx-timeout-reserved.test.js create mode 100644 test/unit/domain/position/timeout-reserved.test.js create mode 100644 test/unit/handlers/transfers/FxFulfilService.test.js create mode 100644 test/unit/handlers/transfers/fxFulfilHandler.test.js create mode 100644 test/unit/handlers/transfers/mocks.js create mode 100644 test/unit/handlers/transfers/prepare.test.js create mode 100644 test/unit/lib/proxyCache.test.js create mode 100644 test/unit/models/fxTransfer/duplicateCheck.test.js create mode 100644 test/unit/models/fxTransfer/watchList.test.js create mode 100644 test/unit/models/participant/externalParticipant.test.js create mode 100644 test/unit/models/participant/externalParticipantCached.test.js create mode 100644 test/unit/models/position/participantPositionChanges.test.js create mode 100644 test/unit/models/transfer/facade-withMockKnex.test.js diff --git a/.circleci/config.yml b/.circleci/config.yml index 378589967..3f2da6420 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,1059 +1,11 @@ -# CircleCI v2 Config version: 2.1 - -## -# orbs -# -# Orbs used in this pipeline -## +setup: true orbs: - anchore: anchore/anchore-engine@1.9.0 - slack: circleci/slack@4.12.5 # Ref: https://github.com/mojaloop/ci-config/tree/main/slack-templates - pr-tools: mojaloop/pr-tools@0.1.10 # Ref: https://github.com/mojaloop/ci-config/ - gh: circleci/github-cli@2.2.0 - -## -# defaults -# -# YAML defaults templates, in alphabetical order -## -defaults_docker_Dependencies: &defaults_docker_Dependencies | - apk --no-cache add bash - apk --no-cache add git - apk --no-cache add ca-certificates - apk --no-cache add curl - apk --no-cache add openssh-client - apk --no-cache add -t build-dependencies make gcc g++ python3 libtool autoconf automake jq - apk --no-cache add -t openssl ncurses coreutils libgcc linux-headers grep util-linux binutils findutils - apk --no-cache add librdkafka-dev - -## Default 'default-machine' executor dependencies -defaults_machine_Dependencies: &defaults_machine_Dependencies | - ## Add Package Repos - ## Ref: https://docs.confluent.io/platform/current/installation/installing_cp/deb-ubuntu.html#get-the-software - wget -qO - https://packages.confluent.io/deb/7.4/archive.key | sudo apt-key add - - sudo add-apt-repository -y "deb https://packages.confluent.io/clients/deb $(lsb_release -cs) main" - - ## Install deps - sudo apt install -y librdkafka-dev curl bash musl-dev libsasl2-dev - sudo ln -s /usr/lib/x86_64-linux-musl/libc.so /lib/libc.musl-x86_64.so.1 - -defaults_awsCliDependencies: &defaults_awsCliDependencies | - apk --no-cache add aws-cli - -defaults_license_scanner: &defaults_license_scanner - name: Install and set up license-scanner - command: | - git clone https://github.com/mojaloop/license-scanner /tmp/license-scanner - cd /tmp/license-scanner && make build default-files set-up - -defaults_npm_auth: &defaults_npm_auth - name: Update NPM registry auth token - command: echo "//registry.npmjs.org/:_authToken=$NPM_TOKEN" > .npmrc - -defaults_npm_publish_release: &defaults_npm_publish_release - name: Publish NPM $RELEASE_TAG artifact - command: | - source $BASH_ENV - echo "Publishing tag $RELEASE_TAG" - npm publish --tag $RELEASE_TAG --access public - -defaults_export_version_from_package: &defaults_export_version_from_package - name: Format the changelog into the github release body and get release tag - command: | - git diff --no-indent-heuristic main~1 HEAD CHANGELOG.md | sed -n '/^+[^+]/ s/^+//p' > /tmp/changes - echo 'export RELEASE_CHANGES=`cat /tmp/changes`' >> $BASH_ENV - echo 'export RELEASE_TAG=`cat package-lock.json | jq -r .version`' >> $BASH_ENV - -defaults_configure_git: &defaults_configure_git - name: Configure git - command: | - git config user.email ${GIT_CI_EMAIL} - git config user.name ${GIT_CI_USER} - -defaults_configure_nvmrc: &defaults_configure_nvmrc - name: Configure NVMRC - command: | - if [ -z "$NVMRC_VERSION" ]; then - echo "==> Configuring NVMRC_VERSION!" - - export ENV_DOT_PROFILE=$HOME/.profile - touch $ENV_DOT_PROFILE - - export NVMRC_VERSION=$(cat $CIRCLE_WORKING_DIRECTORY/.nvmrc) - echo "export NVMRC_VERSION=$NVMRC_VERSION" >> $ENV_DOT_PROFILE - fi - echo "NVMRC_VERSION=$NVMRC_VERSION" - -defaults_configure_nvm: &defaults_configure_nvm - name: Configure NVM - command: | - cd $HOME - export ENV_DOT_PROFILE=$HOME/.profile - touch $ENV_DOT_PROFILE - echo "1. Check/Set NVM_DIR env variable" - if [ -z "$NVM_DIR" ]; then - export NVM_DIR="$HOME/.nvm" - echo "==> NVM_DIR has been exported - $NVM_DIR" - else - echo "==> NVM_DIR already exists - $NVM_DIR" - fi - echo "2. Check/Set NVMRC_VERSION env variable" - if [ -z "$NVMRC_VERSION" ]; then - echo "==> Configuring NVMRC_VERSION!" - export NVMRC_VERSION=$(cat $CIRCLE_WORKING_DIRECTORY/.nvmrc) - echo "export NVMRC_VERSION=$NVMRC_VERSION" >> $ENV_DOT_PROFILE - fi - echo "3. Configure NVM" - ## Lets check if an existing NVM_DIR exists, if it does lets skil - if [ -e "$NVM_DIR" ]; then - echo "==> $NVM_DIR exists. Skipping steps 3!" - # echo "5. Executing $NVM_DIR/nvm.sh" - # [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - else - echo "==> $NVM_DIR does not exists. Executing steps 4-5!" - echo "4. Installing NVM" - curl -o- https://raw.githubusercontent.com/nvm-sh/nvm/v0.39.3/install.sh | bash - echo "5. Executing $NVM_DIR/nvm.sh" - [ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" - fi - ## Ref: https://github.com/nvm-sh/nvm/issues/1102#issuecomment-550572252 - if [ ! -z "$NVM_ARCH_UNOFFICIAL_OVERRIDE" ]; then - echo "==> Handle NVM_ARCH_UNOFFICIAL_OVERRIDE=$NVM_ARCH_UNOFFICIAL_OVERRIDE!" - echo "nvm_get_arch() { nvm_echo \"${NVM_ARCH_UNOFFICIAL_OVERRIDE}\"; }" >> $ENV_DOT_PROFILE - echo "export NVM_NODEJS_ORG_MIRROR=https://unofficial-builds.nodejs.org/download/release" >> $ENV_DOT_PROFILE - source $ENV_DOT_PROFILE - fi - echo "6. Setup Node version" - if [ -n "$NVMRC_VERSION" ]; then - echo "==> Installing Node version: $NVMRC_VERSION" - nvm install $NVMRC_VERSION - nvm alias default $NVMRC_VERSION - nvm use $NVMRC_VERSION - cd $CIRCLE_WORKING_DIRECTORY - else - echo "==> ERROR - NVMRC_VERSION has not been set! - NVMRC_VERSION: $NVMRC_VERSION" - exit 1 - fi - -defaults_display_versions: &defaults_display_versions - name: Display Versions - command: | - echo "What is the active version of Nodejs?" - echo "node: $(node --version)" - echo "yarn: $(yarn --version)" - echo "npm: $(npm --version)" - echo "nvm: $(nvm --version)" - -defaults_environment: &defaults_environment - ## env var for nx to set main branch - MAIN_BRANCH_NAME: main - ## Disable LIBRDKAFKA build since we install it via general dependencies - # BUILD_LIBRDKAFKA: 0 - -## -# Executors -# -# CircleCI Executors -## -executors: - default-docker: - working_directory: &WORKING_DIR /home/circleci/project - shell: "/bin/sh -leo pipefail" ## Ref: https://circleci.com/docs/env-vars/#alpine-linux - environment: - BASH_ENV: /etc/profile ## Ref: https://circleci.com/docs/env-vars/#alpine-linux - NVM_ARCH_UNOFFICIAL_OVERRIDE: x64-musl ## Ref: https://github.com/nvm-sh/nvm/issues/1102#issuecomment-550572252 - docker: - - image: node:18-alpine3.19 # Ref: https://hub.docker.com/_/node?tab=tags&page=1&name=alpine - - default-machine: - working_directory: *WORKING_DIR - shell: "/bin/bash -leo pipefail" - machine: - image: ubuntu-2204:2023.04.2 # Ref: https://circleci.com/developer/machine/image/ubuntu-2204 - -## -# Jobs -# -# A map of CircleCI jobs -## -jobs: - setup: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - run: - name: Update NPM install - command: npm ci - - save_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - paths: - - node_modules - - test-dependencies: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Execute dependency tests - command: npm run dep:check - - test-lint: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Execute lint tests - command: npm run lint - - test-unit: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - # This is needed for legacy core tests. Remove this once 'tape' is fully deprecated. - name: Install tape, tapes and tap-xunit - command: npm install tape tapes tap-xunit - - run: - name: Create dir for test results - command: mkdir -p ./test/results - - run: - name: Execute unit tests - command: npm -s run test:xunit - - store_artifacts: - path: ./test/results - destination: test - - store_test_results: - path: ./test/results - - test-coverage: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - run: - name: Install AWS CLI dependencies - command: *defaults_awsCliDependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Execute code coverage check - command: npm -s run test:coverage-check - - store_artifacts: - path: coverage - destination: test - - store_test_results: - path: coverage - - build-local: - executor: default-machine - environment: - <<: *defaults_environment - steps: - - checkout - - run: - <<: *defaults_configure_nvmrc - - run: - <<: *defaults_display_versions - - run: - name: Build Docker local image - command: | - source ~/.profile - export DOCKER_NODE_VERSION="$NVMRC_VERSION-alpine" - echo "export DOCKER_NODE_VERSION=$NVMRC_VERSION-alpine" >> $BASH_ENV - echo "Building Docker image: ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local --build-arg NODE_VERSION=$DOCKER_NODE_VERSION" - docker build -t ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local --build-arg NODE_VERSION=$DOCKER_NODE_VERSION . - - run: - name: Save docker image to workspace - command: docker save -o /tmp/docker-image.tar ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local - - persist_to_workspace: - root: /tmp - paths: - - ./docker-image.tar - - test-integration: - executor: default-machine - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_machine_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - attach_workspace: - at: /tmp - - run: - name: Create dir for test results - command: mkdir -p ./test/results - - run: - name: Execute integration tests - command: | - # Set Node version to default (Note: this is needed on Ubuntu) - nvm use default - npm ci - - echo "Running integration tests...." - bash ./test/scripts/test-integration.sh - environment: - ENDPOINT_URL: http://localhost:4545/notification - UV_THREADPOOL_SIZE: 12 - WAIT_FOR_REBALANCE: 20 - TEST_INT_RETRY_COUNT: 30 - TEST_INT_RETRY_DELAY: 2 - TEST_INT_REBALANCE_DELAY: 20000 - - store_artifacts: - path: ./test/results - destination: test - - store_test_results: - path: ./test/results - - test-functional: - executor: default-machine - environment: - ML_CORE_TEST_HARNESS_DIR: /tmp/ml-core-test-harness - steps: - - checkout - - attach_workspace: - at: /tmp - - run: - name: Load the pre-built docker image from workspace - command: docker load -i /tmp/docker-image.tar - - run: - name: Execute TTK functional tests - command: bash ./test/scripts/test-functional.sh - - store_artifacts: - path: /tmp/ml-core-test-harness/reports - destination: test - - vulnerability-check: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Create dir for test results - command: mkdir -p ./audit/results - - run: - name: Check for new npm vulnerabilities - command: npm run audit:check -- -o json > ./audit/results/auditResults.json - - store_artifacts: - path: ./audit/results - destination: audit - - audit-licenses: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - run: - <<: *defaults_license_scanner - - checkout - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Run the license-scanner - command: cd /tmp/license-scanner && pathToRepo=$CIRCLE_WORKING_DIRECTORY make run - - store_artifacts: - path: /tmp/license-scanner/results - destination: licenses - - license-scan: - executor: default-machine - environment: - <<: *defaults_environment - steps: - - attach_workspace: - at: /tmp - - run: - name: Load the pre-built docker image from workspace - command: docker load -i /tmp/docker-image.tar - - run: - <<: *defaults_license_scanner - - run: - name: Run the license-scanner - command: cd /tmp/license-scanner && mode=docker dockerImages=${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local make run - - store_artifacts: - path: /tmp/license-scanner/results - destination: licenses - - image-scan: - executor: anchore/anchore_engine - shell: /bin/sh -leo pipefail ## Ref: https://circleci.com/docs/env-vars/#alpine-linux - environment: - <<: *defaults_environment - BASH_ENV: /etc/profile ## Ref: https://circleci.com/docs/env-vars/#alpine-linux - ENV: ~/.profile - NVM_ARCH_UNOFFICIAL_OVERRIDE: x64-musl ## Ref: https://github.com/nvm-sh/nvm/issues/1102#issuecomment-550572252 - working_directory: *WORKING_DIR - steps: - - setup_remote_docker - - attach_workspace: - at: /tmp - - run: - name: Install docker dependencies for anchore - command: | - apk add --update py-pip docker python3-dev libffi-dev openssl-dev gcc libc-dev make jq curl bash - - run: - name: Install AWS CLI dependencies - command: *defaults_awsCliDependencies - - checkout - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='GitHub Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG='${RELEASE_TAG} on ${CIRCLE_BRANCH} branch'" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - echo "export SLACK_CUSTOM_MSG='Anchore Image Scan failed for: \`${DOCKER_ORG}/${CIRCLE_PROJECT_REPONAME}:${CIRCLE_TAG}\`'" >> $BASH_ENV - - run: - <<: *defaults_configure_nvm - - run: - <<: *defaults_display_versions - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - run: - name: Load the pre-built docker image from workspace - command: docker load -i /tmp/docker-image.tar - - run: - name: Download the mojaloop/ci-config repo - command: | - git clone https://github.com/mojaloop/ci-config /tmp/ci-config - # Generate the mojaloop anchore-policy - cd /tmp/ci-config/container-scanning && ./mojaloop-policy-generator.js /tmp/mojaloop-policy.json - - run: - name: Pull base image locally - command: | - echo "Pulling docker image: node:$NVMRC_VERSION-alpine" - docker pull node:$NVMRC_VERSION-alpine - ## Analyze the base and derived image - ## Note: It seems images are scanned in parallel, so preloading the base image result doesn't give us any real performance gain - - anchore/analyze_local_image: - # Force the older version, version 0.7.0 was just published, and is broken - anchore_version: v0.6.1 - image_name: "docker.io/node:$NVMRC_VERSION-alpine ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local" - policy_failure: false - timeout: '500' - # Note: if the generated policy is invalid, this will fallback to the default policy, which we don't want! - policy_bundle_file_path: /tmp/mojaloop-policy.json - - run: - name: Upload Anchore reports to s3 - command: | - aws s3 cp anchore-reports ${AWS_S3_DIR_ANCHORE_REPORTS}/${CIRCLE_PROJECT_REPONAME}/ --recursive - aws s3 rm ${AWS_S3_DIR_ANCHORE_REPORTS}/latest/ --recursive --exclude "*" --include "${CIRCLE_PROJECT_REPONAME}*" - aws s3 cp anchore-reports ${AWS_S3_DIR_ANCHORE_REPORTS}/latest/ --recursive - - run: - name: Evaluate failures - command: /tmp/ci-config/container-scanning/anchore-result-diff.js anchore-reports/node_${NVMRC_VERSION}-alpine-policy.json anchore-reports/${CIRCLE_PROJECT_REPONAME}*-policy.json - - store_artifacts: - path: anchore-reports - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - release: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - restore_cache: - keys: - - dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - <<: *defaults_configure_git - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='GitHub Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG='${RELEASE_TAG} on ${CIRCLE_BRANCH} branch'" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - run: - name: Generate changelog and bump package version - command: npm run release -- --no-verify - - run: - name: Push the release - command: git push --follow-tags origin ${CIRCLE_BRANCH} - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - github-release: - executor: default-machine - shell: "/bin/bash -eo pipefail" - environment: - <<: *defaults_environment - steps: - - run: - name: Install git - command: | - sudo apt-get update && sudo apt-get install -y git - - gh/install - - checkout - - run: - <<: *defaults_configure_git - - run: - name: Fetch updated release branch - command: | - git fetch origin - git checkout origin/${CIRCLE_BRANCH} - - run: - <<: *defaults_export_version_from_package - - run: - name: Check the release changes - command: | - echo "Changes are: ${RELEASE_CHANGES}" - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='Github Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG=v${RELEASE_TAG}" >> $BASH_ENV - echo "export SLACK_RELEASE_URL=https://github.com/mojaloop/${CIRCLE_PROJECT_REPONAME}/releases/tag/v${RELEASE_TAG}" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - run: - name: Create Release - command: | - gh release create "v${RELEASE_TAG}" --title "v${RELEASE_TAG} Release" --draft=false --notes "${RELEASE_CHANGES}" ./CHANGELOG.md - - slack/notify: - event: pass - template: SLACK_TEMP_RELEASE_SUCCESS - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - publish-docker: - executor: default-machine - shell: "/bin/bash -eo pipefail" - environment: - <<: *defaults_environment - steps: - - checkout - - run: - name: Setup for LATEST release - command: | - echo "export RELEASE_TAG=$RELEASE_TAG_PROD" >> $BASH_ENV - echo "RELEASE_TAG=$RELEASE_TAG_PROD" - - PACKAGE_VERSION=$(cat package-lock.json | jq -r .version) - echo "export PACKAGE_VERSION=${PACKAGE_VERSION}" >> $BASH_ENV - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='Docker Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG=v${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - attach_workspace: - at: /tmp - - run: - name: Load the pre-built docker image from workspace - command: | - docker load -i /tmp/docker-image.tar - - run: - name: Login to Docker Hub - command: docker login -u $DOCKER_USER -p $DOCKER_PASS - - run: - name: Re-tag pre built image - command: | - docker tag ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - docker tag ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - - run: - name: Publish Docker image $CIRCLE_TAG & Latest tag to Docker Hub - command: | - echo "Publishing ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" - docker push ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - echo "Publishing ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" - docker push ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - - run: - name: Set Image Digest - command: | - IMAGE_DIGEST=$(docker inspect ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:v${CIRCLE_TAG:1} | jq '.[0].RepoDigests | .[]') - echo "IMAGE_DIGEST=${IMAGE_DIGEST}" - echo "export IMAGE_DIGEST=${IMAGE_DIGEST}" >> $BASH_ENV - - run: - name: Update Slack config - command: | - echo "export SLACK_RELEASE_URL='https://hub.docker.com/layers/${CIRCLE_PROJECT_REPONAME}/${DOCKER_ORG}/${CIRCLE_PROJECT_REPONAME}/v${CIRCLE_TAG:1}/images/${IMAGE_DIGEST}?context=explore'" | sed -r "s/${DOCKER_ORG}\/${CIRCLE_PROJECT_REPONAME}@sha256:/sha256-/g" >> $BASH_ENV - - slack/notify: - event: pass - template: SLACK_TEMP_RELEASE_SUCCESS - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - publish-docker-snapshot: - executor: default-machine - shell: "/bin/bash -eo pipefail" - environment: - <<: *defaults_environment - steps: - - checkout - - run: - name: Setup for SNAPSHOT release - command: | - echo "export RELEASE_TAG=$RELEASE_TAG_SNAPSHOT" >> $BASH_ENV - echo "RELEASE_TAG=$RELEASE_TAG_SNAPSHOT" - - PACKAGE_VERSION=$(cat package-lock.json | jq -r .version) - echo "export PACKAGE_VERSION=${PACKAGE_VERSION}" >> $BASH_ENV - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='Docker Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG=v${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - attach_workspace: - at: /tmp - - run: - name: Load the pre-built docker image from workspace - command: | - docker load -i /tmp/docker-image.tar - - run: - name: Login to Docker Hub - command: docker login -u $DOCKER_USER -p $DOCKER_PASS - - run: - name: Re-tag pre built image - command: | - docker tag ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - docker tag ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:local ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - - run: - name: Publish Docker image $CIRCLE_TAG & Latest tag to Docker Hub - command: | - echo "Publishing ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG" - docker push ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$CIRCLE_TAG - echo "Publishing ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG" - docker push ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:$RELEASE_TAG - - run: - name: Set Image Digest - command: | - IMAGE_DIGEST=$(docker inspect ${DOCKER_ORG:-mojaloop}/$CIRCLE_PROJECT_REPONAME:v${CIRCLE_TAG:1} | jq '.[0].RepoDigests | .[]') - echo "IMAGE_DIGEST=${IMAGE_DIGEST}" - echo "export IMAGE_DIGEST=${IMAGE_DIGEST}" >> $BASH_ENV - - run: - name: Update Slack config - command: | - echo "export SLACK_RELEASE_URL='https://hub.docker.com/layers/${CIRCLE_PROJECT_REPONAME}/${DOCKER_ORG}/${CIRCLE_PROJECT_REPONAME}/v${CIRCLE_TAG:1}/images/${IMAGE_DIGEST}?context=explore'" | sed -r "s/${DOCKER_ORG}\/${CIRCLE_PROJECT_REPONAME}@sha256:/sha256-/g" >> $BASH_ENV - - slack/notify: - event: pass - template: SLACK_TEMP_RELEASE_SUCCESS - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - publish-npm: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Setup for LATEST release - command: | - echo "export RELEASE_TAG=$RELEASE_TAG_PROD" >> $BASH_ENV - echo "RELEASE_TAG=$RELEASE_TAG_PROD" - PACKAGE_VERSION=$(cat package-lock.json | jq -r .version) - echo "export PACKAGE_VERSION=${PACKAGE_VERSION}" >> $BASH_ENV - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='NPM Release'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG=v${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_RELEASE_URL=https://www.npmjs.com/package/@mojaloop/${CIRCLE_PROJECT_REPONAME}/v/${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - run: - <<: *defaults_npm_auth - - run: - <<: *defaults_npm_publish_release - - slack/notify: - event: pass - template: SLACK_TEMP_RELEASE_SUCCESS - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - - publish-npm-snapshot: - executor: default-docker - environment: - <<: *defaults_environment - steps: - - run: - name: Install general dependencies - command: *defaults_docker_Dependencies - - checkout - - restore_cache: - key: dependency-cache-{{ .Environment.CIRCLE_SHA1 }} - - run: - name: Setup for SNAPSHOT release - command: | - echo "export RELEASE_TAG=${RELEASE_TAG_SNAPSHOT}" >> $BASH_ENV - echo "RELEASE_TAG=${RELEASE_TAG_SNAPSHOT}" - echo "Override package version: ${CIRCLE_TAG:1}" - npx standard-version --skip.tag --skip.commit --skip.changelog --release-as ${CIRCLE_TAG:1} - PACKAGE_VERSION=$(cat package-lock.json | jq -r .version) - echo "export PACKAGE_VERSION=${PACKAGE_VERSION}" >> $BASH_ENV - echo "PACKAGE_VERSION=${PACKAGE_VERSION}" - - run: - name: Setup Slack config - command: | - echo "export SLACK_PROJECT_NAME=${CIRCLE_PROJECT_REPONAME}" >> $BASH_ENV - echo "export SLACK_RELEASE_TYPE='NPM Snapshot'" >> $BASH_ENV - echo "export SLACK_RELEASE_TAG=v${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_RELEASE_URL=https://www.npmjs.com/package/@mojaloop/${CIRCLE_PROJECT_REPONAME}/v/${CIRCLE_TAG:1}" >> $BASH_ENV - echo "export SLACK_BUILD_ID=${CIRCLE_BUILD_NUM}" >> $BASH_ENV - echo "export SLACK_CI_URL=${CIRCLE_BUILD_URL}" >> $BASH_ENV - - run: - <<: *defaults_npm_auth - - run: - <<: *defaults_npm_publish_release - - slack/notify: - event: pass - template: SLACK_TEMP_RELEASE_SUCCESS - - slack/notify: - event: fail - template: SLACK_TEMP_RELEASE_FAILURE - -## -# Workflows -# -# CircleCI Workflow config -## + build: mojaloop/build@1.0.22 workflows: - build_and_test: + setup: jobs: - - pr-tools/pr-title-check: - context: org-global - - setup: - context: org-global - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - test-dependencies: - context: org-global - requires: - - setup - filters: - tags: - ignore: /.*/ - branches: - ignore: - - main - - test-lint: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - test-unit: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - test-coverage: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - test-integration: - context: org-global - requires: - - setup - - build-local - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - test-functional: - context: org-global - requires: - - setup - - build-local - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - vulnerability-check: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - audit-licenses: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - build-local: - context: org-global - requires: - - setup - filters: - tags: - only: /.*/ - branches: - ignore: - - /feature*/ - - /bugfix*/ - - license-scan: - context: org-global - requires: - - build-local - filters: - tags: - only: /v[0-9]+(\.[0-9]+)*(\-snapshot(\.[0-9]+)?)?(\-hotfix(\.[0-9]+)?)?(\-perf(\.[0-9]+)?)?/ - branches: - ignore: - - /.*/ - - image-scan: - context: org-global - requires: - - build-local - filters: - tags: - only: /v[0-9]+(\.[0-9]+)*(\-snapshot(\.[0-9]+)?)?(\-hotfix(\.[0-9]+)?)?(\-perf(\.[0-9]+)?)?/ - branches: - ignore: - - /.*/ - # New commits to main release automatically - - release: - context: org-global - requires: - - pr-tools/pr-title-check - ## Only do this check on PRs - # - test-dependencies - - test-lint - - test-unit - - test-coverage - - test-integration - - test-functional - - vulnerability-check - - audit-licenses - - license-scan - - image-scan - filters: - branches: - only: - - main - - /release\/v.*/ - - github-release: - context: org-global - requires: - - release - filters: - branches: - only: - - main - - /release\/v.*/ - - publish-docker: - context: org-global - requires: - - build-local - - pr-tools/pr-title-check - ## Only do this check on PRs - # - test-dependencies - - test-lint - - test-unit - - test-coverage - - test-integration - - test-functional - - vulnerability-check - - audit-licenses - - license-scan - - image-scan - filters: - tags: - only: /v[0-9]+(\.[0-9]+)*/ - branches: - ignore: - - /.*/ - - publish-docker-snapshot: - context: org-global - requires: - - build-local - - pr-tools/pr-title-check - ## Only do this check on PRs - # - test-dependencies - - test-lint - - test-unit - - test-coverage - - test-integration - - test-functional - - vulnerability-check - - audit-licenses - - license-scan - - image-scan - filters: - tags: - only: /v[0-9]+(\.[0-9]+)*\-snapshot+((\.[0-9]+)?)/ - branches: - ignore: - - /.*/ - - publish-npm: - context: org-global - requires: - - pr-tools/pr-title-check - ## Only do this check on PRs - # - test-dependencies - - test-lint - - test-unit - - test-coverage - - test-integration - - test-functional - - vulnerability-check - - audit-licenses - - license-scan - - image-scan - filters: - tags: - only: /v[0-9]+(\.[0-9]+)*/ - branches: - ignore: - - /.*/ - - publish-npm-snapshot: - context: org-global - requires: - - pr-tools/pr-title-check - ## Only do this check on PRs - # - test-dependencies - - test-lint - - test-unit - - test-coverage - - test-integration - - test-functional - - vulnerability-check - - audit-licenses - - license-scan - - image-scan + - build/workflow: filters: tags: - only: /v[0-9]+(\.[0-9]+)*\-snapshot+((\.[0-9]+)?)/ - branches: - ignore: - - /.*/ + only: /v\d+(\.\d+){2}(-[a-zA-Z-][0-9a-zA-Z-]*\.\d+)?/ diff --git a/.ncurc.yaml b/.ncurc.yaml index 79ef9049b..c3fd0c385 100644 --- a/.ncurc.yaml +++ b/.ncurc.yaml @@ -9,5 +9,7 @@ reject: [ "get-port", # sinon v17.0.1 causes 58 tests to fail. This will need to be resolved in a future story. # Issue is tracked here: https://github.com/mojaloop/project/issues/3616 - "sinon" + "sinon", + # glob >= 11 requires node >= 20 + "glob" ] diff --git a/.nvmrc b/.nvmrc index 4a1f488b6..561a1e9a8 100644 --- a/.nvmrc +++ b/.nvmrc @@ -1 +1 @@ -18.17.1 +18.20.3 diff --git a/.nycrc.yml b/.nycrc.yml index 0b43be976..7add54979 100644 --- a/.nycrc.yml +++ b/.nycrc.yml @@ -17,5 +17,20 @@ exclude: [ "**/node_modules/**", '**/migrations/**', '**/ddl/**', - '**/bulk*/**' + '**/bulk*/**', + 'src/shared/logger/**', + 'src/shared/loggingPlugin.js', + 'src/shared/constants.js', + 'src/domain/position/index.js', + 'src/domain/position/binProcessor.js', + 'src/handlers/positions/handler.js', + 'src/handlers/transfers/createRemittanceEntity.js', + 'src/handlers/transfers/FxFulfilService.js', + 'src/models/position/batch.js', + 'src/models/fxTransfer/**', + 'src/models/participant/externalParticipantCached.js', # todo: figure out why it shows only 50% coverage in Branch + 'src/models/transfer/facade.js', ## add more test coverage + 'src/shared/fspiopErrorFactory.js', + 'src/lib/proxyCache.js' # todo: remove this line after adding test coverage ] +## todo: increase test coverage before merging feat/fx-impl to main branch diff --git a/Dockerfile b/Dockerfile index d1207c0cd..b7cbc27aa 100644 --- a/Dockerfile +++ b/Dockerfile @@ -3,26 +3,27 @@ ARG NODE_VERSION=lts-alpine # NOTE: Ensure you set NODE_VERSION Build Argument as follows... # -# export NODE_VERSION="$(cat .nvmrc)-alpine" \ -# docker build \ -# --build-arg NODE_VERSION=$NODE_VERSION \ -# -t mojaloop/central-ledger:local \ -# . \ +# export NODE_VERSION="$(cat .nvmrc)-alpine" +# docker build \ +# --build-arg NODE_VERSION=$NODE_VERSION \ +# -t mojaloop/central-ledger:local \ +# . # # Build Image -FROM node:${NODE_VERSION} as builder +FROM node:${NODE_VERSION} AS builder WORKDIR /opt/app RUN apk --no-cache add git -RUN apk add --no-cache -t build-dependencies make gcc g++ python3 libtool openssl-dev autoconf automake bash \ +RUN apk add --no-cache -t build-dependencies make gcc g++ python3 py3-setuptools libtool openssl-dev autoconf automake bash \ && cd $(npm root -g)/npm \ && npm install -g node-gyp COPY package.json package-lock.json* /opt/app/ RUN npm ci +RUN npm prune --omit=dev FROM node:${NODE_VERSION} WORKDIR /opt/app @@ -32,7 +33,7 @@ RUN mkdir ./logs && touch ./logs/combined.log RUN ln -sf /dev/stdout ./logs/combined.log # Create a non-root user: ml-user -RUN adduser -D ml-user +RUN adduser -D ml-user USER ml-user COPY --chown=ml-user --from=builder /opt/app . @@ -43,7 +44,5 @@ COPY migrations /opt/app/migrations COPY seeds /opt/app/seeds COPY test /opt/app/test -RUN npm prune --production - EXPOSE 3001 CMD ["npm", "run", "start"] diff --git a/README.md b/README.md index b38144ab2..3523eff6f 100644 --- a/README.md +++ b/README.md @@ -56,7 +56,7 @@ Or via docker build directly: ```bash docker build \ - --build-arg NODE_VERSION="$(cat .nvmrc)-alpine" \ + --build-arg NODE_VERSION="$(cat .nvmrc)-alpine3.19" \ -t mojaloop/ml-api-adapter:local \ . ``` @@ -113,12 +113,14 @@ NOTE: Only POSITION.PREPARE and POSITION.COMMIT is supported at this time, with Batch processing can be enabled in the transfer execution flow. Follow the steps below to enable batch processing for a more efficient transfer execution: +Note: The position messages with action 'FX_PREPARE', 'FX_COMMIT' and 'FX_TIMEOUT_RESERVED' are only supported in batch processing. + - **Step 1:** **Create a New Kafka Topic** Create a new Kafka topic named `topic-transfer-position-batch` to handle batch processing events. - **Step 2:** **Configure Action Type Mapping** - Point the prepare handler to the newly created topic for the action type `prepare` using the `KAFKA.EVENT_TYPE_ACTION_TOPIC_MAP` configuration as shown below: + Point the prepare handler to the newly created topic for the action types those are supported in batch processing using the `KAFKA.EVENT_TYPE_ACTION_TOPIC_MAP` configuration as shown below: ``` "KAFKA": { "EVENT_TYPE_ACTION_TOPIC_MAP" : { @@ -126,8 +128,12 @@ Batch processing can be enabled in the transfer execution flow. Follow the steps "PREPARE": "topic-transfer-position-batch", "BULK_PREPARE": "topic-transfer-position", "COMMIT": "topic-transfer-position-batch", + "FX_COMMIT": "topic-transfer-position-batch", "BULK_COMMIT": "topic-transfer-position", "RESERVE": "topic-transfer-position", + "FX_PREPARE": "topic-transfer-position-batch", + "TIMEOUT_RESERVED": "topic-transfer-position-batch", + "FX_TIMEOUT_RESERVED": "topic-transfer-position-batch" } } } @@ -185,7 +191,8 @@ If you want to run integration tests in a repetitive manner, you can startup the Start containers required for Integration Tests ```bash - docker-compose -f docker-compose.yml up -d mysql kafka init-kafka kafka-debug-console + source ./docker/env.sh + docker compose up -d mysql kafka init-kafka redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 ``` Run wait script which will report once all required containers are up and running @@ -220,7 +227,8 @@ If you want to run integration tests in a repetitive manner, you can startup the Start containers required for Integration Tests, including a `central-ledger` container which will be used as a proxy shell. ```bash - docker-compose -f docker-compose.yml -f docker-compose.integration.yml up -d kafka mysql central-ledger + source ./docker/env.sh + docker-compose -f docker-compose.yml -f docker-compose.integration.yml up -d kafka mysql central-ledger init-kafka redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 ``` Run the Integration Tests from the `central-ledger` container @@ -235,24 +243,42 @@ If you want to run override position topic tests you can repeat the above and us #### For running integration tests for batch processing interactively - Run dependecies -``` -docker-compose up -d mysql kafka init-kafka kafka-debug-console +```bash +source ./docker/env.sh +docker compose up -d mysql kafka init-kafka redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 npm run wait-4-docker ``` - Run central-ledger services ``` nvm use npm run migrate -env "CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__PREPARE=topic-transfer-position-batch" npm start +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__PREPARE=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__COMMIT=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__RESERVE=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__TIMEOUT_RESERVED=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_TIMEOUT_RESERVED=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__ABORT=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_ABORT=topic-transfer-position-batch +npm start ``` - Additionally, run position batch handler in a new terminal ``` +nvm use export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__PREPARE=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_PREPARE=topic-transfer-position-batch export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__COMMIT=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__TIMEOUT_RESERVED=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_TIMEOUT_RESERVED=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__ABORT=topic-transfer-position-batch +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_ABORT=topic-transfer-position-batch export CLEDG_HANDLERS__API__DISABLED=true node src/handlers/index.js handler --positionbatch ``` -- Run tests using `npx tape 'test/integration-override/**/handlerBatch.test.js'` +- Run tests using the following commands in a new terminal +``` +nvm use +npm run test:int-override +``` If you want to just run all of the integration suite non-interactively then use npm run `test:integration`. @@ -263,7 +289,11 @@ It will handle docker start up, migration, service starting and testing. Be sure If you want to run functional tests locally utilizing the [ml-core-test-harness](https://github.com/mojaloop/ml-core-test-harness), you can run the following commands: ```bash -docker build -t mojaloop/central-ledger:local . +export NODE_VERSION="$(cat .nvmrc)-alpine" +docker build \ + --build-arg NODE_VERSION=$NODE_VERSION \ + -t mojaloop/central-ledger:local \ + . ``` ```bash diff --git a/audit-ci.jsonc b/audit-ci.jsonc index a6d37cc53..c75bb449a 100644 --- a/audit-ci.jsonc +++ b/audit-ci.jsonc @@ -4,6 +4,20 @@ // Only use one of ["low": true, "moderate": true, "high": true, "critical": true] "moderate": true, "allowlist": [ // NOTE: Please add as much information as possible to any items added to the allowList - "GHSA-w5p7-h5w8-2hfq" // tap-spec>tap-out>trim; This has been analyzed and this is acceptable as it is used to run tests. + "GHSA-w5p7-h5w8-2hfq", // tap-spec>tap-out>trim; This has been analyzed and this is acceptable as it is used to run tests. + "GHSA-2mvq-xp48-4c77", // https://github.com/advisories/GHSA-2mvq-xp48-4c77 + "GHSA-5854-jvxx-2cg9", // https://github.com/advisories/GHSA-5854-jvxx-2cg9 + "GHSA-7hx8-2rxv-66xv", // https://github.com/advisories/GHSA-7hx8-2rxv-66xv + "GHSA-c429-5p7v-vgjp", // https://github.com/advisories/GHSA-c429-5p7v-vgjp + "GHSA-g64q-3vg8-8f93", // https://github.com/advisories/GHSA-g64q-3vg8-8f93 + "GHSA-mg85-8mv5-ffjr", // https://github.com/advisories/GHSA-mg85-8mv5-ffjr + "GHSA-8hc4-vh64-cxmj", // https://github.com/advisories/GHSA-8hc4-vh64-cxmj + "GHSA-952p-6rrq-rcjv", // https://github.com/advisories/GHSA-952p-6rrq-rcjv + "GHSA-9wv6-86v2-598j", // https://github.com/advisories/GHSA-9wv6-86v2-598j + "GHSA-qwcr-r2fm-qrc7", // https://github.com/advisories/GHSA-qwcr-r2fm-qrc7 + "GHSA-cm22-4g7w-348p", // https://github.com/advisories/GHSA-cm22-4g7w-348p + "GHSA-m6fv-jmcg-4jfg", // https://github.com/advisories/GHSA-m6fv-jmcg-4jfg + "GHSA-qw6h-vgh9-j6wx", // https://github.com/advisories/GHSA-qw6h-vgh9-j6wx + "GHSA-3xgq-45jj-v275" // High vulnerability https://github.com/advisories/GHSA-3xgq-45jj-v275 ignoring for now since devDependency ] -} \ No newline at end of file +} diff --git a/config/default.json b/config/default.json index a244a7b1f..fae0711ea 100644 --- a/config/default.json +++ b/config/default.json @@ -78,20 +78,36 @@ }, "INTERNAL_TRANSFER_VALIDITY_SECONDS": "432000", "ENABLE_ON_US_TRANSFERS": false, + "PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED": false, "CACHE": { "CACHE_ENABLED": false, "MAX_BYTE_SIZE": 10000000, "EXPIRES_IN_MS": 1000 }, + "PROXY_CACHE": { + "enabled": true, + "type": "redis-cluster", + "proxyConfig": { + "cluster": [ + { "host": "localhost", "port": 6379 } + ] + } + }, "API_DOC_ENDPOINTS_ENABLED": true, "KAFKA": { "EVENT_TYPE_ACTION_TOPIC_MAP" : { "POSITION":{ "PREPARE": null, + "FX_PREPARE": "topic-transfer-position-batch", "BULK_PREPARE": null, "COMMIT": null, "BULK_COMMIT": null, - "RESERVE": null + "RESERVE": null, + "FX_RESERVE": "topic-transfer-position-batch", + "TIMEOUT_RESERVED": null, + "FX_TIMEOUT_RESERVED": "topic-transfer-position-batch", + "ABORT": null, + "FX_ABORT": "topic-transfer-position-batch" } }, "TOPIC_TEMPLATES": { diff --git a/docker-compose.yml b/docker-compose.yml index 89c1c33ae..1ed34ac16 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,9 +1,22 @@ -version: "3.7" - networks: cl-mojaloop-net: name: cl-mojaloop-net + +# @see https://uninterrupted.tech/blog/hassle-free-redis-cluster-deployment-using-docker/ +x-redis-node: &REDIS_NODE + image: docker.io/bitnami/redis-cluster:6.2.14 + environment: &REDIS_ENVS + ALLOW_EMPTY_PASSWORD: yes + REDIS_CLUSTER_DYNAMIC_IPS: no + REDIS_CLUSTER_ANNOUNCE_IP: ${REDIS_CLUSTER_ANNOUNCE_IP} + REDIS_NODES: redis-node-0:6379 redis-node-1:9301 redis-node-2:9302 redis-node-3:9303 redis-node-4:9304 redis-node-5:9305 + healthcheck: + test: [ "CMD", "redis-cli", "ping" ] + timeout: 2s + networks: + - cl-mojaloop-net + services: central-ledger: image: mojaloop/central-ledger:local @@ -31,10 +44,14 @@ services: - CLEDG_MONGODB__DISABLED=false networks: - cl-mojaloop-net + extra_hosts: + - "redis-node-0:host-gateway" depends_on: - mysql - kafka - objstore + - redis-node-0 + # - redis healthcheck: test: ["CMD", "sh", "-c" ,"apk --no-cache add curl", "&&", "curl", "http://localhost:3001/health"] timeout: 20s @@ -94,6 +111,77 @@ services: retries: 10 start_period: 40s interval: 30s + + redis-node-0: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_CLUSTER_CREATOR: yes + REDIS_PORT_NUMBER: 6379 + depends_on: + - redis-node-1 + - redis-node-2 + ports: + - "6379:6379" + - "16379:16379" + redis-node-1: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_PORT_NUMBER: 9301 + ports: + - "9301:9301" + - "19301:19301" + redis-node-2: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_PORT_NUMBER: 9302 + ports: + - "9302:9302" + - "19302:19302" + redis-node-3: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_PORT_NUMBER: 9303 + ports: + - "9303:9303" + - "19303:19303" + redis-node-4: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_PORT_NUMBER: 9304 + ports: + - "9304:9304" + - "19304:19304" + redis-node-5: + <<: *REDIS_NODE + environment: + <<: *REDIS_ENVS + REDIS_PORT_NUMBER: 9305 + ports: + - "9305:9305" + - "19305:19305" + +## To be used with proxyCache.type === 'redis' +# redis: +# image: redis:6.2.4-alpine +# restart: "unless-stopped" +# environment: +# <<: *REDIS_ENVS +# REDIS_CLUSTER_CREATOR: yes +# depends_on: +# - redis-node-1 +# - redis-node-2 +# - redis-node-3 +# - redis-node-4 +# - redis-node-5 +# ports: +# - "6379:6379" +# networks: +# - cl-mojaloop-net mockserver: image: jamesdbloom/mockserver diff --git a/docker/central-ledger/default.json b/docker/central-ledger/default.json index 5571f464a..a8b233332 100644 --- a/docker/central-ledger/default.json +++ b/docker/central-ledger/default.json @@ -82,6 +82,15 @@ "MAX_BYTE_SIZE": 10000000, "EXPIRES_IN_MS": 1000 }, + "PROXY_CACHE": { + "enabled": true, + "type": "redis-cluster", + "proxyConfig": { + "cluster": [ + { "host": "redis-node-0", "port": 6379 } + ] + } + }, "KAFKA": { "TOPIC_TEMPLATES": { "PARTICIPANT_TOPIC_TEMPLATE": { diff --git a/docker/config-modifier/configs/central-ledger.js b/docker/config-modifier/configs/central-ledger.js index 904c98ba8..902498719 100644 --- a/docker/config-modifier/configs/central-ledger.js +++ b/docker/config-modifier/configs/central-ledger.js @@ -12,7 +12,25 @@ module.exports = { PASSWORD: '', DATABASE: 'mlos' }, + PROXY_CACHE: { + enabled: true, + type: 'redis', + proxyConfig: { + cluster: undefined, + host: 'redis', + port: 6379 + } + }, KAFKA: { + EVENT_TYPE_ACTION_TOPIC_MAP: { + POSITION: { + PREPARE: 'topic-transfer-position-batch', + BULK_PREPARE: null, + COMMIT: 'topic-transfer-position-batch', + BULK_COMMIT: null, + RESERVE: 'topic-transfer-position-batch' + } + }, CONSUMER: { BULK: { PREPARE: { @@ -72,6 +90,13 @@ module.exports = { 'metadata.broker.list': 'kafka:29092' } } + }, + POSITION_BATCH: { + config: { + rdkafkaConf: { + 'metadata.broker.list': 'kafka:29092' + } + } } }, ADMIN: { diff --git a/docker/env.sh b/docker/env.sh new file mode 100755 index 000000000..d3e0da0e4 --- /dev/null +++ b/docker/env.sh @@ -0,0 +1,15 @@ +#!/bin/sh + +# Retrieve the external IP address of the host machine (on macOS) +# or the IP address of the docker0 interface (on Linux) +get_external_ip() { + if [ "$(uname)" = "Linux" ]; then + echo "$(ip addr show docker0 | grep 'inet ' | awk '{print $2}' | cut -d/ -f1)" + else + # Need to find a way to support Windows here + echo "$(route get ifconfig.me | grep interface | sed -e 's/.*: //' | xargs ipconfig getifaddr)" + fi +} + +# set/override dynamic variables +export REDIS_CLUSTER_ANNOUNCE_IP=$(get_external_ip) diff --git a/docker/kafka/scripts/provision.sh b/docker/kafka/scripts/provision.sh index 14a08c2aa..41485addc 100644 --- a/docker/kafka/scripts/provision.sh +++ b/docker/kafka/scripts/provision.sh @@ -25,8 +25,11 @@ topics=( "topic-bulk-prepare" "topic-bulk-fulfil" "topic-bulk-processing" - "topic-bulk-get", + "topic-bulk-get" "topic-transfer-position-batch" + "topic-fx-quotes-post" + "topic-fx-quotes-put" + "topic-fx-quotes-get" ) # Loop through the topics and create them using kafka-topics.sh diff --git a/docker/ml-api-adapter/default.json b/docker/ml-api-adapter/default.json index e701c2891..d58b20fce 100644 --- a/docker/ml-api-adapter/default.json +++ b/docker/ml-api-adapter/default.json @@ -1,4 +1,8 @@ { + "HUB_PARTICIPANT": { + "ID": 1, + "NAME": "Hub" + }, "PORT": 3000, "HOSTNAME": "http://ml-api-adapter", "ENDPOINT_SOURCE_URL": "http://host.docker.internal:3001", @@ -13,7 +17,6 @@ }, "JWS": { "JWS_SIGN": false, - "FSPIOP_SOURCE_TO_SIGN": "switch", "JWS_SIGNING_KEY_PATH": "secrets/jwsSigningKey.key" } }, diff --git a/documentation/db/erd-transfer-timeout.png b/documentation/db/erd-transfer-timeout.png new file mode 100644 index 0000000000000000000000000000000000000000..b8da0b8c7d9ba6900ec74bc44023e7b8a35e91ee GIT binary patch literal 251696 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­D;iZPi^?jGx9A3 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 - many to one +# >-< - many to many +# -0 - one to zero or one +# 0- - zero or one to one +# 0-0 - zero or one to zero or one +# -0< - one to zero or many +# >0- - zero or many to one +# +//////////////////////////////////// + +transfer +--------------------- +transferId varchar(36) PK +amount decimal(18,4) +currencyId varchar(3) FK - currency.currencyId +ilpCondition varchar(256) +expirationDate datetime +createdDate datetime + + +transferStateChange__TSC +--------------------- +transferStateChangeId bigint UN AI PK +transferId varchar(36) FK >- transfer.transferId +transferStateId varchar(50) FK - transferState.transferStateId +reason varchar(512) +createdDate datetime + + +transferTimeout__TT +--------------------- +transferTimeoutId bigint UN AI PK +transferId varchar(36) UNIQUE FK - transfer.transferId +expirationDate datetime +createdDate datetime + + +transferError__TE +--------------------- +transferId varchar(36) PK +transferStateChangeId bigint UN FK - transferStateChange.transferStateChangeId +errorCode int UN +errorDescription varchar(128) +createdDate datetime + + +segment +--------------------- +segmentId int UN AI PK +segmentType varchar(50) +enumeration int +tableName varchar(50) +value bigint +changedDate datetime +# row example: 1, 'timeout', 0, 'transferStateChange', 255, '2024-04-24 18:07:15' + + +expiringTransfer +--------------------- +expiringTransferId bigint UN AI PK +transferId varchar(36) UNIQUE FK - transfer.transferId +expirationDate datetime INDEX +createdDate datetime +# todo: clarify, how we use this table + + + +# transfer (557, 340) +# segment (348, 608) +# expiringTransfer (1033, 574) +# view: (5, -16) +# zoom: 1.089 +# transferStateChange__TSC (38, 236) +# transferTimeout__TT (974, 204) +# transferError__TE (518, 34) diff --git a/documentation/fx-implementation/README.md b/documentation/fx-implementation/README.md new file mode 100644 index 000000000..299fe740c --- /dev/null +++ b/documentation/fx-implementation/README.md @@ -0,0 +1,48 @@ +# FX Implementation + +## Implementation for Payer-Side Currency Conversion (Happy Path Only) + +We have developed functionality for foreign exchange (FX) transfer focusing on a specific scenario: Payer-side currency conversion. + +### Testing using ml-core-test-harness + +![Test Scenario](./assets/test-scenario.drawio.svg) + +To test the functionality, you can utilize [mojaloop/ml-core-test-harness](https://github.com/mojaloop/ml-core-test-harness): + +1. Clone the repository: + ``` + git clone https://github.com/mojaloop/ml-core-test-harness.git + ``` +2. Checkout to the branch `feat/fx-impl`: + ``` + git checkout feat/fx-impl + ``` +3. Run the services: + ``` + docker-compose --profile all-services --profile ttk-provisioning --profile ttk-tests --profile debug up -d + ``` +4. Open the testing toolkit web UI at `http://localhost:9660`. +5. Navigate to `Test Runner`, click on `Collection Manager`, and import the folder `docker/ml-testing-toolkit/test-cases/collections`. +6. Select the file `fxp/payer_conversion.json`. +7. Run the test case by clicking on the `Run` button. +8. Verify that all tests have passed. +9. Observe the sequence of requests and responses in each item of the test case. +10. Open the last item, `Get Accounts for FXP AFTER transfer`, and go to `Scripts->Console Logs` to observe the position movements of different participant accounts, as shown below: + ``` + "Payer Position BWP : 0 -> 300 (300)" + + "Payee Position TZS : 0 -> -48000 (-48000)" + + "FXP Source Currency BWP : 0 -> -300 (-300)" + + "FXP Target Currency TZS : 0 -> 48000 (48000)" + ``` + +### Implementation + +The implementation follows the information available in the repository [mojaloop/currency-conversion](https://github.com/mojaloop/currency-conversion). + +The flow diagram below illustrates the transfer with payer-side currency conversion: + +![FX Position Movements](./assets/fx-position-movements.drawio.svg) diff --git a/documentation/fx-implementation/assets/fx-position-movements.drawio.svg b/documentation/fx-implementation/assets/fx-position-movements.drawio.svg new file mode 100644 index 000000000..cd09ab325 --- /dev/null +++ b/documentation/fx-implementation/assets/fx-position-movements.drawio.svg @@ -0,0 +1,4 @@ + + + +
Prepare Handler
Prepare Handler
topic-transfer-prepare
topic-transfer-prepare
ML API Adapter
ML API Adapter
1. POST /FxTransfers
1. POST /FxTransfers
4. POST /transfers
4. POST /transfers
2. fx-prepare
2. fx-prep...
topic-transfer-position
topic-transfer-position
3. fx-prepare
3. fx-prepare
Position Handler
Position Handler
topic-notification-event
topic-notification-event
FXP
FXP
Payee
Payee
Payer
Payer
Payer
Payer
Fulfil Handler
Fulfil Handler
5. prepare
5. prepare
6. prepare
6. prepare
topic-transfer-fulfil
topic-transfer-fulfil
7. fulfil
7. fulfil
8. commit
8. commit
Position Handler
Position Handler
9. commit
9. commit
FXP Target
FXP Target
Payee
Payee
FXP Source
FXP Source
Text is not SVG - cannot display
\ No newline at end of file diff --git a/documentation/fx-implementation/assets/test-scenario.drawio.svg b/documentation/fx-implementation/assets/test-scenario.drawio.svg new file mode 100644 index 000000000..4cb969e4e --- /dev/null +++ b/documentation/fx-implementation/assets/test-scenario.drawio.svg @@ -0,0 +1,4 @@ + + + +
ML Switch
ML Switch
TTK
(Payer)
TTK...
1. POST /fxTransfer
1. POST /fxTransfer
3. PUT /fxTransfer
3. PUT /fxTransfer
5. POST /transfer
5. POST /transfer
TTK
(FXP)
TTK...
TTK
(Payee)
TTK...
4. PUT /fxTransfer
4. PUT /fxTransfer
7. PUT /transfer
7. PUT /transfer
8. PUT /transfer
8. PUT /transfer
2. POST /fxTransfer
2. POST /fxTransfer
Payer position
Payer position
ML Core Test Harness
ML Core Test Harness
FXP Target Position
FXP Target Posi...
6. POST /transfer
6. POST /transfer
Payee Position
Payee Position
FXP Source Position
FXP Source Posi...
Text is not SVG - cannot display
\ No newline at end of file diff --git a/documentation/sequence-diagrams/Handler - FX timeout.plantuml b/documentation/sequence-diagrams/Handler - FX timeout.plantuml new file mode 100644 index 000000000..0cb2f3e97 --- /dev/null +++ b/documentation/sequence-diagrams/Handler - FX timeout.plantuml @@ -0,0 +1,123 @@ +@startuml +title Transfer/ FX transfer Timeout-Handler Flow + +autonumber +hide footbox +skinparam ParticipantPadding 10 + +box "Central Services" #MistyRose +participant "Timeout \n handler (cron)" as toh +participant "Position \n handler" as ph +database "central-ledger\nDB" as clDb +end box +box Kafka +queue "topic-\n transfer-position" as topicTP +queue "topic-\n notification-event" as topicNE +end box +box "ML API Adapter Services" #LightBlue +participant "Notification \n handler" as nh +end box +participant "FXP" as fxp +actor "DFSP_1 \nPayer" as payer +actor "DFSP_2 \nPayee" as payee + +legend +DB tables: + +TT - transferTimeout fxTT - fxTransferTimeout +TSC - transferStateChange fxTSC - fxTransferStateChange +TE - transferError fxTE - fxTransferError +end legend + + +autonumber 1 +toh --> toh : run on cronTime\n HANDLERS_TIMEOUT_TIMEXP (default: 15sec) +activate toh +toh -> clDb : cleanup TT for transfers in particular states: \n [COMMITTED, ABORTED, RECEIVED_FULFIL, RECEIVED_REJECT, RESERVED_TIMEOUT] + +toh -> clDb : Insert (transferId, expirationDate) into TT for transfers in particular states:\n [RECEIVED_PREPARE, RESERVED] +toh -> clDb : Insert EXPIRED_PREPARED state into TSC for transfers in RECEIVED_PREPARE states +toh -> clDb : Insert RESERVED_TIMEOUT state into TSC for transfers in RESERVED state +toh -> clDb : Insert expired error info into TE + +toh -> clDb : get expired transfers details from TT + +toh --> toh : for each expired transfer +activate toh +autonumber 8.1 +alt state === EXPIRED_PREPARED +toh ->o topicNE : produce notification timeout-received message +else state === RESERVED_TIMEOUT +toh ->o topicTP : produce position timeout-reserved message +end +toh -> clDb : find related fxTransfer using cyril and check if it's NOT expeired yet +alt related NOT expired fxTransfer found +toh -> clDb : Upsert row with (fxTransferId, expirationDate) into fxTT +note right: expirationDate === transfer.expirationDate \n OR now? +alt fxState === RESERVED or RECEIVED_FULFIL_DEPENDENT +toh -> clDb : Update fxState to RESERVED_TIMEOUT into fxTSC +toh ->o topicTP : produce position fx-timeout-reserved message +else fxState === RECEIVED_PREPARE +toh -> clDb : Update fxState to EXPIRED_PREPARED into fxTSC +toh ->o topicNE : produce notification fx-timeout-received message +end +end +deactivate toh +deactivate toh + +autonumber 9 +toh --> toh : run fxTimeout logic on cronTime\n HANDLERS_TIMEOUT_TIMEXP (default: 15sec) +activate toh +toh -> clDb : cleanup fxTT for fxTransfers in particular states: \n [COMMITTED, ABORTED, RECEIVED_FULFIL_DEPENDENT, RECEIVED_REJECT, RESERVED_TIMEOUT] + +toh -> clDb : Insert (fxTransferId, expirationDate) into fxTT for fxTransfers in particular states:\n [RECEIVED_PREPARE, RESERVED] +toh -> clDb : Insert EXPIRED_PREPARED state into fxTSC for fxTransfers in RECEIVED_PREPARE states +toh -> clDb : Insert RESERVED_TIMEOUT state into fxTSC for fxTransfers in RESERVED state +toh -> clDb : Insert expired error info into fxTE + +toh -> clDb : get expired fxTransfers details from fxTT + +toh --> toh : for each expired fxTransfer +activate toh +autonumber 16.1 +alt state === EXPIRED_PREPARED +toh ->o topicNE : produce notification fx-timeout-received message +else state === RESERVED_TIMEOUT +toh ->o topicTP : produce position fx-timeout-reserved message +end +toh -> clDb : find related transfer using cyril and check it's NOT expired yet +note right: think, what if related transfer is already commited? +alt related NOT expired transfer found +toh -> clDb : Upsert (transferId, expirationDate) into TT +toh -> clDb : Insert expired error info into TE +alt state === RECEIVED_PREPARE +toh -> clDb : Insert EXPIRED_PREPARED state into TSC with reason "related fxTransfer expired" +toh ->o topicNE : produce notification timeout-received message +else state === RESERVED +toh -> clDb : Insert RESERVED_TIMEOUT state into TSC with reason "related fxTransfer expired" +toh ->o topicTP : produce position timeout-reserved message +end +end + +deactivate toh +deactivate toh + +autonumber 17 +topicNE o-> nh : consume notification\n message +activate nh +nh -> payer : send error notification\n callback to payer +deactivate nh + +topicTP o-> ph : consume position timeout/fx-timeout\n message +activate ph +ph --> ph : process timeout / fx-timeout transfer +ph ->o topicNE : produce notification timeout / fx-timeout messages + +deactivate ph + +topicNE o-> nh : consume notification\n message +activate nh +nh -> payee : send error notification\n callback to payee +deactivate nh + +@enduml diff --git a/documentation/sequence-diagrams/Handler - FX timeout.png b/documentation/sequence-diagrams/Handler - FX timeout.png new file mode 100644 index 0000000000000000000000000000000000000000..0074d43a5532479dea7c10c1e9714be36fdbcfcb GIT binary patch literal 276688 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 U+T8~-iO(2qH~BVao9)T}07CKBc>n+a literal 0 HcmV?d00001 diff --git a/documentation/sequence-diagrams/Handler - timeout.plantuml b/documentation/sequence-diagrams/Handler - timeout.plantuml new file mode 100644 index 000000000..3042a1540 --- /dev/null +++ b/documentation/sequence-diagrams/Handler - timeout.plantuml @@ -0,0 +1,81 @@ +@startuml +title Transfer Timeout-Handler Flow \n(current impl.) + +autonumber +hide footbox +skinparam ParticipantPadding 10 + +box "Central Services" #MistyRose +participant "Timeout \n handler (cron)" as toh +participant "Position \n handler" as ph +database "central-ledger\nDB" as clDb +end box +box Kafka +queue "topic-\n transfer-position" as topicTP +queue "topic-\n notification-event" as topicNE +end box +box "ML API Adapter Services" #LightBlue +participant "Notification \n handler" as nh +end box +actor "DFSP_1 \nPayer" as payer +actor "DFSP_2 \nPayee" as payee + +toh --> toh : run on cronTime\n HANDLERS_TIMEOUT_TIMEXP +activate toh +toh --> toh : cleanup transferTimeout (TT) +note right : TT innerJoin TSC\n where TSC.transferStateId in [...] +activate toh +autonumber 2.1 +toh -> clDb : delete from TT by ttIdList +note right : table: TT (transferTimeout) +deactivate toh + +autonumber 3 +toh -> clDb : get segmentId, intervalMin, intervalMax +note right : tables:\n segment,\n TSC (transferStateChange) + +toh --> toh : update timeoutExpireReserved and get expiredTransfers +activate toh +autonumber 6.1 +toh -> clDb : Insert expirationDate into TT\n for transfers in [intervalMin, ... intervalMax] +note right : table: TT +toh -> clDb : Insert EXPIRED_PREPARED into TSC for RECEIVED_PREPARE state +note right : table: TSC +toh -> clDb : Insert RESERVED_TIMEOUT into TSC for RESERVED state +note right : table: TSC +toh -> clDb : Insert error info into transferError (TE) +note right : table: TE +toh -> clDb : get expired transfers details from TT +note right : TT innerJoin other tables +deactivate toh + +autonumber 7 +toh --> toh : for each expiredTransfer +activate toh +alt state === EXPIRED_PREPARED +autonumber 7.1 +toh ->o topicNE : produce notification timeout-received message +else state === RESERVED_TIMEOUT +autonumber 7.1 +toh ->o topicTP : produce position timeout-reserved message +end +deactivate toh +deactivate toh + +autonumber 8 +topicNE o-> nh : consume notification\n message +activate nh +nh -> payer : send notification\n callback to payer +deactivate nh + +topicTP o-> ph : consume position timeout\n message +activate ph +ph --> ph : process position timeout +ph ->o topicNE +deactivate ph +topicNE o-> nh : consume notification\n message +activate nh +nh -> payee : send notification\n callback to payee +deactivate nh + +@enduml diff --git a/documentation/sequence-diagrams/Handler - timeout.png b/documentation/sequence-diagrams/Handler - timeout.png new file mode 100644 index 0000000000000000000000000000000000000000..eb43611b438a3721483c067d7b38fa30a17a9513 GIT binary patch literal 134131 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 literal 0 HcmV?d00001 diff --git a/documentation/state-diagrams/transfer-internal-states-diagram.png b/documentation/state-diagrams/transfer-internal-states-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..d2d7b32452a1f93ce93b543ce4d742da156d4f58 GIT binary patch literal 173384 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 AasU7T literal 0 HcmV?d00001 diff --git a/documentation/state-diagrams/transfer-internal-states.plantuml b/documentation/state-diagrams/transfer-internal-states.plantuml new file mode 100644 index 000000000..5636e94d1 --- /dev/null +++ b/documentation/state-diagrams/transfer-internal-states.plantuml @@ -0,0 +1,74 @@ +@startuml + +state RECEIVED { + state RECEIVED_PREPARE { + } +} + +state RESERVED_ { + state RESERVED { + } + state RESERVED_FORWARDED { + } + state RECEIVED_FULFIL { + } + state RECEIVED_FULFIL_DEPENDENT { + } + state RESERVED_TIMEOUT { + } + state RECEIVED_REJECT { + } + state RECEIVED_ERROR { + } +} + +state COMMITTED { +} + +state ABORTED { + state ABORTED_ERROR { + } + state ABORTED_REJECTED { + } + state EXPIRED_PREPARED { + } + state EXPIRED_RESERVED { + } + state FAILED { + } + state INVALID { + } +} + +RECEIVED_FULFIL_DEPENDENT : only FX-transfer +RECEIVED_FULFIL : only transfer + +[*] --> RECEIVED_PREPARE : Transfer Prepare Request [Prepare handler] \n (validation & dupl.check passed) +[*] --> INVALID : Validation failed \n [Prepare handler] +RECEIVED_PREPARE --> RESERVED : [Position handler]: Liquidity check passed, \n funds reserved +RESERVED --> RECEIVED_REJECT : Reject callback from Payee with status "ABORTED" + +RECEIVED_FULFIL --> COMMITTED : Transfer committed [Position handler] \n (commit funds, assign T. to settlement window) +RECEIVED_REJECT --> ABORTED_REJECTED : Transfer Aborted by Payee +RECEIVED_ERROR --> ABORTED_ERROR : Hub aborts T. +RECEIVED_PREPARE --> EXPIRED_PREPARED : Timeout handler \n detects T. being EXPIRED + +RESERVED --> RECEIVED_FULFIL : Fulfil callback from Payee \n with status "COMMITTED" \n [Fulfil handler]: \n fulfilment check passed +RESERVED --> RECEIVED_ERROR : Fulfil callback from Payee fails validation\n [Fulfil handler] +RESERVED --> RECEIVED_FULFIL_DEPENDENT : Recieved FX transfer fulfilment +RESERVED --> RESERVED_FORWARDED : A Proxy participant has acknowledged the transfer to be forwarded +RESERVED --> RESERVED_TIMEOUT : Timeout handler + +RESERVED_FORWARDED --> RECEIVED_FULFIL : Fulfil callback from Payee \n with status "COMMITTED" \n [Fulfil handler]: \n fulfilment check passed +RESERVED_FORWARDED --> RECEIVED_ERROR : Fulfil callback from Payee fails validation\n [Fulfil handler] +RESERVED_FORWARDED --> RECEIVED_FULFIL_DEPENDENT : Recieved FX transfer fulfilment + +RECEIVED_FULFIL_DEPENDENT --> COMMITTED : Dependant transfer committed [Position handler] \n (commit funds, assign T. to settlement window) +RECEIVED_FULFIL_DEPENDENT --> RESERVED_TIMEOUT : Dependant transfer is timed out + +RESERVED_TIMEOUT --> EXPIRED_RESERVED : Hub aborts T. due to being EXPIRED + +COMMITTED --> [*] +ABORTED --> [*] + +@enduml diff --git a/documentation/state-diagrams/transfer-states.plantuml b/documentation/state-diagrams/transfer-states.plantuml new file mode 100644 index 000000000..d945d1506 --- /dev/null +++ b/documentation/state-diagrams/transfer-states.plantuml @@ -0,0 +1,13 @@ +@startuml +hide empty description + +[*] --> RECEIVED : Transfer Prepare Request +RECEIVED --> RESERVED : Net debit cap limit check passed +RECEIVED --> ABORTED : Failed validation OR timeout +RESERVED --> ABORTED : Abort response from Payee +RESERVED --> COMMITTED : Fulfil Response from Payee + +COMMITTED --> [*] +ABORTED --> [*] + +@enduml diff --git a/migrations/310204_transferParticipant-participantId.js b/migrations/310204_transferParticipant-participantId.js new file mode 100644 index 000000000..fee87e99f --- /dev/null +++ b/migrations/310204_transferParticipant-participantId.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('transferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('transferParticipant', (t) => { + t.integer('participantId').unsigned().notNullable() + // Disabling this as its throwing error while running the migration with existing data in the table + // t.foreign('participantId').references('participantId').inTable('participant') + t.index('participantId') + t.integer('participantCurrencyId').unsigned().nullable().alter() + }) + } + }) +} + +exports.down = async (knex) => { + return await knex.schema.hasTable('transferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('transferParticipant', (t) => { + t.dropIndex('participantId') + t.dropColumn('participantId') + t.integer('participantCurrencyId').unsigned().notNullable().alter() + }) + } + }) +} diff --git a/migrations/310403_participantPositionChange-participantCurrencyId.js b/migrations/310403_participantPositionChange-participantCurrencyId.js new file mode 100644 index 000000000..e25a9ffd1 --- /dev/null +++ b/migrations/310403_participantPositionChange-participantCurrencyId.js @@ -0,0 +1,47 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * ModusBox + - Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('participantPositionChange').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.integer('participantCurrencyId').unsigned().notNullable() + t.foreign('participantCurrencyId').references('participantCurrencyId').inTable('participantCurrency') + }) + } + }) +} + +exports.down = async (knex) => { + return await knex.schema.hasTable('participantPositionChange').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.dropColumn('participantCurrencyId') + }) + } + }) +} diff --git a/migrations/310404_participantPositionChange-change.js b/migrations/310404_participantPositionChange-change.js new file mode 100644 index 000000000..81632f9e3 --- /dev/null +++ b/migrations/310404_participantPositionChange-change.js @@ -0,0 +1,46 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * ModusBox + - Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('participantPositionChange').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.decimal('change', 18, 2).notNullable() + }) + } + }) +} + +exports.down = async (knex) => { + return await knex.schema.hasTable('participantPositionChange').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.dropColumn('change') + }) + } + }) +} diff --git a/migrations/600010_fxTransferType.js b/migrations/600010_fxTransferType.js new file mode 100644 index 000000000..767667df9 --- /dev/null +++ b/migrations/600010_fxTransferType.js @@ -0,0 +1,43 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferType').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferType', (t) => { + t.increments('fxTransferTypeId').primary().notNullable() + t.string('name', 50).notNullable() + t.string('description', 512).defaultTo(null).nullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxParticipantCurrencyType') +} diff --git a/migrations/600011_fxTransferType-indexes.js b/migrations/600011_fxTransferType-indexes.js new file mode 100644 index 000000000..9d7cbabab --- /dev/null +++ b/migrations/600011_fxTransferType-indexes.js @@ -0,0 +1,38 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferType', (t) => { + t.unique('name') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferType', (t) => { + t.dropUnique('name') + }) +} diff --git a/migrations/600012_fxParticipantCurrencyType.js b/migrations/600012_fxParticipantCurrencyType.js new file mode 100644 index 000000000..1efd6bf52 --- /dev/null +++ b/migrations/600012_fxParticipantCurrencyType.js @@ -0,0 +1,43 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxParticipantCurrencyType').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxParticipantCurrencyType', (t) => { + t.increments('fxParticipantCurrencyTypeId').primary().notNullable() + t.string('name', 50).notNullable() + t.string('description', 512).defaultTo(null).nullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxParticipantCurrencyType') +} diff --git a/migrations/600013_fxParticipantCurrencyType-indexes.js b/migrations/600013_fxParticipantCurrencyType-indexes.js new file mode 100644 index 000000000..c77743674 --- /dev/null +++ b/migrations/600013_fxParticipantCurrencyType-indexes.js @@ -0,0 +1,38 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxParticipantCurrencyType', (t) => { + t.unique('name') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxParticipantCurrencyType', (t) => { + t.dropUnique('name') + }) +} diff --git a/migrations/600100_fxTransferDuplicateCheck.js b/migrations/600100_fxTransferDuplicateCheck.js new file mode 100644 index 000000000..e7260830a --- /dev/null +++ b/migrations/600100_fxTransferDuplicateCheck.js @@ -0,0 +1,42 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + + 'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferDuplicateCheck').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferDuplicateCheck', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.string('hash', 256).notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferDuplicateCheck') +} diff --git a/migrations/600110_fxTransferErrorDuplicateCheck.js.js b/migrations/600110_fxTransferErrorDuplicateCheck.js.js new file mode 100644 index 000000000..2906a1d5a --- /dev/null +++ b/migrations/600110_fxTransferErrorDuplicateCheck.js.js @@ -0,0 +1,17 @@ +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferErrorDuplicateCheck').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferErrorDuplicateCheck', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.string('hash', 256).notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferErrorDuplicateCheck') +} diff --git a/migrations/600200_fxTransfer.js b/migrations/600200_fxTransfer.js new file mode 100644 index 000000000..161b4e27b --- /dev/null +++ b/migrations/600200_fxTransfer.js @@ -0,0 +1,51 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + + 'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransfer').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransfer', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransferDuplicateCheck') + t.string('determiningTransferId', 36).defaultTo(null).nullable() + t.decimal('sourceAmount', 18, 4).notNullable() + t.decimal('targetAmount', 18, 4).notNullable() + t.string('sourceCurrency', 3).notNullable() + t.foreign('sourceCurrency').references('currencyId').inTable('currency') + t.string('targetCurrency', 3).notNullable() + t.foreign('targetCurrency').references('currencyId').inTable('currency') + t.string('ilpCondition', 256).notNullable() + t.dateTime('expirationDate').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransfer') +} diff --git a/migrations/600201_fxTransfer-indexes.js b/migrations/600201_fxTransfer-indexes.js new file mode 100644 index 000000000..541c8fb02 --- /dev/null +++ b/migrations/600201_fxTransfer-indexes.js @@ -0,0 +1,40 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransfer', (t) => { + t.index('sourceCurrency') + t.index('targetCurrency') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransfer', (t) => { + t.dropIndex('sourceCurrency') + t.dropIndex('targetCurrency') + }) +} diff --git a/migrations/600400_fxTransferStateChange.js b/migrations/600400_fxTransferStateChange.js new file mode 100644 index 000000000..bd028ab5e --- /dev/null +++ b/migrations/600400_fxTransferStateChange.js @@ -0,0 +1,46 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferStateChange').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferStateChange', (t) => { + t.bigIncrements('fxTransferStateChangeId').primary().notNullable() + t.string('commitRequestId', 36).notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.string('transferStateId', 50).notNullable() + t.foreign('transferStateId').references('transferStateId').inTable('transferState') + t.string('reason', 512).defaultTo(null).nullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferStateChange') +} diff --git a/migrations/600401_fxTransferStateChange-indexes.js b/migrations/600401_fxTransferStateChange-indexes.js new file mode 100644 index 000000000..03ffdb66f --- /dev/null +++ b/migrations/600401_fxTransferStateChange-indexes.js @@ -0,0 +1,40 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferStateChange', (t) => { + t.index('commitRequestId') + t.index('transferStateId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferStateChange', (t) => { + t.dropIndex('commitRequestId') + t.dropIndex('transferStateId') + }) +} diff --git a/migrations/600501_fxWatchList.js b/migrations/600501_fxWatchList.js new file mode 100644 index 000000000..167d32628 --- /dev/null +++ b/migrations/600501_fxWatchList.js @@ -0,0 +1,46 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + + 'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxWatchList').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxWatchList', (t) => { + t.bigIncrements('fxWatchListId').primary().notNullable() + t.string('commitRequestId', 36).notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.string('determiningTransferId', 36).notNullable() + t.integer('fxTransferTypeId').unsigned().notNullable() + t.foreign('fxTransferTypeId').references('fxTransferTypeId').inTable('fxTransferType') + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxWatchList') +} diff --git a/migrations/600502_fxWatchList-indexes.js b/migrations/600502_fxWatchList-indexes.js new file mode 100644 index 000000000..84bbf5a22 --- /dev/null +++ b/migrations/600502_fxWatchList-indexes.js @@ -0,0 +1,40 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxWatchList', (t) => { + t.index('commitRequestId') + t.index('determiningTransferId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxWatchList', (t) => { + t.dropIndex('commitRequestId') + t.dropIndex('determiningTransferId') + }) +} diff --git a/migrations/600600_fxTransferFulfilmentDuplicateCheck.js b/migrations/600600_fxTransferFulfilmentDuplicateCheck.js new file mode 100644 index 000000000..c907eca28 --- /dev/null +++ b/migrations/600600_fxTransferFulfilmentDuplicateCheck.js @@ -0,0 +1,43 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferFulfilmentDuplicateCheck').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferFulfilmentDuplicateCheck', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.string('hash', 256).nullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferFulfilmentDuplicateCheck') +} diff --git a/migrations/600601_fxTransferFulfilmentDuplicateCheck-indexes.js b/migrations/600601_fxTransferFulfilmentDuplicateCheck-indexes.js new file mode 100644 index 000000000..dc265fd63 --- /dev/null +++ b/migrations/600601_fxTransferFulfilmentDuplicateCheck-indexes.js @@ -0,0 +1,38 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferFulfilmentDuplicateCheck', (t) => { + t.index('commitRequestId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferFulfilmentDuplicateCheck', (t) => { + t.dropIndex('commitRequestId') + }) +} diff --git a/migrations/600700_fxTransferFulfilment.js b/migrations/600700_fxTransferFulfilment.js new file mode 100644 index 000000000..d20b680c6 --- /dev/null +++ b/migrations/600700_fxTransferFulfilment.js @@ -0,0 +1,47 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferFulfilment').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferFulfilment', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.string('ilpFulfilment', 256).nullable() + t.dateTime('completedDate').notNullable() + t.boolean('isValid').nullable() + t.bigInteger('settlementWindowId').unsigned().nullable() + t.foreign('settlementWindowId').references('settlementWindowId').inTable('settlementWindow') + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferFulfilment') +} diff --git a/migrations/600701_fxTransferFulfilment-indexes.js b/migrations/600701_fxTransferFulfilment-indexes.js new file mode 100644 index 000000000..e32b93de9 --- /dev/null +++ b/migrations/600701_fxTransferFulfilment-indexes.js @@ -0,0 +1,42 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferFulfilment', (t) => { + t.index('commitRequestId') + t.index('settlementWindowId') + t.unique(['commitRequestId', 'ilpFulfilment']) + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferFulfilment', (t) => { + t.dropIndex('transferId') + t.dropIndex('settlementWindowId') + t.unique(['transferId', 'ilpFulfilment']) + }) +} diff --git a/migrations/600800_fxTransferExtension.js b/migrations/600800_fxTransferExtension.js new file mode 100644 index 000000000..2bb0845cb --- /dev/null +++ b/migrations/600800_fxTransferExtension.js @@ -0,0 +1,47 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Kalin Krustev + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferExtension').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferExtension', (t) => { + t.bigIncrements('fxTransferExtensionId').primary().notNullable() + t.string('commitRequestId', 36).notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.boolean('isFulfilment').defaultTo(false).notNullable() + t.boolean('isError').defaultTo(false).notNullable() + t.string('key', 128).notNullable() + t.text('value').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferExtension') +} diff --git a/migrations/601400_fxTransferTimeout.js b/migrations/601400_fxTransferTimeout.js new file mode 100644 index 000000000..90bc01ac5 --- /dev/null +++ b/migrations/601400_fxTransferTimeout.js @@ -0,0 +1,43 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + - Eugen Klymniuk + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferTimeout').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferTimeout', (t) => { + t.bigIncrements('fxTransferTimeoutId').primary().notNullable() + t.string('commitRequestId', 36).notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.dateTime('expirationDate').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferTimeout') +} diff --git a/migrations/601401_fxTransferTimeout-indexes.js b/migrations/601401_fxTransferTimeout-indexes.js new file mode 100644 index 000000000..6a85c66d2 --- /dev/null +++ b/migrations/601401_fxTransferTimeout-indexes.js @@ -0,0 +1,37 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + - Eugen Klymniuk + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferTimeout', (t) => { + t.unique('commitRequestId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferTimeout', (t) => { + t.dropUnique('commitRequestId') + }) +} diff --git a/migrations/601500_fxTransferError.js b/migrations/601500_fxTransferError.js new file mode 100644 index 000000000..ce53eaef6 --- /dev/null +++ b/migrations/601500_fxTransferError.js @@ -0,0 +1,44 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + - Eugen Klymniuk + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferError').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferError', (t) => { + t.string('commitRequestId', 36).primary().notNullable() + t.bigInteger('fxTransferStateChangeId').unsigned().notNullable() + t.foreign('fxTransferStateChangeId').references('fxTransferStateChangeId').inTable('fxTransferStateChange') + t.integer('errorCode').unsigned().notNullable() + t.string('errorDescription', 128).notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferError') +} diff --git a/migrations/601501_fxTransferError-indexes.js b/migrations/601501_fxTransferError-indexes.js new file mode 100644 index 000000000..a63f278f9 --- /dev/null +++ b/migrations/601501_fxTransferError-indexes.js @@ -0,0 +1,37 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + - Eugen Klymniuk + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferError', (t) => { + t.index('fxTransferStateChangeId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferError', (t) => { + t.dropIndex('fxTransferStateChangeId') + }) +} diff --git a/migrations/610200_fxTransferParticipant.js b/migrations/610200_fxTransferParticipant.js new file mode 100644 index 000000000..40b15f4ad --- /dev/null +++ b/migrations/610200_fxTransferParticipant.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferParticipant').then(function(exists) { + if (!exists) { + return knex.schema.createTable('fxTransferParticipant', (t) => { + t.bigIncrements('fxTransferParticipantId').primary().notNullable() + t.string('commitRequestId', 36).notNullable() + t.foreign('commitRequestId').references('commitRequestId').inTable('fxTransfer') + t.integer('participantCurrencyId').unsigned().notNullable() + t.foreign('participantCurrencyId').references('participantCurrencyId').inTable('participantCurrency') + t.integer('transferParticipantRoleTypeId').unsigned().notNullable() + t.foreign('transferParticipantRoleTypeId').references('transferParticipantRoleTypeId').inTable('transferParticipantRoleType') + t.integer('ledgerEntryTypeId').unsigned().notNullable() + t.foreign('ledgerEntryTypeId').references('ledgerEntryTypeId').inTable('ledgerEntryType') + t.integer('fxParticipantCurrencyTypeId').unsigned() + t.foreign('fxParticipantCurrencyTypeId').references('fxParticipantCurrencyTypeId').inTable('fxParticipantCurrencyType') + t.decimal('amount', 18, 4).notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.dropTableIfExists('fxTransferParticipant') +} diff --git a/migrations/610201_fxTransferParticipant-indexes.js b/migrations/610201_fxTransferParticipant-indexes.js new file mode 100644 index 000000000..3f413afff --- /dev/null +++ b/migrations/610201_fxTransferParticipant-indexes.js @@ -0,0 +1,44 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = function (knex) { + return knex.schema.table('fxTransferParticipant', (t) => { + t.index('commitRequestId') + t.index('participantCurrencyId') + t.index('transferParticipantRoleTypeId') + t.index('ledgerEntryTypeId') + }) +} + +exports.down = function (knex) { + return knex.schema.table('fxTransferParticipant', (t) => { + t.dropIndex('commitRequestId') + t.dropIndex('participantCurrencyId') + t.dropIndex('transferParticipantRoleTypeId') + t.dropIndex('ledgerEntryTypeId') + }) +} diff --git a/migrations/610202_fxTransferParticipant-participantId.js b/migrations/610202_fxTransferParticipant-participantId.js new file mode 100644 index 000000000..15000ac7e --- /dev/null +++ b/migrations/610202_fxTransferParticipant-participantId.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('fxTransferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('fxTransferParticipant', (t) => { + t.integer('participantId').unsigned().notNullable() + // Disabling this as its throwing error while running the migration with existing data in the table + // t.foreign('participantId').references('participantId').inTable('participant') + t.index('participantId') + t.integer('participantCurrencyId').unsigned().nullable().alter() + }) + } + }) +} + +exports.down = async (knex) => { + return await knex.schema.hasTable('fxTransferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('fxTransferParticipant', (t) => { + t.dropIndex('participantId') + t.dropColumn('participantId') + t.integer('participantCurrencyId').unsigned().notNullable().alter() + }) + } + }) +} diff --git a/migrations/610403_participantPositionChange-fxTransfer.js b/migrations/610403_participantPositionChange-fxTransfer.js new file mode 100644 index 000000000..bdf853c96 --- /dev/null +++ b/migrations/610403_participantPositionChange-fxTransfer.js @@ -0,0 +1,46 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * INFITX + - Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('participantPositionChange').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.bigInteger('transferStateChangeId').unsigned().defaultTo(null).alter() + t.bigInteger('fxTransferStateChangeId').unsigned().defaultTo(null) + t.foreign('fxTransferStateChangeId').references('fxTransferStateChangeId').inTable('fxTransferStateChange') + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.alterTable('participantPositionChange', (t) => { + t.dropForeign('fxTransferStateChangeId') + t.dropColumn('fxTransferStateChangeId') + t.bigInteger('transferStateChangeId').unsigned().notNullable().alter() + }) +} diff --git a/migrations/800101_feature-fixSubIdRef.js b/migrations/800101_feature-fixSubIdRef.js index 3a0f410df..085b6ae71 100644 --- a/migrations/800101_feature-fixSubIdRef.js +++ b/migrations/800101_feature-fixSubIdRef.js @@ -24,8 +24,8 @@ Contributors * Gates Foundation - Name Surname - * ModusBox - - Vijay Kumar Guthi + * Infitx + - Vijay Kumar Guthi -------------- ******/ diff --git a/migrations/910101_feature904DataMigration.js b/migrations/910101_feature904DataMigration.js index e798759e1..6d3c1ffbd 100644 --- a/migrations/910101_feature904DataMigration.js +++ b/migrations/910101_feature904DataMigration.js @@ -44,62 +44,56 @@ const tableNameSuffix = Time.getYMDString(new Date()) */ const migrateData = async (knex) => { return knex.transaction(async trx => { - try { - let exists = false - exists = await knex.schema.hasTable(`transferExtension${tableNameSuffix}`) - if (exists) { - await knex.transacting(trx).raw(` - insert into transferExtension (transferExtensionId, transferId, \`key\`, \`value\`, isFulfilment, isError, createdDate) - select te.transferExtensionId, te.transferId, te.\`key\`, te.\`value\`, - case when te.transferFulfilmentId is null then 0 else 1 end, - case when te.transferErrorId is null then 0 else 1 end, - te.createdDate - from transferExtension${tableNameSuffix} as te`) - } - exists = await knex.schema.hasTable(`transferFulfilmentDuplicateCheck${tableNameSuffix}`) && - await knex.schema.hasTable(`transferFulfilment${tableNameSuffix}`) - if (exists) { - await knex.transacting(trx).raw(` - insert into transferFulfilmentDuplicateCheck (transferId, \`hash\`, createdDate) - select transferId, \`hash\`, createdDate from transferFulfilmentDuplicateCheck${tableNameSuffix} - where transferFulfilmentId in( - select transferFulfilmentId - from ( - select transferFulfilmentId, transferId, ilpFulfilment, completedDate, isValid, settlementWindowId, createdDate, - row_number() over(partition by transferId order by isValid desc, createdDate) rowNumber - from transferFulfilment${tableNameSuffix}) t - where t.rowNumber = 1)`) - } - exists = await knex.schema.hasTable(`transferFulfilment${tableNameSuffix}`) - if (exists) { - await knex.transacting(trx).raw(` - insert into transferFulfilment (transferId, ilpFulfilment, completedDate, isValid, settlementWindowId, createdDate) - select t.transferId, t.ilpFulfilment, t.completedDate, t.isValid, t.settlementWindowId, t.createdDate + let exists = false + exists = await knex.schema.hasTable(`transferExtension${tableNameSuffix}`) + if (exists) { + await knex.transacting(trx).raw(` + insert into transferExtension (transferExtensionId, transferId, \`key\`, \`value\`, isFulfilment, isError, createdDate) + select te.transferExtensionId, te.transferId, te.\`key\`, te.\`value\`, + case when te.transferFulfilmentId is null then 0 else 1 end, + case when te.transferErrorId is null then 0 else 1 end, + te.createdDate + from transferExtension${tableNameSuffix} as te`) + } + exists = await knex.schema.hasTable(`transferFulfilmentDuplicateCheck${tableNameSuffix}`) && + await knex.schema.hasTable(`transferFulfilment${tableNameSuffix}`) + if (exists) { + await knex.transacting(trx).raw(` + insert into transferFulfilmentDuplicateCheck (transferId, \`hash\`, createdDate) + select transferId, \`hash\`, createdDate from transferFulfilmentDuplicateCheck${tableNameSuffix} + where transferFulfilmentId in( + select transferFulfilmentId from ( select transferFulfilmentId, transferId, ilpFulfilment, completedDate, isValid, settlementWindowId, createdDate, row_number() over(partition by transferId order by isValid desc, createdDate) rowNumber from transferFulfilment${tableNameSuffix}) t - where t.rowNumber = 1`) - } - exists = await knex.schema.hasTable(`transferErrorDuplicateCheck${tableNameSuffix}`) - if (exists) { - await knex.transacting(trx).raw(` - insert into transferErrorDuplicateCheck (transferId, \`hash\`, createdDate) - select transferId, \`hash\`, createdDate - from transferErrorDuplicateCheck${tableNameSuffix}`) - } - exists = await knex.schema.hasTable(`transferError${tableNameSuffix}`) - if (exists) { - await knex.transacting(trx).raw(` - insert into transferError (transferId, transferStateChangeId, errorCode, errorDescription, createdDate) - select tsc.transferId, te.transferStateChangeId, te.errorCode, te.errorDescription, te.createdDate - from transferError${tableNameSuffix} te - join transferStateChange tsc on tsc.transferStateChangeId = te.transferStateChangeId`) - } - await trx.commit - } catch (err) { - await trx.rollback - throw err + where t.rowNumber = 1)`) + } + exists = await knex.schema.hasTable(`transferFulfilment${tableNameSuffix}`) + if (exists) { + await knex.transacting(trx).raw(` + insert into transferFulfilment (transferId, ilpFulfilment, completedDate, isValid, settlementWindowId, createdDate) + select t.transferId, t.ilpFulfilment, t.completedDate, t.isValid, t.settlementWindowId, t.createdDate + from ( + select transferFulfilmentId, transferId, ilpFulfilment, completedDate, isValid, settlementWindowId, createdDate, + row_number() over(partition by transferId order by isValid desc, createdDate) rowNumber + from transferFulfilment${tableNameSuffix}) t + where t.rowNumber = 1`) + } + exists = await knex.schema.hasTable(`transferErrorDuplicateCheck${tableNameSuffix}`) + if (exists) { + await knex.transacting(trx).raw(` + insert into transferErrorDuplicateCheck (transferId, \`hash\`, createdDate) + select transferId, \`hash\`, createdDate + from transferErrorDuplicateCheck${tableNameSuffix}`) + } + exists = await knex.schema.hasTable(`transferError${tableNameSuffix}`) + if (exists) { + await knex.transacting(trx).raw(` + insert into transferError (transferId, transferStateChangeId, errorCode, errorDescription, createdDate) + select tsc.transferId, te.transferStateChangeId, te.errorCode, te.errorDescription, te.createdDate + from transferError${tableNameSuffix} te + join transferStateChange tsc on tsc.transferStateChangeId = te.transferStateChangeId`) } }) } diff --git a/migrations/910102_feature949DataMigration.js b/migrations/910102_feature949DataMigration.js index 30bc7dee4..2bcb7e0f6 100644 --- a/migrations/910102_feature949DataMigration.js +++ b/migrations/910102_feature949DataMigration.js @@ -41,232 +41,226 @@ const RUN_DATA_MIGRATIONS = Config.DB_RUN_DATA_MIGRATIONS */ const migrateData = async (knex) => { return knex.transaction(async trx => { - try { - await knex.raw('update currency set scale = \'2\' where currencyId = \'AED\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'AFA\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AFN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ALL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AMD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ANG\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AOA\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'AOR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ARS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AUD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AWG\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'AZN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BAM\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BBD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BDT\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BGN\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'BHD\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'BIF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BMD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BND\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BOB\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BRL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BSD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BTN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BWP\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'BYN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'BZD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CAD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CDF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CHF\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'CLP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CNY\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'COP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CRC\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CUC\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CUP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CVE\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'CZK\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'DJF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'DKK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'DOP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'DZD\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'EEK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'EGP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ERN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ETB\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'EUR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'FJD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'FKP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GBP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GEL\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'GGP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GHS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GIP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GMD\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'GNF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GTQ\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'GYD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'HKD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'HNL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'HRK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'HTG\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'HUF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'IDR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ILS\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'IMP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'INR\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'IQD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'IRR\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'ISK\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'JEP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'JMD\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'JOD\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'JPY\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KES\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KGS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KHR\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'KMF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KPW\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'KRW\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'KWD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KYD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'KZT\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'LAK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'LBP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'LKR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'LRD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'LSL\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'LTL\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'LVL\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'LYD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MAD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MDL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MGA\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MKD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MMK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MNT\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MOP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MRO\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MUR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MVR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MWK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MXN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MYR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'MZN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NAD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NGN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NIO\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NOK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NPR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'NZD\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'OMR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PAB\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PEN\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PGK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PHP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PKR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'PLN\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'PYG\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'QAR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'RON\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'RSD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'RUB\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'RWF\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SAR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SBD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SCR\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SDG\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SEK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SGD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SHP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SLL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SOS\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'SPL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SRD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'STD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SVC\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SYP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'SZL\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'THB\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TJS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TMT\'').transacting(trx) - await knex.raw('update currency set scale = \'3\' where currencyId = \'TND\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TOP\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TRY\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TTD\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'TVD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TWD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'TZS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'UAH\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'UGX\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'USD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'UYU\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'UZS\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'VEF\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'VND\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'VUV\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'WST\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'XAF\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XAG\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XAU\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'XCD\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XDR\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XFO\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XFU\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'XOF\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XPD\'').transacting(trx) - await knex.raw('update currency set scale = \'0\' where currencyId = \'XPF\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'XPT\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'YER\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ZAR\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'ZMK\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ZMW\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWD\'').transacting(trx) - await knex.raw('update currency set scale = \'2\' where currencyId = \'ZWL\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWN\'').transacting(trx) - await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AED\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'AFA\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AFN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ALL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AMD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ANG\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AOA\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'AOR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ARS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AUD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AWG\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'AZN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BAM\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BBD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BDT\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BGN\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'BHD\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'BIF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BMD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BND\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BOB\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BRL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BSD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BTN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BWP\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'BYN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'BZD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CAD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CDF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CHF\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'CLP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CNY\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'COP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CRC\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CUC\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CUP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CVE\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'CZK\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'DJF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'DKK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'DOP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'DZD\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'EEK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'EGP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ERN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ETB\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'EUR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'FJD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'FKP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GBP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GEL\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'GGP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GHS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GIP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GMD\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'GNF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GTQ\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'GYD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'HKD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'HNL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'HRK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'HTG\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'HUF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'IDR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ILS\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'IMP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'INR\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'IQD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'IRR\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'ISK\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'JEP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'JMD\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'JOD\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'JPY\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KES\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KGS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KHR\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'KMF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KPW\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'KRW\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'KWD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KYD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'KZT\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'LAK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'LBP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'LKR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'LRD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'LSL\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'LTL\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'LVL\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'LYD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MAD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MDL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MGA\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MKD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MMK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MNT\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MOP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MRO\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MUR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MVR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MWK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MXN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MYR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'MZN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NAD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NGN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NIO\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NOK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NPR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'NZD\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'OMR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PAB\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PEN\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PGK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PHP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PKR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'PLN\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'PYG\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'QAR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'RON\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'RSD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'RUB\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'RWF\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SAR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SBD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SCR\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SDG\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SEK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SGD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SHP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SLL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SOS\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'SPL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SRD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'STD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SVC\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SYP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'SZL\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'THB\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TJS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TMT\'').transacting(trx) + await knex.raw('update currency set scale = \'3\' where currencyId = \'TND\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TOP\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TRY\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TTD\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'TVD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TWD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'TZS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'UAH\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'UGX\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'USD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'UYU\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'UZS\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'VEF\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'VND\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'VUV\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'WST\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'XAF\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XAG\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XAU\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'XCD\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XDR\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XFO\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XFU\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'XOF\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XPD\'').transacting(trx) + await knex.raw('update currency set scale = \'0\' where currencyId = \'XPF\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'XPT\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'YER\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ZAR\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'ZMK\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ZMW\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWD\'').transacting(trx) + await knex.raw('update currency set scale = \'2\' where currencyId = \'ZWL\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWN\'').transacting(trx) + await knex.raw('update currency set scale = \'4\' where currencyId = \'ZWR\'').transacting(trx) - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'BOV\', \'Bolivia Mvdol\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'BOV\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'BYR\', \'Belarussian Ruble\', 0)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'0\' where currencyId = \'BYR\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'CHE\', \'Switzerland WIR Euro\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'CHE\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'CHW\', \'Switzerland WIR Franc\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'CHW\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'CLF\', \'Unidad de Fomento\', 4)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'CLF\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'COU\', \'Unidad de Valor Real\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'COU\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'MXV\', \'Mexican Unidad de Inversion (UDI)\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'MXV\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'SSP\', \'South Sudanese Pound\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'SSP\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'USN\', \'US Dollar (Next day)\', 2)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'USN\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'UYI\', \'Uruguay Peso en Unidades Indexadas (URUIURUI)\', 0)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'0\' where currencyId = \'UYI\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'XSU\', \'Sucre\', 4)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XSU\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'XTS\', \'Reserved for testing purposes\', 4)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XTS\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'XUA\', \'African Development Bank (ADB) Unit of Account\', 4)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XUA\'').transacting(trx) } - try { - await knex.raw('insert into currency (currencyId, name, scale) values (\'XXX\', \'Assigned for transactions where no currency is involved\', 4)').transacting(trx) - } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XXX\'').transacting(trx) } - await trx.commit - } catch (err) { - await trx.rollback - throw err - } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'BOV\', \'Bolivia Mvdol\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'BOV\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'BYR\', \'Belarussian Ruble\', 0)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'0\' where currencyId = \'BYR\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'CHE\', \'Switzerland WIR Euro\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'CHE\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'CHW\', \'Switzerland WIR Franc\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'CHW\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'CLF\', \'Unidad de Fomento\', 4)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'CLF\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'COU\', \'Unidad de Valor Real\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'COU\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'MXV\', \'Mexican Unidad de Inversion (UDI)\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'MXV\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'SSP\', \'South Sudanese Pound\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'SSP\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'USN\', \'US Dollar (Next day)\', 2)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'2\' where currencyId = \'USN\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'UYI\', \'Uruguay Peso en Unidades Indexadas (URUIURUI)\', 0)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'0\' where currencyId = \'UYI\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'XSU\', \'Sucre\', 4)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XSU\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'XTS\', \'Reserved for testing purposes\', 4)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XTS\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'XUA\', \'African Development Bank (ADB) Unit of Account\', 4)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XUA\'').transacting(trx) } + try { + await knex.raw('insert into currency (currencyId, name, scale) values (\'XXX\', \'Assigned for transactions where no currency is involved\', 4)').transacting(trx) + } catch (e) { await knex.raw('update currency set scale = \'4\' where currencyId = \'XXX\'').transacting(trx) } }) } diff --git a/migrations/950103_dropTransferParticipantStateChange.js b/migrations/950103_dropTransferParticipantStateChange.js index 1956566ef..2cdfab6b7 100644 --- a/migrations/950103_dropTransferParticipantStateChange.js +++ b/migrations/950103_dropTransferParticipantStateChange.js @@ -19,7 +19,7 @@ - Name Surname * ModusBox - - Deon Botha + - Deon Botha -------------- ******/ diff --git a/migrations/950104_settlementModel-settlementAccountTypeId.js b/migrations/950104_settlementModel-settlementAccountTypeId.js index d3ec68abd..99a5393c7 100644 --- a/migrations/950104_settlementModel-settlementAccountTypeId.js +++ b/migrations/950104_settlementModel-settlementAccountTypeId.js @@ -41,27 +41,22 @@ exports.up = async (knex) => { t.integer('settlementAccountTypeId').unsigned().defaultTo(null) }) await knex.transaction(async (trx) => { - try { - await knex.select('s.settlementModelId', 's.name', 'lat.name AS latName') - .from('settlementModel AS s') - .transacting(trx) - .innerJoin('ledgerAccountType as lat', 's.ledgerAccountTypeId', 'lat.ledgerAccountTypeId') - .then(async (models) => { - for (const model of models) { - let settlementAccountName - if (model.latName === 'POSITION') { - settlementAccountName = 'SETTLEMENT' - } else { - settlementAccountName = model.latName + '_SETTLEMENT' - } - await knex('settlementModel').transacting(trx).update({ settlementAccountTypeId: knex('ledgerAccountType').select('ledgerAccountTypeId').where('name', settlementAccountName) }) - .where('settlementModelId', model.settlementModelId) + await knex.select('s.settlementModelId', 's.name', 'lat.name AS latName') + .from('settlementModel AS s') + .transacting(trx) + .innerJoin('ledgerAccountType as lat', 's.ledgerAccountTypeId', 'lat.ledgerAccountTypeId') + .then(async (models) => { + for (const model of models) { + let settlementAccountName + if (model.latName === 'POSITION') { + settlementAccountName = 'SETTLEMENT' + } else { + settlementAccountName = model.latName + '_SETTLEMENT' } - }) - await trx.commit - } catch (e) { - await trx.rollback - } + await knex('settlementModel').transacting(trx).update({ settlementAccountTypeId: knex('ledgerAccountType').select('ledgerAccountTypeId').where('name', settlementAccountName) }) + .where('settlementModelId', model.settlementModelId) + } + }) }) await knex.schema.alterTable('settlementModel', (t) => { t.integer('settlementAccountTypeId').alter().notNullable() diff --git a/migrations/950108_participantProxy.js b/migrations/950108_participantProxy.js new file mode 100644 index 000000000..7b6c2ef58 --- /dev/null +++ b/migrations/950108_participantProxy.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +'use strict' + +exports.up = async (knex) => { + return await knex.schema.hasTable('participant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('participant', (t) => { + t.boolean('isProxy').defaultTo(false).notNullable() + + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.alterTable('participant', (t) => { + t.dropColumn('isProxy') + }) +} diff --git a/migrations/950109_fxQuote.js b/migrations/950109_fxQuote.js new file mode 100644 index 000000000..99d8e9763 --- /dev/null +++ b/migrations/950109_fxQuote.js @@ -0,0 +1,53 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuote').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuote', (t) => { + t.string('conversionRequestId', 36).primary().notNullable() + + // time keeping + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuote') +} diff --git a/migrations/950110_fxQuoteResponse.js b/migrations/950110_fxQuoteResponse.js new file mode 100644 index 000000000..523132dd0 --- /dev/null +++ b/migrations/950110_fxQuoteResponse.js @@ -0,0 +1,59 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteResponse').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteResponse', (t) => { + t.bigIncrements('fxQuoteResponseId').primary().notNullable() + + // reference to the original fxQuote + t.string('conversionRequestId', 36).notNullable() + t.foreign('conversionRequestId').references('conversionRequestId').inTable('fxQuote') + + // ilpCondition sent in FXP response + t.string('ilpCondition', 256).notNullable() + + // time keeping + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteResponse') +} diff --git a/migrations/950111_fxQuoteError.js b/migrations/950111_fxQuoteError.js new file mode 100644 index 000000000..e74c25ad3 --- /dev/null +++ b/migrations/950111_fxQuoteError.js @@ -0,0 +1,57 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteError').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteError', (t) => { + t.bigIncrements('fxQuoteErrorId').primary().notNullable() + t.string('conversionRequestId', 36).notNullable() + t.foreign('conversionRequestId').references('conversionRequestId').inTable('fxQuote') + t.bigInteger('fxQuoteResponseId').unsigned().defaultTo(null).nullable().comment('The response to the initial fxQuote') + t.foreign('fxQuoteResponseId').references('fxQuoteResponseId').inTable('fxQuoteResponse') + t.integer('errorCode').unsigned().notNullable() + t.string('errorDescription', 128).notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('quoteError') +} diff --git a/migrations/950113_fxQuoteDuplicateCheck.js b/migrations/950113_fxQuoteDuplicateCheck.js new file mode 100644 index 000000000..3c7afd9dc --- /dev/null +++ b/migrations/950113_fxQuoteDuplicateCheck.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteDuplicateCheck').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteDuplicateCheck', (t) => { + t.string('conversionRequestId', 36).primary().notNullable() + t.string('hash', 1024).defaultTo(null).nullable().comment('hash value received for the quote request') + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteDuplicateCheck') +} diff --git a/migrations/950114_fxQuoteResponseDuplicateCheck.js b/migrations/950114_fxQuoteResponseDuplicateCheck.js new file mode 100644 index 000000000..74a048083 --- /dev/null +++ b/migrations/950114_fxQuoteResponseDuplicateCheck.js @@ -0,0 +1,55 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteResponseDuplicateCheck').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteResponseDuplicateCheck', (t) => { + t.bigIncrements('fxQuoteResponseId').primary().unsigned().comment('The response to the initial quote') + t.foreign('fxQuoteResponseId').references('fxQuoteResponseId').inTable('fxQuoteResponse') + t.string('conversionRequestId', 36).notNullable() + t.foreign('conversionRequestId').references('conversionRequestId').inTable('fxQuote') + t.string('hash', 255).defaultTo(null).nullable().comment('hash value received for the quote response') + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteResponseDuplicateCheck') +} diff --git a/migrations/950115_fxQuoteConversionTerms.js b/migrations/950115_fxQuoteConversionTerms.js new file mode 100644 index 000000000..82d57901f --- /dev/null +++ b/migrations/950115_fxQuoteConversionTerms.js @@ -0,0 +1,70 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteConversionTerms').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteConversionTerms', (t) => { + t.string('conversionId').primary().notNullable() + t.string('determiningTransferId', 36).defaultTo(null).nullable() + + // reference to the original fxQuote + t.string('conversionRequestId', 36).notNullable() + t.foreign('conversionRequestId').references('conversionRequestId').inTable('fxQuote') + + t.integer('amountTypeId').unsigned().notNullable().comment('This is part of the transaction type that contains valid elements for - Amount Type') + t.foreign('amountTypeId').references('amountTypeId').inTable('amountType') + t.string('initiatingFsp', 255) + t.string('counterPartyFsp', 255) + t.decimal('sourceAmount', 18, 4).notNullable() + t.string('sourceCurrency', 3).notNullable() + t.foreign('sourceCurrency').references('currencyId').inTable('currency') + // Should only be nullable in POST /fxQuote request + t.decimal('targetAmount', 18, 4).defaultTo(null).nullable() + t.string('targetCurrency', 3).notNullable() + t.foreign('targetCurrency').references('currencyId').inTable('currency') + + // time keeping + t.dateTime('expirationDate').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteConversionTerms') +} diff --git a/migrations/950116_fxQuoteConversionTermsExtension.js b/migrations/950116_fxQuoteConversionTermsExtension.js new file mode 100644 index 000000000..7daf55cbf --- /dev/null +++ b/migrations/950116_fxQuoteConversionTermsExtension.js @@ -0,0 +1,55 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteConversionTermsExtension').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteConversionTermsExtension', (t) => { + t.bigIncrements('fxQuoteConversionTermExtension').primary().notNullable() + t.string('conversionId', 36).notNullable() + t.foreign('conversionId').references('conversionId').inTable('fxQuoteConversionTerms') + t.string('key', 128).notNullable() + t.text('value').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteConversionTermsExtension') +} diff --git a/migrations/950117_fxQuoteResponseConversionTerms.js b/migrations/950117_fxQuoteResponseConversionTerms.js new file mode 100644 index 000000000..b60b75686 --- /dev/null +++ b/migrations/950117_fxQuoteResponseConversionTerms.js @@ -0,0 +1,73 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteResponseConversionTerms').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteResponseConversionTerms', (t) => { + t.string('conversionId').primary().notNullable() + t.string('determiningTransferId', 36).defaultTo(null).nullable() + + // reference to the original fxQuote + t.string('conversionRequestId', 36).notNullable() + t.foreign('conversionRequestId').references('conversionRequestId').inTable('fxQuote') + + // reference to the original fxQuoteResponse + t.bigIncrements('fxQuoteResponseId', 36).notNullable() + t.foreign('fxQuoteResponseId').references('fxQuoteResponseId').inTable('fxQuoteResponse') + + t.integer('amountTypeId').unsigned().notNullable().comment('This is part of the transaction type that contains valid elements for - Amount Type') + t.foreign('amountTypeId').references('amountTypeId').inTable('amountType') + t.string('initiatingFsp', 255) + t.string('counterPartyFsp', 255) + t.decimal('sourceAmount', 18, 4).notNullable() + t.string('sourceCurrency', 3).notNullable() + t.foreign('sourceCurrency').references('currencyId').inTable('currency') + t.decimal('targetAmount', 18, 4).notNullable() + t.string('targetCurrency', 3).notNullable() + t.foreign('targetCurrency').references('currencyId').inTable('currency') + + // time keeping + t.dateTime('expirationDate').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteResponseConversionTerms') +} diff --git a/migrations/950118_fxQuoteResponseConversionTermsExtension.js b/migrations/950118_fxQuoteResponseConversionTermsExtension.js new file mode 100644 index 000000000..1cd7317fc --- /dev/null +++ b/migrations/950118_fxQuoteResponseConversionTermsExtension.js @@ -0,0 +1,55 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +// Notes: these changes are required for the quoting-service and are not used by central-ledger +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxQuoteResponseConversionTermsExtension').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxQuoteResponseConversionTermsExtension', (t) => { + t.bigIncrements('fxQuoteResponseConversionTermsExtension').primary().notNullable() + t.string('conversionId', 36).notNullable() + t.foreign('conversionId').references('conversionId').inTable('fxQuoteResponseConversionTerms') + t.string('key', 128).notNullable() + t.text('value').notNullable() + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable().comment('System dateTime stamp pertaining to the inserted record') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxQuoteResponseConversionTermsExtension') +} diff --git a/migrations/950119_fxCharge.js b/migrations/950119_fxCharge.js new file mode 100644 index 000000000..81799e192 --- /dev/null +++ b/migrations/950119_fxCharge.js @@ -0,0 +1,61 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +'use strict' + +exports.up = (knex) => { + return knex.schema.hasTable('fxCharge').then((exists) => { + if (!exists) { + return knex.schema.createTable('fxCharge', (t) => { + t.bigIncrements('fxChargeId').primary().notNullable() + t.string('chargeType', 32).notNullable().comment('A description of the charge which is being levied.') + + // fxCharge should only be sent back in the response to an fxQuote + // so reference the terms in fxQuoteResponse `conversionTerms` + t.string('conversionId', 36).notNullable() + t.foreign('conversionId').references('conversionId').inTable('fxQuoteResponseConversionTerms') + + t.decimal('sourceAmount', 18, 4).nullable().comment('The amount of the charge which is being levied, expressed in the source currency.') + t.string('sourceCurrency', 3).nullable().comment('The currency in which the source amount charge is being levied.') + + t.decimal('targetAmount', 18, 4).nullable().comment('The amount of the charge which is being levied, expressed in the target currency.') + t.string('targetCurrency', 3).nullable().comment('The currency in which the target amount charge is being levied.') + }) + } + }) +} + +exports.down = (knex) => { + return knex.schema.dropTableIfExists('fxCharge') +} diff --git a/migrations/960100_create_externalParticipant.js b/migrations/960100_create_externalParticipant.js new file mode 100644 index 000000000..a0f4ab5f7 --- /dev/null +++ b/migrations/960100_create_externalParticipant.js @@ -0,0 +1,47 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +exports.up = async (knex) => { + return knex.schema.hasTable('externalParticipant').then(function(exists) { + if (!exists) { + return knex.schema.createTable('externalParticipant', (t) => { + t.bigIncrements('externalParticipantId').primary().notNullable() + t.string('name', 30).notNullable() + t.unique('name') + t.dateTime('createdDate').defaultTo(knex.fn.now()).notNullable() + t.integer('proxyId').unsigned().notNullable() + t.foreign('proxyId').references('participantId').inTable('participant') + }) + } + }) +} + +exports.down = function (knex) { + return knex.schema.hasTable('externalParticipant').then(function(exists) { + if (!exists) { + return knex.schema.dropTableIfExists('externalParticipant') + } + }) +} diff --git a/migrations/960110_alter_transferParticipant__addFiled_externalParticipantId.js b/migrations/960110_alter_transferParticipant__addFiled_externalParticipantId.js new file mode 100644 index 000000000..13b01119e --- /dev/null +++ b/migrations/960110_alter_transferParticipant__addFiled_externalParticipantId.js @@ -0,0 +1,50 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const EP_ID_FIELD = 'externalParticipantId' + +exports.up = async (knex) => { + return knex.schema.hasTable('transferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('transferParticipant', (t) => { + t.bigint(EP_ID_FIELD).unsigned().nullable() + t.foreign(EP_ID_FIELD).references(EP_ID_FIELD).inTable('externalParticipant') + t.index(EP_ID_FIELD) + }) + } + }) +} + +exports.down = async (knex) => { + return knex.schema.hasTable('transferParticipant').then(function(exists) { + if (exists) { + return knex.schema.alterTable('transferParticipant', (t) => { + t.dropIndex(EP_ID_FIELD) + t.dropForeign(EP_ID_FIELD) + t.dropColumn(EP_ID_FIELD) + }) + } + }) +} diff --git a/migrations/960111_alter_fxTransferParticipant__addFiled_externalParticipantId.js b/migrations/960111_alter_fxTransferParticipant__addFiled_externalParticipantId.js new file mode 100644 index 000000000..ecf4adefd --- /dev/null +++ b/migrations/960111_alter_fxTransferParticipant__addFiled_externalParticipantId.js @@ -0,0 +1,50 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const EP_ID_FIELD = 'externalParticipantId' + +exports.up = async (knex) => { + return knex.schema.hasTable('fxTransferParticipant').then((exists) => { + if (exists) { + return knex.schema.alterTable('fxTransferParticipant', (t) => { + t.bigint(EP_ID_FIELD).unsigned().nullable() + t.foreign(EP_ID_FIELD).references(EP_ID_FIELD).inTable('externalParticipant') + t.index(EP_ID_FIELD) + }) + } + }) +} + +exports.down = async (knex) => { + return knex.schema.hasTable('fxTransferParticipant').then((exists) => { + if (exists) { + return knex.schema.alterTable('fxTransferParticipant', (t) => { + t.dropIndex(EP_ID_FIELD) + t.dropForeign(EP_ID_FIELD) + t.dropColumn(EP_ID_FIELD) + }) + } + }) +} diff --git a/package-lock.json b/package-lock.json index 1535b785d..9d3f562c4 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,67 +1,72 @@ { "name": "@mojaloop/central-ledger", - "version": "17.7.8", + "version": "17.8.0-snapshot.34", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@mojaloop/central-ledger", - "version": "17.7.8", + "version": "17.8.0-snapshot.34", "license": "Apache-2.0", "dependencies": { "@hapi/basic": "7.0.2", "@hapi/catbox-memory": "6.0.2", "@hapi/good": "9.0.1", - "@hapi/hapi": "21.3.10", + "@hapi/hapi": "21.3.12", "@hapi/inert": "7.1.0", "@hapi/joi": "17.1.1", "@hapi/vision": "7.0.3", - "@mojaloop/central-services-error-handling": "13.0.1", + "@mojaloop/central-services-error-handling": "13.0.2", "@mojaloop/central-services-health": "15.0.0", - "@mojaloop/central-services-logger": "11.3.1", - "@mojaloop/central-services-metrics": "12.0.8", - "@mojaloop/central-services-shared": "18.3.8", - "@mojaloop/central-services-stream": "11.3.1", - "@mojaloop/database-lib": "11.0.5", + "@mojaloop/central-services-logger": "11.5.1", + "@mojaloop/central-services-metrics": "12.4.2", + "@mojaloop/central-services-shared": "18.14.1", + "@mojaloop/central-services-stream": "11.4.1", + "@mojaloop/database-lib": "11.0.6", "@mojaloop/event-sdk": "14.1.1", + "@mojaloop/inter-scheme-proxy-cache-lib": "2.3.1", "@mojaloop/ml-number": "11.2.4", "@mojaloop/object-store-lib": "12.0.3", "@now-ims/hapi-now-auth": "2.1.0", - "ajv": "8.16.0", + "ajv": "8.17.1", "ajv-keywords": "5.1.0", "base64url": "3.0.1", "blipp": "4.0.2", "commander": "12.1.0", - "cron": "3.1.7", + "cron": "3.3.1", "decimal.js": "10.4.3", "docdash": "2.0.2", "event-stream": "4.0.1", "five-bells-condition": "5.0.1", - "glob": "10.4.1", + "glob": "10.4.3", + "hapi-auth-basic": "5.0.0", "hapi-auth-bearer-token": "8.0.0", - "hapi-swagger": "17.2.1", + "hapi-swagger": "17.3.2", "ilp-packet": "2.2.0", "knex": "3.1.0", "lodash": "4.17.21", "moment": "2.30.1", "mongo-uri-builder": "^4.0.0", + "parse-strings-in-object": "2.0.0", "rc": "1.2.8", "require-glob": "^4.1.0" }, "devDependencies": { + "@types/mock-knex": "0.4.8", "async-retry": "1.3.3", - "audit-ci": "^7.0.1", + "audit-ci": "^7.1.0", "get-port": "5.1.1", - "jsdoc": "4.0.3", + "jsdoc": "4.0.4", "jsonpath": "1.1.1", - "nodemon": "3.1.3", - "npm-check-updates": "16.14.20", - "nyc": "17.0.0", + "mock-knex": "0.4.13", + "nodemon": "3.1.9", + "npm-check-updates": "17.1.11", + "nyc": "17.1.0", "pre-commit": "1.2.2", "proxyquire": "2.1.3", "replace": "^1.2.2", "sinon": "17.0.0", - "standard": "17.1.0", + "standard": "17.1.2", "standard-version": "^9.5.0", "tap-spec": "^5.0.0", "tap-xunit": "2.4.1", @@ -95,9 +100,9 @@ } }, "node_modules/@apidevtools/json-schema-ref-parser": { - "version": "11.6.2", - "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.6.2.tgz", - "integrity": "sha512-ENUdLLT04aDbbHCRwfKf8gR67AhV0CdFrOAtk+FcakBAgaq6ds3HLK9X0BCyiFUz8pK9uP+k6YZyJaGG7Mt7vQ==", + "version": "11.7.0", + "resolved": "https://registry.npmjs.org/@apidevtools/json-schema-ref-parser/-/json-schema-ref-parser-11.7.0.tgz", + "integrity": "sha512-pRrmXMCwnmrkS3MLgAIW5dXRzeTv6GLjkjb4HmxNnvAKXN1Nfzp4KmGADBQvlVUcqi+a5D+hfGDLLnd5NnYxog==", "dependencies": { "@jsdevtools/ono": "^7.1.3", "@types/json-schema": "^7.0.15", @@ -554,11 +559,9 @@ } }, "node_modules/@colors/colors": { - "version": "1.5.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.5.0.tgz", - "integrity": "sha512-ooWCrlZP11i8GImSjTHYHLkvFDP48nS4+204nGb1RiX/WXYHmJA2III9/e2DWVabCESdW7hBAEzHRqUn9OUVvQ==", - "dev": true, - "optional": true, + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", + "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", "engines": { "node": ">=0.1.90" } @@ -705,12 +708,6 @@ "resolved": "https://registry.npmjs.org/@exodus/schemasafe/-/schemasafe-1.3.0.tgz", "integrity": "sha512-5Aap/GaRupgNx/feGBwLLTVv8OQFfv3pq2lPRzPg9R+IOBnDgghTGW7l7EuVXOvg5cc/xSAlRW8rBrjIC3Nvqw==" }, - "node_modules/@gar/promisify": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@gar/promisify/-/promisify-1.1.3.tgz", - "integrity": "sha512-k2Ty1JcVojjJFwrg/ThKi2ujJ7XNLYaFGNB/bWT9wGR+oSMJHMa5w+CUq6p/pVrKeNNgA7pCqEcjSnHVoqJQFw==", - "dev": true - }, "node_modules/@grpc/grpc-js": { "version": "1.10.9", "resolved": "https://registry.npmjs.org/@grpc/grpc-js/-/grpc-js-1.10.9.tgz", @@ -798,9 +795,9 @@ } }, "node_modules/@hapi/bounce": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.1.tgz", - "integrity": "sha512-G+/Pp9c1Ha4FDP+3Sy/Xwg2O4Ahaw3lIZFSX+BL4uWi64CmiETuZPxhKDUD4xBMOUZbBlzvO8HjiK8ePnhBadA==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/@hapi/bounce/-/bounce-3.0.2.tgz", + "integrity": "sha512-d0XmlTi3H9HFDHhQLjg4F4auL1EY3Wqj7j7/hGDhFFe6xAbnm3qiGrXeT93zZnPH8gH+SKAFYiRzu26xkXcH3g==", "dependencies": { "@hapi/boom": "^10.0.1", "@hapi/hoek": "^11.0.2" @@ -897,19 +894,19 @@ "deprecated": "This version has been deprecated and is no longer supported or maintained" }, "node_modules/@hapi/hapi": { - "version": "21.3.10", - "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.3.10.tgz", - "integrity": "sha512-CmEcmTREW394MaGGKvWpoOK4rG8tKlpZLs30tbaBzhCrhiL2Ti/HARek9w+8Ya4nMBGcd+kDAzvU44OX8Ms0Jg==", + "version": "21.3.12", + "resolved": "https://registry.npmjs.org/@hapi/hapi/-/hapi-21.3.12.tgz", + "integrity": "sha512-GCUP12dkb3QMjpFl+wEFO73nqKRmsnD5um/QDOn6lj2GjGBrDXPcT194mNARO+PPNXZOR4KmvIpHt/lceUncfg==", "dependencies": { - "@hapi/accept": "^6.0.1", + "@hapi/accept": "^6.0.3", "@hapi/ammo": "^6.0.1", "@hapi/boom": "^10.0.1", - "@hapi/bounce": "^3.0.1", + "@hapi/bounce": "^3.0.2", "@hapi/call": "^9.0.1", "@hapi/catbox": "^12.1.1", "@hapi/catbox-memory": "^6.0.2", "@hapi/heavy": "^8.0.1", - "@hapi/hoek": "^11.0.2", + "@hapi/hoek": "^11.0.6", "@hapi/mimos": "^7.0.1", "@hapi/podium": "^5.0.1", "@hapi/shot": "^6.0.1", @@ -917,7 +914,7 @@ "@hapi/statehood": "^8.1.1", "@hapi/subtext": "^8.1.0", "@hapi/teamwork": "^6.0.0", - "@hapi/topo": "^6.0.1", + "@hapi/topo": "^6.0.2", "@hapi/validate": "^2.0.1" }, "engines": { @@ -953,9 +950,9 @@ } }, "node_modules/@hapi/hoek": { - "version": "11.0.4", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.4.tgz", - "integrity": "sha512-PnsP5d4q7289pS2T2EgGz147BFJ2Jpb4yrEdkpz2IhgEUzos1S7HTl7ezWh1yfYzYlj89KzLdCRkqsP6SIryeQ==" + "version": "11.0.7", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-11.0.7.tgz", + "integrity": "sha512-HV5undWkKzcB4RZUusqOpcgxOaq6VOAH7zhhIr2g3G8NF/MlFO75SjOr2NfuSx0Mh40+1FqCkagKLJRykUWoFQ==" }, "node_modules/@hapi/inert": { "version": "7.1.0", @@ -1294,6 +1291,11 @@ "node": ">=6.9.0" } }, + "node_modules/@ioredis/commands": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz", + "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==" + }, "node_modules/@isaacs/cliui": { "version": "8.0.2", "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz", @@ -1496,19 +1498,12 @@ } }, "node_modules/@mojaloop/central-services-error-handling": { - "version": "13.0.1", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-error-handling/-/central-services-error-handling-13.0.1.tgz", - "integrity": "sha512-Hl0KBHX30LbF127tgqNK/fdo0hwa6Bt23tb8DesLstYawKtCesJtk9lPuo6jE+dafNeG2QusUwVQyI+7kwAUHQ==", + "version": "13.0.2", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-error-handling/-/central-services-error-handling-13.0.2.tgz", + "integrity": "sha512-HSxI7OrtPdA94aHNWmAD50Ve8lR6FmgOX2LaZSL/TPfx22PVTTht0eXU+IQSN/srF20f2tvCa2CdFxWBQf6Ilg==", "dependencies": { + "fast-safe-stringify": "2.1.1", "lodash": "4.17.21" - }, - "peerDependencies": { - "@mojaloop/sdk-standard-components": ">=18.x.x" - }, - "peerDependenciesMeta": { - "@mojaloop/sdk-standard-components": { - "optional": false - } } }, "node_modules/@mojaloop/central-services-health": { @@ -1570,51 +1565,88 @@ } }, "node_modules/@mojaloop/central-services-logger": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-logger/-/central-services-logger-11.3.1.tgz", - "integrity": "sha512-XVU2K5grE1ZcIyxUXeMlvoVkeIcs9y1/0EKxa2Bk5sEbqXUtHuR8jqbAGlwaUIi9T9YWZRJyVC77nOQe/X1teA==", + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-logger/-/central-services-logger-11.5.1.tgz", + "integrity": "sha512-l+6+w35NqFJn1Xl82l55x71vCARWTkO6hYAgwbFuqVRqX0jqaRi4oiXG2WwPRVMLqVv8idAboCMX/I6vg/d4Kw==", "dependencies": { - "@types/node": "^20.12.7", "parse-strings-in-object": "2.0.0", "rc": "1.2.8", "safe-stable-stringify": "^2.4.3", - "winston": "3.13.0" + "winston": "3.14.2" + } + }, + "node_modules/@mojaloop/central-services-logger/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, + "node_modules/@mojaloop/central-services-logger/node_modules/winston": { + "version": "3.14.2", + "resolved": "https://registry.npmjs.org/winston/-/winston-3.14.2.tgz", + "integrity": "sha512-CO8cdpBB2yqzEf8v895L+GNKYJiEq8eKlHU38af3snQBQ+sdAIUepjMSguOIJC7ICbzm0ZI+Af2If4vIJrtmOg==", + "dependencies": { + "@colors/colors": "^1.6.0", + "@dabh/diagnostics": "^2.0.2", + "async": "^3.2.3", + "is-stream": "^2.0.0", + "logform": "^2.6.0", + "one-time": "^1.0.0", + "readable-stream": "^3.4.0", + "safe-stable-stringify": "^2.3.1", + "stack-trace": "0.0.x", + "triple-beam": "^1.3.0", + "winston-transport": "^4.7.0" + }, + "engines": { + "node": ">= 12.0.0" } }, "node_modules/@mojaloop/central-services-metrics": { - "version": "12.0.8", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-metrics/-/central-services-metrics-12.0.8.tgz", - "integrity": "sha512-eYWX56zMlj0M0bE6qBLzhwDjo0C4LUQLcQW8du3xJ3mhxH0fSmw+Y5wsmuPmUVQZ90EU4S8l39VcXwh6ludLVg==", + "version": "12.4.2", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-metrics/-/central-services-metrics-12.4.2.tgz", + "integrity": "sha512-0XFW9nBJNY70tya/DEYlGl12adfb/3cAWuHv88vF8JI+JQAIE/6ePyET1Wb3tMp0BUcjFF5b1XbbYcOF69wKZQ==", "dependencies": { - "prom-client": "14.2.0" + "prom-client": "15.1.3" } }, "node_modules/@mojaloop/central-services-shared": { - "version": "18.3.8", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-shared/-/central-services-shared-18.3.8.tgz", - "integrity": "sha512-Wk+uG+mnOFrFNeDq0ffE+OXvcAtfemSPocPdCRFvnF0p123tV9CiH540R29XrXlRTLt78JS4N3GBYyR7E3ZfBA==", + "version": "18.14.1", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-shared/-/central-services-shared-18.14.1.tgz", + "integrity": "sha512-m04gmTfm7WBqdFgZBPljgUWRQ+htCTQJQ9jjblvj4b5Rbw0YI4gntsjyBxFKcVIIqluCeSmhzb9GPTIP/6Simg==", "dependencies": { "@hapi/catbox": "12.1.1", "@hapi/catbox-memory": "5.0.1", - "axios": "1.7.2", + "@mojaloop/inter-scheme-proxy-cache-lib": "2.3.1", + "axios": "1.7.9", "clone": "2.1.2", - "dotenv": "16.4.5", + "dotenv": "16.4.7", "env-var": "7.5.0", "event-stream": "4.0.1", - "immutable": "4.3.6", + "fast-safe-stringify": "^2.1.1", + "immutable": "5.0.3", + "ioredis": "^5.4.1", "lodash": "4.17.21", "mustache": "4.2.0", - "openapi-backend": "5.10.6", - "raw-body": "2.5.2", + "openapi-backend": "5.11.1", + "raw-body": "3.0.0", "rc": "1.2.8", "shins": "2.6.0", + "ulidx": "2.4.1", "uuid4": "2.0.3", "widdershins": "^4.0.1", - "yaml": "2.4.5" + "yaml": "2.6.1" }, "peerDependencies": { "@mojaloop/central-services-error-handling": ">=13.x.x", - "@mojaloop/central-services-logger": ">=11.x.x", + "@mojaloop/central-services-logger": ">=11.5.x", "@mojaloop/central-services-metrics": ">=12.x.x", "@mojaloop/event-sdk": ">=14.1.1", "ajv": "8.x.x", @@ -1649,12 +1681,6 @@ "@hapi/hoek": "9.x.x" } }, - "node_modules/@mojaloop/central-services-shared/node_modules/@hapi/boom/node_modules/@hapi/hoek": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.3.tgz", - "integrity": "sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg==", - "deprecated": "This version has been deprecated and is no longer supported or maintained" - }, "node_modules/@mojaloop/central-services-shared/node_modules/@hapi/catbox-memory": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/@hapi/catbox-memory/-/catbox-memory-5.0.1.tgz", @@ -1664,18 +1690,17 @@ "@hapi/hoek": "9.x.x" } }, - "node_modules/@mojaloop/central-services-shared/node_modules/@hapi/catbox-memory/node_modules/@hapi/hoek": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.3.tgz", - "integrity": "sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg==", - "deprecated": "This version has been deprecated and is no longer supported or maintained" + "node_modules/@mojaloop/central-services-shared/node_modules/@hapi/hoek": { + "version": "9.3.0", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.3.0.tgz", + "integrity": "sha512-/c6rf4UJlmHlC9b5BaNvzAcFv7HZ2QHaV0D4/HNlBdvFnvQq8RI4kYdhyPCl7Xj+oWvTWQ8ujhqS53LIgAe6KQ==" }, "node_modules/@mojaloop/central-services-stream": { - "version": "11.3.1", - "resolved": "https://registry.npmjs.org/@mojaloop/central-services-stream/-/central-services-stream-11.3.1.tgz", - "integrity": "sha512-mSdWvEFJEjKkZdDs+e1yeZm/gFfXTqA+eVRIBmp8p67QJy36ZTaAvrvebGYKZ60MBN2syDrqL+DbQMJdoxHLEA==", + "version": "11.4.1", + "resolved": "https://registry.npmjs.org/@mojaloop/central-services-stream/-/central-services-stream-11.4.1.tgz", + "integrity": "sha512-LbnT/JAqliL8xWf/vCt5fJGIP8+o5Gcx265GPXbJPKSQrJ8UV5cUs1CIv0S/nYjrZuvfeEI3RQYuc93NF9qO3g==", "dependencies": { - "async": "3.2.5", + "async": "3.2.6", "async-exit-hook": "2.0.1", "events": "3.3.0", "node-rdkafka": "2.18.0" @@ -1694,9 +1719,9 @@ } }, "node_modules/@mojaloop/database-lib": { - "version": "11.0.5", - "resolved": "https://registry.npmjs.org/@mojaloop/database-lib/-/database-lib-11.0.5.tgz", - "integrity": "sha512-u7MOtJIwwlyxeFlUplf7kcdjnyOZpXS1rqEQw21WBIRTl4RXqQl6/ThTCIjCxxGc4dK/BfZz7Spo10RHcWvSgw==", + "version": "11.0.6", + "resolved": "https://registry.npmjs.org/@mojaloop/database-lib/-/database-lib-11.0.6.tgz", + "integrity": "sha512-5rg8aBkHEaz6MkgVZqXkYFFVKAc80iQejmyZaws3vuZnrG6YfAhTGQTSZCDfYX3WqtDpt4OE8yhYeBua82ftMA==", "dependencies": { "knex": "3.1.0", "lodash": "4.17.21", @@ -1741,6 +1766,21 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.3.tgz", "integrity": "sha512-xNvxJEOUiWPGhUuUdQgAJPKOOJfGnIyKySOc09XkKsgdUV/3E2zvwZYdejjmRgPCgcym1juLH3226yA7sEFJKQ==" }, + "node_modules/@mojaloop/inter-scheme-proxy-cache-lib": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@mojaloop/inter-scheme-proxy-cache-lib/-/inter-scheme-proxy-cache-lib-2.3.1.tgz", + "integrity": "sha512-94HhBs/DJOwyE24CSVBpySrulMHN/xntc9c/0ZjpOzVHBsu/HJmfiA/CuwTo0GGNFrCxM9FgwjccafQeEs2B1A==", + "dependencies": { + "@mojaloop/central-services-logger": "11.5.1", + "ajv": "^8.17.1", + "convict": "^6.2.4", + "fast-safe-stringify": "^2.1.1", + "ioredis": "^5.4.1" + }, + "engines": { + "node": ">=18.x" + } + }, "node_modules/@mojaloop/ml-number": { "version": "11.2.4", "resolved": "https://registry.npmjs.org/@mojaloop/ml-number/-/ml-number-11.2.4.tgz", @@ -1765,60 +1805,10 @@ } } }, - "node_modules/@mojaloop/sdk-standard-components": { - "version": "18.1.0", - "resolved": "https://registry.npmjs.org/@mojaloop/sdk-standard-components/-/sdk-standard-components-18.1.0.tgz", - "integrity": "sha512-8g4JuVl3f9t80OEtvn9BeUtlZIW4kcL40f72FZobtqQjAZ+yz4J0BlWS/OEJDpuYV1qoyxGiuMRojKqP2Yio7g==", - "peer": true, - "dependencies": { - "base64url": "3.0.1", - "fast-safe-stringify": "^2.1.1", - "ilp-packet": "2.2.0", - "jsonwebtoken": "9.0.2", - "jws": "4.0.0" - } - }, - "node_modules/@mojaloop/sdk-standard-components/node_modules/jsonwebtoken": { - "version": "9.0.0", - "resolved": "https://registry.npmjs.org/jsonwebtoken/-/jsonwebtoken-9.0.0.tgz", - "integrity": "sha512-tuGfYXxkQGDPnLJ7SibiQgVgeDgfbPq2k2ICcbgqW8WxWLBAxKQM/ZCu/IT8SOSwmaYl4dpTFCW5xZv7YbbWUw==", - "peer": true, - "dependencies": { - "jws": "^3.2.2", - "lodash": "^4.17.21", - "ms": "^2.1.1", - "semver": "^7.3.8" - }, - "engines": { - "node": ">=12", - "npm": ">=6" - } - }, - "node_modules/@mojaloop/sdk-standard-components/node_modules/jsonwebtoken/node_modules/jws": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/jws/-/jws-3.2.2.tgz", - "integrity": "sha512-YHlZCB6lMTllWDtSPHz/ZXTsi8S00usEV6v1tjq8tOUZzw7DpSDWVXjXDre6ed1w/pd495ODpHZYSdkRTsa0HA==", - "peer": true, - "dependencies": { - "jwa": "^1.4.1", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/@mojaloop/sdk-standard-components/node_modules/jwa": { - "version": "1.4.1", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-1.4.1.tgz", - "integrity": "sha512-qiLX/xhEEFKUAJ6FiBMbes3w9ATzyk5W7Hvzpa/SLYdxNtng+gcurvrI7TbACjIXlsJyr05/S1oUhZrc63evQA==", - "peer": true, - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, "node_modules/@mongodb-js/saslprep": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.1.tgz", - "integrity": "sha512-t7c5K033joZZMspnHg/gWPE4kandgc2OxE74aYOtGKfgB9VPuVJPix0H6fhmm2erj5PBJ21mqcx34lpIGtUCsQ==", + "version": "1.1.9", + "resolved": "https://registry.npmjs.org/@mongodb-js/saslprep/-/saslprep-1.1.9.tgz", + "integrity": "sha512-tVkljjeEaAhCqTzajSdgbQ6gE6f3oneVwa3iXR6csiEwXXOFsiC6Uh9iAjAhXPtqa/XMDHWjjeNH/77m/Yq2dw==", "optional": true, "dependencies": { "sparse-bitfield": "^3.0.3" @@ -1919,427 +1909,134 @@ "safe-buffer": "^5.0.1" } }, - "node_modules/@npmcli/fs": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-3.1.0.tgz", - "integrity": "sha512-7kZUAaLscfgbwBQRbvdMYaZOWyMEcPTH/tJjnyAWJ/dvvs9Ef+CERx/qJb9GExJpl1qipaDGn7KqHnFGGixd0w==", - "dev": true, - "dependencies": { - "semver": "^7.3.5" - }, + "node_modules/@opentelemetry/api": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz", + "integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==", "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=8.0.0" } }, - "node_modules/@npmcli/git": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/@npmcli/git/-/git-4.1.0.tgz", - "integrity": "sha512-9hwoB3gStVfa0N31ymBmrX+GuDGdVA/QWShZVqE0HK2Af+7QGGrCTbZia/SW0ImUTjTne7SP91qxDmtXvDHRPQ==", - "dev": true, - "dependencies": { - "@npmcli/promise-spawn": "^6.0.0", - "lru-cache": "^7.4.4", - "npm-pick-manifest": "^8.0.0", - "proc-log": "^3.0.0", - "promise-inflight": "^1.0.1", - "promise-retry": "^2.0.1", - "semver": "^7.3.5", - "which": "^3.0.0" - }, + "node_modules/@pkgjs/parseargs": { + "version": "0.11.0", + "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", + "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", + "optional": true, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "node": ">=14" } }, - "node_modules/@npmcli/git/node_modules/which": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", - "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", - "dev": true, + "node_modules/@protobufjs/aspromise": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", + "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" + }, + "node_modules/@protobufjs/base64": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", + "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" + }, + "node_modules/@protobufjs/codegen": { + "version": "2.0.4", + "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", + "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" + }, + "node_modules/@protobufjs/eventemitter": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", + "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" + }, + "node_modules/@protobufjs/fetch": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", + "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "@protobufjs/aspromise": "^1.1.1", + "@protobufjs/inquire": "^1.1.0" } }, - "node_modules/@npmcli/installed-package-contents": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/installed-package-contents/-/installed-package-contents-2.0.2.tgz", - "integrity": "sha512-xACzLPhnfD51GKvTOOuNX2/V4G4mz9/1I2MfDoye9kBM3RYe5g2YbscsaGoTlaWqkxeiapBWyseULVKpSVHtKQ==", + "node_modules/@protobufjs/float": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", + "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" + }, + "node_modules/@protobufjs/inquire": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", + "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" + }, + "node_modules/@protobufjs/path": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", + "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" + }, + "node_modules/@protobufjs/pool": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", + "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" + }, + "node_modules/@protobufjs/utf8": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", + "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" + }, + "node_modules/@sideway/address": { + "version": "4.1.4", + "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", + "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", + "dependencies": { + "@hapi/hoek": "^9.0.0" + } + }, + "node_modules/@sideway/address/node_modules/@hapi/hoek": { + "version": "9.0.3", + "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.3.tgz", + "integrity": "sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg==", + "deprecated": "This version has been deprecated and is no longer supported or maintained" + }, + "node_modules/@sideway/formula": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", + "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" + }, + "node_modules/@sideway/pinpoint": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", + "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" + }, + "node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", "dev": true, "dependencies": { - "npm-bundled": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "bin": { - "installed-package-contents": "lib/index.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" + "type-detect": "4.0.8" } }, - "node_modules/@npmcli/move-file": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/@npmcli/move-file/-/move-file-2.0.1.tgz", - "integrity": "sha512-mJd2Z5TjYWq/ttPLLGqArdtnC74J6bOzg4rMDnN+p1xTacZ2yPRCk2y0oSWQtygLR9YVQXgOcONrwtnk3JupxQ==", - "deprecated": "This functionality has been moved to @npmcli/fs", + "node_modules/@sinonjs/fake-timers": { + "version": "11.2.2", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", + "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", "dev": true, "dependencies": { - "mkdirp": "^1.0.4", - "rimraf": "^3.0.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/@npmcli/move-file/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "node_modules/@sinonjs/samsam": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", + "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", "dev": true, "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" + "@sinonjs/commons": "^2.0.0", + "lodash.get": "^4.4.2", + "type-detect": "^4.0.8" } }, - "node_modules/@npmcli/move-file/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/move-file/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/@npmcli/move-file/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/@npmcli/node-gyp": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@npmcli/node-gyp/-/node-gyp-3.0.0.tgz", - "integrity": "sha512-gp8pRXC2oOxu0DUE1/M3bYtb1b3/DbJ5aM113+XJBgfXdussRAsX0YOrOhdd8WvnAR6auDBvJomGAkLKA5ydxA==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/promise-spawn": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/promise-spawn/-/promise-spawn-6.0.2.tgz", - "integrity": "sha512-gGq0NJkIGSwdbUt4yhdF8ZrmkGKVz9vAdVzpOfnom+V8PLSmSOVhZwbNvZZS1EYcJN5hzzKBxmmVVAInM6HQLg==", - "dev": true, - "dependencies": { - "which": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/promise-spawn/node_modules/which": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", - "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/run-script": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/@npmcli/run-script/-/run-script-6.0.2.tgz", - "integrity": "sha512-NCcr1uQo1k5U+SYlnIrbAh3cxy+OQT1VtqiAbxdymSlptbzBb62AjH2xXgjNCoP073hoa1CfCAcwoZ8k96C4nA==", - "dev": true, - "dependencies": { - "@npmcli/node-gyp": "^3.0.0", - "@npmcli/promise-spawn": "^6.0.0", - "node-gyp": "^9.0.0", - "read-package-json-fast": "^3.0.0", - "which": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@npmcli/run-script/node_modules/which": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/which/-/which-3.0.1.tgz", - "integrity": "sha512-XA1b62dzQzLfaEOSQFTCOd5KFf/1VSzZo7/7TUjnya6u0vGGKzU96UQBZTAThCb2j4/xjBAyii1OhRLJEivHvg==", - "dev": true, - "dependencies": { - "isexe": "^2.0.0" - }, - "bin": { - "node-which": "bin/which.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@pkgjs/parseargs": { - "version": "0.11.0", - "resolved": "https://registry.npmjs.org/@pkgjs/parseargs/-/parseargs-0.11.0.tgz", - "integrity": "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==", - "optional": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/@pnpm/config.env-replace": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz", - "integrity": "sha512-htyl8TWnKL7K/ESFa1oW2UB5lVDxuF5DpM7tBi6Hu2LNL3mWkIzNLG6N4zoCUP1lCKNxWy/3iu8mS8MvToGd6w==", - "dev": true, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@pnpm/network.ca-file/-/network.ca-file-1.0.2.tgz", - "integrity": "sha512-YcPQ8a0jwYU9bTdJDpXjMi7Brhkr1mXsXrUJvjqM2mQDgkRiz8jFaQGOdaLxgjtUfQgZhKy/O3cG/YwmgKaxLA==", - "dev": true, - "dependencies": { - "graceful-fs": "4.2.10" - }, - "engines": { - "node": ">=12.22.0" - } - }, - "node_modules/@pnpm/network.ca-file/node_modules/graceful-fs": { - "version": "4.2.10", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.10.tgz", - "integrity": "sha512-9ByhssR2fPVsNZj478qUUbKfmL0+t5BDVyjShtyZZLiK7ZDAArFFfopyOTj0M05wE2tJPisA4iTnnXl2YoPvOA==", - "dev": true - }, - "node_modules/@pnpm/npm-conf": { - "version": "2.2.2", - "resolved": "https://registry.npmjs.org/@pnpm/npm-conf/-/npm-conf-2.2.2.tgz", - "integrity": "sha512-UA91GwWPhFExt3IizW6bOeY/pQ0BkuNwKjk9iQW9KqxluGCrg4VenZ0/L+2Y0+ZOtme72EVvg6v0zo3AMQRCeA==", - "dev": true, - "dependencies": { - "@pnpm/config.env-replace": "^1.1.0", - "@pnpm/network.ca-file": "^1.0.1", - "config-chain": "^1.1.11" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/@protobufjs/aspromise": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/aspromise/-/aspromise-1.1.2.tgz", - "integrity": "sha512-j+gKExEuLmKwvz3OgROXtrJ2UG2x8Ch2YZUxahh+s1F2HZ+wAceUNLkvy6zKCPVRkU++ZWQrdxsUeQXmcg4uoQ==" - }, - "node_modules/@protobufjs/base64": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/base64/-/base64-1.1.2.tgz", - "integrity": "sha512-AZkcAA5vnN/v4PDqKyMR5lx7hZttPDgClv83E//FMNhR2TMcLUhfRUBHCmSl0oi9zMgDDqRUJkSxO3wm85+XLg==" - }, - "node_modules/@protobufjs/codegen": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/@protobufjs/codegen/-/codegen-2.0.4.tgz", - "integrity": "sha512-YyFaikqM5sH0ziFZCN3xDC7zeGaB/d0IUb9CATugHWbd1FRFwWwt4ld4OYMPWu5a3Xe01mGAULCdqhMlPl29Jg==" - }, - "node_modules/@protobufjs/eventemitter": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/eventemitter/-/eventemitter-1.1.0.tgz", - "integrity": "sha512-j9ednRT81vYJ9OfVuXG6ERSTdEL1xVsNgqpkxMsbIabzSo3goCjDIveeGv5d03om39ML71RdmrGNjG5SReBP/Q==" - }, - "node_modules/@protobufjs/fetch": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/fetch/-/fetch-1.1.0.tgz", - "integrity": "sha512-lljVXpqXebpsijW71PZaCYeIcE5on1w5DlQy5WH6GLbFryLUrBD4932W/E2BSpfRJWseIL4v/KPgBFxDOIdKpQ==", - "dependencies": { - "@protobufjs/aspromise": "^1.1.1", - "@protobufjs/inquire": "^1.1.0" - } - }, - "node_modules/@protobufjs/float": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/@protobufjs/float/-/float-1.0.2.tgz", - "integrity": "sha512-Ddb+kVXlXst9d+R9PfTIxh1EdNkgoRe5tOX6t01f1lYWOvJnSPDBlG241QLzcyPdoNTsblLUdujGSE4RzrTZGQ==" - }, - "node_modules/@protobufjs/inquire": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/inquire/-/inquire-1.1.0.tgz", - "integrity": "sha512-kdSefcPdruJiFMVSbn801t4vFK7KB/5gd2fYvrxhuJYg8ILrmn9SKSX2tZdV6V+ksulWqS7aXjBcRXl3wHoD9Q==" - }, - "node_modules/@protobufjs/path": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/@protobufjs/path/-/path-1.1.2.tgz", - "integrity": "sha512-6JOcJ5Tm08dOHAbdR3GrvP+yUUfkjG5ePsHYczMFLq3ZmMkAD98cDgcT2iA1lJ9NVwFd4tH/iSSoe44YWkltEA==" - }, - "node_modules/@protobufjs/pool": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/pool/-/pool-1.1.0.tgz", - "integrity": "sha512-0kELaGSIDBKvcgS4zkjz1PeddatrjYcmMWOlAuAPwAeccUrPHdUqo/J6LiymHHEiJT5NrF1UVwxY14f+fy4WQw==" - }, - "node_modules/@protobufjs/utf8": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@protobufjs/utf8/-/utf8-1.1.0.tgz", - "integrity": "sha512-Vvn3zZrhQZkkBE8LSuW3em98c0FwgO4nxzv6OdSxPKJIEKY2bGbHn+mhGIPerzI4twdxaP8/0+06HBpwf345Lw==" - }, - "node_modules/@sideway/address": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/@sideway/address/-/address-4.1.4.tgz", - "integrity": "sha512-7vwq+rOHVWjyXxVlR76Agnvhy8I9rpzjosTESvmhNeXOXdZZB15Fl+TI9x1SiHZH5Jv2wTGduSxFDIaq0m3DUw==", - "dependencies": { - "@hapi/hoek": "^9.0.0" - } - }, - "node_modules/@sideway/address/node_modules/@hapi/hoek": { - "version": "9.0.3", - "resolved": "https://registry.npmjs.org/@hapi/hoek/-/hoek-9.0.3.tgz", - "integrity": "sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg==", - "deprecated": "This version has been deprecated and is no longer supported or maintained" - }, - "node_modules/@sideway/formula": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/@sideway/formula/-/formula-3.0.1.tgz", - "integrity": "sha512-/poHZJJVjx3L+zVD6g9KgHfYnb443oi7wLu/XKojDviHy6HOEOA6z1Trk5aR1dGcmPenJEgb2sK2I80LeS3MIg==" - }, - "node_modules/@sideway/pinpoint": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sideway/pinpoint/-/pinpoint-2.0.0.tgz", - "integrity": "sha512-RNiOoTPkptFtSVzQevY/yWtZwf/RxyVnPy/OcA9HBM3MlGDnBEYL5B41H0MTn0Uec8Hi+2qUtTfG2WWZBmMejQ==" - }, - "node_modules/@sigstore/bundle": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/@sigstore/bundle/-/bundle-1.1.0.tgz", - "integrity": "sha512-PFutXEy0SmQxYI4texPw3dd2KewuNqv7OuK1ZFtY2fM754yhvG2KdgwIhRnoEE2uHdtdGNQ8s0lb94dW9sELog==", - "dev": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.2.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/protobuf-specs": { - "version": "0.2.1", - "resolved": "https://registry.npmjs.org/@sigstore/protobuf-specs/-/protobuf-specs-0.2.1.tgz", - "integrity": "sha512-XTWVxnWJu+c1oCshMLwnKvz8ZQJJDVOlciMfgpJBQbThVjKTCG8dwyhgLngBD2KN0ap9F/gOV8rFDEx8uh7R2A==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/sign": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@sigstore/sign/-/sign-1.0.0.tgz", - "integrity": "sha512-INxFVNQteLtcfGmcoldzV6Je0sbbfh9I16DM4yJPw3j5+TFP8X6uIiA18mvpEa9yyeycAKgPmOA3X9hVdVTPUA==", - "dev": true, - "dependencies": { - "@sigstore/bundle": "^1.1.0", - "@sigstore/protobuf-specs": "^0.2.0", - "make-fetch-happen": "^11.0.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@sigstore/tuf": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/@sigstore/tuf/-/tuf-1.0.3.tgz", - "integrity": "sha512-2bRovzs0nJZFlCN3rXirE4gwxCn97JNjMmwpecqlbgV9WcxX7WRuIrgzx/X7Ib7MYRbyUTpBYE0s2x6AmZXnlg==", - "dev": true, - "dependencies": { - "@sigstore/protobuf-specs": "^0.2.0", - "tuf-js": "^1.1.7" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@sindresorhus/is": { - "version": "5.6.0", - "resolved": "https://registry.npmjs.org/@sindresorhus/is/-/is-5.6.0.tgz", - "integrity": "sha512-TV7t8GKYaJWsn00tFDqBw8+Uqmr8A0fRU1tvTQhyZzGv0sJCGRQL3JGMI3ucuKo3XIZdUP+Lx7/gh2t3lewy7g==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/is?sponsor=1" - } - }, - "node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/@sinonjs/fake-timers": { - "version": "11.2.2", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-11.2.2.tgz", - "integrity": "sha512-G2piCSxQ7oWOxwGSAyFHfPIsyeJGXYtc6mFbnFA+kRXkiEnTl8c/8jul2S329iFBnDI9HGoeWWAZvuvOkZccgw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/@sinonjs/samsam": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/samsam/-/samsam-8.0.0.tgz", - "integrity": "sha512-Bp8KUVlLp8ibJZrnvq2foVhP0IVX2CIprMJPK0vqGqgrDa0OHVKeZyBykqskkrdxV6yKBPmGasO8LVjAKR3Gew==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^2.0.0", - "lodash.get": "^4.4.2", - "type-detect": "^4.0.8" - } - }, - "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", + "node_modules/@sinonjs/samsam/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", "dev": true, "dependencies": { "type-detect": "4.0.8" @@ -2351,55 +2048,6 @@ "integrity": "sha512-sXXKG+uL9IrKqViTtao2Ws6dy0znu9sOaP1di/jKGW1M6VssO8vlpXCQcpZ+jisQ1tTFAC5Jo/EOzFbggBagFQ==", "dev": true }, - "node_modules/@szmarczak/http-timer": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/@szmarczak/http-timer/-/http-timer-5.0.1.tgz", - "integrity": "sha512-+PmQX0PiAYPMeVYe237LJAYvOMYW1j2rH5YROyS3b4CTVJum34HfRvKvAzozHAQG0TnHNdUfY9nCeUyRAs//cw==", - "dev": true, - "dependencies": { - "defer-to-connect": "^2.0.1" - }, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/@tootallnate/once": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-2.0.0.tgz", - "integrity": "sha512-XCuKFP5PS55gnMVu3dty8KPatLqUoy/ZYzDzAGCQ8JNFCkLXzmI7vNHCR+XpbZaMWQK/vQubr7PkYq8g470J/A==", - "dev": true, - "engines": { - "node": ">= 10" - } - }, - "node_modules/@tufjs/canonical-json": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/@tufjs/canonical-json/-/canonical-json-1.0.0.tgz", - "integrity": "sha512-QTnf++uxunWvG2z3UFNzAoQPHxnSXOwtaI3iJ+AohhV+5vONuArPjJE7aPXPVXfXJsqrVbZBu9b81AJoSd09IQ==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@tufjs/models": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/@tufjs/models/-/models-1.0.4.tgz", - "integrity": "sha512-qaGV9ltJP0EO25YfFUPhxRVK0evXFIAGicsVXuRim4Ed9cjPxYhNnNJ49SFmbeLgtxpslIkX317IgpfcHPVj/A==", - "dev": true, - "dependencies": { - "@tufjs/canonical-json": "1.0.0", - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/@types/http-cache-semantics": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/@types/http-cache-semantics/-/http-cache-semantics-4.0.4.tgz", - "integrity": "sha512-1m0bIFVc7eJWyve9S0RnuRgcQqF/Xd5QsUZAZeQFr1Q3/p9JWoQQEqmVy+DPTNpGXwhgIetAoYF8JSc33q29QA==", - "dev": true - }, "node_modules/@types/json-schema": { "version": "7.0.15", "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", @@ -2444,6 +2092,15 @@ "integrity": "sha512-hov8bUuiLiyFPGyFPE1lwWhmzYbirOXQNNo40+y3zow8aFVTeyn3VWL0VFFfdNddA8S4Vf0Tc062rzyNr7Paag==", "dev": true }, + "node_modules/@types/mock-knex": { + "version": "0.4.8", + "resolved": "https://registry.npmjs.org/@types/mock-knex/-/mock-knex-0.4.8.tgz", + "integrity": "sha512-xRoaH9GmsgP5JBdMadzJSg/63HCifgJZsWmCJ5Z1rA36Fg3Y7Yb03dMzMIk5sHnBWcPkWqY/zyDO4nStI+Frbg==", + "dev": true, + "dependencies": { + "@types/node": "*" + } + }, "node_modules/@types/node": { "version": "20.12.7", "resolved": "https://registry.npmjs.org/@types/node/-/node-20.12.7.tgz", @@ -2458,12 +2115,6 @@ "integrity": "sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==", "dev": true }, - "node_modules/@types/semver-utils": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/@types/semver-utils/-/semver-utils-1.1.3.tgz", - "integrity": "sha512-T+YwkslhsM+CeuhYUxyAjWm7mJ5am/K10UX40RuA6k6Lc7eGtq8iY2xOzy7Vq0GOqhl/xZl5l2FwURZMTPTUww==", - "dev": true - }, "node_modules/@types/triple-beam": { "version": "1.3.5", "resolved": "https://registry.npmjs.org/@types/triple-beam/-/triple-beam-1.3.5.tgz", @@ -2524,40 +2175,16 @@ "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", "dev": true, - "peerDependencies": { - "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" - } - }, - "node_modules/add-stream": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", - "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", - "dev": true - }, - "node_modules/agent-base": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", - "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", - "dev": true, - "dependencies": { - "debug": "4" - }, - "engines": { - "node": ">= 6.0.0" - } - }, - "node_modules/agentkeepalive": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/agentkeepalive/-/agentkeepalive-4.5.0.tgz", - "integrity": "sha512-5GG/5IbQQpC9FpkRGsSvZI5QYeSCzlJHdpBQntCsuTOxhKD8lqKhrleg2Yi7yvMIf82Ycmmqln9U8V9qwEiJew==", - "dev": true, - "dependencies": { - "humanize-ms": "^1.2.1" - }, - "engines": { - "node": ">= 8.0.0" + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/add-stream": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/add-stream/-/add-stream-1.0.0.tgz", + "integrity": "sha512-qQLMr+8o0WC4FZGQTcJiKBVC59JylcPSrTtk6usvmIDFUOCKegapy1VHQwRbFMOFyb/inzUVqHs+eMYKDM1YeQ==", + "dev": true + }, "node_modules/aggregate-error": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/aggregate-error/-/aggregate-error-3.1.0.tgz", @@ -2572,12 +2199,12 @@ } }, "node_modules/ajv": { - "version": "8.16.0", - "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.16.0.tgz", - "integrity": "sha512-F0twR8U1ZU67JIEtekUcLkXkoO5mMMmgGD8sK/xUFzJ805jxHQl92hImFAqqXMyMYjSPOyUPAwHYhB72g5sTXw==", + "version": "8.17.1", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-8.17.1.tgz", + "integrity": "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g==", "dependencies": { "fast-deep-equal": "^3.1.3", - "fast-uri": "^2.3.0", + "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" }, @@ -2637,47 +2264,6 @@ "node": ">=0.10.0" } }, - "node_modules/ansi-align": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/ansi-align/-/ansi-align-3.0.1.tgz", - "integrity": "sha512-IOfwwBF5iczOjp/WeY4YxyjqAFMQoZufdQWDd19SEExbVLNXqvpzSJ/M7Za4/sCPmQ0+GRquoA7bGcINcxew6w==", - "dev": true, - "dependencies": { - "string-width": "^4.1.0" - } - }, - "node_modules/ansi-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/ansi-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/ansi-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/ansi-regex": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", @@ -2724,58 +2310,28 @@ "node": ">=8" } }, - "node_modules/aproba": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.0.0.tgz", - "integrity": "sha512-lYe4Gx7QT+MKGbDsA+Z+he/Wtef0BiwDOlK/XkBrdfsh9J/jPPXbX0tE9x9cl27Tmu5gg3QUbUrQYa/y+KOHPQ==", - "dev": true - }, "node_modules/archy": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/archy/-/archy-1.0.0.tgz", "integrity": "sha512-Xg+9RwCg/0p32teKdGMPTPnVXKD0w3DfHnFTficozsAgsvq2XenPJq/MYpzzQ/v8zrOyJn6Ds39VA4JIDwFfqw==", "dev": true }, - "node_modules/are-we-there-yet": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-3.0.1.tgz", - "integrity": "sha512-QZW4EDmGwlYur0Yyf/b2uGucHQMa8aFUP7eu9ddR73vvhFyt4V0Vl3QHPcTNJ8l6qYOBdxgXdnBXQrHilfRQBg==", - "dev": true, - "dependencies": { - "delegates": "^1.0.0", - "readable-stream": "^3.6.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/are-we-there-yet/node_modules/readable-stream": { - "version": "3.6.2", - "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", - "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", - "dev": true, - "dependencies": { - "inherits": "^2.0.3", - "string_decoder": "^1.1.1", - "util-deprecate": "^1.0.1" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", "integrity": "sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==" }, "node_modules/array-buffer-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.0.tgz", - "integrity": "sha512-LPuwb2P+NrQw3XhxGc36+XSvuBPopovXYTR9Ew++Du9Yb/bx5AzBfrIsBoj0EZUifjQU+sHL21sseZ3jerWO/A==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/array-buffer-byte-length/-/array-buffer-byte-length-1.0.1.tgz", + "integrity": "sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "is-array-buffer": "^3.0.1" + "call-bind": "^1.0.5", + "is-array-buffer": "^3.0.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -2793,15 +2349,16 @@ "dev": true }, "node_modules/array-includes": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.7.tgz", - "integrity": "sha512-dlcsNBIiWhPkHdOEEKnehA+RNUWDc4UqFtnIXU4uuYDPtA4LDkr7qip2p0VvFAEXNDr0yWZ9PJyIRiGjRLQzwQ==", + "version": "3.1.8", + "resolved": "https://registry.npmjs.org/array-includes/-/array-includes-3.1.8.tgz", + "integrity": "sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", "is-string": "^1.0.7" }, "engines": { @@ -2819,6 +2376,26 @@ "node": ">=8" } }, + "node_modules/array.prototype.findlast": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/array.prototype.findlast/-/array.prototype.findlast-1.2.5.tgz", + "integrity": "sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/array.prototype.findlastindex": { "version": "1.2.3", "resolved": "https://registry.npmjs.org/array.prototype.findlastindex/-/array.prototype.findlastindex-1.2.3.tgz", @@ -2875,30 +2452,34 @@ } }, "node_modules/array.prototype.tosorted": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.2.tgz", - "integrity": "sha512-HuQCHOlk1Weat5jzStICBCd83NxiIMwqDg/dHEsoefabn/hJRj5pVdWcPUSpRrwhwxZOsQassMpgN/xRYFBMIg==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/array.prototype.tosorted/-/array.prototype.tosorted-1.1.4.tgz", + "integrity": "sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "es-shim-unscopables": "^1.0.0", - "get-intrinsic": "^1.2.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-shim-unscopables": "^1.0.2" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/arraybuffer.prototype.slice": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.2.tgz", - "integrity": "sha512-yMBKppFur/fbHu9/6USUe03bZ4knMYiwFBcyiaXB8Go0qNehwX6inYPzK9U0NeQvGxKthcmHcaR8P5MStSRBAw==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/arraybuffer.prototype.slice/-/arraybuffer.prototype.slice-1.0.3.tgz", + "integrity": "sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "is-array-buffer": "^3.0.2", + "array-buffer-byte-length": "^1.0.1", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", + "es-abstract": "^1.22.3", + "es-errors": "^1.2.1", + "get-intrinsic": "^1.2.3", + "is-array-buffer": "^3.0.4", "is-shared-array-buffer": "^1.0.2" }, "engines": { @@ -2928,9 +2509,9 @@ } }, "node_modules/async": { - "version": "3.2.5", - "resolved": "https://registry.npmjs.org/async/-/async-3.2.5.tgz", - "integrity": "sha512-baNZyqaaLhyLVKm/DlvdW051MSgO6b8eVfIezl9E5PqWxFgzLm/wQntEW4zOytVburDEr0JlALEpdOFwvErLsg==" + "version": "3.2.6", + "resolved": "https://registry.npmjs.org/async/-/async-3.2.6.tgz", + "integrity": "sha512-htCUDlxyyCLMgaM3xXg0C0LW2xqfuQ6p05pCEIsXuyQ+a1koYKTuBMzRNwmybfLgvJDMd0r1LTn4+E0Ti6C2AA==" }, "node_modules/async-exit-hook": { "version": "2.0.1", @@ -2949,24 +2530,15 @@ "retry": "0.13.1" } }, - "node_modules/asynciterator.prototype": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/asynciterator.prototype/-/asynciterator.prototype-1.0.0.tgz", - "integrity": "sha512-wwHYEIS0Q80f5mosx3L/dfG5t5rjEa9Ft51GTaNt862EnpyGHpgz2RkZvLPp1oF5TnAiTohkEKVEu8pQPJI7Vg==", - "dev": true, - "dependencies": { - "has-symbols": "^1.0.3" - } - }, "node_modules/asynckit": { "version": "0.4.0", "resolved": "https://registry.npmjs.org/asynckit/-/asynckit-0.4.0.tgz", "integrity": "sha512-Oei9OH4tRh0YqU3GxhX79dM/mwVgvbZJaSNaRk+bshkj0S5cfHcgYakreBjrHwatXKbz+IoIdYLxrKim2MjW0Q==" }, "node_modules/audit-ci": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.0.1.tgz", - "integrity": "sha512-NAZuQYyZHmtrNGpS4qfUp8nFvB+6UdfSOg7NUcsyvuDVfulXH3lpnN2PcXOUj7Jr3epAoQ6BCpXmjMODC8SBgQ==", + "version": "7.1.0", + "resolved": "https://registry.npmjs.org/audit-ci/-/audit-ci-7.1.0.tgz", + "integrity": "sha512-PjjEejlST57S/aDbeWLic0glJ8CNl/ekY3kfGFPMrPkmuaYaDKcMH0F9x9yS9Vp6URhuefSCubl/G0Y2r6oP0g==", "dev": true, "dependencies": { "cross-spawn": "^7.0.3", @@ -2987,10 +2559,13 @@ } }, "node_modules/available-typed-arrays": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.5.tgz", - "integrity": "sha512-DMD0KiN46eipeziST1LPP/STfDU0sufISXmjSgvVsoU2tqxctQeASejWcfNtxYKqETM1UxQ8sp2OrSBWpHY6sw==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/available-typed-arrays/-/available-typed-arrays-1.0.7.tgz", + "integrity": "sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==", "dev": true, + "dependencies": { + "possible-typed-array-names": "^1.0.0" + }, "engines": { "node": ">= 0.4" }, @@ -2999,9 +2574,9 @@ } }, "node_modules/axios": { - "version": "1.7.2", - "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.2.tgz", - "integrity": "sha512-2A8QhOMrbomlDuiLeK9XibIBzuHeRcqqNOHp0Cyp5EoJ1IFDh+XZH3A6BkXtv0K4gFGCI0Y4BM7B1wOEi0Rmgw==", + "version": "1.7.9", + "resolved": "https://registry.npmjs.org/axios/-/axios-1.7.9.tgz", + "integrity": "sha512-LhLcE7Hbiryz8oMDdDptSrWowmB4Bl6RCt6sIJKpRB4XtVf0iEgewX3au/pJqm+Py1kCASkb/FFKjxQaLtxJvw==", "dependencies": { "follow-redirects": "^1.15.6", "form-data": "^4.0.0", @@ -3084,9 +2659,9 @@ "integrity": "sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA==" }, "node_modules/body-parser": { - "version": "1.20.2", - "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.2.tgz", - "integrity": "sha512-ml9pReCu3M61kGlqoTm2umSXTlRTuGTx0bfYj+uIUKKYycG5NtSbeetV3faSU6R7ajOPw0g/J1PvK4qNy7s5bA==", + "version": "1.20.3", + "resolved": "https://registry.npmjs.org/body-parser/-/body-parser-1.20.3.tgz", + "integrity": "sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==", "dependencies": { "bytes": "3.1.2", "content-type": "~1.0.5", @@ -3096,7 +2671,7 @@ "http-errors": "2.0.0", "iconv-lite": "0.4.24", "on-finished": "2.4.1", - "qs": "6.11.0", + "qs": "6.13.0", "raw-body": "2.5.2", "type-is": "~1.6.18", "unpipe": "1.0.0" @@ -3130,18 +2705,18 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/body-parser/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", + "node_modules/body-parser/node_modules/raw-body": { + "version": "2.5.2", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", + "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", "dependencies": { - "side-channel": "^1.0.4" + "bytes": "3.1.2", + "http-errors": "2.0.0", + "iconv-lite": "0.4.24", + "unpipe": "1.0.0" }, "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "node": ">= 0.8" } }, "node_modules/boolbase": { @@ -3149,63 +2724,20 @@ "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==" }, - "node_modules/boxen": { - "version": "7.1.1", - "resolved": "https://registry.npmjs.org/boxen/-/boxen-7.1.1.tgz", - "integrity": "sha512-2hCgjEmP8YLWQ130n2FerGv7rYpfBmnmp9Uy2Le1vge6X3gZIfSmEzP5QTDElFxcvVcXlEn8Aq6MU/PZygIOog==", - "dev": true, + "node_modules/boom": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-7.3.0.tgz", + "integrity": "sha512-Swpoyi2t5+GhOEGw8rEsKvTxFLIDiiKoUc2gsoV6Lyr43LHBIzch3k2MvYUs8RTROrIkVJ3Al0TkaOGjnb+B6A==", + "deprecated": "This module has moved and is now available at @hapi/boom. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.", "dependencies": { - "ansi-align": "^3.0.1", - "camelcase": "^7.0.1", - "chalk": "^5.2.0", - "cli-boxes": "^3.0.0", - "string-width": "^5.1.2", - "type-fest": "^2.13.0", - "widest-line": "^4.0.1", - "wrap-ansi": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/camelcase": { - "version": "7.0.1", - "resolved": "https://registry.npmjs.org/camelcase/-/camelcase-7.0.1.tgz", - "integrity": "sha512-xlx1yCK2Oc1APsPXDL2LdlNP6+uu8OCDdhOBSVT279M/S+y75O30C2VuD8T2ogdePBBl7PfPF4504tnLgX3zfw==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/boxen/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" + "hoek": "6.x.x" } }, - "node_modules/boxen/node_modules/type-fest": { - "version": "2.19.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-2.19.0.tgz", - "integrity": "sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==", - "dev": true, - "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/boom/node_modules/hoek": { + "version": "6.1.3", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.3.tgz", + "integrity": "sha512-YXXAAhmF9zpQbC7LEcREFtXfGq5K1fmd+4PHkBq8NUqmzW3G+Dq10bI/i0KucLRwss3YYFQ0fSfoxBZYiGUqtQ==", + "deprecated": "This module has moved and is now available at @hapi/hoek. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues." }, "node_modules/brace-expansion": { "version": "2.0.1", @@ -3305,56 +2837,6 @@ "node": ">= 0.8" } }, - "node_modules/cacache": { - "version": "17.1.4", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-17.1.4.tgz", - "integrity": "sha512-/aJwG2l3ZMJ1xNAnqbMpA40of9dj/pIH3QfiuQSqjfPJF747VR0J/bHn+/KdNnHKc6XQcWt/AfRSBft82W1d2A==", - "dev": true, - "dependencies": { - "@npmcli/fs": "^3.1.0", - "fs-minipass": "^3.0.0", - "glob": "^10.2.2", - "lru-cache": "^7.7.1", - "minipass": "^7.0.3", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "p-map": "^4.0.0", - "ssri": "^10.0.0", - "tar": "^6.1.11", - "unique-filename": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/cacheable-lookup": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/cacheable-lookup/-/cacheable-lookup-7.0.0.tgz", - "integrity": "sha512-+qJyx4xiKra8mZrcwhjMRMUhD5NR1R8esPkzIYxX96JiecFoxAXFuz/GpR3+ev4PE1WamHip78wV0vcmPQtp8w==", - "dev": true, - "engines": { - "node": ">=14.16" - } - }, - "node_modules/cacheable-request": { - "version": "10.2.14", - "resolved": "https://registry.npmjs.org/cacheable-request/-/cacheable-request-10.2.14.tgz", - "integrity": "sha512-zkDT5WAF4hSSoUgyfg5tFIxz8XQK+25W/TLVojJTMKBaxevLBBtLxgqguAuVQB8PVW79FVjHcU+GJ9tVbDZ9mQ==", - "dev": true, - "dependencies": { - "@types/http-cache-semantics": "^4.0.2", - "get-stream": "^6.0.1", - "http-cache-semantics": "^4.1.1", - "keyv": "^4.5.3", - "mimic-response": "^4.0.0", - "normalize-url": "^8.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - } - }, "node_modules/caching-transform": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/caching-transform/-/caching-transform-4.0.0.tgz", @@ -3486,20 +2968,24 @@ } }, "node_modules/cheerio": { - "version": "1.0.0-rc.12", - "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0-rc.12.tgz", - "integrity": "sha512-VqR8m68vM46BNnuZ5NtnGBKIE/DfN0cRIzg9n40EIq9NOv90ayxLBXA8fXC5gquFRGJSTRqBq25Jt2ECLR431Q==", + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/cheerio/-/cheerio-1.0.0.tgz", + "integrity": "sha512-quS9HgjQpdaXOvsZz82Oz7uxtXiy6UIsIQcpBj7HRw2M63Skasm9qlDocAM7jNuaxdhpPU7c4kJN+gA5MCu4ww==", "dependencies": { "cheerio-select": "^2.1.0", "dom-serializer": "^2.0.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "htmlparser2": "^8.0.1", - "parse5": "^7.0.0", - "parse5-htmlparser2-tree-adapter": "^7.0.0" + "domutils": "^3.1.0", + "encoding-sniffer": "^0.2.0", + "htmlparser2": "^9.1.0", + "parse5": "^7.1.2", + "parse5-htmlparser2-tree-adapter": "^7.0.0", + "parse5-parser-stream": "^7.1.2", + "undici": "^6.19.5", + "whatwg-mimetype": "^4.0.0" }, "engines": { - "node": ">= 6" + "node": ">=18.17" }, "funding": { "url": "https://github.com/cheeriojs/cheerio?sponsor=1" @@ -3547,96 +3033,13 @@ "fsevents": "~2.3.2" } }, - "node_modules/chownr": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", - "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", - "dev": true, - "engines": { - "node": ">=10" - } - }, - "node_modules/ci-info": { - "version": "3.9.0", - "resolved": "https://registry.npmjs.org/ci-info/-/ci-info-3.9.0.tgz", - "integrity": "sha512-NIxF55hv4nSqQswkAeiOi1r83xy8JldOFDTWiug55KBu9Jnblncd2U6ViHmYgHf01TPZS77NJBhBMKdWj9HQMQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/sibiraj-s" - } - ], - "engines": { - "node": ">=8" - } - }, "node_modules/clean-stack": { "version": "2.2.0", - "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", - "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, - "node_modules/cli-boxes": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/cli-boxes/-/cli-boxes-3.0.0.tgz", - "integrity": "sha512-/lzGpEWL/8PfI0BmBOPRwp0c/wFNX1RdUML3jK/RcSBA9T8mZDdQpqYBKtCFTOfQbwPqWEOpjqW+Fnayc0969g==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/cli-table3": { - "version": "0.6.3", - "resolved": "https://registry.npmjs.org/cli-table3/-/cli-table3-0.6.3.tgz", - "integrity": "sha512-w5Jac5SykAeZJKntOxJCrm63Eg5/4dhMWIcuTbo9rpE+brgaSZo0RuNJZeOyMgsUdhDeojvgyQLmjI+K50ZGyg==", - "dev": true, - "dependencies": { - "string-width": "^4.2.0" - }, - "engines": { - "node": "10.* || >= 12.*" - }, - "optionalDependencies": { - "@colors/colors": "1.5.0" - } - }, - "node_modules/cli-table3/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/cli-table3/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/cli-table3/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "resolved": "https://registry.npmjs.org/clean-stack/-/clean-stack-2.2.0.tgz", + "integrity": "sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==", "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, "engines": { - "node": ">=8" + "node": ">=6" } }, "node_modules/cliui": { @@ -3705,6 +3108,14 @@ "node": ">=0.8" } }, + "node_modules/cluster-key-slot": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz", + "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==", + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/code-point-at": { "version": "1.1.0", "resolved": "https://registry.npmjs.org/code-point-at/-/code-point-at-1.1.0.tgz", @@ -3747,15 +3158,6 @@ "simple-swizzle": "^0.2.2" } }, - "node_modules/color-support": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", - "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", - "dev": true, - "bin": { - "color-support": "bin.js" - } - }, "node_modules/color/node_modules/color-convert": { "version": "1.9.3", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-1.9.3.tgz", @@ -3892,74 +3294,6 @@ "typedarray": "^0.0.6" } }, - "node_modules/config-chain": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/config-chain/-/config-chain-1.1.13.tgz", - "integrity": "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ==", - "dev": true, - "dependencies": { - "ini": "^1.3.4", - "proto-list": "~1.2.1" - } - }, - "node_modules/config-chain/node_modules/ini": { - "version": "1.3.8", - "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", - "integrity": "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==", - "dev": true - }, - "node_modules/configstore": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/configstore/-/configstore-6.0.0.tgz", - "integrity": "sha512-cD31W1v3GqUlQvbBCGcXmd2Nj9SvLDOP1oQ0YFuLETufzSPaKp11rYBsSOm7rCsW3OnIRAFM3OxRhceaXNYHkA==", - "dev": true, - "dependencies": { - "dot-prop": "^6.0.1", - "graceful-fs": "^4.2.6", - "unique-string": "^3.0.0", - "write-file-atomic": "^3.0.3", - "xdg-basedir": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/yeoman/configstore?sponsor=1" - } - }, - "node_modules/configstore/node_modules/dot-prop": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/dot-prop/-/dot-prop-6.0.1.tgz", - "integrity": "sha512-tE7ztYzXHIeyvc7N+hR3oi7FIbf/NIjVP9hmAt3yMXzrQ072/fpjGLx2GxNxGxUl5V73MEqYzioOMoVhGMJ5cA==", - "dev": true, - "dependencies": { - "is-obj": "^2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/configstore/node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/console-control-strings": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", - "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", - "dev": true - }, "node_modules/content-disposition": { "version": "0.5.4", "resolved": "https://registry.npmjs.org/content-disposition/-/content-disposition-0.5.4.tgz", @@ -4314,12 +3648,24 @@ "integrity": "sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==", "dev": true }, + "node_modules/convict": { + "version": "6.2.4", + "resolved": "https://registry.npmjs.org/convict/-/convict-6.2.4.tgz", + "integrity": "sha512-qN60BAwdMVdofckX7AlohVJ2x9UvjTNoKVXCL2LxFk1l7757EJqf1nySdMkPQer0bt8kQ5lQiyZ9/2NvrFBuwQ==", + "dependencies": { + "lodash.clonedeep": "^4.5.0", + "yargs-parser": "^20.2.7" + }, + "engines": { + "node": ">=6" + } + }, "node_modules/cookie": { - "version": "0.5.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.5.0.tgz", - "integrity": "sha512-YZ3GUyn/o8gfKJlnlX7g7xq4gyO6OSuhGPKaaGssGB2qgDUS0gPgtTvoyZLTt9Ab6dC4hfc9dV5arkvc/OCmrw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", "engines": { - "node": ">= 0.6" + "node": ">=18" } }, "node_modules/cookie-signature": { @@ -4333,18 +3679,18 @@ "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" }, "node_modules/cron": { - "version": "3.1.7", - "resolved": "https://registry.npmjs.org/cron/-/cron-3.1.7.tgz", - "integrity": "sha512-tlBg7ARsAMQLzgwqVxy8AZl/qlTc5nibqYwtNGoCrd+cV+ugI+tvZC1oT/8dFH8W455YrywGykx/KMmAqOr7Jw==", + "version": "3.3.1", + "resolved": "https://registry.npmjs.org/cron/-/cron-3.3.1.tgz", + "integrity": "sha512-KpvuzJEbeTMTfLsXhUuDfsFYr8s5roUlLKb4fa68GszWrA4783C7q6m9yj4vyc6neyD/V9e0YiADSX2c+yRDXg==", "dependencies": { "@types/luxon": "~3.4.0", - "luxon": "~3.4.0" + "luxon": "~3.5.0" } }, "node_modules/cross-spawn": { - "version": "7.0.3", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz", - "integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==", + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", @@ -4354,33 +3700,6 @@ "node": ">= 8" } }, - "node_modules/crypto-random-string": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/crypto-random-string/-/crypto-random-string-4.0.0.tgz", - "integrity": "sha512-x8dy3RnvYdlUcPOjkEHqozhiwzKNSq7GcPuXFbnyMOCHxX8V3OgIg/pYuabl2sbUPfIJaeAQB7PMOK8DFIdoRA==", - "dev": true, - "dependencies": { - "type-fest": "^1.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/crypto-random-string/node_modules/type-fest": { - "version": "1.4.0", - "resolved": "https://registry.npmjs.org/type-fest/-/type-fest-1.4.0.tgz", - "integrity": "sha512-yGSza74xk0UG8k+pLh5oeoYirvIiWo5t0/o3zHHAO2tRDiZcxWP7fywNlXhqb6/r6sWvwi+RsyQMWhVLe4BVuA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/css-select": { "version": "5.1.0", "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.1.0.tgz", @@ -4416,6 +3735,57 @@ "node": ">=8" } }, + "node_modules/data-view-buffer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", + "integrity": "sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-length": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/data-view-byte-length/-/data-view-byte-length-1.0.1.tgz", + "integrity": "sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, + "node_modules/data-view-byte-offset": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/data-view-byte-offset/-/data-view-byte-offset-1.0.0.tgz", + "integrity": "sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", + "is-data-view": "^1.0.1" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/dateformat": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-3.0.3.tgz", @@ -4484,33 +3854,6 @@ "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.4.3.tgz", "integrity": "sha512-VBBaLc1MgL5XpzgIP7ny5Z6Nx3UrRkIViUkPUdtl9aya5amy3De1gsUUSB1g3+3sExYNjCAsAznmukyxCb1GRA==" }, - "node_modules/decompress-response": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/decompress-response/-/decompress-response-6.0.0.tgz", - "integrity": "sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==", - "dev": true, - "dependencies": { - "mimic-response": "^3.1.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/decompress-response/node_modules/mimic-response": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-3.1.0.tgz", - "integrity": "sha512-z0yWI+4FDrrweS8Zmt4Ej5HdJmky15+L2e6Wgn3+iK5fWzb6T3fhNFq2+MeTRb064c6Wr4N/wv0DzQTjNzHNGQ==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/deep-equal": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz", @@ -4589,15 +3932,6 @@ "node": ">=0.8" } }, - "node_modules/defer-to-connect": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/defer-to-connect/-/defer-to-connect-2.0.1.tgz", - "integrity": "sha512-4tvttepXG1VaYGrRibk5EwJd1t4udunSOVMdLSAL6mId1ix438oPwPZMALY41FCijukO1L0twNcGsdzS7dHgDg==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/define-data-property": { "version": "1.1.4", "resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz", @@ -4648,11 +3982,13 @@ "node": ">=0.4.0" } }, - "node_modules/delegates": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", - "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", - "dev": true + "node_modules/denque": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", + "integrity": "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw==", + "engines": { + "node": ">=0.10" + } }, "node_modules/depd": { "version": "2.0.0", @@ -4747,17 +4083,6 @@ "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" } }, - "node_modules/dom-serializer/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/domelementtype": { "version": "2.3.0", "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", @@ -4820,9 +4145,9 @@ } }, "node_modules/dotenv": { - "version": "16.4.5", - "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.5.tgz", - "integrity": "sha512-ZmdL2rui+eB2YwhsWzjInR8LldtZHGDoQ1ugH85ppHKwpUHL7j7rN0Ti9NCnGiQbhaZ11FpR+7ao1dNsmduNUg==", + "version": "16.4.7", + "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-16.4.7.tgz", + "integrity": "sha512-47qPchRCykZC03FhkYAhrvwU4xDBFIj1QPqaarj6mdM/hgUzfPHcpkHJOn3mJAufFeeAxAzeGsr5X0M4k6fLZQ==", "engines": { "node": ">=12" }, @@ -5049,20 +4374,23 @@ "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, "node_modules/encodeurl": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", - "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-2.0.0.tgz", + "integrity": "sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==", "engines": { "node": ">= 0.8" } }, - "node_modules/encoding": { - "version": "0.1.13", - "resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz", - "integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==", - "optional": true, + "node_modules/encoding-sniffer": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/encoding-sniffer/-/encoding-sniffer-0.2.0.tgz", + "integrity": "sha512-ju7Wq1kg04I3HtiYIOrUrdfdDvkyO9s5XM8QAj/bN61Yo/Vb4vgJxy5vi4Yxk01gWHbrofpPtpxM8bKger9jhg==", "dependencies": { - "iconv-lite": "^0.6.2" + "iconv-lite": "^0.6.3", + "whatwg-encoding": "^3.1.1" + }, + "funding": { + "url": "https://github.com/fb55/encoding-sniffer?sponsor=1" } }, "node_modules/end-of-stream": { @@ -5074,22 +4402,16 @@ } }, "node_modules/entities": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", - "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "engines": { + "node": ">=0.12" + }, "funding": { "url": "https://github.com/fb55/entities?sponsor=1" } }, - "node_modules/env-paths": { - "version": "2.2.1", - "resolved": "https://registry.npmjs.org/env-paths/-/env-paths-2.2.1.tgz", - "integrity": "sha512-+h1lkLKhZMTYjog1VEpJNG7NZJWcuc2DDk/qsqSTRRCOXiLjeQ1d1/udrUGhqMxUgAlwKNZ0cf2uqan5GLuS2A==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/env-var": { "version": "7.5.0", "resolved": "https://registry.npmjs.org/env-var/-/env-var-7.5.0.tgz", @@ -5098,12 +4420,6 @@ "node": ">=10" } }, - "node_modules/err-code": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/err-code/-/err-code-2.0.3.tgz", - "integrity": "sha512-2bmlRpNKBxT/CRmPOlyISQpNj+qSeYvcym/uT0Jx2bMOlKLtSy1ZmLuVxSEKKyor/N5yhvp/ZiG1oE3DEYMSFA==", - "dev": true - }, "node_modules/error-callsites": { "version": "2.0.4", "resolved": "https://registry.npmjs.org/error-callsites/-/error-callsites-2.0.4.tgz", @@ -5122,50 +4438,57 @@ } }, "node_modules/es-abstract": { - "version": "1.22.3", - "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.22.3.tgz", - "integrity": "sha512-eiiY8HQeYfYH2Con2berK+To6GrK2RxbPawDkGq4UiCQQfZHb6wX9qQqkbpPqaxQFcl8d9QzZqo0tGE0VcrdwA==", + "version": "1.23.3", + "resolved": "https://registry.npmjs.org/es-abstract/-/es-abstract-1.23.3.tgz", + "integrity": "sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==", "dev": true, "dependencies": { - "array-buffer-byte-length": "^1.0.0", - "arraybuffer.prototype.slice": "^1.0.2", - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.5", - "es-set-tostringtag": "^2.0.1", + "array-buffer-byte-length": "^1.0.1", + "arraybuffer.prototype.slice": "^1.0.3", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", + "data-view-buffer": "^1.0.1", + "data-view-byte-length": "^1.0.1", + "data-view-byte-offset": "^1.0.0", + "es-define-property": "^1.0.0", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "es-set-tostringtag": "^2.0.3", "es-to-primitive": "^1.2.1", "function.prototype.name": "^1.1.6", - "get-intrinsic": "^1.2.2", - "get-symbol-description": "^1.0.0", + "get-intrinsic": "^1.2.4", + "get-symbol-description": "^1.0.2", "globalthis": "^1.0.3", "gopd": "^1.0.1", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "hasown": "^2.0.0", - "internal-slot": "^1.0.5", - "is-array-buffer": "^3.0.2", + "hasown": "^2.0.2", + "internal-slot": "^1.0.7", + "is-array-buffer": "^3.0.4", "is-callable": "^1.2.7", - "is-negative-zero": "^2.0.2", + "is-data-view": "^1.0.1", + "is-negative-zero": "^2.0.3", "is-regex": "^1.1.4", - "is-shared-array-buffer": "^1.0.2", + "is-shared-array-buffer": "^1.0.3", "is-string": "^1.0.7", - "is-typed-array": "^1.1.12", + "is-typed-array": "^1.1.13", "is-weakref": "^1.0.2", "object-inspect": "^1.13.1", "object-keys": "^1.1.1", - "object.assign": "^4.1.4", - "regexp.prototype.flags": "^1.5.1", - "safe-array-concat": "^1.0.1", - "safe-regex-test": "^1.0.0", - "string.prototype.trim": "^1.2.8", - "string.prototype.trimend": "^1.0.7", - "string.prototype.trimstart": "^1.0.7", - "typed-array-buffer": "^1.0.0", - "typed-array-byte-length": "^1.0.0", - "typed-array-byte-offset": "^1.0.0", - "typed-array-length": "^1.0.4", + "object.assign": "^4.1.5", + "regexp.prototype.flags": "^1.5.2", + "safe-array-concat": "^1.1.2", + "safe-regex-test": "^1.0.3", + "string.prototype.trim": "^1.2.9", + "string.prototype.trimend": "^1.0.8", + "string.prototype.trimstart": "^1.0.8", + "typed-array-buffer": "^1.0.2", + "typed-array-byte-length": "^1.0.1", + "typed-array-byte-offset": "^1.0.2", + "typed-array-length": "^1.0.6", "unbox-primitive": "^1.0.2", - "which-typed-array": "^1.1.13" + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -5194,36 +4517,51 @@ } }, "node_modules/es-iterator-helpers": { - "version": "1.0.15", - "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.15.tgz", - "integrity": "sha512-GhoY8uYqd6iwUl2kgjTm4CZAf6oo5mHK7BPqx3rKgx893YSsy0LGHV6gfqqQvZt/8xM8xeOnfXBCfqclMKkJ5g==", + "version": "1.0.19", + "resolved": "https://registry.npmjs.org/es-iterator-helpers/-/es-iterator-helpers-1.0.19.tgz", + "integrity": "sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==", "dev": true, "dependencies": { - "asynciterator.prototype": "^1.0.0", - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "define-properties": "^1.2.1", - "es-abstract": "^1.22.1", - "es-set-tostringtag": "^2.0.1", - "function-bind": "^1.1.1", - "get-intrinsic": "^1.2.1", + "es-abstract": "^1.23.3", + "es-errors": "^1.3.0", + "es-set-tostringtag": "^2.0.3", + "function-bind": "^1.1.2", + "get-intrinsic": "^1.2.4", "globalthis": "^1.0.3", - "has-property-descriptors": "^1.0.0", - "has-proto": "^1.0.1", + "has-property-descriptors": "^1.0.2", + "has-proto": "^1.0.3", "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", + "internal-slot": "^1.0.7", "iterator.prototype": "^1.1.2", - "safe-array-concat": "^1.0.1" + "safe-array-concat": "^1.1.2" + }, + "engines": { + "node": ">= 0.4" + } + }, + "node_modules/es-object-atoms": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.0.0.tgz", + "integrity": "sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==", + "dev": true, + "dependencies": { + "es-errors": "^1.3.0" + }, + "engines": { + "node": ">= 0.4" } }, "node_modules/es-set-tostringtag": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.2.tgz", - "integrity": "sha512-BuDyupZt65P9D2D2vA/zqcI3G5xRsklm5N3xCwuiy+/vKy8i0ifdsQP1sLgO4tZDSCaQUSnmC48khknGMV3D2Q==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/es-set-tostringtag/-/es-set-tostringtag-2.0.3.tgz", + "integrity": "sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2", - "has-tostringtag": "^1.0.0", - "hasown": "^2.0.0" + "get-intrinsic": "^1.2.4", + "has-tostringtag": "^1.0.2", + "hasown": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -5274,18 +4612,6 @@ "node": ">=6" } }, - "node_modules/escape-goat": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/escape-goat/-/escape-goat-4.0.0.tgz", - "integrity": "sha512-2Sd4ShcWxbx6OY1IHyla/CVNwvg7XwZVoXZHcSu9w9SReNP1EzzD5T8NWKIR38fIqEns9kDWKUQTXXAmlDrdPg==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/escape-html": { "version": "1.0.3", "resolved": "https://registry.npmjs.org/escape-html/-/escape-html-1.0.3.tgz", @@ -5737,33 +5063,36 @@ } }, "node_modules/eslint-plugin-react": { - "version": "7.33.2", - "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.33.2.tgz", - "integrity": "sha512-73QQMKALArI8/7xGLNI/3LylrEYrlKZSb5C9+q3OtOewTnMQi5cT+aE9E41sLCmli3I9PGGmD1yiZydyo4FEPw==", + "version": "7.36.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react/-/eslint-plugin-react-7.36.1.tgz", + "integrity": "sha512-/qwbqNXZoq+VP30s1d4Nc1C5GTxjJQjk4Jzs4Wq2qzxFM7dSmuG2UkIjg2USMLh3A/aVcUNrK7v0J5U1XEGGwA==", "dev": true, + "license": "MIT", "dependencies": { - "array-includes": "^3.1.6", - "array.prototype.flatmap": "^1.3.1", - "array.prototype.tosorted": "^1.1.1", + "array-includes": "^3.1.8", + "array.prototype.findlast": "^1.2.5", + "array.prototype.flatmap": "^1.3.2", + "array.prototype.tosorted": "^1.1.4", "doctrine": "^2.1.0", - "es-iterator-helpers": "^1.0.12", + "es-iterator-helpers": "^1.0.19", "estraverse": "^5.3.0", + "hasown": "^2.0.2", "jsx-ast-utils": "^2.4.1 || ^3.0.0", "minimatch": "^3.1.2", - "object.entries": "^1.1.6", - "object.fromentries": "^2.0.6", - "object.hasown": "^1.1.2", - "object.values": "^1.1.6", + "object.entries": "^1.1.8", + "object.fromentries": "^2.0.8", + "object.values": "^1.2.0", "prop-types": "^15.8.1", - "resolve": "^2.0.0-next.4", + "resolve": "^2.0.0-next.5", "semver": "^6.3.1", - "string.prototype.matchall": "^4.0.8" + "string.prototype.matchall": "^4.0.11", + "string.prototype.repeat": "^1.0.0" }, "engines": { "node": ">=4" }, "peerDependencies": { - "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8" + "eslint": "^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7" } }, "node_modules/eslint-plugin-react/node_modules/brace-expansion": { @@ -6110,29 +5439,18 @@ } }, "node_modules/execa/node_modules/cross-spawn": { - "version": "6.0.5", - "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.5.tgz", - "integrity": "sha512-eTVLrBSt7fjbDygz805pMnstIs2VTBNkRm0qxZd+M7A5XDdxVRWO5MxGBXZhjY4cqLYLdtrGqRf8mBPmzwSpWQ==", - "dependencies": { - "nice-try": "^1.0.4", - "path-key": "^2.0.1", - "semver": "^5.5.0", - "shebang-command": "^1.2.0", - "which": "^1.2.9" - }, - "engines": { - "node": ">=4.8" - } - }, - "node_modules/execa/node_modules/get-stream": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", - "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "version": "6.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-6.0.6.tgz", + "integrity": "sha512-VqCUuhcd1iB+dsv8gxPttb5iZh/D0iubSP21g36KXdEuf6I5JiioesUVjpCdHV9MZRUfVFlvwtIUyPfxo5trtw==", "dependencies": { - "pump": "^3.0.0" + "nice-try": "^1.0.4", + "path-key": "^2.0.1", + "semver": "^5.5.0", + "shebang-command": "^1.2.0", + "which": "^1.2.9" }, "engines": { - "node": ">=6" + "node": ">=4.8" } }, "node_modules/execa/node_modules/is-stream": { @@ -6194,43 +5512,37 @@ "which": "bin/which" } }, - "node_modules/exponential-backoff": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/exponential-backoff/-/exponential-backoff-3.1.1.tgz", - "integrity": "sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==", - "dev": true - }, "node_modules/express": { - "version": "4.19.2", - "resolved": "https://registry.npmjs.org/express/-/express-4.19.2.tgz", - "integrity": "sha512-5T6nhjsT+EOMzuck8JjBHARTHfMht0POzlA60WV2pMD3gyXw2LZnZ+ueGdNxG+0calOJcWKbpFcuzLZ91YWq9Q==", + "version": "4.21.2", + "resolved": "https://registry.npmjs.org/express/-/express-4.21.2.tgz", + "integrity": "sha512-28HqgMZAmih1Czt9ny7qr6ek2qddF4FclbMzwhCREB6OFfH+rXAnuNCwo1/wFvrtbgsQDb4kSbX9de9lFbrXnA==", "dependencies": { "accepts": "~1.3.8", "array-flatten": "1.1.1", - "body-parser": "1.20.2", + "body-parser": "1.20.3", "content-disposition": "0.5.4", "content-type": "~1.0.4", - "cookie": "0.6.0", + "cookie": "0.7.1", "cookie-signature": "1.0.6", "debug": "2.6.9", "depd": "2.0.0", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "etag": "~1.8.1", - "finalhandler": "1.2.0", + "finalhandler": "1.3.1", "fresh": "0.5.2", "http-errors": "2.0.0", - "merge-descriptors": "1.0.1", + "merge-descriptors": "1.0.3", "methods": "~1.1.2", "on-finished": "2.4.1", "parseurl": "~1.3.3", - "path-to-regexp": "0.1.7", + "path-to-regexp": "0.1.12", "proxy-addr": "~2.0.7", - "qs": "6.11.0", + "qs": "6.13.0", "range-parser": "~1.2.1", "safe-buffer": "5.2.1", - "send": "0.18.0", - "serve-static": "1.15.0", + "send": "0.19.0", + "serve-static": "1.16.2", "setprototypeof": "1.2.0", "statuses": "2.0.1", "type-is": "~1.6.18", @@ -6239,12 +5551,16 @@ }, "engines": { "node": ">= 0.10.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" } }, "node_modules/express/node_modules/cookie": { - "version": "0.6.0", - "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.6.0.tgz", - "integrity": "sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==", + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz", + "integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==", "engines": { "node": ">= 0.6" } @@ -6262,20 +5578,6 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, - "node_modules/express/node_modules/qs": { - "version": "6.11.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.11.0.tgz", - "integrity": "sha512-MvjoMCJwEarSbUYk5O+nmoSzSutSsTwF85zcHPQ9OrlFoZOYIjaqBAJIqIXjptyD5vThxGq52Xu/MaJzRkIk4Q==", - "dependencies": { - "side-channel": "^1.0.4" - }, - "engines": { - "node": ">=0.6" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/extensible-error": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/extensible-error/-/extensible-error-1.0.2.tgz", @@ -6312,21 +5614,15 @@ "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", "dev": true }, - "node_modules/fast-memoize": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/fast-memoize/-/fast-memoize-2.5.2.tgz", - "integrity": "sha512-Ue0LwpDYErFbmNnZSF0UH6eImUwDmogUO1jyE+JbN2gsQz/jICm1Ve7t9QT0rNSsfJt+Hs4/S3GnsDVjL4HVrw==", - "dev": true - }, "node_modules/fast-safe-stringify": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" }, "node_modules/fast-uri": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-2.3.0.tgz", - "integrity": "sha512-eel5UKGn369gGEWOqBShmFJWfq/xSJvsgDzgLYC845GneayWvXBf0lJCBn5qTABfewy1ZDPoaR5OZCP+kssfuw==" + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/fast-uri/-/fast-uri-3.0.1.tgz", + "integrity": "sha512-MWipKbbYiYI0UC7cl8m/i/IWTqfC8YXsqjzybjddLsFjStroQzsHXkc73JutMvBiXmOvapk+axIl79ig5t55Bw==" }, "node_modules/fastq": { "version": "1.15.0", @@ -6426,12 +5722,12 @@ } }, "node_modules/finalhandler": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.2.0.tgz", - "integrity": "sha512-5uXcUVftlQMFnWC9qu/svkWv3GTd2PfUhK/3PLkYNAe7FbqJMt3515HaxE6eRL74GdsriiwujiawdaB1BpEISg==", + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/finalhandler/-/finalhandler-1.3.1.tgz", + "integrity": "sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==", "dependencies": { "debug": "2.6.9", - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "on-finished": "2.4.1", "parseurl": "~1.3.3", @@ -6514,63 +5810,6 @@ "node": ">=12.0.0" } }, - "node_modules/flat-cache/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/flat-cache/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/flat-cache/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/flat-cache/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/flatted": { "version": "3.2.9", "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.2.9.tgz", @@ -6583,9 +5822,9 @@ "integrity": "sha512-GRnmB5gPyJpAhTQdSZTSp9uaPSvl09KoYcMQtsB9rQoOmzs9dH6ffeccH+Z+cv6P68Hu5bC6JjRh4Ah/mHSNRw==" }, "node_modules/follow-redirects": { - "version": "1.15.6", - "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.6.tgz", - "integrity": "sha512-wWN62YITEaOpSK584EZXJafH1AGpO8RVgElfkuXbTOrPX4fIfOyEpW/CsiNd8JdYrAoOvafRTOEnvsO++qCqFA==", + "version": "1.15.9", + "resolved": "https://registry.npmjs.org/follow-redirects/-/follow-redirects-1.15.9.tgz", + "integrity": "sha512-gew4GsXizNgdoRyqmyfMHyAmXsZDk6mHkSxZFCzW9gwlbtOW44CDtYavM+y+72qD/Vq2l550kMF52DT8fOLJqQ==", "funding": [ { "type": "individual", @@ -6616,9 +5855,9 @@ "integrity": "sha512-k6GAGDyqLe9JaebCsFCoudPPWfihKu8pylYXRlqP1J7ms39iPoTtk2fviNglIeQEwdh0bQeKJ01ZPyuyQvKzwg==" }, "node_modules/foreground-child": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.1.1.tgz", - "integrity": "sha512-TMKDUnIte6bfb5nWv7V/caI169OHgvwjb7V4WkeUvbQQdjr5rWKqHFiKWb/fcOwB+CzBT+qbWjvj+DVwRskpIg==", + "version": "3.3.0", + "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz", + "integrity": "sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==", "dependencies": { "cross-spawn": "^7.0.0", "signal-exit": "^4.0.1" @@ -6631,9 +5870,9 @@ } }, "node_modules/form-data": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.0.tgz", - "integrity": "sha512-ETEklSGi5t0QMZuiXoA/Q6vcnxcLQP5vdugSpuAyi6SVGi2clPPp+xgEhuMaHC+zGgn31Kd235W35f7Hykkaww==", + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/form-data/-/form-data-4.0.1.tgz", + "integrity": "sha512-tzN8e4TX8+kkxGPK8D5u0FNmjPUjw3lwC9lSLxxoB/+GtsJG91CO8bSWy73APlgAZzZbXEYZJuxjkHH2w+Ezhw==", "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", @@ -6643,15 +5882,6 @@ "node": ">= 6" } }, - "node_modules/form-data-encoder": { - "version": "2.1.4", - "resolved": "https://registry.npmjs.org/form-data-encoder/-/form-data-encoder-2.1.4.tgz", - "integrity": "sha512-yDYSgNMraqvnxiEXO4hi88+YZxaHC6QKzb5N84iRCTDeRO7ZALpir/lVmf/uXUhnwUr2O4HU8s/n6x+yNjQkHw==", - "dev": true, - "engines": { - "node": ">= 14.17" - } - }, "node_modules/forwarded": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/forwarded/-/forwarded-0.2.0.tgz", @@ -6660,15 +5890,6 @@ "node": ">= 0.6" } }, - "node_modules/fp-and-or": { - "version": "0.1.4", - "resolved": "https://registry.npmjs.org/fp-and-or/-/fp-and-or-0.1.4.tgz", - "integrity": "sha512-+yRYRhpnFPWXSly/6V4Lw9IfOV26uu30kynGJ03PW+MnjOEQe45RZ141QcS0aJehYBYA50GfCDnsRbFJdhssRw==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/fresh": { "version": "0.5.2", "resolved": "https://registry.npmjs.org/fresh/-/fresh-0.5.2.tgz", @@ -6702,18 +5923,6 @@ } ] }, - "node_modules/fs-minipass": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-3.0.3.tgz", - "integrity": "sha512-XUBA9XClHbnJWSfBzjkm6RvPsyg3sryZt06BEQoXcF7EK/xpGaQYJgQKDJSUH5SGZ76Y7pFx1QBnXz09rU5Fbw==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/fs-readfile-promise": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/fs-readfile-promise/-/fs-readfile-promise-2.0.1.tgz", @@ -6807,63 +6016,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/gauge": { - "version": "4.0.4", - "resolved": "https://registry.npmjs.org/gauge/-/gauge-4.0.4.tgz", - "integrity": "sha512-f9m+BEN5jkg6a0fZjleidjN51VE1X+mPFQ2DJ0uv1V39oCLCbsGe6yjbBnp7eK7z/+GAon99a3nHuqbuuthyPg==", - "dev": true, - "dependencies": { - "aproba": "^1.0.3 || ^2.0.0", - "color-support": "^1.1.3", - "console-control-strings": "^1.1.0", - "has-unicode": "^2.0.1", - "signal-exit": "^3.0.7", - "string-width": "^4.2.3", - "strip-ansi": "^6.0.1", - "wide-align": "^1.1.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/gauge/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/gauge/node_modules/signal-exit": { - "version": "3.0.7", - "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", - "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", - "dev": true - }, - "node_modules/gauge/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/gauge/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/gensync": { "version": "1.0.0-beta.2", "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", @@ -7067,25 +6219,25 @@ } }, "node_modules/get-stream": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-6.0.1.tgz", - "integrity": "sha512-ts6Wi+2j3jQjqi70w5AlN8DFnkSwC+MqmxEzdEALB2qXZYV3X/b1CTfgPLGJNMeAWxdPfU8FO1ms3NUfaHCPYg==", - "dev": true, - "engines": { - "node": ">=10" + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/get-stream/-/get-stream-4.1.0.tgz", + "integrity": "sha512-GMat4EJ5161kIy2HevLlr4luNjBgvmj413KaQA7jt4V8B4RDsfpHk7WQ9GVqfYyyx8OS/L66Kox+rJRNklLK7w==", + "dependencies": { + "pump": "^3.0.0" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "engines": { + "node": ">=6" } }, "node_modules/get-symbol-description": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.0.tgz", - "integrity": "sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/get-symbol-description/-/get-symbol-description-1.0.2.tgz", + "integrity": "sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.5", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4" }, "engines": { "node": ">= 0.4" @@ -7181,21 +6333,22 @@ "dev": true }, "node_modules/glob": { - "version": "10.4.1", - "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.1.tgz", - "integrity": "sha512-2jelhlq3E4ho74ZyVLN03oKdAZVUa6UDZzFLVH1H7dnoax+y9qyaq8zBkfDIggjniU19z0wU18y16jMB2eyVIw==", + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.3.tgz", + "integrity": "sha512-Q38SGlYRpVtDBPSWEylRyctn7uDeTp4NQERTLiCT1FqA9JXPYWqAVmQU6qh4r/zMM5ehxTcbaO8EjhWnvEhmyg==", "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", + "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" }, "engines": { - "node": ">=16 || 14 >=14.18" + "node": ">=18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -7212,30 +6365,6 @@ "node": ">= 6" } }, - "node_modules/global-dirs": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/global-dirs/-/global-dirs-3.0.1.tgz", - "integrity": "sha512-NBcGGFbBA9s1VzD41QXDG+3++t9Mn5t1FpLdhESY6oKY4gYTFpX4wO3sqGUa0Srjtbfj3szX0RnemmrVRUdULA==", - "dev": true, - "dependencies": { - "ini": "2.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/global-dirs/node_modules/ini": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/ini/-/ini-2.0.0.tgz", - "integrity": "sha512-7PnF4oN3CvZF23ADhA5wRaYEQpJ8qygSkbtTXWBeXWXmEVRXK+1ITciHWwHhsjv1TmW0MgacIv6hEi5pX5NQdA==", - "dev": true, - "engines": { - "node": ">=10" - } - }, "node_modules/globals": { "version": "11.12.0", "resolved": "https://registry.npmjs.org/globals/-/globals-11.12.0.tgz", @@ -7290,31 +6419,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/got": { - "version": "12.6.1", - "resolved": "https://registry.npmjs.org/got/-/got-12.6.1.tgz", - "integrity": "sha512-mThBblvlAF1d4O5oqyvN+ZxLAYwIJK7bpMxgYqPD9okW0C3qm5FFn7k811QrcuEBwaogR3ngOFoCfs6mRv7teQ==", - "dev": true, - "dependencies": { - "@sindresorhus/is": "^5.2.0", - "@szmarczak/http-timer": "^5.0.1", - "cacheable-lookup": "^7.0.0", - "cacheable-request": "^10.2.8", - "decompress-response": "^6.0.0", - "form-data-encoder": "^2.1.2", - "get-stream": "^6.0.1", - "http2-wrapper": "^2.1.10", - "lowercase-keys": "^3.0.0", - "p-cancelable": "^3.0.0", - "responselike": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sindresorhus/got?sponsor=1" - } - }, "node_modules/graceful-fs": { "version": "4.2.11", "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", @@ -7346,6 +6450,50 @@ "uglify-js": "^3.1.4" } }, + "node_modules/hapi": { + "version": "18.1.0", + "resolved": "https://registry.npmjs.org/hapi/-/hapi-18.1.0.tgz", + "integrity": "sha512-nSU1VLyTAgp7P5gy47QzJIP2JAb+wOFvJIV3gnL0lFj/mD+HuTXhyUsDYXjF/dhADMVXVEz31z6SUHBJhtsvGA==", + "deprecated": "This version contains severe security issues and defects and should not be used! Please upgrade to the latest version of @hapi/hapi or consider a commercial license (https://github.com/hapijs/hapi/issues/4114)", + "hasShrinkwrap": true, + "peer": true, + "dependencies": { + "accept": "3.x.x", + "ammo": "3.x.x", + "boom": "7.x.x", + "bounce": "1.x.x", + "call": "5.x.x", + "catbox": "10.x.x", + "catbox-memory": "4.x.x", + "heavy": "6.x.x", + "hoek": "6.x.x", + "joi": "14.x.x", + "mimos": "4.x.x", + "podium": "3.x.x", + "shot": "4.x.x", + "somever": "2.x.x", + "statehood": "6.x.x", + "subtext": "6.x.x", + "teamwork": "3.x.x", + "topo": "3.x.x" + } + }, + "node_modules/hapi-auth-basic": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/hapi-auth-basic/-/hapi-auth-basic-5.0.0.tgz", + "integrity": "sha512-4ceLge/CYBtEAvfnbwBPPck2wb9O7wksaeSOF0C1lp8GX2IuIm8BqtZtvDGLhqNH5j3ztP4im/TfCj3oYQ9bgA==", + "deprecated": "This module has moved and is now available at @hapi/basic. Please update your dependencies as this version is no longer maintained an may contain bugs and security issues.", + "dependencies": { + "boom": "7.x.x", + "hoek": "5.x.x" + }, + "engines": { + "node": ">=8.9.0" + }, + "peerDependencies": { + "hapi": ">=17.x.x" + } + }, "node_modules/hapi-auth-bearer-token": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/hapi-auth-bearer-token/-/hapi-auth-bearer-token-8.0.0.tgz", @@ -7368,26 +6516,194 @@ "integrity": "sha512-jKtjLLDiH95b002sJVc5c74PE6KKYftuyVdVmsuYId5stTaWcRFqE+5ukZI4gDUKjGn8wv2C3zPn3/nyjEI7gg==", "deprecated": "This version has been deprecated and is no longer supported or maintained" }, - "node_modules/hapi-swagger": { - "version": "17.2.1", - "resolved": "https://registry.npmjs.org/hapi-swagger/-/hapi-swagger-17.2.1.tgz", - "integrity": "sha512-IaF3OHfYjzDuyi5EQgS0j0xB7sbAAD4DaTwexdhPYqEBI/J7GWMXFbftGObCIOeMVDufjoSBZWeaarEkNn6/ww==", - "dependencies": { - "@apidevtools/json-schema-ref-parser": "^11.1.0", - "@hapi/boom": "^10.0.1", - "@hapi/hoek": "^11.0.2", - "handlebars": "^4.7.8", - "http-status": "^1.7.3", - "swagger-parser": "^10.0.3", - "swagger-ui-dist": "^5.9.1" - }, - "engines": { - "node": ">=16.0.0" - }, - "peerDependencies": { - "@hapi/hapi": ">=20.x.x", - "joi": "17.x" - } + "node_modules/hapi-swagger": { + "version": "17.3.2", + "resolved": "https://registry.npmjs.org/hapi-swagger/-/hapi-swagger-17.3.2.tgz", + "integrity": "sha512-mj1KPBl5UY4rLTLj9CrgNCps29iZ7vKNTEey3Ztm7fZ/DrMdJ7KHdxSucACyaFdPAiEpfJtHvm/5lxJVXVxa4g==", + "dependencies": { + "@apidevtools/json-schema-ref-parser": "^11.7.0", + "@hapi/boom": "^10.0.1", + "@hapi/hoek": "^11.0.4", + "handlebars": "^4.7.8", + "http-status": "^1.7.4", + "swagger-parser": "^10.0.3", + "swagger-ui-dist": "^5.17.14" + }, + "engines": { + "node": ">=16.0.0" + }, + "peerDependencies": { + "@hapi/hapi": ">=20.x.x", + "joi": "17.x" + } + }, + "node_modules/hapi/node_modules/accept": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/accept/-/accept-3.1.3.tgz", + "integrity": "sha512-OgOEAidVEOKPup+Gv2+2wdH2AgVKI9LxsJ4hicdJ6cY0faUuZdZoi56kkXWlHp9qicN1nWQLmW5ZRGk+SBS5xg==", + "peer": true + }, + "node_modules/hapi/node_modules/ammo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/ammo/-/ammo-3.0.3.tgz", + "integrity": "sha512-vo76VJ44MkUBZL/BzpGXaKzMfroF4ZR6+haRuw9p+eSWfoNaH2AxVc8xmiEPC08jhzJSeM6w7/iMUGet8b4oBQ==", + "peer": true + }, + "node_modules/hapi/node_modules/b64": { + "version": "4.1.2", + "resolved": "https://registry.npmjs.org/b64/-/b64-4.1.2.tgz", + "integrity": "sha512-+GUspBxlH3CJaxMUGUE1EBoWM6RKgWiYwUDal0qdf8m3ArnXNN1KzKVo5HOnE/FSq4HHyWf3TlHLsZI8PKQgrQ==", + "extraneous": true + }, + "node_modules/hapi/node_modules/boom": { + "version": "7.3.0", + "resolved": "https://registry.npmjs.org/boom/-/boom-7.3.0.tgz", + "integrity": "sha512-Swpoyi2t5+GhOEGw8rEsKvTxFLIDiiKoUc2gsoV6Lyr43LHBIzch3k2MvYUs8RTROrIkVJ3Al0TkaOGjnb+B6A==", + "peer": true + }, + "node_modules/hapi/node_modules/bounce": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/bounce/-/bounce-1.2.3.tgz", + "integrity": "sha512-3G7B8CyBnip5EahCZJjnvQ1HLyArC6P5e+xcolo13BVI9ogFaDOsNMAE7FIWliHtIkYI8/nTRCvCY9tZa3Mu4g==", + "peer": true + }, + "node_modules/hapi/node_modules/bourne": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/bourne/-/bourne-1.1.1.tgz", + "integrity": "sha512-Ou0l3W8+n1FuTOoIfIrCk9oF9WVWc+9fKoAl67XQr9Ws0z7LgILRZ7qtc9xdT4BveSKtnYXfKPgn8pFAqeQRew==", + "extraneous": true + }, + "node_modules/hapi/node_modules/call": { + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/call/-/call-5.0.3.tgz", + "integrity": "sha512-eX16KHiAYXugbFu6VifstSdwH6aMuWWb4s0qvpq1nR1b+Sf+u68jjttg8ixDBEldPqBi30bDU35OJQWKeTLKxg==", + "peer": true + }, + "node_modules/hapi/node_modules/catbox": { + "version": "10.0.6", + "resolved": "https://registry.npmjs.org/catbox/-/catbox-10.0.6.tgz", + "integrity": "sha512-gQWCnF/jbHcfwGbQ4FQxyRiAwLRipqWTTXjpq7rTqqdcsnZosFa0L3LsCZcPTF33QIeMMkS7QmFBHt6QdzGPvg==", + "peer": true + }, + "node_modules/hapi/node_modules/catbox-memory": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/catbox-memory/-/catbox-memory-4.0.1.tgz", + "integrity": "sha512-ZmqNiLsYCIu9qvBJ/MQbznDV2bFH5gFiH67TgIJgSSffJFtTXArT+MM3AvJQlby9NSkLHOX4eH/uuUqnch/Ldw==", + "peer": true + }, + "node_modules/hapi/node_modules/content": { + "version": "4.0.6", + "resolved": "https://registry.npmjs.org/content/-/content-4.0.6.tgz", + "integrity": "sha512-lR9ND3dXiMdmsE84K6l02rMdgiBVmtYWu1Vr/gfSGHcIcznBj2QxmSdUgDuNFOA+G9yrb1IIWkZ7aKtB6hDGyA==", + "extraneous": true + }, + "node_modules/hapi/node_modules/cryptiles": { + "version": "4.1.3", + "resolved": "https://registry.npmjs.org/cryptiles/-/cryptiles-4.1.3.tgz", + "integrity": "sha512-gT9nyTMSUC1JnziQpPbxKGBbUg8VL7Zn2NB4E1cJYvuXdElHrwxrV9bmltZGDzet45zSDGyYceueke1TjynGzw==", + "extraneous": true + }, + "node_modules/hapi/node_modules/heavy": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/heavy/-/heavy-6.1.2.tgz", + "integrity": "sha512-cJp884bqhiebNcEHydW0g6V1MUGYOXRPw9c7MFiHQnuGxtbWuSZpsbojwb2kxb3AA1/Rfs8CNiV9MMOF8pFRDg==", + "peer": true + }, + "node_modules/hapi/node_modules/hoek": { + "version": "6.1.2", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-6.1.2.tgz", + "integrity": "sha512-6qhh/wahGYZHFSFw12tBbJw5fsAhhwrrG/y3Cs0YMTv2WzMnL0oLPnQJjv1QJvEfylRSOFuP+xCu+tdx0tD16Q==", + "peer": true + }, + "node_modules/hapi/node_modules/iron": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/iron/-/iron-5.0.6.tgz", + "integrity": "sha512-zYUMOSkEXGBdwlV/AXF9zJC0aLuTJUKHkGeYS5I2g225M5i6SrxQyGJGhPgOR8BK1omL6N5i6TcwfsXbP8/Exw==", + "extraneous": true + }, + "node_modules/hapi/node_modules/joi": { + "version": "14.3.1", + "resolved": "https://registry.npmjs.org/joi/-/joi-14.3.1.tgz", + "integrity": "sha512-LQDdM+pkOrpAn4Lp+neNIFV3axv1Vna3j38bisbQhETPMANYRbFJFUyOZcOClYvM/hppMhGWuKSFEK9vjrB+bQ==", + "peer": true + }, + "node_modules/hapi/node_modules/mime-db": { + "version": "1.37.0", + "resolved": "https://registry.npmjs.org/mime-db/-/mime-db-1.37.0.tgz", + "integrity": "sha512-R3C4db6bgQhlIhPU48fUtdVmKnflq+hRdad7IyKhtFj06VPNVdk2RhiYL3UjQIlso8L+YxAtFkobT0VK+S/ybg==", + "extraneous": true + }, + "node_modules/hapi/node_modules/mimos": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/mimos/-/mimos-4.0.2.tgz", + "integrity": "sha512-5XBsDqBqzSN88XPPH/TFpOalWOjHJM5Z2d3AMx/30iq+qXvYKd/8MPhqBwZDOLtoaIWInR3nLzMQcxfGK9djXA==", + "peer": true + }, + "node_modules/hapi/node_modules/nigel": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/nigel/-/nigel-3.0.4.tgz", + "integrity": "sha512-3SZCCS/duVDGxFpTROHEieC+itDo4UqL9JNUyQJv3rljudQbK6aqus5B4470OxhESPJLN93Qqxg16rH7DUjbfQ==", + "extraneous": true + }, + "node_modules/hapi/node_modules/pez": { + "version": "4.0.5", + "resolved": "https://registry.npmjs.org/pez/-/pez-4.0.5.tgz", + "integrity": "sha512-HvL8uiFIlkXbx/qw4B8jKDCWzo7Pnnd65Uvanf9OOCtb20MRcb9gtTVBf9NCnhETif1/nzbDHIjAWC/sUp7LIQ==", + "extraneous": true + }, + "node_modules/hapi/node_modules/podium": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/podium/-/podium-3.2.0.tgz", + "integrity": "sha512-rbwvxwVkI6gRRlxZQ1zUeafrpGxZ7QPHIheinehAvGATvGIPfWRkaTeWedc5P4YjXJXEV8ZbBxPtglNylF9hjw==", + "peer": true + }, + "node_modules/hapi/node_modules/shot": { + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/shot/-/shot-4.0.7.tgz", + "integrity": "sha512-RKaKAGKxJ11EjJl0cf2fYVSsd4KB5Cncb9J0v7w+0iIaXpxNqFWTYNDNhBX7f0XSyDrjOH9a4OWZ9Gp/ZML+ew==", + "peer": true + }, + "node_modules/hapi/node_modules/somever": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/somever/-/somever-2.0.0.tgz", + "integrity": "sha512-9JaIPP+HxwYGqCDqqK3tRaTqdtQHoK6Qy3IrXhIt2q5x8fs8RcfU7BMWlFTCOgFazK8p88zIv1tHQXvAwtXMyw==", + "peer": true + }, + "node_modules/hapi/node_modules/statehood": { + "version": "6.0.9", + "resolved": "https://registry.npmjs.org/statehood/-/statehood-6.0.9.tgz", + "integrity": "sha512-jbFg1+MYEqfC7ABAoWZoeF4cQUtp3LUvMDUGExL76cMmleBHG7I6xlZFsE8hRi7nEySIvutHmVlLmBe9+2R5LQ==", + "peer": true + }, + "node_modules/hapi/node_modules/subtext": { + "version": "6.0.12", + "resolved": "https://registry.npmjs.org/subtext/-/subtext-6.0.12.tgz", + "integrity": "sha512-yT1wCDWVgqvL9BIkWzWqgj5spUSYo/Enu09iUV8t2ZvHcr2tKGTGg2kc9tUpVEsdhp1ihsZeTAiDqh0TQciTPQ==", + "peer": true + }, + "node_modules/hapi/node_modules/teamwork": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/teamwork/-/teamwork-3.0.3.tgz", + "integrity": "sha512-OCB56z+G70iA1A1OFoT+51TPzfcgN0ks75uN3yhxA+EU66WTz2BevNDK4YzMqfaL5tuAvxy4iFUn35/u8pxMaQ==", + "peer": true + }, + "node_modules/hapi/node_modules/topo": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/topo/-/topo-3.0.3.tgz", + "integrity": "sha512-IgpPtvD4kjrJ7CRA3ov2FhWQADwv+Tdqbsf1ZnPUSAtCJ9e1Z44MmoSGDXGk4IppoZA7jd/QRkNddlLJWlUZsQ==", + "peer": true + }, + "node_modules/hapi/node_modules/vise": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/vise/-/vise-3.0.2.tgz", + "integrity": "sha512-X52VtdRQbSBXdjcazRiY3eRgV3vTQ0B+7Wh8uC9cVv7lKfML5m9+9NHlbcgCY0R9EAqD1v/v7o9mhGh2A3ANFg==", + "extraneous": true + }, + "node_modules/hapi/node_modules/wreck": { + "version": "14.1.3", + "resolved": "https://registry.npmjs.org/wreck/-/wreck-14.1.3.tgz", + "integrity": "sha512-hb/BUtjX3ObbwO3slCOLCenQ4EP8e+n8j6FmTne3VhEFp5XV1faSJojiyxVSvw34vgdeTG5baLTl4NmjwokLlw==", + "extraneous": true }, "node_modules/har-schema": { "version": "2.0.0", @@ -7496,9 +6812,9 @@ } }, "node_modules/has-proto": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.1.tgz", - "integrity": "sha512-7qE+iP+O+bgF9clE5+UoBFzE65mlBiVj3tKCrlNQ0Ogwm0BjpT/gK4SlLYDMybDh5I3TCTKnPPa0oMG7JDYrhg==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/has-proto/-/has-proto-1.0.3.tgz", + "integrity": "sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==", "engines": { "node": ">= 0.4" }, @@ -7518,12 +6834,12 @@ } }, "node_modules/has-tostringtag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.0.tgz", - "integrity": "sha512-kFjcSNhnlGV1kyoGk7OXKSawH5JOb/LzUc5w9B02hOTO0dfFRjbHQKvg1d6cf3HbeUmtU9VbbV3qzZ2Teh97WQ==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz", + "integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==", "dev": true, "dependencies": { - "has-symbols": "^1.0.2" + "has-symbols": "^1.0.3" }, "engines": { "node": ">= 0.4" @@ -7532,24 +6848,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/has-unicode": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", - "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", - "dev": true - }, - "node_modules/has-yarn": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/has-yarn/-/has-yarn-3.0.0.tgz", - "integrity": "sha512-IrsVwUHhEULx3R8f/aA8AHuEzAorplsab/v8HBzEiIukwq5i/EC+xmOW+HfP1OaDP+2JkgT1yILHN2O3UFIbcA==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/hasha": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/hasha/-/hasha-5.2.2.tgz", @@ -7567,9 +6865,9 @@ } }, "node_modules/hasown": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.0.tgz", - "integrity": "sha512-vUptKVTpIJhcczKBbgnS+RtcuYMB8+oNzPK2/Hp3hanz8JmpATdmmgLgSaadVREkDm+e2giHwY3ZRkyjSIDDFA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", + "integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==", "dependencies": { "function-bind": "^1.1.2" }, @@ -7591,16 +6889,13 @@ "integrity": "sha512-FK1vmMj8BbEipEy8DLIvp71t5UsC7n2D6En/UfM/91PCwmOpj6f2iu0Y0coRC62KSRHHC+dquM2xMULV/X7NFg==", "deprecated": "Use the 'highlight.js' package instead https://npm.im/highlight.js" }, - "node_modules/hosted-git-info": { - "version": "5.2.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-5.2.1.tgz", - "integrity": "sha512-xIcQYMnhcx2Nr4JTjsFmwwnr9vldugPy9uVm0o87bjqqWMv9GaqsTeT+i99wTl0mk1uLxJtHxLb8kymqTENQsw==", - "dev": true, - "dependencies": { - "lru-cache": "^7.5.1" - }, + "node_modules/hoek": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/hoek/-/hoek-5.0.4.tgz", + "integrity": "sha512-Alr4ZQgoMlnere5FZJsIyfIjORBqZll5POhDsF4q64dPuJR6rNxXdDxtHSQq8OXRurhmx+PWYEE8bXRROY8h0w==", + "deprecated": "This version has been deprecated in accordance with the hapi support policy (hapi.im/support). Please upgrade to the latest version to get the best features, bug fixes, and security patches. If you are unable to upgrade at this time, paid support is available for older versions (hapi.im/commercial).", "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": ">=8.9.0" } }, "node_modules/html-escaper": { @@ -7610,9 +6905,9 @@ "dev": true }, "node_modules/htmlparser2": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", - "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "version": "9.1.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-9.1.0.tgz", + "integrity": "sha512-5zfg6mHUoaer/97TxnGpxmbR7zJtPwIYFMZ/H5ucTlPZhKvtum05yiPK3Mgai3a0DyVxv7qYqoweaEd2nrYQzQ==", "funding": [ "https://github.com/fb55/htmlparser2?sponsor=1", { @@ -7623,27 +6918,10 @@ "dependencies": { "domelementtype": "^2.3.0", "domhandler": "^5.0.3", - "domutils": "^3.0.1", - "entities": "^4.4.0" - } - }, - "node_modules/htmlparser2/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "domutils": "^3.1.0", + "entities": "^4.5.0" } }, - "node_modules/http-cache-semantics": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/http-cache-semantics/-/http-cache-semantics-4.1.1.tgz", - "integrity": "sha512-er295DKPVsV82j5kw1Gjt+ADA/XYHsajl82cGNQG2eyoPkvgUhX+nDIyelzhIWbbsXP39EHcI6l5tYs2FYqYXQ==", - "dev": true - }, "node_modules/http-errors": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.0.tgz", @@ -7659,24 +6937,10 @@ "node": ">= 0.8" } }, - "node_modules/http-proxy-agent": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/http-proxy-agent/-/http-proxy-agent-5.0.0.tgz", - "integrity": "sha512-n2hY8YdoRE1i7r6M0w9DIw5GgZN0G25P8zLCRQ8rjXtTU3vsNFBI/vWK/UIeE6g5MUUz6avwAPXmL6Fy9D/90w==", - "dev": true, - "dependencies": { - "@tootallnate/once": "2", - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/http-status": { - "version": "1.7.3", - "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.7.3.tgz", - "integrity": "sha512-GS8tL1qHT2nBCMJDYMHGkkkKQLNkIAHz37vgO68XKvzv+XyqB4oh/DfmMHdtRzfqSJPj1xKG2TaELZtlCz6BEQ==", + "version": "1.7.4", + "resolved": "https://registry.npmjs.org/http-status/-/http-status-1.7.4.tgz", + "integrity": "sha512-c2qSwNtTlHVYAhMj9JpGdyo0No/+DiKXCJ9pHtZ2Yf3QmPnBIytKSRT7BuyIiQ7icXLynavGmxUqkOjSrAuMuA==", "engines": { "node": ">= 0.4.0" } @@ -7686,44 +6950,6 @@ "resolved": "https://registry.npmjs.org/http2-client/-/http2-client-1.3.5.tgz", "integrity": "sha512-EC2utToWl4RKfs5zd36Mxq7nzHHBuomZboI0yYL6Y0RmBgT7Sgkq4rQ0ezFTYoIsSs7Tm9SJe+o2FcAg6GBhGA==" }, - "node_modules/http2-wrapper": { - "version": "2.2.0", - "resolved": "https://registry.npmjs.org/http2-wrapper/-/http2-wrapper-2.2.0.tgz", - "integrity": "sha512-kZB0wxMo0sh1PehyjJUWRFEd99KC5TLjZ2cULC4f9iqJBAmKQQXEICjxl5iPJRwP40dpeHFqqhm7tYCvODpqpQ==", - "dev": true, - "dependencies": { - "quick-lru": "^5.1.1", - "resolve-alpn": "^1.2.0" - }, - "engines": { - "node": ">=10.19.0" - } - }, - "node_modules/http2-wrapper/node_modules/quick-lru": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/quick-lru/-/quick-lru-5.1.1.tgz", - "integrity": "sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==", - "dev": true, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/https-proxy-agent": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", - "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", - "dev": true, - "dependencies": { - "agent-base": "6", - "debug": "4" - }, - "engines": { - "node": ">= 6" - } - }, "node_modules/httpsnippet": { "version": "1.25.0", "resolved": "https://registry.npmjs.org/httpsnippet/-/httpsnippet-1.25.0.tgz", @@ -7874,20 +7100,10 @@ "node": ">=0.8.0" } }, - "node_modules/humanize-ms": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/humanize-ms/-/humanize-ms-1.2.1.tgz", - "integrity": "sha512-Fl70vYtsAFb/C06PTS9dZBo7ihau+Tu/DNCk/OyHhea07S+aeMWpFFkUaXRa8fI+ScZbEI8dfSxwY7gxZ9SAVQ==", - "dev": true, - "dependencies": { - "ms": "^2.0.0" - } - }, "node_modules/iconv-lite": { "version": "0.6.3", "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", - "optional": true, "dependencies": { "safer-buffer": ">= 2.1.2 < 3.0.0" }, @@ -7909,18 +7125,6 @@ "integrity": "sha512-Ius2VYcGNk7T90CppJqcIkS5ooHUZyIQK+ClZfMfMNFEF9VSE73Fq+906u/CWu92x4gzZMWOwfFYckPObzdEbA==", "dev": true }, - "node_modules/ignore-walk": { - "version": "6.0.3", - "resolved": "https://registry.npmjs.org/ignore-walk/-/ignore-walk-6.0.3.tgz", - "integrity": "sha512-C7FfFoTA+bI10qfeydT8aZbvr91vAEU+2W5BZUlzPec47oNb07SsOfwYrtxuvOYdUApPP/Qlh4DtAO51Ekk2QA==", - "dev": true, - "dependencies": { - "minimatch": "^9.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/ilp-packet": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/ilp-packet/-/ilp-packet-2.2.0.tgz", @@ -7949,9 +7153,9 @@ } }, "node_modules/immutable": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/immutable/-/immutable-4.3.6.tgz", - "integrity": "sha512-Ju0+lEMyzMVZarkTn/gqRpdqd5dOPaz1mCZ0SH3JV6iFw81PldE/PEB1hWVEA288HPt4WXW8O7AWxB10M+03QQ==" + "version": "5.0.3", + "resolved": "https://registry.npmjs.org/immutable/-/immutable-5.0.3.tgz", + "integrity": "sha512-P8IdPQHq3lA1xVeBRi5VPqUm5HDgKnx0Ru51wZz5mjxHr5n3RWhjIpOFU7ybkUxfB+5IToy+OLaHYDBIWsv+uw==" }, "node_modules/import-fresh": { "version": "3.3.0", @@ -7990,15 +7194,6 @@ "node": ">=4" } }, - "node_modules/import-lazy": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/import-lazy/-/import-lazy-4.0.0.tgz", - "integrity": "sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/imurmurhash": { "version": "0.1.4", "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", @@ -8017,12 +7212,6 @@ "node": ">=8" } }, - "node_modules/infer-owner": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/infer-owner/-/infer-owner-1.0.4.tgz", - "integrity": "sha512-IClj+Xz94+d7irH5qRyfJonOdfTzuDaifE6ZPWfx0N0+/ATZCbuTPq2prFl526urkQd90WyUKIh1DfBQ2hMz9A==", - "dev": true - }, "node_modules/inflight": { "version": "1.0.6", "resolved": "https://registry.npmjs.org/inflight/-/inflight-1.0.6.tgz", @@ -8038,22 +7227,13 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, - "node_modules/ini": { - "version": "4.1.1", - "resolved": "https://registry.npmjs.org/ini/-/ini-4.1.1.tgz", - "integrity": "sha512-QQnnxNyfvmHFIsj7gkPcYymR8Jdw/o7mp5ZFihxn6h8Ci6fh3Dx4E1gPjpQEpIuPo9XVNY/ZUwh4BPMjGyL01g==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/internal-slot": { - "version": "1.0.6", - "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.6.tgz", - "integrity": "sha512-Xj6dv+PsbtwyPpEflsejS+oIZxmMlV44zAhG479uYu89MsjcYOhCFnNyKrkJrihbsiasQyY0afoCl/9BLR65bg==", + "version": "1.0.7", + "resolved": "https://registry.npmjs.org/internal-slot/-/internal-slot-1.0.7.tgz", + "integrity": "sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==", "dev": true, "dependencies": { - "get-intrinsic": "^1.2.2", + "es-errors": "^1.3.0", "hasown": "^2.0.0", "side-channel": "^1.0.4" }, @@ -8077,6 +7257,29 @@ "node": ">=4" } }, + "node_modules/ioredis": { + "version": "5.4.1", + "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.4.1.tgz", + "integrity": "sha512-2YZsvl7jopIa1gaePkeMtd9rAcSjOOjPtpcLlOeusyO+XH2SK5ZcT+UCrElPP+WVIInh2TzeI4XW9ENaSLVVHA==", + "dependencies": { + "@ioredis/commands": "^1.1.1", + "cluster-key-slot": "^1.1.0", + "debug": "^4.3.4", + "denque": "^2.1.0", + "lodash.defaults": "^4.2.0", + "lodash.isarguments": "^3.1.0", + "redis-errors": "^1.2.0", + "redis-parser": "^3.0.0", + "standard-as-callback": "^2.1.0" + }, + "engines": { + "node": ">=12.22.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/ioredis" + } + }, "node_modules/ip-address": { "version": "9.0.5", "resolved": "https://registry.npmjs.org/ip-address/-/ip-address-9.0.5.tgz", @@ -8119,14 +7322,16 @@ } }, "node_modules/is-array-buffer": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.2.tgz", - "integrity": "sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==", + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.4.tgz", + "integrity": "sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==", "dev": true, "dependencies": { "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.0", - "is-typed-array": "^1.1.10" + "get-intrinsic": "^1.2.1" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8209,18 +7414,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-ci": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/is-ci/-/is-ci-3.0.1.tgz", - "integrity": "sha512-ZYvCgrefwqoQ6yTyYUbQu64HsITZ3NfKX1lzaEYdkTDcfKzzCI/wthRRYKkdjHKFVgNiXKAKm65Zo1pk2as/QQ==", - "dev": true, - "dependencies": { - "ci-info": "^3.2.0" - }, - "bin": { - "is-ci": "bin.js" - } - }, "node_modules/is-core-module": { "version": "2.13.1", "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.13.1.tgz", @@ -8232,6 +7425,21 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/is-data-view": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-data-view/-/is-data-view-1.0.1.tgz", + "integrity": "sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==", + "dev": true, + "dependencies": { + "is-typed-array": "^1.1.13" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/is-date-object": { "version": "1.0.5", "resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.0.5.tgz", @@ -8313,41 +7521,22 @@ "node": ">=0.10.0" } }, - "node_modules/is-installed-globally": { - "version": "0.4.0", - "resolved": "https://registry.npmjs.org/is-installed-globally/-/is-installed-globally-0.4.0.tgz", - "integrity": "sha512-iwGqO3J21aaSkC7jWnHP/difazwS7SFeIqxv6wEtLU8Y5KlzFTjyqcSIT0d8s4+dDhKytsk9PJZ2BkS5eZwQRQ==", + "node_modules/is-map": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.3.tgz", + "integrity": "sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==", "dev": true, - "dependencies": { - "global-dirs": "^3.0.0", - "is-path-inside": "^3.0.2" - }, "engines": { - "node": ">=10" + "node": ">= 0.4" }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/is-lambda": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/is-lambda/-/is-lambda-1.0.1.tgz", - "integrity": "sha512-z7CMFGNrENq5iFB9Bqo64Xk6Y9sg+epq1myIcdHaGnbMTYOxvzsEtdYqQUylB7LxfkvgrrjP32T6Ywciio9UIQ==", - "dev": true - }, - "node_modules/is-map": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-map/-/is-map-2.0.2.tgz", - "integrity": "sha512-cOZFQQozTha1f4MxLFzlgKYPTyj26picdZTx82hbc/Xf4K/tZOOXSCkMvU4pKioRXGDLJRn0GM7Upe7kR721yg==", - "dev": true, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-negative-zero": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.2.tgz", - "integrity": "sha512-dqJvarLawXsFbNDeJW7zAz8ItJ9cd28YufuuFzh0G8pNHjJMnY08Dv7sYX2uF5UpQOwieAeOExEYAWWfu7ZZUA==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-negative-zero/-/is-negative-zero-2.0.3.tgz", + "integrity": "sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==", "dev": true, "engines": { "node": ">= 0.4" @@ -8356,18 +7545,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/is-npm": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/is-npm/-/is-npm-6.0.0.tgz", - "integrity": "sha512-JEjxbSmtPSt1c8XTkVrlujcXdKV1/tvuQ7GwKcAlyiVLeYFQ2VHat8xfrDJsIkhCdF/tZ7CiIR3sy141c6+gPQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/is-number": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz", @@ -8460,21 +7637,27 @@ } }, "node_modules/is-set": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.2.tgz", - "integrity": "sha512-+2cnTEZeY5z/iXGbLhPrOAaK/Mau5k5eXq9j14CpRTftq0pAJu2MwVRSZhyZWBzx3o6X795Lz6Bpb6R0GKf37g==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-set/-/is-set-2.0.3.tgz", + "integrity": "sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/is-shared-array-buffer": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.2.tgz", - "integrity": "sha512-sqN2UDu1/0y6uvXyStCOzyhAjCSlHceFoMKJW8W9EU9cvic/QdsZ0kEU93HEy3IUEFZIiH/3w+AH/UQbPHNdhA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/is-shared-array-buffer/-/is-shared-array-buffer-1.0.3.tgz", + "integrity": "sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2" + "call-bind": "^1.0.7" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8534,12 +7717,12 @@ } }, "node_modules/is-typed-array": { - "version": "1.1.12", - "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.12.tgz", - "integrity": "sha512-Z14TF2JNG8Lss5/HMqt0//T9JeHXttXy5pH/DBU4vi98ozO2btxzq9MwYDZYnKwU8nRsz/+GVFVRDq3DkVuSPg==", + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/is-typed-array/-/is-typed-array-1.1.13.tgz", + "integrity": "sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==", "dev": true, "dependencies": { - "which-typed-array": "^1.1.11" + "which-typed-array": "^1.1.14" }, "engines": { "node": ">= 0.4" @@ -8555,10 +7738,13 @@ "dev": true }, "node_modules/is-weakmap": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.1.tgz", - "integrity": "sha512-NSBR4kH5oVj1Uwvv970ruUkCV7O1mzgVFO4/rev2cLRda9Tm9HrL70ZPut4rOHgY0FNrUu9BCbXA2sdQ+x0chA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/is-weakmap/-/is-weakmap-2.0.2.tgz", + "integrity": "sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==", "dev": true, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -8576,13 +7762,16 @@ } }, "node_modules/is-weakset": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.2.tgz", - "integrity": "sha512-t2yVvttHkQktwnNNmBQ98AhENLdPUTDTE21uPqAQ0ARwQfGeQKRVS0NNurH7bTf7RrvcVn1OOge45CnBeHCSmg==", + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/is-weakset/-/is-weakset-2.0.3.tgz", + "integrity": "sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.1" + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -8605,15 +7794,6 @@ "node": ">=4" } }, - "node_modules/is-yarn-global": { - "version": "0.4.1", - "resolved": "https://registry.npmjs.org/is-yarn-global/-/is-yarn-global-0.4.1.tgz", - "integrity": "sha512-/kppl+R+LO5VmhYSEWARUFjodS25D68gvj8W7z0I7OWhUla5xWu8KL6CtB2V0R6yqhnRgbcaREMr4EEM6htLPQ==", - "dev": true, - "engines": { - "node": ">=12" - } - }, "node_modules/isarray": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz", @@ -8670,54 +7850,12 @@ "archy": "^1.0.0", "cross-spawn": "^7.0.3", "istanbul-lib-coverage": "^3.2.0", - "p-map": "^3.0.0", - "rimraf": "^3.0.0", - "uuid": "^8.3.2" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/istanbul-lib-processinfo/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" + "p-map": "^3.0.0", + "rimraf": "^3.0.0", + "uuid": "^8.3.2" }, "engines": { - "node": "*" + "node": ">=8" } }, "node_modules/istanbul-lib-processinfo/node_modules/p-map": { @@ -8732,21 +7870,6 @@ "node": ">=8" } }, - "node_modules/istanbul-lib-processinfo/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/istanbul-lib-report": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", @@ -8817,14 +7940,14 @@ } }, "node_modules/jackspeak": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.1.2.tgz", - "integrity": "sha512-kWmLKn2tRtfYMF/BakihVVRzBKOxz4gJMiL2Rj91WnAB5TPZumSH99R/Yf1qE1u4uRimvCSJfm6hnxohXeEXjQ==", + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/jackspeak/-/jackspeak-3.4.2.tgz", + "integrity": "sha512-qH3nOSj8q/8+Eg8LUPOq3C+6HWkpUioIjDsq1+D4zY91oZvpPttw8GwtF1nReRYKXl+1AORyFqtm2f5Q1SB6/Q==", "dependencies": { "@isaacs/cliui": "^8.0.2" }, "engines": { - "node": ">=14" + "node": "14 >=14.21 || 16 >=16.20 || >=18" }, "funding": { "url": "https://github.com/sponsors/isaacs" @@ -8834,9 +7957,9 @@ } }, "node_modules/jake": { - "version": "10.9.1", - "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.1.tgz", - "integrity": "sha512-61btcOHNnLnsOdtLgA5efqQWjnSi/vow5HbI7HMdKKWqvrKR1bLK3BPlJn9gcSaP2ewuamUSMB5XEy76KUIS2w==", + "version": "10.9.2", + "resolved": "https://registry.npmjs.org/jake/-/jake-10.9.2.tgz", + "integrity": "sha512-2P4SQ0HrLQ+fw6llpLnOaGAvN2Zu6778SJMrCUwns4fOoG9ayrTiZk3VV8sCPkVZF8ab0zksVpS8FDY5pRCNBA==", "dependencies": { "async": "^3.2.3", "chalk": "^4.0.2", @@ -8944,10 +8067,11 @@ "integrity": "sha512-4bYVV3aAMtDTTu4+xsDYa6sy9GyJ69/amsu9sYF2zqjiEoZA5xJi3BrfX3uY+/IekIu7MwdObdbDWpoZdBv3/A==" }, "node_modules/jsdoc": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.3.tgz", - "integrity": "sha512-Nu7Sf35kXJ1MWDZIMAuATRQTg1iIPdzh7tqJ6jjvaU/GfDf+qi5UV8zJR3Mo+/pYFvm8mzay4+6O5EWigaQBQw==", + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/jsdoc/-/jsdoc-4.0.4.tgz", + "integrity": "sha512-zeFezwyXeG4syyYHbvh1A967IAqq/67yXtXvuL5wnqCkFZe8I0vKfm+EO+YEvLguo6w9CDUbrAXVtJSHh2E8rw==", "dev": true, + "license": "Apache-2.0", "dependencies": { "@babel/parser": "^7.20.15", "@jsdoc/salty": "^0.2.1", @@ -9005,24 +8129,6 @@ "integrity": "sha512-mrqyZKfX5EhL7hvqcV6WG1yYjnjeuYDzDhhcAAUrq8Po85NBQBJP+ZDUT75qZQ98IkUoBqdkExkukOU7Ts2wrw==", "dev": true }, - "node_modules/json-parse-even-better-errors": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/json-parse-even-better-errors/-/json-parse-even-better-errors-3.0.0.tgz", - "integrity": "sha512-iZbGHafX/59r39gPwVPRBGw0QQKnA7tte5pSMrhWOW7swGsVvVTjmfyAV9pNqk8YGT7tRCdxRu8uzcgZwoDooA==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/json-parse-helpfulerror": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/json-parse-helpfulerror/-/json-parse-helpfulerror-1.0.3.tgz", - "integrity": "sha512-XgP0FGR77+QhUxjXkwOMkC94k3WtqEBfcnjWqhRd82qTat4SWKRE+9kUnynz/shm3I4ea2+qISvTIeGTNU7kJg==", - "dev": true, - "dependencies": { - "jju": "^1.1.0" - } - }, "node_modules/json-pointer": { "version": "0.6.2", "resolved": "https://registry.npmjs.org/json-pointer/-/json-pointer-0.6.2.tgz", @@ -9060,12 +8166,6 @@ "node": ">=6" } }, - "node_modules/jsonlines": { - "version": "0.1.1", - "resolved": "https://registry.npmjs.org/jsonlines/-/jsonlines-0.1.1.tgz", - "integrity": "sha512-ekDrAGso79Cvf+dtm+mL8OBI2bmAOt3gssYs833De/C9NmIpWDWyUO4zPgB5x2/OhY366dkhgfPMYfwZF7yOZA==", - "dev": true - }, "node_modules/jsonparse": { "version": "1.3.1", "resolved": "https://registry.npmjs.org/jsonparse/-/jsonparse-1.3.1.tgz", @@ -9145,27 +8245,6 @@ "integrity": "sha512-g3UB796vUFIY90VIv/WX3L2c8CS2MdWUww3CNrYmqza1Fg0DURc2K/O4YrnklBdQarSJ/y8JnJYDGc+1iumQjg==", "dev": true }, - "node_modules/jwa": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/jwa/-/jwa-2.0.0.tgz", - "integrity": "sha512-jrZ2Qx916EA+fq9cEAeCROWPTfCwi1IVHqT2tapuqLEVVDKFDENFw1oL+MwrTvH6msKxsd1YTDVw6uKEcsrLEA==", - "peer": true, - "dependencies": { - "buffer-equal-constant-time": "1.0.1", - "ecdsa-sig-formatter": "1.0.11", - "safe-buffer": "^5.0.1" - } - }, - "node_modules/jws": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jws/-/jws-4.0.0.tgz", - "integrity": "sha512-KDncfTmOZoOMTFG4mBlG0qUIOlc03fmzH+ru6RgYVZhPkyiy/92Owlt/8UEN+a4TXR1FQetfIpJE8ApdvdVxTg==", - "peer": true, - "dependencies": { - "jwa": "^2.0.0", - "safe-buffer": "^5.0.1" - } - }, "node_modules/kareem": { "version": "2.5.1", "resolved": "https://registry.npmjs.org/kareem/-/kareem-2.5.1.tgz", @@ -9201,15 +8280,6 @@ "graceful-fs": "^4.1.9" } }, - "node_modules/kleur": { - "version": "4.1.5", - "resolved": "https://registry.npmjs.org/kleur/-/kleur-4.1.5.tgz", - "integrity": "sha512-o+NO+8WrRiQEE4/7nwRJhN1HWpVmJm511pBHUxPLtp0BUISzlBplORYSmTclCnJvQq2tKu/sgl3xVpkc7ZWuQQ==", - "dev": true, - "engines": { - "node": ">=6" - } - }, "node_modules/knex": { "version": "3.1.0", "resolved": "https://registry.npmjs.org/knex/-/knex-3.1.0.tgz", @@ -9278,20 +8348,10 @@ "resolved": "https://registry.npmjs.org/kuler/-/kuler-2.0.0.tgz", "integrity": "sha512-Xq9nH7KlWZmXAtodXDDRE7vs6DU1gTU8zYDHDiWLSip45Egwq3plLHzPn27NgvzL2r1LMPC1vdqh98sQxtqj4A==" }, - "node_modules/latest-version": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/latest-version/-/latest-version-7.0.0.tgz", - "integrity": "sha512-KvNT4XqAMzdcL6ka6Tl3i2lYeFDgXNCuIX+xNx6ZMVR1dFq+idXd9FLKNMOIx0t9mJ9/HudyX4oZWXZQ0UJHeg==", - "dev": true, - "dependencies": { - "package-json": "^8.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } + "node_modules/layerr": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/layerr/-/layerr-3.0.0.tgz", + "integrity": "sha512-tv754Ki2dXpPVApOrjTyRo4/QegVb9eVFq4mjqp4+NM5NaX7syQvN5BBNfV/ZpAHCEHV24XdUVrBAoka4jt3pA==" }, "node_modules/lazy-cache": { "version": "1.0.4", @@ -9335,6 +8395,7 @@ "version": "5.0.0", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-5.0.0.tgz", "integrity": "sha512-5aHCbzQRADcdP+ATqnDuhhJ/MRIqDkZX5pyjFHRRysS8vZ5AbqGEoFIb6pYHPZ+L/OC2Lc+xT8uHVVR5CAK/wQ==", + "dev": true, "dependencies": { "uc.micro": "^2.0.0" } @@ -9342,7 +8403,8 @@ "node_modules/linkify-it/node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true }, "node_modules/load-json-file": { "version": "5.3.0", @@ -9403,6 +8465,16 @@ "resolved": "https://registry.npmjs.org/lodash.camelcase/-/lodash.camelcase-4.3.0.tgz", "integrity": "sha512-TwuEnCnxbc3rAvhf/LbG7tJUDzhqXyFnv3dtzLOPgCG/hODL7WFnsbwktkD7yUV0RrreP/l1PALq/YSg6VvjlA==" }, + "node_modules/lodash.clonedeep": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz", + "integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==" + }, + "node_modules/lodash.defaults": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz", + "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==" + }, "node_modules/lodash.flattendeep": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/lodash.flattendeep/-/lodash.flattendeep-4.4.0.tgz", @@ -9414,6 +8486,11 @@ "resolved": "https://registry.npmjs.org/lodash.get/-/lodash.get-4.4.2.tgz", "integrity": "sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==" }, + "node_modules/lodash.isarguments": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz", + "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==" + }, "node_modules/lodash.isequal": { "version": "4.5.0", "resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz", @@ -9446,14 +8523,6 @@ "node": ">= 12.0.0" } }, - "node_modules/logform/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/long": { "version": "5.2.3", "resolved": "https://registry.npmjs.org/long/-/long-5.2.3.tgz", @@ -9479,18 +8548,6 @@ "loose-envify": "cli.js" } }, - "node_modules/lowercase-keys": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/lowercase-keys/-/lowercase-keys-3.0.0.tgz", - "integrity": "sha512-ozCC6gdQ+glXOQsveKD0YsDy8DSQFjDTz4zyzEHNV5+JP5D62LmfDZ6o1cycFx9ouG940M5dE8C8CTewdj2YWQ==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/lru-cache": { "version": "7.18.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-7.18.3.tgz", @@ -9500,9 +8557,9 @@ } }, "node_modules/luxon": { - "version": "3.4.3", - "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.4.3.tgz", - "integrity": "sha512-tFWBiv3h7z+T/tDaoxA8rqTxy1CHV6gHS//QdaH4pulbq/JuBSGgQspQQqcgnwdAx6pNI7cmvz5Sv/addzHmUg==", + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.5.0.tgz", + "integrity": "sha512-rh+Zjr6DNfUYR3bPwJEnuwDdqMbxZW7LOQfUN4B54+Cl+0o5zaU9RJ6bcidfDtC1cWCZXQ+nvX8bf6bAji37QQ==", "engines": { "node": ">=12" } @@ -9531,41 +8588,6 @@ "semver": "bin/semver.js" } }, - "node_modules/make-fetch-happen": { - "version": "11.1.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-11.1.1.tgz", - "integrity": "sha512-rLWS7GCSTcEujjVBs2YqG7Y4643u8ucvCJeSRqiLYhesrDuzeuFIk37xREzAsfQaqzl8b9rNCE4m6J8tvX4Q8w==", - "dev": true, - "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^17.0.0", - "http-cache-semantics": "^4.1.1", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^5.0.0", - "minipass-fetch": "^3.0.0", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^10.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/make-fetch-happen/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/map-age-cleaner": { "version": "0.1.3", "resolved": "https://registry.npmjs.org/map-age-cleaner/-/map-age-cleaner-0.1.3.tgz", @@ -9598,6 +8620,7 @@ "version": "14.1.0", "resolved": "https://registry.npmjs.org/markdown-it/-/markdown-it-14.1.0.tgz", "integrity": "sha512-a54IwgWPaeBCAAsv13YgmALOF1elABB08FxO9i+r4VFk5Vl4pKokRPeX8u5TCgSsPi6ec1otfLjdOpVcgbpshg==", + "dev": true, "dependencies": { "argparse": "^2.0.1", "entities": "^4.4.0", @@ -9620,17 +8643,6 @@ "markdown-it": "*" } }, - "node_modules/markdown-it-attrs": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/markdown-it-attrs/-/markdown-it-attrs-1.2.1.tgz", - "integrity": "sha512-EYYKLF9RvQJx1Etsb6EsBGWL7qNQLpg9BRej5f06+UdX75T5gvldEn7ts6bkLIQqugE15SGn4lw1CXDS1A+XUA==", - "engines": { - "node": ">=6" - }, - "peerDependencies": { - "markdown-it": ">=7.0.1" - } - }, "node_modules/markdown-it-emoji": { "version": "1.4.0", "resolved": "https://registry.npmjs.org/markdown-it-emoji/-/markdown-it-emoji-1.4.0.tgz", @@ -9641,26 +8653,17 @@ "resolved": "https://registry.npmjs.org/markdown-it-lazy-headers/-/markdown-it-lazy-headers-0.1.3.tgz", "integrity": "sha512-65BxqvmYLpVifv6MvTElthY8zvZ/TpZBCdshr/mTpsFkqwcwWtfD3YoSE7RYSn7ugnEAAaj2gywszq+hI/Pxgg==" }, - "node_modules/markdown-it/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, "node_modules/markdown-it/node_modules/mdurl": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/mdurl/-/mdurl-2.0.0.tgz", - "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==" + "integrity": "sha512-Lf+9+2r+Tdp5wXDXC4PcIBjTDtq4UKjCPMQhKIuzpJNW0b96kVqSwW0bT7FhRSfmAiFYgP+SCRvdrDozfh0U5w==", + "dev": true }, "node_modules/markdown-it/node_modules/uc.micro": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/uc.micro/-/uc.micro-2.1.0.tgz", - "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==" + "integrity": "sha512-ARDJmphmdvUk6Glw7y9DQ2bFkKBHwQHLi2lsaH6PPmz/Ka9sFOBsBluozhDltWmnv9u/cF6Rt87znRTPV+yp/A==", + "dev": true }, "node_modules/marked": { "version": "4.3.0", @@ -9936,9 +8939,12 @@ } }, "node_modules/merge-descriptors": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.1.tgz", - "integrity": "sha512-cCi6g3/Zr1iqQi6ySbseM1Xvooa98N0w31jzUYrXPX2xqObmFGHJ0tQ5u74H3mVh7wLouTseZyYIq39g8cNp1w==" + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/merge-descriptors/-/merge-descriptors-1.0.3.tgz", + "integrity": "sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==", + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } }, "node_modules/merge2": { "version": "1.4.1", @@ -9957,11 +8963,11 @@ } }, "node_modules/micromatch": { - "version": "4.0.5", - "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.5.tgz", - "integrity": "sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==", + "version": "4.0.8", + "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", + "integrity": "sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==", "dependencies": { - "braces": "^3.0.2", + "braces": "^3.0.3", "picomatch": "^2.3.1" }, "engines": { @@ -10006,18 +9012,6 @@ "node": ">=6" } }, - "node_modules/mimic-response": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/mimic-response/-/mimic-response-4.0.0.tgz", - "integrity": "sha512-e5ISH9xMYU0DzrT+jl8q2ze9D6eWBto+I8CNpe+VI+K2J/F/k3PdkdTdz4wvGVH4NTpo+NRYTVIuMQEMMcsLqg==", - "dev": true, - "engines": { - "node": "^12.20.0 || ^14.13.1 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/min-indent": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", @@ -10033,207 +9027,47 @@ "integrity": "sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==" }, "node_modules/minimatch": { - "version": "9.0.4", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", - "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=16 || 14 >=14.17" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/minimist": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", - "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/minimist-options": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", - "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", - "dev": true, - "dependencies": { - "arrify": "^1.0.1", - "is-plain-obj": "^1.1.0", - "kind-of": "^6.0.3" - }, - "engines": { - "node": ">= 6" - } - }, - "node_modules/minipass": { - "version": "7.1.2", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", - "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", - "engines": { - "node": ">=16 || 14 >=14.17" - } - }, - "node_modules/minipass-collect": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/minipass-collect/-/minipass-collect-1.0.2.tgz", - "integrity": "sha512-6T6lH0H8OG9kITm/Jm6tdooIbogG9e0tLgpY6mphXSm/A9u8Nq1ryBG+Qspiub9LjWlBPsPS3tWQ/Botq4FdxA==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-collect/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-fetch": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-3.0.4.tgz", - "integrity": "sha512-jHAqnA728uUpIaFm7NWsCnqKT6UqZz7GcI/bDpPATuwYyKwJwW0remxSCxUlKiEty+eopHGa3oc8WxgQ1FFJqg==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" - } - }, - "node_modules/minipass-flush": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/minipass-flush/-/minipass-flush-1.0.5.tgz", - "integrity": "sha512-JmQSYYpPUqX5Jyn1mXaRwOda1uQ8HP5KAT/oDSLCzt1BYRhQU0/hDtsB1ufZfEEzMZ9aAVmsBw8+FWsIXlClWw==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/minipass-flush/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-json-stream": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/minipass-json-stream/-/minipass-json-stream-1.0.1.tgz", - "integrity": "sha512-ODqY18UZt/I8k+b7rl2AENgbWE8IDYam+undIJONvigAz8KR5GWblsFTEfQs0WODsjbSXWlm+JHEv8Gr6Tfdbg==", - "dev": true, - "dependencies": { - "jsonparse": "^1.3.1", - "minipass": "^3.0.0" - } - }, - "node_modules/minipass-json-stream/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/minipass-pipeline/-/minipass-pipeline-1.2.4.tgz", - "integrity": "sha512-xuIq7cIOt09RPRJ19gdi4b+RiNvDFYe5JH+ggNvBqGqpQXcru3PcRmOZuHBKWK1Txf9+cQ+HMVN4d6z46LZP7A==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-pipeline/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/minipass-sized": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/minipass-sized/-/minipass-sized-1.0.3.tgz", - "integrity": "sha512-MbkQQ2CTiBMlA2Dm/5cY+9SWFEN8pzzOXi6rlM5Xxq0Yqbda5ZQy9sU75a673FE9ZK0Zsbr6Y5iP6u9nktfg2g==", - "dev": true, + "version": "9.0.4", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.4.tgz", + "integrity": "sha512-KqWh+VchfxcMNRAJjj2tnsSJdNbHsVgnkBhTNrW7AjVo6OvLtxw8zfT9oLw1JSohlFzJ8jCoTgaoXvJ+kHt6fw==", "dependencies": { - "minipass": "^3.0.0" + "brace-expansion": "^2.0.1" }, "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/minipass-sized/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/minizlib": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", - "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "node_modules/minimist-options": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/minimist-options/-/minimist-options-4.1.0.tgz", + "integrity": "sha512-Q4r8ghd80yhO/0j1O3B2BjweX3fiHg9cdOwjJd2J76Q135c+NDxGCqdYKQ1SKBuFfgWbAUzBfvYjPUEeNgqN1A==", "dev": true, "dependencies": { - "minipass": "^3.0.0", - "yallist": "^4.0.0" + "arrify": "^1.0.1", + "is-plain-obj": "^1.1.0", + "kind-of": "^6.0.3" }, "engines": { - "node": ">= 8" + "node": ">= 6" } }, - "node_modules/minizlib/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, + "node_modules/minipass": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", + "integrity": "sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==", "engines": { - "node": ">=8" + "node": ">=16 || 14 >=14.17" } }, "node_modules/mkdirp": { @@ -10267,6 +9101,29 @@ "lodash": "^4.17.21" } }, + "node_modules/mock-knex": { + "version": "0.4.13", + "resolved": "https://registry.npmjs.org/mock-knex/-/mock-knex-0.4.13.tgz", + "integrity": "sha512-UmZlxiJH7bBdzjSWcrLJ1tnLfPNL7GfJO1IWL4sHWfMzLqdA3VAVWhotq1YiyE5NwVcrQdoXj3TGGjhTkBeIcQ==", + "dev": true, + "dependencies": { + "bluebird": "^3.4.1", + "lodash": "^4.14.2", + "semver": "^5.3.0" + }, + "peerDependencies": { + "knex": "> 0.8" + } + }, + "node_modules/mock-knex/node_modules/semver": { + "version": "5.7.2", + "resolved": "https://registry.npmjs.org/semver/-/semver-5.7.2.tgz", + "integrity": "sha512-cBznnQ9KjJqU67B52RMC65CMarK2600WFnbkcaiwWq3xy/5haFJlshgnpjovMVJ+Hff49d8GEn0b87C5pDQ10g==", + "dev": true, + "bin": { + "semver": "bin/semver" + } + }, "node_modules/modify-values": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/modify-values/-/modify-values-1.0.1.tgz", @@ -10299,9 +9156,9 @@ } }, "node_modules/mongodb": { - "version": "5.9.0", - "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.0.tgz", - "integrity": "sha512-g+GCMHN1CoRUA+wb1Agv0TI4YTSiWr42B5ulkiAfLLHitGK1R+PkSAf3Lr5rPZwi/3F04LiaZEW0Kxro9Fi2TA==", + "version": "5.9.2", + "resolved": "https://registry.npmjs.org/mongodb/-/mongodb-5.9.2.tgz", + "integrity": "sha512-H60HecKO4Bc+7dhOv4sJlgvenK4fQNqqUIlXxZYQNbfEWSALGAwGoyJd/0Qwk4TttFXUOHJ2ZJQe/52ScaUwtQ==", "dependencies": { "bson": "^5.5.0", "mongodb-connection-string-url": "^2.6.0", @@ -10348,13 +9205,13 @@ } }, "node_modules/mongoose": { - "version": "7.6.4", - "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.6.4.tgz", - "integrity": "sha512-kadPkS/f5iZJrrMxxOvSoOAErXmdnb28lMvHmuYgmV1ZQTpRqpp132PIPHkJMbG4OC2H0eSXYw/fNzYTH+LUcw==", + "version": "7.8.3", + "resolved": "https://registry.npmjs.org/mongoose/-/mongoose-7.8.3.tgz", + "integrity": "sha512-eFnbkKgyVrICoHB6tVJ4uLanS7d5AIo/xHkEbQeOv6g2sD7gh/1biRwvFifsmbtkIddQVNr3ROqHik6gkknN3g==", "dependencies": { "bson": "^5.5.0", "kareem": "2.5.1", - "mongodb": "5.9.0", + "mongodb": "5.9.2", "mpath": "0.9.0", "mquery": "5.0.0", "ms": "2.1.3", @@ -10433,9 +9290,9 @@ "integrity": "sha512-bk3gXBZDGILuuo/6sKtr0DQmSThYHLtNCdSdXk9YkxD/jK6X2vmCyyXBBxyqZ4XcnzTyYEAThfX3DCEnLf6igw==" }, "node_modules/nanoid": { - "version": "3.3.7", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz", - "integrity": "sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==", + "version": "3.3.8", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.8.tgz", + "integrity": "sha512-WNLf5Sd8oZxOm+TzppcYk8gVOgP+l58xNy58D0nbUnOxOWRWvlcCV4kUF7ltmI6PsrLl/BgKEyS4mqsGChFN0w==", "funding": [ { "type": "github", @@ -10465,384 +9322,116 @@ }, "node_modules/neo-async": { "version": "2.6.2", - "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", - "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" - }, - "node_modules/nice-try": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", - "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" - }, - "node_modules/nise": { - "version": "5.1.5", - "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", - "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^2.0.0", - "@sinonjs/fake-timers": "^10.0.2", - "@sinonjs/text-encoding": "^0.7.1", - "just-extend": "^4.0.2", - "path-to-regexp": "^1.7.0" - } - }, - "node_modules/nise/node_modules/@sinonjs/commons": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", - "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/nise/node_modules/@sinonjs/fake-timers": { - "version": "10.3.0", - "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", - "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", - "dev": true, - "dependencies": { - "@sinonjs/commons": "^3.0.0" - } - }, - "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", - "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", - "dev": true, - "dependencies": { - "type-detect": "4.0.8" - } - }, - "node_modules/nise/node_modules/isarray": { - "version": "0.0.1", - "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", - "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", - "dev": true - }, - "node_modules/nise/node_modules/path-to-regexp": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.8.0.tgz", - "integrity": "sha512-n43JRhlUKUAlibEJhPeir1ncUID16QnEjNpwzNdO3Lm4ywrBpBZ5oLD0I6br9evr1Y9JTqwRtAh7JLoOzAQdVA==", - "dev": true, - "dependencies": { - "isarray": "0.0.1" - } - }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", - "dependencies": { - "whatwg-url": "^5.0.0" - }, - "engines": { - "node": "4.x || >=6.0.0" - }, - "peerDependencies": { - "encoding": "^0.1.0" - }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } - } - }, - "node_modules/node-fetch-h2": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", - "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", - "dependencies": { - "http2-client": "^1.2.5" - }, - "engines": { - "node": "4.x || >=6.0.0" - } - }, - "node_modules/node-fetch/node_modules/tr46": { - "version": "0.0.3", - "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", - "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" - }, - "node_modules/node-fetch/node_modules/webidl-conversions": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", - "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" - }, - "node_modules/node-fetch/node_modules/whatwg-url": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", - "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", - "dependencies": { - "tr46": "~0.0.3", - "webidl-conversions": "^3.0.0" - } - }, - "node_modules/node-gyp": { - "version": "9.4.1", - "resolved": "https://registry.npmjs.org/node-gyp/-/node-gyp-9.4.1.tgz", - "integrity": "sha512-OQkWKbjQKbGkMf/xqI1jjy3oCTgMKJac58G2+bjZb3fza6gW2YrCSdMQYaoTb70crvE//Gngr4f0AgVHmqHvBQ==", - "dev": true, - "dependencies": { - "env-paths": "^2.2.0", - "exponential-backoff": "^3.1.1", - "glob": "^7.1.4", - "graceful-fs": "^4.2.6", - "make-fetch-happen": "^10.0.3", - "nopt": "^6.0.0", - "npmlog": "^6.0.0", - "rimraf": "^3.0.2", - "semver": "^7.3.5", - "tar": "^6.1.2", - "which": "^2.0.2" - }, - "bin": { - "node-gyp": "bin/node-gyp.js" - }, - "engines": { - "node": "^12.13 || ^14.13 || >=16" - } - }, - "node_modules/node-gyp/node_modules/@npmcli/fs": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/@npmcli/fs/-/fs-2.1.2.tgz", - "integrity": "sha512-yOJKRvohFOaLqipNtwYB9WugyZKhC/DZC4VYPmpaCzDBrA8YpK3qHZ8/HGscMnE4GqbkLNuVcCnxkeQEdGt6LQ==", - "dev": true, - "dependencies": { - "@gar/promisify": "^1.1.3", - "semver": "^7.3.5" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, - "node_modules/node-gyp/node_modules/cacache": { - "version": "16.1.3", - "resolved": "https://registry.npmjs.org/cacache/-/cacache-16.1.3.tgz", - "integrity": "sha512-/+Emcj9DAXxX4cwlLmRI9c166RuL3w30zp4R7Joiv2cQTtTtA+jeuCAjH3ZlGnYS3tKENSrKhAzVVP9GVyzeYQ==", - "dev": true, - "dependencies": { - "@npmcli/fs": "^2.1.0", - "@npmcli/move-file": "^2.0.0", - "chownr": "^2.0.0", - "fs-minipass": "^2.1.0", - "glob": "^8.0.1", - "infer-owner": "^1.0.4", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "mkdirp": "^1.0.4", - "p-map": "^4.0.0", - "promise-inflight": "^1.0.1", - "rimraf": "^3.0.2", - "ssri": "^9.0.0", - "tar": "^6.1.11", - "unique-filename": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/node-gyp/node_modules/cacache/node_modules/brace-expansion": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", - "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0" - } - }, - "node_modules/node-gyp/node_modules/cacache/node_modules/glob": { - "version": "8.1.0", - "resolved": "https://registry.npmjs.org/glob/-/glob-8.1.0.tgz", - "integrity": "sha512-r8hpEjiQEYlF2QU0df3dS+nxxSIreXQS1qRhMJM0Q5NDdR386C7jb7Hwwod8Fgiuex+k0GFjgft18yvxm5XoCQ==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^5.0.1", - "once": "^1.3.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/node-gyp/node_modules/cacache/node_modules/minimatch": { - "version": "5.1.6", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-5.1.6.tgz", - "integrity": "sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==", - "dev": true, - "dependencies": { - "brace-expansion": "^2.0.1" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/node-gyp/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/node-gyp/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } + "resolved": "https://registry.npmjs.org/neo-async/-/neo-async-2.6.2.tgz", + "integrity": "sha512-Yd3UES5mWCSqR+qNT93S3UoYUkqAZ9lLg8a7g9rimsWmYGK8cVToA4/sF3RrshdyV3sAGMXVUmpMYOw+dLpOuw==" + }, + "node_modules/nice-try": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/nice-try/-/nice-try-1.0.5.tgz", + "integrity": "sha512-1nh45deeb5olNY7eX82BkPO7SSxR5SSYJiPTrTdFUVYwAl8CKMA5N9PjTYkHiRjisVcxcQ1HXdLhx2qxxJzLNQ==" }, - "node_modules/node-gyp/node_modules/make-fetch-happen": { - "version": "10.2.1", - "resolved": "https://registry.npmjs.org/make-fetch-happen/-/make-fetch-happen-10.2.1.tgz", - "integrity": "sha512-NgOPbRiaQM10DYXvN3/hhGVI2M5MtITFryzBGxHM5p4wnFxsVCbxkrBrDsk+EZ5OB4jEOT7AjDxtdF+KVEFT7w==", + "node_modules/nise": { + "version": "5.1.5", + "resolved": "https://registry.npmjs.org/nise/-/nise-5.1.5.tgz", + "integrity": "sha512-VJuPIfUFaXNRzETTQEEItTOP8Y171ijr+JLq42wHes3DiryR8vT+1TXQW/Rx8JNUhyYYWyIvjXTU6dOhJcs9Nw==", "dev": true, "dependencies": { - "agentkeepalive": "^4.2.1", - "cacache": "^16.1.0", - "http-cache-semantics": "^4.1.0", - "http-proxy-agent": "^5.0.0", - "https-proxy-agent": "^5.0.0", - "is-lambda": "^1.0.1", - "lru-cache": "^7.7.1", - "minipass": "^3.1.6", - "minipass-collect": "^1.0.2", - "minipass-fetch": "^2.0.3", - "minipass-flush": "^1.0.5", - "minipass-pipeline": "^1.2.4", - "negotiator": "^0.6.3", - "promise-retry": "^2.0.1", - "socks-proxy-agent": "^7.0.0", - "ssri": "^9.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "@sinonjs/commons": "^2.0.0", + "@sinonjs/fake-timers": "^10.0.2", + "@sinonjs/text-encoding": "^0.7.1", + "just-extend": "^4.0.2", + "path-to-regexp": "^1.7.0" } }, - "node_modules/node-gyp/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "node_modules/nise/node_modules/@sinonjs/commons": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-2.0.0.tgz", + "integrity": "sha512-uLa0j859mMrg2slwQYdO/AkrOfmH+X6LTVmNTS9CqexuE2IvVORIkSpJLqePAbEnKJ77aMmCwr1NUZ57120Xcg==", "dev": true, "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" + "type-detect": "4.0.8" } }, - "node_modules/node-gyp/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "node_modules/nise/node_modules/@sinonjs/fake-timers": { + "version": "10.3.0", + "resolved": "https://registry.npmjs.org/@sinonjs/fake-timers/-/fake-timers-10.3.0.tgz", + "integrity": "sha512-V4BG07kuYSUkTCSBHG8G8TNhM+F19jXFWnQtzj+we8DrkpSBCee9Z3Ms8yiGer/dlmhe35/Xdgyo3/0rQKg7YA==", "dev": true, "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" + "@sinonjs/commons": "^3.0.0" } }, - "node_modules/node-gyp/node_modules/minipass-fetch": { - "version": "2.1.2", - "resolved": "https://registry.npmjs.org/minipass-fetch/-/minipass-fetch-2.1.2.tgz", - "integrity": "sha512-LT49Zi2/WMROHYoqGgdlQIZh8mLPZmOrN2NdJjMXxYe4nkN6FUyuPuOAOedNJDrx0IRGg9+4guZewtp8hE6TxA==", + "node_modules/nise/node_modules/@sinonjs/fake-timers/node_modules/@sinonjs/commons": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/@sinonjs/commons/-/commons-3.0.0.tgz", + "integrity": "sha512-jXBtWAF4vmdNmZgD5FoKsVLv3rPgDnLgPbU84LIJ3otV44vJlDRokVng5v8NFJdCf/da9legHcKaRuZs4L7faA==", "dev": true, "dependencies": { - "minipass": "^3.1.6", - "minipass-sized": "^1.0.3", - "minizlib": "^2.1.2" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - }, - "optionalDependencies": { - "encoding": "^0.1.13" + "type-detect": "4.0.8" } }, - "node_modules/node-gyp/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "node_modules/nise/node_modules/isarray": { + "version": "0.0.1", + "resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz", + "integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==", + "dev": true + }, + "node_modules/nise/node_modules/path-to-regexp": { + "version": "1.9.0", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-1.9.0.tgz", + "integrity": "sha512-xIp7/apCFJuUHdDLWe8O1HIkb0kQrOMb/0u6FXQjemHn/ii5LrIzU6bdECnsiTF/GjZkMEKg1xdiZwNqDYlZ6g==", "dev": true, "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" + "isarray": "0.0.1" } }, - "node_modules/node-gyp/node_modules/ssri": { - "version": "9.0.1", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-9.0.1.tgz", - "integrity": "sha512-o57Wcn66jMQvfHG1FlYbWeZWW/dHZhJXjpIcTfXldXEk5nz5lStPo3mK0OJQfGR3RbZUlbISexbljkJzuEj/8Q==", - "dev": true, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", "dependencies": { - "minipass": "^3.1.1" + "whatwg-url": "^5.0.0" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } } }, - "node_modules/node-gyp/node_modules/unique-filename": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-2.0.1.tgz", - "integrity": "sha512-ODWHtkkdx3IAR+veKxFV+VBkUMcN+FaqzUUd7IZzt+0zhDZFPFxhlqwPF3YQvMHx1TD0tdgYl+kuPnJ8E6ql7A==", - "dev": true, + "node_modules/node-fetch-h2": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/node-fetch-h2/-/node-fetch-h2-2.3.0.tgz", + "integrity": "sha512-ofRW94Ab0T4AOh5Fk8t0h8OBWrmjb0SSB20xh1H8YnPV9EJ+f5AMoYSUQ2zgJ4Iq2HAK0I2l5/Nequ8YzFS3Hg==", "dependencies": { - "unique-slug": "^3.0.0" + "http2-client": "^1.2.5" }, "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "node": "4.x || >=6.0.0" } }, - "node_modules/node-gyp/node_modules/unique-slug": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-3.0.0.tgz", - "integrity": "sha512-8EyMynh679x/0gqE9fT9oilG+qEt+ibFyqjuVTsZn1+CMxH+XLlpvr2UZx4nVcCwTpx81nICr2JQFkM+HPLq4w==", - "dev": true, + "node_modules/node-fetch/node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==" + }, + "node_modules/node-fetch/node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==" + }, + "node_modules/node-fetch/node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" } }, "node_modules/node-preload": { @@ -10885,9 +9474,9 @@ "dev": true }, "node_modules/nodemon": { - "version": "3.1.3", - "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.3.tgz", - "integrity": "sha512-m4Vqs+APdKzDFpuaL9F9EVOF85+h070FnkHVEoU4+rmT6Vw0bmNl7s61VEkY/cJkL7RCv1p4urnUDUMrS5rk2w==", + "version": "3.1.9", + "resolved": "https://registry.npmjs.org/nodemon/-/nodemon-3.1.9.tgz", + "integrity": "sha512-hdr1oIb2p6ZSxu3PB2JWWYS7ZQ0qvaZsc3hK8DR8f02kRzc8rjYmxAIvdz+aYC+8F2IjNaB7HMcSDg8nQpJxyg==", "dev": true, "dependencies": { "chokidar": "^3.5.2", @@ -10955,48 +9544,6 @@ "node": ">=4" } }, - "node_modules/nopt": { - "version": "6.0.0", - "resolved": "https://registry.npmjs.org/nopt/-/nopt-6.0.0.tgz", - "integrity": "sha512-ZwLpbTgdhuZUnZzjd7nb1ZV+4DoiC6/sfiVKok72ym/4Tlf+DFdlHYmT2JPmcNNWV6Pi3SDf1kT+A4r9RTuT9g==", - "dev": true, - "dependencies": { - "abbrev": "^1.0.0" - }, - "bin": { - "nopt": "bin/nopt.js" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, - "node_modules/normalize-package-data": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/normalize-package-data/-/normalize-package-data-5.0.0.tgz", - "integrity": "sha512-h9iPVIfrVZ9wVYQnxFgtw1ugSvGEMOlyPWWtm8BMJhnwyEL/FLbYbTY3V3PpjI/BUK67n9PEWDu6eHzu1fB15Q==", - "dev": true, - "dependencies": { - "hosted-git-info": "^6.0.0", - "is-core-module": "^2.8.1", - "semver": "^7.3.5", - "validate-npm-package-license": "^3.0.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/normalize-package-data/node_modules/hosted-git-info": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", - "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", - "dev": true, - "dependencies": { - "lru-cache": "^7.5.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/normalize-path": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz", @@ -11005,211 +9552,18 @@ "node": ">=0.10.0" } }, - "node_modules/normalize-url": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/normalize-url/-/normalize-url-8.0.0.tgz", - "integrity": "sha512-uVFpKhj5MheNBJRTiMZ9pE/7hD1QTeEvugSJW/OmLzAp78PB5O6adfMNTvmfKhXBkvCzC+rqifWcVYpGFwTjnw==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-bundled": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/npm-bundled/-/npm-bundled-3.0.0.tgz", - "integrity": "sha512-Vq0eyEQy+elFpzsKjMss9kxqb9tG3YHg4dsyWuUENuzvSUWe1TCnW/vV9FkhvBk/brEDoDiVd+M1Btosa6ImdQ==", - "dev": true, - "dependencies": { - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/npm-check-updates": { - "version": "16.14.20", - "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-16.14.20.tgz", - "integrity": "sha512-sYbIhun4DrjO7NFOTdvs11nCar0etEhZTsEjL47eM0TuiGMhmYughRCxG2SpGRmGAQ7AkwN7bw2lWzoE7q6yOQ==", - "dev": true, - "dependencies": { - "@types/semver-utils": "^1.1.1", - "chalk": "^5.3.0", - "cli-table3": "^0.6.3", - "commander": "^10.0.1", - "fast-memoize": "^2.5.2", - "find-up": "5.0.0", - "fp-and-or": "^0.1.4", - "get-stdin": "^8.0.0", - "globby": "^11.0.4", - "hosted-git-info": "^5.1.0", - "ini": "^4.1.1", - "js-yaml": "^4.1.0", - "json-parse-helpfulerror": "^1.0.3", - "jsonlines": "^0.1.1", - "lodash": "^4.17.21", - "make-fetch-happen": "^11.1.1", - "minimatch": "^9.0.3", - "p-map": "^4.0.0", - "pacote": "15.2.0", - "parse-github-url": "^1.0.2", - "progress": "^2.0.3", - "prompts-ncu": "^3.0.0", - "rc-config-loader": "^4.1.3", - "remote-git-tags": "^3.0.0", - "rimraf": "^5.0.5", - "semver": "^7.5.4", - "semver-utils": "^1.1.4", - "source-map-support": "^0.5.21", - "spawn-please": "^2.0.2", - "strip-ansi": "^7.1.0", - "strip-json-comments": "^5.0.1", - "untildify": "^4.0.0", - "update-notifier": "^6.0.2" - }, - "bin": { - "ncu": "build/src/bin/cli.js", - "npm-check-updates": "build/src/bin/cli.js" - }, - "engines": { - "node": ">=14.14" - } - }, - "node_modules/npm-check-updates/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/npm-check-updates/node_modules/commander": { - "version": "10.0.1", - "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", - "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==", - "dev": true, - "engines": { - "node": ">=14" - } - }, - "node_modules/npm-check-updates/node_modules/strip-json-comments": { - "version": "5.0.1", - "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-5.0.1.tgz", - "integrity": "sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==", - "dev": true, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/npm-install-checks": { - "version": "6.3.0", - "resolved": "https://registry.npmjs.org/npm-install-checks/-/npm-install-checks-6.3.0.tgz", - "integrity": "sha512-W29RiK/xtpCGqn6f3ixfRYGk+zRyr+Ew9F2E20BfXxT5/euLdA/Nm7fO7OeTGuAmTs30cpgInyJ0cYe708YTZw==", - "dev": true, - "dependencies": { - "semver": "^7.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-normalize-package-bin": { - "version": "3.0.1", - "resolved": "https://registry.npmjs.org/npm-normalize-package-bin/-/npm-normalize-package-bin-3.0.1.tgz", - "integrity": "sha512-dMxCf+zZ+3zeQZXKxmyuCKlIDPGuv8EF940xbkC4kQVDTtqoh6rJFO+JTKSA6/Rwi0getWmtuy4Itup0AMcaDQ==", + "version": "17.1.11", + "resolved": "https://registry.npmjs.org/npm-check-updates/-/npm-check-updates-17.1.11.tgz", + "integrity": "sha512-TR2RuGIH7P3Qrb0jfdC/nT7JWqXPKjDlxuNQt3kx4oNVf1Pn5SBRB7KLypgYZhruivJthgTtfkkyK4mz342VjA==", "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-package-arg": { - "version": "10.1.0", - "resolved": "https://registry.npmjs.org/npm-package-arg/-/npm-package-arg-10.1.0.tgz", - "integrity": "sha512-uFyyCEmgBfZTtrKk/5xDfHp6+MdrqGotX/VoOyEEl3mBwiEE5FlBaePanazJSVMPT7vKepcjYBY2ztg9A3yPIA==", - "dev": true, - "dependencies": { - "hosted-git-info": "^6.0.0", - "proc-log": "^3.0.0", - "semver": "^7.3.5", - "validate-npm-package-name": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-package-arg/node_modules/hosted-git-info": { - "version": "6.1.1", - "resolved": "https://registry.npmjs.org/hosted-git-info/-/hosted-git-info-6.1.1.tgz", - "integrity": "sha512-r0EI+HBMcXadMrugk0GCQ+6BQV39PiWAZVfq7oIckeGiN7sjRGyQxPdft3nQekFTCQbYxLBH+/axZMeH8UX6+w==", - "dev": true, - "dependencies": { - "lru-cache": "^7.5.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-packlist": { - "version": "7.0.4", - "resolved": "https://registry.npmjs.org/npm-packlist/-/npm-packlist-7.0.4.tgz", - "integrity": "sha512-d6RGEuRrNS5/N84iglPivjaJPxhDbZmlbTwTDX2IbcRHG5bZCdtysYMhwiPvcF4GisXHGn7xsxv+GQ7T/02M5Q==", - "dev": true, - "dependencies": { - "ignore-walk": "^6.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-pick-manifest": { - "version": "8.0.2", - "resolved": "https://registry.npmjs.org/npm-pick-manifest/-/npm-pick-manifest-8.0.2.tgz", - "integrity": "sha512-1dKY+86/AIiq1tkKVD3l0WI+Gd3vkknVGAggsFeBkTvbhMQ1OND/LKkYv4JtXPKUJ8bOTCyLiqEg2P6QNdK+Gg==", - "dev": true, - "dependencies": { - "npm-install-checks": "^6.0.0", - "npm-normalize-package-bin": "^3.0.0", - "npm-package-arg": "^10.0.0", - "semver": "^7.3.5" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-registry-fetch": { - "version": "14.0.5", - "resolved": "https://registry.npmjs.org/npm-registry-fetch/-/npm-registry-fetch-14.0.5.tgz", - "integrity": "sha512-kIDMIo4aBm6xg7jOttupWZamsZRkAqMqwqqbVXnUqstY5+tapvv6bkH/qMR76jdgV+YljEUCyWx3hRYMrJiAgA==", - "dev": true, - "dependencies": { - "make-fetch-happen": "^11.0.0", - "minipass": "^5.0.0", - "minipass-fetch": "^3.0.0", - "minipass-json-stream": "^1.0.1", - "minizlib": "^2.1.2", - "npm-package-arg": "^10.0.0", - "proc-log": "^3.0.0" + "bin": { + "ncu": "build/cli.js", + "npm-check-updates": "build/cli.js" }, "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/npm-registry-fetch/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" + "node": "^18.18.0 || >=20.0.0", + "npm": ">=8.12.1" } }, "node_modules/npm-run-path": { @@ -11231,21 +9585,6 @@ "node": ">=4" } }, - "node_modules/npmlog": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-6.0.2.tgz", - "integrity": "sha512-/vBvz5Jfr9dT/aFWd0FIRf+T/Q2WBsLENygUaFUqstqsycmZAP/t5BvFJTK0viFmSUxiUKTUplWy5vt+rvKIxg==", - "dev": true, - "dependencies": { - "are-we-there-yet": "^3.0.0", - "console-control-strings": "^1.1.0", - "gauge": "^4.0.3", - "set-blocking": "^2.0.0" - }, - "engines": { - "node": "^12.13.0 || ^14.15.0 || >=16.0.0" - } - }, "node_modules/nth-check": { "version": "2.1.1", "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", @@ -11266,9 +9605,9 @@ } }, "node_modules/nyc": { - "version": "17.0.0", - "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.0.0.tgz", - "integrity": "sha512-ISp44nqNCaPugLLGGfknzQwSwt10SSS5IMoPR7GLoMAyS18Iw5js8U7ga2VF9lYuMZ42gOHr3UddZw4WZltxKg==", + "version": "17.1.0", + "resolved": "https://registry.npmjs.org/nyc/-/nyc-17.1.0.tgz", + "integrity": "sha512-U42vQ4czpKa0QdI1hu950XuNhYqgoM+ZF1HT+VuUHL9hPfDPVvNQyltmMqdE9bUHMVa+8yNbc3QKTj8zQhlVxQ==", "dev": true, "dependencies": { "@istanbuljs/load-nyc-config": "^1.0.0", @@ -11278,7 +9617,7 @@ "decamelize": "^1.2.0", "find-cache-dir": "^3.2.0", "find-up": "^4.1.0", - "foreground-child": "^2.0.0", + "foreground-child": "^3.3.0", "get-package-type": "^0.1.0", "glob": "^7.1.6", "istanbul-lib-coverage": "^3.0.0", @@ -11334,29 +9673,16 @@ "dev": true }, "node_modules/nyc/node_modules/find-up": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", - "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", - "dev": true, - "dependencies": { - "locate-path": "^5.0.0", - "path-exists": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/nyc/node_modules/foreground-child": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", - "integrity": "sha512-dCIq9FpEcyQyXKCkyzmlPTFNgrCzPudOe+mhvJU5zAtlBnGVy2yKxtfsxK2tQBThwq225jcvBjpw1Gr40uzZCA==", + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-4.1.0.tgz", + "integrity": "sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==", "dev": true, "dependencies": { - "cross-spawn": "^7.0.0", - "signal-exit": "^3.0.2" + "locate-path": "^5.0.0", + "path-exists": "^4.0.0" }, "engines": { - "node": ">=8.0.0" + "node": ">=8" } }, "node_modules/nyc/node_modules/glob": { @@ -11442,21 +9768,6 @@ "node": ">=8" } }, - "node_modules/nyc/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/nyc/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -11676,13 +9987,13 @@ } }, "node_modules/object.assign": { - "version": "4.1.4", - "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.4.tgz", - "integrity": "sha512-1mxKf0e58bvyjSCtKYY4sRe9itRk3PJpquJOjeIkz885CczcI4IvJJDLPS72oowuSh+pBxUFROpX+TU++hxhZQ==", + "version": "4.1.5", + "resolved": "https://registry.npmjs.org/object.assign/-/object.assign-4.1.5.tgz", + "integrity": "sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.1.4", + "call-bind": "^1.0.5", + "define-properties": "^1.2.1", "has-symbols": "^1.0.3", "object-keys": "^1.1.1" }, @@ -11694,28 +10005,29 @@ } }, "node_modules/object.entries": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.7.tgz", - "integrity": "sha512-jCBs/0plmPsOnrKAfFQXRG2NFjlhZgjjcBLSmTnEhU8U6vVTsVe8ANeQJCHTl3gSsI4J+0emOoCgoKlmQPMgmA==", + "version": "1.1.8", + "resolved": "https://registry.npmjs.org/object.entries/-/object.entries-1.1.8.tgz", + "integrity": "sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" } }, "node_modules/object.fromentries": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.7.tgz", - "integrity": "sha512-UPbPHML6sL8PI/mOqPwsH4G6iyXcCGzLin8KvEPenOZN5lpCNBZZQ+V62vdjB1mQHrmqGQt5/OJzemUA+KJmEA==", + "version": "2.0.8", + "resolved": "https://registry.npmjs.org/object.fromentries/-/object.fromentries-2.0.8.tgz", + "integrity": "sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -11736,28 +10048,15 @@ "get-intrinsic": "^1.2.1" } }, - "node_modules/object.hasown": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/object.hasown/-/object.hasown-1.1.3.tgz", - "integrity": "sha512-fFI4VcYpRHvSLXxP7yiZOMAd331cPfd2p7PFDVbgUsYOfCT3tICVqXWngbjr4m49OvsBwUBQ6O2uQoJvy3RexA==", - "dev": true, - "dependencies": { - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, "node_modules/object.values": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.1.7.tgz", - "integrity": "sha512-aU6xnDFYT3x17e/f0IiiwlGPTy2jzMySGfUB4fq6z7CV8l85CWHDk5ErhyhpfDHhrOMwGFhSQkhMGHaIotA6Ng==", + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/object.values/-/object.values-1.2.0.tgz", + "integrity": "sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -11807,14 +10106,14 @@ } }, "node_modules/openapi-backend": { - "version": "5.10.6", - "resolved": "https://registry.npmjs.org/openapi-backend/-/openapi-backend-5.10.6.tgz", - "integrity": "sha512-vTjBRys/O4JIHdlRHUKZ7pxS+gwIJreAAU9dvYRFrImtPzQ5qxm5a6B8BTVT9m6I8RGGsShJv35MAc3Tu2/y/A==", + "version": "5.11.1", + "resolved": "https://registry.npmjs.org/openapi-backend/-/openapi-backend-5.11.1.tgz", + "integrity": "sha512-TsIbku0692sU1X5Ewhzj49ivd8EIT0GDtwbB7XvT234lY3+oJfTe3cUWaBzeKGeX+a1mjcluuJ4V+wTancRbdA==", "dependencies": { "@apidevtools/json-schema-ref-parser": "^11.1.0", "ajv": "^8.6.2", "bath-es5": "^3.0.3", - "cookie": "^0.5.0", + "cookie": "^1.0.1", "dereference-json-schema": "^0.2.1", "lodash": "^4.17.15", "mock-json-schema": "^1.0.7", @@ -11830,9 +10129,9 @@ } }, "node_modules/openapi-sampler": { - "version": "1.3.1", - "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.3.1.tgz", - "integrity": "sha512-Ert9mvc2tLPmmInwSyGZS+v4Ogu9/YoZuq9oP3EdUklg2cad6+IGndP9yqJJwbgdXwZibiq5fpv6vYujchdJFg==", + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/openapi-sampler/-/openapi-sampler-1.5.1.tgz", + "integrity": "sha512-tIWIrZUKNAsbqf3bd9U1oH6JEXo8LNYuDlXw26By67EygpjT+ArFnsxxyTMjFWRfbqo5ozkvgSQDK69Gd8CddA==", "dependencies": { "@types/json-schema": "^7.0.7", "json-pointer": "0.6.2" @@ -11904,15 +10203,6 @@ "node": ">= 0.4.0" } }, - "node_modules/p-cancelable": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/p-cancelable/-/p-cancelable-3.0.0.tgz", - "integrity": "sha512-mlVgR3PGuzlo0MmTdk4cXqXWlwQDLnONTAg6sm62XkMJEiRxN3GL3SffkYvqwonbkJBcrI7Uvv5Zh9yjvn2iUw==", - "dev": true, - "engines": { - "node": ">=12.20" - } - }, "node_modules/p-defer": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/p-defer/-/p-defer-1.0.0.tgz", @@ -11967,21 +10257,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/p-map": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/p-map/-/p-map-4.0.0.tgz", - "integrity": "sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==", - "dev": true, - "dependencies": { - "aggregate-error": "^3.0.0" - }, - "engines": { - "node": ">=10" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/p-try": { "version": "2.2.0", "resolved": "https://registry.npmjs.org/p-try/-/p-try-2.2.0.tgz", @@ -12005,64 +10280,10 @@ "node": ">=8" } }, - "node_modules/package-json": { - "version": "8.1.1", - "resolved": "https://registry.npmjs.org/package-json/-/package-json-8.1.1.tgz", - "integrity": "sha512-cbH9IAIJHNj9uXi196JVsRlt7cHKak6u/e6AkL/bkRelZ7rlL3X1YKxsZwa36xipOEKAsdtmaG6aAJoM1fx2zA==", - "dev": true, - "dependencies": { - "got": "^12.1.0", - "registry-auth-token": "^5.0.1", - "registry-url": "^6.0.0", - "semver": "^7.3.7" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/pacote": { - "version": "15.2.0", - "resolved": "https://registry.npmjs.org/pacote/-/pacote-15.2.0.tgz", - "integrity": "sha512-rJVZeIwHTUta23sIZgEIM62WYwbmGbThdbnkt81ravBplQv+HjyroqnLRNH2+sLJHcGZmLRmhPwACqhfTcOmnA==", - "dev": true, - "dependencies": { - "@npmcli/git": "^4.0.0", - "@npmcli/installed-package-contents": "^2.0.1", - "@npmcli/promise-spawn": "^6.0.1", - "@npmcli/run-script": "^6.0.0", - "cacache": "^17.0.0", - "fs-minipass": "^3.0.0", - "minipass": "^5.0.0", - "npm-package-arg": "^10.0.0", - "npm-packlist": "^7.0.0", - "npm-pick-manifest": "^8.0.0", - "npm-registry-fetch": "^14.0.0", - "proc-log": "^3.0.0", - "promise-retry": "^2.0.1", - "read-package-json": "^6.0.0", - "read-package-json-fast": "^3.0.0", - "sigstore": "^1.3.0", - "ssri": "^10.0.0", - "tar": "^6.1.11" - }, - "bin": { - "pacote": "lib/bin.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/pacote/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } + "node_modules/package-json-from-dist": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/package-json-from-dist/-/package-json-from-dist-1.0.0.tgz", + "integrity": "sha512-dATvCeZN/8wQsGywez1mzHtTlP22H8OEfPrVMLNr4/eGa+ijtLn/6M5f0dY8UKNrC2O9UCU6SSoG3qRKnt7STw==" }, "node_modules/parent-module": { "version": "2.0.0", @@ -12075,18 +10296,6 @@ "node": ">=8" } }, - "node_modules/parse-github-url": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/parse-github-url/-/parse-github-url-1.0.2.tgz", - "integrity": "sha512-kgBf6avCbO3Cn6+RnzRGLkUsv4ZVqv/VfAYkRsyBcgkshNvVBkRn1FEZcW0Jb+npXQWm2vHPnnOqFteZxRRGNw==", - "dev": true, - "bin": { - "parse-github-url": "cli.js" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/parse-json": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/parse-json/-/parse-json-4.0.0.tgz", @@ -12142,15 +10351,15 @@ "url": "https://github.com/inikulin/parse5?sponsor=1" } }, - "node_modules/parse5/node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "engines": { - "node": ">=0.12" + "node_modules/parse5-parser-stream": { + "version": "7.1.2", + "resolved": "https://registry.npmjs.org/parse5-parser-stream/-/parse5-parser-stream-7.1.2.tgz", + "integrity": "sha512-JyeQc9iwFLn5TbvvqACIF/VXG6abODeB3Fwmv/TGdLk2LfbWkaySGY72at4+Ty7EkPZj854u4CrICqNk2qIbow==", + "dependencies": { + "parse5": "^7.0.0" }, "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" + "url": "https://github.com/inikulin/parse5?sponsor=1" } }, "node_modules/parseurl": { @@ -12208,17 +10417,14 @@ } }, "node_modules/path-scurry/node_modules/lru-cache": { - "version": "10.2.2", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.2.2.tgz", - "integrity": "sha512-9hp3Vp2/hFQUiIwKo8XCeFVnrg8Pk3TYNPIR7tJADKi5YfcF7vEaK7avFHTlSy3kOKYaJQaalfEo6YuXdceBOQ==", - "engines": { - "node": "14 || >=16.14" - } + "version": "10.4.3", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", + "integrity": "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==" }, "node_modules/path-to-regexp": { - "version": "0.1.7", - "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.7.tgz", - "integrity": "sha512-5DFkuoqlv1uYQKxy8omFBeJPQcdoE07Kv2sferDCrAq1ohOU+MSDswDIbnx3YAM60qIOnYa53wBhXW0EbMonrQ==" + "version": "0.1.12", + "resolved": "https://registry.npmjs.org/path-to-regexp/-/path-to-regexp-0.1.12.tgz", + "integrity": "sha512-RA1GjUVMnvYFxuqovrEqZoxxW5NUZqbwKtYz/Tt7nXerk0LbLblQmrsgdeOxV5SFHf0UDggjS/bSeOZwt1pmEQ==" }, "node_modules/path-type": { "version": "4.0.0", @@ -12427,10 +10633,19 @@ "node": ">=0.10.0" } }, + "node_modules/possible-typed-array-names": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/possible-typed-array-names/-/possible-typed-array-names-1.0.0.tgz", + "integrity": "sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==", + "dev": true, + "engines": { + "node": ">= 0.4" + } + }, "node_modules/postcss": { - "version": "8.4.38", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.38.tgz", - "integrity": "sha512-Wglpdk03BSfXkHoQa3b/oulrotAkwrlLDRSOb9D0bN86FdRyE9lppSp33aHNPgBa0JKCoB+drFLZkQoRRYae5A==", + "version": "8.4.45", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.45.tgz", + "integrity": "sha512-7KTLTdzdZZYscUc65XmjFiB73vBhBfbPztCYdUNvlaso9PrzjzcmjqBPR0lNGkcVlcO4BjiO5rK/qNz+XAen1Q==", "funding": [ { "type": "opencollective", @@ -12447,7 +10662,7 @@ ], "dependencies": { "nanoid": "^3.3.7", - "picocolors": "^1.0.0", + "picocolors": "^1.0.1", "source-map-js": "^1.2.0" }, "engines": { @@ -12549,15 +10764,6 @@ "node": ">=0.10.0" } }, - "node_modules/proc-log": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/proc-log/-/proc-log-3.0.0.tgz", - "integrity": "sha512-++Vn7NS4Xf9NacaU9Xq3URUuqZETPsf8L4j5/ckhaRYsfPeRyzGw+iDjFhV/Jr3uNmTvvddEJFWh5R1gRgUH8A==", - "dev": true, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/process-nextick-args": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/process-nextick-args/-/process-nextick-args-2.0.1.tgz", @@ -12575,65 +10781,16 @@ "node": ">=8" } }, - "node_modules/progress": { - "version": "2.0.3", - "resolved": "https://registry.npmjs.org/progress/-/progress-2.0.3.tgz", - "integrity": "sha512-7PiHtLll5LdnKIMw100I+8xJXR5gW2QwWYkT6iJva0bXitZKa/XMrSbdmg3r2Xnaidz9Qumd0VPaMrZlF9V9sA==", - "dev": true, - "engines": { - "node": ">=0.4.0" - } - }, "node_modules/prom-client": { - "version": "14.2.0", - "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-14.2.0.tgz", - "integrity": "sha512-sF308EhTenb/pDRPakm+WgiN+VdM/T1RaHj1x+MvAuT8UiQP8JmOEbxVqtkbfR4LrvOg5n7ic01kRBDGXjYikA==", + "version": "15.1.3", + "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz", + "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==", "dependencies": { + "@opentelemetry/api": "^1.4.0", "tdigest": "^0.1.1" }, "engines": { - "node": ">=10" - } - }, - "node_modules/promise-inflight": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/promise-inflight/-/promise-inflight-1.0.1.tgz", - "integrity": "sha512-6zWPyEOFaQBJYcGMHBKTKJ3u6TBsnMFOIZSa6ce1e/ZrrsOlnHRHbabMjLiBYKp+n44X9eUI6VUPaukCXHuG4g==", - "dev": true - }, - "node_modules/promise-retry": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/promise-retry/-/promise-retry-2.0.1.tgz", - "integrity": "sha512-y+WKFlBR8BGXnsNlIHFGPZmyDf3DFMoLhaflAnyZgV6rG6xu+JwesTo2Q9R6XwYmtmwAFCkAk3e35jEdoeh/3g==", - "dev": true, - "dependencies": { - "err-code": "^2.0.2", - "retry": "^0.12.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/promise-retry/node_modules/retry": { - "version": "0.12.0", - "resolved": "https://registry.npmjs.org/retry/-/retry-0.12.0.tgz", - "integrity": "sha512-9LkiTwjUh6rT555DtE9rTX+BKByPfrMzEAtnlEtdEwr3Nkffwiihqe2bWADg+OQRjt9gl6ICdmB/ZFDCGAtSow==", - "dev": true, - "engines": { - "node": ">= 4" - } - }, - "node_modules/prompts-ncu": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/prompts-ncu/-/prompts-ncu-3.0.0.tgz", - "integrity": "sha512-qyz9UxZ5MlPKWVhWrCmSZ1ahm2GVYdjLb8og2sg0IPth1KRuhcggHGuijz0e41dkx35p1t1q3GRISGH7QGALFA==", - "dev": true, - "dependencies": { - "kleur": "^4.0.1", - "sisteransi": "^1.0.5" - }, - "engines": { - "node": ">= 14" + "node": "^16 || ^18 || >=20" } }, "node_modules/prop-types": { @@ -12647,12 +10804,6 @@ "react-is": "^16.13.1" } }, - "node_modules/proto-list": { - "version": "1.2.4", - "resolved": "https://registry.npmjs.org/proto-list/-/proto-list-1.2.4.tgz", - "integrity": "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA==", - "dev": true - }, "node_modules/protobufjs": { "version": "7.3.0", "resolved": "https://registry.npmjs.org/protobufjs/-/protobufjs-7.3.0.tgz", @@ -12747,23 +10898,9 @@ "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode.js/-/punycode.js-2.3.1.tgz", "integrity": "sha512-uxFIHU0YlHYhDQtV4R9J6a52SLx28BCjT+4ieh7IGbgwVJWO+km431c4yRlREUAsAmt/uMjQUyQHNEPf0M39CA==", - "engines": { - "node": ">=6" - } - }, - "node_modules/pupa": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/pupa/-/pupa-3.1.0.tgz", - "integrity": "sha512-FLpr4flz5xZTSJxSeaheeMKN/EDzMdK7b8PTOC6a5PYFKTucWbdqjgqaEyH0shFiSJrVB1+Qqi4Tk19ccU6Aug==", "dev": true, - "dependencies": { - "escape-goat": "^4.0.0" - }, "engines": { - "node": ">=12.20" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "node": ">=6" } }, "node_modules/q": { @@ -12777,9 +10914,9 @@ } }, "node_modules/qs": { - "version": "6.12.0", - "resolved": "https://registry.npmjs.org/qs/-/qs-6.12.0.tgz", - "integrity": "sha512-trVZiI6RMOkO476zLGaBIzszOdFPnCCXHPG9kn0yuS1uz6xdVxPfZdB3vUig9pxPFDM9BRAgz/YUIVQ1/vuiUg==", + "version": "6.13.0", + "resolved": "https://registry.npmjs.org/qs/-/qs-6.13.0.tgz", + "integrity": "sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==", "dependencies": { "side-channel": "^1.0.6" }, @@ -12832,30 +10969,19 @@ } }, "node_modules/raw-body": { - "version": "2.5.2", - "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-2.5.2.tgz", - "integrity": "sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==", + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/raw-body/-/raw-body-3.0.0.tgz", + "integrity": "sha512-RmkhL8CAyCRPXCE28MMH0z2PNWQBNk2Q09ZdxM9IOOXwxwZbN+qbWaatPkdkWIKL2ZVDImrN/pK5HTRz2PcS4g==", "dependencies": { "bytes": "3.1.2", "http-errors": "2.0.0", - "iconv-lite": "0.4.24", + "iconv-lite": "0.6.3", "unpipe": "1.0.0" }, "engines": { "node": ">= 0.8" } }, - "node_modules/raw-body/node_modules/iconv-lite": { - "version": "0.4.24", - "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.4.24.tgz", - "integrity": "sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==", - "dependencies": { - "safer-buffer": ">= 2.1.2 < 3" - }, - "engines": { - "node": ">=0.10.0" - } - }, "node_modules/rc": { "version": "1.2.8", "resolved": "https://registry.npmjs.org/rc/-/rc-1.2.8.tgz", @@ -12870,18 +10996,6 @@ "rc": "cli.js" } }, - "node_modules/rc-config-loader": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/rc-config-loader/-/rc-config-loader-4.1.3.tgz", - "integrity": "sha512-kD7FqML7l800i6pS6pvLyIE2ncbk9Du8Q0gp/4hMPhJU6ZxApkoLcGD8ZeqgiAlfwZ6BlETq6qqe+12DUL207w==", - "dev": true, - "dependencies": { - "debug": "^4.3.4", - "js-yaml": "^4.1.0", - "json5": "^2.2.2", - "require-from-string": "^2.0.2" - } - }, "node_modules/rc/node_modules/ini": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/ini/-/ini-1.3.8.tgz", @@ -12907,34 +11021,6 @@ "integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==", "dev": true }, - "node_modules/read-package-json": { - "version": "6.0.4", - "resolved": "https://registry.npmjs.org/read-package-json/-/read-package-json-6.0.4.tgz", - "integrity": "sha512-AEtWXYfopBj2z5N5PbkAOeNHRPUg5q+Nen7QLxV8M2zJq1ym6/lCz3fYNTCXe19puu2d06jfHhrP7v/S2PtMMw==", - "dev": true, - "dependencies": { - "glob": "^10.2.2", - "json-parse-even-better-errors": "^3.0.0", - "normalize-package-data": "^5.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/read-package-json-fast": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/read-package-json-fast/-/read-package-json-fast-3.0.2.tgz", - "integrity": "sha512-0J+Msgym3vrLOUB3hzQCuZHII0xkNGCtz/HJH9xZshwv9DbDwkw1KaE3gx/e2J5rpEY5rtOy6cyhKOPrkP7FZw==", - "dev": true, - "dependencies": { - "json-parse-even-better-errors": "^3.0.0", - "npm-normalize-package-bin": "^3.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/read-pkg": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/read-pkg/-/read-pkg-3.0.0.tgz", @@ -13164,16 +11250,36 @@ "node": ">=8" } }, + "node_modules/redis-errors": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz", + "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==", + "engines": { + "node": ">=4" + } + }, + "node_modules/redis-parser": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz", + "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==", + "dependencies": { + "redis-errors": "^1.0.0" + }, + "engines": { + "node": ">=4" + } + }, "node_modules/reflect.getprototypeof": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.4.tgz", - "integrity": "sha512-ECkTw8TmJwW60lOTR+ZkODISW6RQ8+2CL3COqtiJKLd6MmB45hN51HprHFziKLGkAuTGQhBb91V8cy+KHlaCjw==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/reflect.getprototypeof/-/reflect.getprototypeof-1.0.6.tgz", + "integrity": "sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.1", + "es-errors": "^1.3.0", + "get-intrinsic": "^1.2.4", "globalthis": "^1.0.3", "which-builtin-type": "^1.1.3" }, @@ -13193,14 +11299,15 @@ } }, "node_modules/regexp.prototype.flags": { - "version": "1.5.1", - "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.1.tgz", - "integrity": "sha512-sy6TXMN+hnP/wMy+ISxg3krXx7BAtWVO4UouuCN/ziM9UEne0euamVNafDfvC83bRNr95y0V5iijeDQFUNpvrg==", + "version": "1.5.2", + "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.2.tgz", + "integrity": "sha512-NcDiDkTLuPR+++OCKB0nWafEmhg/Da8aUPLPMQbK+bxKKCm1/S5he+AqYa4PlMCVBalb4/yxIRub6qkEx5yJbw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "set-function-name": "^2.0.0" + "call-bind": "^1.0.6", + "define-properties": "^1.2.1", + "es-errors": "^1.3.0", + "set-function-name": "^2.0.1" }, "engines": { "node": ">= 0.4" @@ -13218,34 +11325,7 @@ "node": ">=8" }, "funding": { - "url": "https://github.com/sponsors/mysticatea" - } - }, - "node_modules/registry-auth-token": { - "version": "5.0.2", - "resolved": "https://registry.npmjs.org/registry-auth-token/-/registry-auth-token-5.0.2.tgz", - "integrity": "sha512-o/3ikDxtXaA59BmZuZrJZDJv8NMDGSj+6j6XaeBmHw8eY1i1qd9+6H+LjVvQXx3HN6aRCGa1cUdJ9RaJZUugnQ==", - "dev": true, - "dependencies": { - "@pnpm/npm-conf": "^2.1.0" - }, - "engines": { - "node": ">=14" - } - }, - "node_modules/registry-url": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/registry-url/-/registry-url-6.0.1.tgz", - "integrity": "sha512-+crtS5QjFRqFCoQmvGduwYWEBng99ZvmFvF+cUJkGYF1L1BfU8C6Zp9T7f5vPAwyLkUExpvK+ANVZmGU49qi4Q==", - "dev": true, - "dependencies": { - "rc": "1.2.8" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" + "url": "https://github.com/sponsors/mysticatea" } }, "node_modules/release-zalgo": { @@ -13260,15 +11340,6 @@ "node": ">=4" } }, - "node_modules/remote-git-tags": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/remote-git-tags/-/remote-git-tags-3.0.0.tgz", - "integrity": "sha512-C9hAO4eoEsX+OXA4rla66pXZQ+TLQ8T9dttgQj18yuKlPMTVkIkdYXvlMC55IuUsIkV6DpmQYi10JKFLaU+l7w==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/repeat-string": { "version": "1.6.1", "resolved": "https://registry.npmjs.org/repeat-string/-/repeat-string-1.6.1.tgz", @@ -13642,12 +11713,6 @@ "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/resolve-alpn": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/resolve-alpn/-/resolve-alpn-1.2.1.tgz", - "integrity": "sha512-0a1F4l73/ZFZOakJnQ3FvkJ2+gSTQWz/r2KE5OdDY0TxPm5h4GkqkWWfM47T7HsbnOtcJVEF4epCVy6u7Q3K+g==", - "dev": true - }, "node_modules/resolve-from": { "version": "5.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-5.0.0.tgz", @@ -13656,21 +11721,6 @@ "node": ">=8" } }, - "node_modules/responselike": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/responselike/-/responselike-3.0.0.tgz", - "integrity": "sha512-40yHxbNcl2+rzXvZuVkrYohathsSJlMTXKryG5y8uciHv1+xDLHQpgjG64JUO9nrEq2jGLH6IZ8BcZyw3wrweg==", - "dev": true, - "dependencies": { - "lowercase-keys": "^3.0.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/resumer": { "version": "0.0.0", "resolved": "https://registry.npmjs.org/resumer/-/resumer-0.0.0.tgz", @@ -13710,23 +11760,64 @@ } }, "node_modules/rimraf": { - "version": "5.0.5", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-5.0.5.tgz", - "integrity": "sha512-CqDakW+hMe/Bz202FPEymy68P+G50RfMQK+Qo5YUqc9SPipvbGjCGKd0RSKEelbsfQuw3g5NZDSrlZZAJurH1A==", + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", + "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", + "deprecated": "Rimraf versions prior to v4 are no longer supported", "dev": true, "dependencies": { - "glob": "^10.3.7" + "glob": "^7.1.3" }, "bin": { - "rimraf": "dist/esm/bin.mjs" + "rimraf": "bin.js" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/rimraf/node_modules/brace-expansion": { + "version": "1.1.11", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", + "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0", + "concat-map": "0.0.1" + } + }, + "node_modules/rimraf/node_modules/glob": { + "version": "7.2.3", + "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", + "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", + "deprecated": "Glob versions prior to v9 are no longer supported", + "dev": true, + "dependencies": { + "fs.realpath": "^1.0.0", + "inflight": "^1.0.4", + "inherits": "2", + "minimatch": "^3.1.1", + "once": "^1.3.0", + "path-is-absolute": "^1.0.0" }, "engines": { - "node": ">=14" + "node": "*" }, "funding": { "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/rimraf/node_modules/minimatch": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", + "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", + "dev": true, + "dependencies": { + "brace-expansion": "^1.1.7" + }, + "engines": { + "node": "*" + } + }, "node_modules/run-parallel": { "version": "1.2.0", "resolved": "https://registry.npmjs.org/run-parallel/-/run-parallel-1.2.0.tgz", @@ -13750,13 +11841,13 @@ } }, "node_modules/safe-array-concat": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.0.1.tgz", - "integrity": "sha512-6XbUAseYE2KtOuGueyeobCySj9L4+66Tn6KQMOPQJrAJEowYKW/YR/MGJZl7FdydUdaFu4LYyDZjxf4/Nmo23Q==", + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/safe-array-concat/-/safe-array-concat-1.1.2.tgz", + "integrity": "sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", + "call-bind": "^1.0.7", + "get-intrinsic": "^1.2.4", "has-symbols": "^1.0.3", "isarray": "^2.0.5" }, @@ -13793,15 +11884,18 @@ ] }, "node_modules/safe-regex-test": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.0.tgz", - "integrity": "sha512-JBUUzyOgEwXQY1NuPtvcj/qcBDbDmEvWufhlnXZIm75DEHp+afM1r1ujJpJsV/gSM4t59tpDyPi1sd6ZaPFfsA==", + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/safe-regex-test/-/safe-regex-test-1.0.3.tgz", + "integrity": "sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.1.3", + "call-bind": "^1.0.6", + "es-errors": "^1.3.0", "is-regex": "^1.1.4" }, + "engines": { + "node": ">= 0.4" + }, "funding": { "url": "https://github.com/sponsors/ljharb" } @@ -13832,6 +11926,24 @@ "postcss": "^8.3.11" } }, + "node_modules/sanitize-html/node_modules/htmlparser2": { + "version": "8.0.2", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-8.0.2.tgz", + "integrity": "sha512-GYdjWKDkbRLkZ5geuHs5NY1puJ+PXwP7+fHPRz06Eirsb9ugf6d8kkXav6ADhcODhFFPMIXyxkxSuMf3D6NCFA==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "dependencies": { + "domelementtype": "^2.3.0", + "domhandler": "^5.0.3", + "domutils": "^3.0.1", + "entities": "^4.4.0" + } + }, "node_modules/semver": { "version": "7.5.4", "resolved": "https://registry.npmjs.org/semver/-/semver-7.5.4.tgz", @@ -13846,27 +11958,6 @@ "node": ">=10" } }, - "node_modules/semver-diff": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/semver-diff/-/semver-diff-4.0.0.tgz", - "integrity": "sha512-0Ju4+6A8iOnpL/Thra7dZsSlOHYAHIeMxfhWQRI1/VLcT3WDBZKKtQt/QkBOsiIN9ZpuvHE6cGZ0x4glCMmfiA==", - "dev": true, - "dependencies": { - "semver": "^7.3.5" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, - "node_modules/semver-utils": { - "version": "1.1.4", - "resolved": "https://registry.npmjs.org/semver-utils/-/semver-utils-1.1.4.tgz", - "integrity": "sha512-EjnoLE5OGmDAVV/8YDoN5KiajNadjzIp9BAHOhYeQHt7j0UWxjmgsx4YD48wp4Ue1Qogq38F1GNUJNqF1kKKxA==", - "dev": true - }, "node_modules/semver/node_modules/lru-cache": { "version": "6.0.0", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-6.0.0.tgz", @@ -13879,9 +11970,9 @@ } }, "node_modules/send": { - "version": "0.18.0", - "resolved": "https://registry.npmjs.org/send/-/send-0.18.0.tgz", - "integrity": "sha512-qqWzuOjSFOuqPjFe4NOsMLafToQQwBSOEpS+FwEt3A2V3vKubTquT3vmLTQpFgMXp8AlFWFuP1qKaJZOtPpVXg==", + "version": "0.19.0", + "resolved": "https://registry.npmjs.org/send/-/send-0.19.0.tgz", + "integrity": "sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==", "dependencies": { "debug": "2.6.9", "depd": "2.0.0", @@ -13914,6 +12005,14 @@ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz", "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==" }, + "node_modules/send/node_modules/encodeurl": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/encodeurl/-/encodeurl-1.0.2.tgz", + "integrity": "sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==", + "engines": { + "node": ">= 0.8" + } + }, "node_modules/serialize-error": { "version": "8.1.0", "resolved": "https://registry.npmjs.org/serialize-error/-/serialize-error-8.1.0.tgz", @@ -13940,14 +12039,14 @@ } }, "node_modules/serve-static": { - "version": "1.15.0", - "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.15.0.tgz", - "integrity": "sha512-XGuRDNjXUijsUL0vl6nSD7cwURuzEgglbOaFuZM9g3kwDXOWVTck0jLzjPzGD+TazWbboZYu52/9/XPdUgne9g==", + "version": "1.16.2", + "resolved": "https://registry.npmjs.org/serve-static/-/serve-static-1.16.2.tgz", + "integrity": "sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==", "dependencies": { - "encodeurl": "~1.0.2", + "encodeurl": "~2.0.0", "escape-html": "~1.0.3", "parseurl": "~1.3.3", - "send": "0.18.0" + "send": "0.19.0" }, "engines": { "node": ">= 0.8.0" @@ -13975,14 +12074,15 @@ } }, "node_modules/set-function-name": { - "version": "2.0.1", - "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.1.tgz", - "integrity": "sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==", + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz", + "integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==", "dev": true, "dependencies": { - "define-data-property": "^1.0.1", + "define-data-property": "^1.1.4", + "es-errors": "^1.3.0", "functions-have-names": "^1.2.3", - "has-property-descriptors": "^1.0.0" + "has-property-descriptors": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -14056,6 +12156,14 @@ "wordwrap": "0.0.2" } }, + "node_modules/shins/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/shins/node_modules/linkify-it": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/linkify-it/-/linkify-it-3.0.3.tgz", @@ -14079,6 +12187,17 @@ "markdown-it": "bin/markdown-it.js" } }, + "node_modules/shins/node_modules/markdown-it-attrs": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/markdown-it-attrs/-/markdown-it-attrs-1.2.1.tgz", + "integrity": "sha512-EYYKLF9RvQJx1Etsb6EsBGWL7qNQLpg9BRej5f06+UdX75T5gvldEn7ts6bkLIQqugE15SGn4lw1CXDS1A+XUA==", + "engines": { + "node": ">=6" + }, + "peerDependencies": { + "markdown-it": ">=7.0.1" + } + }, "node_modules/shins/node_modules/source-map": { "version": "0.5.7", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.5.7.tgz", @@ -14213,25 +12332,6 @@ "url": "https://github.com/sponsors/isaacs" } }, - "node_modules/sigstore": { - "version": "1.9.0", - "resolved": "https://registry.npmjs.org/sigstore/-/sigstore-1.9.0.tgz", - "integrity": "sha512-0Zjz0oe37d08VeOtBIuB6cRriqXse2e8w+7yIy2XSXjshRKxbc2KkhXjL229jXSxEm7UbcjS76wcJDGQddVI9A==", - "dev": true, - "dependencies": { - "@sigstore/bundle": "^1.1.0", - "@sigstore/protobuf-specs": "^0.2.0", - "@sigstore/sign": "^1.0.0", - "@sigstore/tuf": "^1.0.3", - "make-fetch-happen": "^11.0.1" - }, - "bin": { - "sigstore": "bin/sigstore.js" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/simple-swizzle": { "version": "0.2.2", "resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz", @@ -14275,12 +12375,6 @@ "url": "https://opencollective.com/sinon" } }, - "node_modules/sisteransi": { - "version": "1.0.5", - "resolved": "https://registry.npmjs.org/sisteransi/-/sisteransi-1.0.5.tgz", - "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", - "dev": true - }, "node_modules/slash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/slash/-/slash-3.0.0.tgz", @@ -14311,20 +12405,6 @@ "npm": ">= 3.0.0" } }, - "node_modules/socks-proxy-agent": { - "version": "7.0.0", - "resolved": "https://registry.npmjs.org/socks-proxy-agent/-/socks-proxy-agent-7.0.0.tgz", - "integrity": "sha512-Fgl0YPZ902wEsAyiQ+idGd1A7rSFx/ayC1CQVMw5P+EQx2V0SgpGtf6OKFhVjPflPUl9YMmEOnmfjCdMUsygww==", - "dev": true, - "dependencies": { - "agent-base": "^6.0.2", - "debug": "^4.3.3", - "socks": "^2.6.2" - }, - "engines": { - "node": ">= 10" - } - }, "node_modules/source-map": { "version": "0.6.1", "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.6.1.tgz", @@ -14334,23 +12414,13 @@ } }, "node_modules/source-map-js": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.0.tgz", - "integrity": "sha512-itJW8lvSA0TXEphiRoawsCksnlf8SyvmFzIhltqAHluXd88pkCd+cXJVHTDwdCr0IzwptSm035IHQktUu1QUMg==", + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", "engines": { "node": ">=0.10.0" } }, - "node_modules/source-map-support": { - "version": "0.5.21", - "resolved": "https://registry.npmjs.org/source-map-support/-/source-map-support-0.5.21.tgz", - "integrity": "sha512-uBHU3L3czsIyYXKX88fdrGovxdSCoTGDRZ6SYXtSRxLZUzHg5P/66Ht6uoUlHu9EZod+inXhKo3qQgwXUT/y1w==", - "dev": true, - "dependencies": { - "buffer-from": "^1.0.0", - "source-map": "^0.6.0" - } - }, "node_modules/sparse-bitfield": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/sparse-bitfield/-/sparse-bitfield-3.0.3.tgz", @@ -14360,18 +12430,6 @@ "memory-pager": "^1.0.2" } }, - "node_modules/spawn-please": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/spawn-please/-/spawn-please-2.0.2.tgz", - "integrity": "sha512-KM8coezO6ISQ89c1BzyWNtcn2V2kAVtwIXd3cN/V5a0xPYc1F/vydrRc01wsKFEQ/p+V1a4sw4z2yMITIXrgGw==", - "dev": true, - "dependencies": { - "cross-spawn": "^7.0.3" - }, - "engines": { - "node": ">=14" - } - }, "node_modules/spawn-sync": { "version": "1.0.15", "resolved": "https://registry.npmjs.org/spawn-sync/-/spawn-sync-1.0.15.tgz", @@ -14400,16 +12458,6 @@ "node": ">=8" } }, - "node_modules/spawn-wrap/node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", - "dev": true, - "dependencies": { - "balanced-match": "^1.0.0", - "concat-map": "0.0.1" - } - }, "node_modules/spawn-wrap/node_modules/foreground-child": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-2.0.0.tgz", @@ -14423,53 +12471,6 @@ "node": ">=8.0.0" } }, - "node_modules/spawn-wrap/node_modules/glob": { - "version": "7.2.3", - "resolved": "https://registry.npmjs.org/glob/-/glob-7.2.3.tgz", - "integrity": "sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==", - "dev": true, - "dependencies": { - "fs.realpath": "^1.0.0", - "inflight": "^1.0.4", - "inherits": "2", - "minimatch": "^3.1.1", - "once": "^1.3.0", - "path-is-absolute": "^1.0.0" - }, - "engines": { - "node": "*" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, - "node_modules/spawn-wrap/node_modules/minimatch": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.2.tgz", - "integrity": "sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==", - "dev": true, - "dependencies": { - "brace-expansion": "^1.1.7" - }, - "engines": { - "node": "*" - } - }, - "node_modules/spawn-wrap/node_modules/rimraf": { - "version": "3.0.2", - "resolved": "https://registry.npmjs.org/rimraf/-/rimraf-3.0.2.tgz", - "integrity": "sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==", - "dev": true, - "dependencies": { - "glob": "^7.1.3" - }, - "bin": { - "rimraf": "bin.js" - }, - "funding": { - "url": "https://github.com/sponsors/isaacs" - } - }, "node_modules/spawn-wrap/node_modules/signal-exit": { "version": "3.0.7", "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", @@ -14556,18 +12557,6 @@ "node": ">= 0.6" } }, - "node_modules/ssri": { - "version": "10.0.5", - "resolved": "https://registry.npmjs.org/ssri/-/ssri-10.0.5.tgz", - "integrity": "sha512-bSf16tAFkGeRlUNDjXu8FzaMQt6g2HZJrun7mtMbIPOddxt3GLMSz5VWUWcqTJUPfLEaDIepGxv+bYQW49596A==", - "dev": true, - "dependencies": { - "minipass": "^7.0.3" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/stack-trace": { "version": "0.0.10", "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-0.0.10.tgz", @@ -14577,9 +12566,9 @@ } }, "node_modules/standard": { - "version": "17.1.0", - "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.0.tgz", - "integrity": "sha512-jaDqlNSzLtWYW4lvQmU0EnxWMUGQiwHasZl5ZEIwx3S/ijZDjZOzs1y1QqKwKs5vqnFpGtizo4NOYX2s0Voq/g==", + "version": "17.1.2", + "resolved": "https://registry.npmjs.org/standard/-/standard-17.1.2.tgz", + "integrity": "sha512-WLm12WoXveKkvnPnPnaFUUHuOB2cUdAsJ4AiGHL2G0UNMrcRAWY2WriQaV8IQ3oRmYr0AWUbLNr94ekYFAHOrA==", "dev": true, "funding": [ { @@ -14595,6 +12584,7 @@ "url": "https://feross.org/support" } ], + "license": "MIT", "dependencies": { "eslint": "^8.41.0", "eslint-config-standard": "17.1.0", @@ -14602,8 +12592,8 @@ "eslint-plugin-import": "^2.27.5", "eslint-plugin-n": "^15.7.0", "eslint-plugin-promise": "^6.1.1", - "eslint-plugin-react": "^7.32.2", - "standard-engine": "^15.0.0", + "eslint-plugin-react": "^7.36.1", + "standard-engine": "^15.1.0", "version-guard": "^1.1.1" }, "bin": { @@ -14613,6 +12603,11 @@ "node": "^12.22.0 || ^14.17.0 || >=16.0.0" } }, + "node_modules/standard-as-callback": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz", + "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==" + }, "node_modules/standard-engine": { "version": "15.1.0", "resolved": "https://registry.npmjs.org/standard-engine/-/standard-engine-15.1.0.tgz", @@ -14938,39 +12933,56 @@ "dependencies": { "ansi-regex": "^5.0.1" }, - "engines": { - "node": ">=8" + "engines": { + "node": ">=8" + } + }, + "node_modules/string.prototype.matchall": { + "version": "4.0.11", + "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.11.tgz", + "integrity": "sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==", + "dev": true, + "dependencies": { + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.2", + "es-errors": "^1.3.0", + "es-object-atoms": "^1.0.0", + "get-intrinsic": "^1.2.4", + "gopd": "^1.0.1", + "has-symbols": "^1.0.3", + "internal-slot": "^1.0.7", + "regexp.prototype.flags": "^1.5.2", + "set-function-name": "^2.0.2", + "side-channel": "^1.0.6" + }, + "engines": { + "node": ">= 0.4" + }, + "funding": { + "url": "https://github.com/sponsors/ljharb" } }, - "node_modules/string.prototype.matchall": { - "version": "4.0.10", - "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.10.tgz", - "integrity": "sha512-rGXbGmOEosIQi6Qva94HUjgPs9vKW+dkG7Y8Q5O2OYkWL6wFaTRZO8zM4mhP94uX55wgyrXzfS2aGtGzUL7EJQ==", + "node_modules/string.prototype.repeat": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/string.prototype.repeat/-/string.prototype.repeat-1.0.0.tgz", + "integrity": "sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1", - "get-intrinsic": "^1.2.1", - "has-symbols": "^1.0.3", - "internal-slot": "^1.0.5", - "regexp.prototype.flags": "^1.5.0", - "set-function-name": "^2.0.0", - "side-channel": "^1.0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" + "define-properties": "^1.1.3", + "es-abstract": "^1.17.5" } }, "node_modules/string.prototype.trim": { - "version": "1.2.8", - "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.8.tgz", - "integrity": "sha512-lfjY4HcixfQXOfaqCvcBuOIapyaroTXhbkfJN3gcB1OtyupngWK4sEET9Knd0cXd28kTUqu/kHoV4HKSJdnjiQ==", + "version": "1.2.9", + "resolved": "https://registry.npmjs.org/string.prototype.trim/-/string.prototype.trim-1.2.9.tgz", + "integrity": "sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-abstract": "^1.23.0", + "es-object-atoms": "^1.0.0" }, "engines": { "node": ">= 0.4" @@ -14980,28 +12992,31 @@ } }, "node_modules/string.prototype.trimend": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.7.tgz", - "integrity": "sha512-Ni79DqeB72ZFq1uH/L6zJ+DKZTkOtPIHovb3YZHQViE+HDouuU4mBrLOLDn5Dde3RF8qw5qVETEjhu9locMLvA==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimend/-/string.prototype.trimend-1.0.8.tgz", + "integrity": "sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" }, "funding": { "url": "https://github.com/sponsors/ljharb" } }, "node_modules/string.prototype.trimstart": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.7.tgz", - "integrity": "sha512-NGhtDFu3jCEm7B4Fy0DpLewdJQOZcQ0rGbwQ/+stjnrp2i+rlKeCvos9hOIeCmqwratM47OBxY7uFZzjxHXmrg==", + "version": "1.0.8", + "resolved": "https://registry.npmjs.org/string.prototype.trimstart/-/string.prototype.trimstart-1.0.8.tgz", + "integrity": "sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "define-properties": "^1.2.0", - "es-abstract": "^1.22.1" + "call-bind": "^1.0.7", + "define-properties": "^1.2.1", + "es-object-atoms": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -15147,9 +13162,9 @@ } }, "node_modules/swagger-ui-dist": { - "version": "5.9.3", - "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.9.3.tgz", - "integrity": "sha512-/OgHfO96RWXF+p/EOjEnvKNEh94qAG/VHukgmVKh5e6foX9kas1WbjvQnDDj0sSTAMr9MHRBqAWytDcQi0VOrg==" + "version": "5.17.14", + "resolved": "https://registry.npmjs.org/swagger-ui-dist/-/swagger-ui-dist-5.17.14.tgz", + "integrity": "sha512-CVbSfaLpstV65OnSjbXfVd6Sta3q3F7Cj/yYuvHMp1P90LztOLs6PfUnKEVAeiIVQt9u2SaPwv0LiH/OyMjHRw==" }, "node_modules/swagger2openapi": { "version": "7.0.8", @@ -15572,56 +13587,6 @@ "node": "*" } }, - "node_modules/tar": { - "version": "6.2.1", - "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", - "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", - "dev": true, - "dependencies": { - "chownr": "^2.0.0", - "fs-minipass": "^2.0.0", - "minipass": "^5.0.0", - "minizlib": "^2.1.1", - "mkdirp": "^1.0.3", - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=10" - } - }, - "node_modules/tar/node_modules/fs-minipass": { - "version": "2.1.0", - "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", - "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", - "dev": true, - "dependencies": { - "minipass": "^3.0.0" - }, - "engines": { - "node": ">= 8" - } - }, - "node_modules/tar/node_modules/fs-minipass/node_modules/minipass": { - "version": "3.3.6", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", - "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", - "dev": true, - "dependencies": { - "yallist": "^4.0.0" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/tar/node_modules/minipass": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", - "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/tarn": { "version": "3.0.2", "resolved": "https://registry.npmjs.org/tarn/-/tarn-3.0.2.tgz", @@ -15891,20 +13856,6 @@ "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.6.2.tgz", "integrity": "sha512-AEYxH93jGFPn/a2iVAwW87VuUIkR1FVUKB77NwMF7nBTDkDrrT/Hpt/IrCJ0QXhW27jTBDcf5ZY7w6RiqTMw2Q==" }, - "node_modules/tuf-js": { - "version": "1.1.7", - "resolved": "https://registry.npmjs.org/tuf-js/-/tuf-js-1.1.7.tgz", - "integrity": "sha512-i3P9Kgw3ytjELUfpuKVDNBJvk4u5bXL6gskv572mcevPbSKCV3zt3djhmlEQ65yERjIbOSncy7U4cQJaB1CBCg==", - "dev": true, - "dependencies": { - "@tufjs/models": "1.0.4", - "debug": "^4.3.4", - "make-fetch-happen": "^11.1.1" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/tweetnacl": { "version": "0.14.5", "resolved": "https://registry.npmjs.org/tweetnacl/-/tweetnacl-0.14.5.tgz", @@ -15953,29 +13904,30 @@ } }, "node_modules/typed-array-buffer": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.0.tgz", - "integrity": "sha512-Y8KTSIglk9OZEr8zywiIHG/kmQ7KWyjseXs1CbSo8vC42w7hg2HgYTxSWwP0+is7bWDc1H+Fo026CpHFwm8tkw==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-buffer/-/typed-array-buffer-1.0.2.tgz", + "integrity": "sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", - "get-intrinsic": "^1.2.1", - "is-typed-array": "^1.1.10" + "call-bind": "^1.0.7", + "es-errors": "^1.3.0", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" } }, "node_modules/typed-array-byte-length": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.0.tgz", - "integrity": "sha512-Or/+kvLxNpeQ9DtSydonMxCx+9ZXOswtwJn17SNLvhptaXYDJvkFFP5zbfU/uLmvnBJlI4yrnXRxpdWH/M5tNA==", + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/typed-array-byte-length/-/typed-array-byte-length-1.0.1.tgz", + "integrity": "sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -15985,16 +13937,17 @@ } }, "node_modules/typed-array-byte-offset": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.0.tgz", - "integrity": "sha512-RD97prjEt9EL8YgAgpOkf3O4IF9lhJFr9g0htQkm0rchFp/Vx7LW5Q8fSXXub7BXAODyUQohRMyOc3faCPd0hg==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/typed-array-byte-offset/-/typed-array-byte-offset-1.0.2.tgz", + "integrity": "sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.2", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "has-proto": "^1.0.1", - "is-typed-array": "^1.1.10" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13" }, "engines": { "node": ">= 0.4" @@ -16004,14 +13957,20 @@ } }, "node_modules/typed-array-length": { - "version": "1.0.4", - "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.4.tgz", - "integrity": "sha512-KjZypGq+I/H7HI5HlOoGHkWUUGq+Q0TPhQurLbyrVrvnKTBgzLhIJ7j6J/XTQOi0d1RjyZ0wdas8bKs2p0x3Ng==", + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/typed-array-length/-/typed-array-length-1.0.6.tgz", + "integrity": "sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==", "dev": true, "dependencies": { - "call-bind": "^1.0.2", + "call-bind": "^1.0.7", "for-each": "^0.3.3", - "is-typed-array": "^1.1.9" + "gopd": "^1.0.1", + "has-proto": "^1.0.3", + "is-typed-array": "^1.1.13", + "possible-typed-array-names": "^1.0.0" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16055,6 +14014,17 @@ "integrity": "sha512-vb2s1lYx2xBtUgy+ta+b2J/GLVUR+wmpINwHePmPRhOsIVCG2wDzKJ0n14GslH1BifsqVzSOwQhRaCAsZ/nI4Q==", "optional": true }, + "node_modules/ulidx": { + "version": "2.4.1", + "resolved": "https://registry.npmjs.org/ulidx/-/ulidx-2.4.1.tgz", + "integrity": "sha512-xY7c8LPyzvhvew0Fn+Ek3wBC9STZAuDI/Y5andCKi9AX6/jvfaX45PhsDX8oxgPL0YFp0Jhr8qWMbS/p9375Xg==", + "dependencies": { + "layerr": "^3.0.0" + }, + "engines": { + "node": ">=16" + } + }, "node_modules/unbox-primitive": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/unbox-primitive/-/unbox-primitive-1.0.2.tgz", @@ -16082,50 +14052,19 @@ "integrity": "sha512-+A5Sja4HP1M08MaXya7p5LvjuM7K6q/2EaC0+iovj/wOcMsTzMvDFbasi/oSapiwOlt252IqsKqPjCl7huKS0A==", "dev": true }, + "node_modules/undici": { + "version": "6.19.8", + "resolved": "https://registry.npmjs.org/undici/-/undici-6.19.8.tgz", + "integrity": "sha512-U8uCCl2x9TK3WANvmBavymRzxbfFYG+tAu+fgx3zxQy3qdagQqBLwJVrdyO1TBfUXvfKveMKJZhpvUYoOjM+4g==", + "engines": { + "node": ">=18.17" + } + }, "node_modules/undici-types": { "version": "5.26.5", "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==" }, - "node_modules/unique-filename": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-filename/-/unique-filename-3.0.0.tgz", - "integrity": "sha512-afXhuC55wkAmZ0P18QsVE6kp8JaxrEokN2HGIoIVv2ijHQd419H0+6EigAFcIzXeMIkcIkNBpB3L/DXB3cTS/g==", - "dev": true, - "dependencies": { - "unique-slug": "^4.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/unique-slug": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/unique-slug/-/unique-slug-4.0.0.tgz", - "integrity": "sha512-WrcA6AyEfqDX5bWige/4NQfPZMtASNVxdmWR76WESYQVAACSgWcR6e9i0mofqqBxYFtL4oAxPIptY73/0YE1DQ==", - "dev": true, - "dependencies": { - "imurmurhash": "^0.1.4" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, - "node_modules/unique-string": { - "version": "3.0.0", - "resolved": "https://registry.npmjs.org/unique-string/-/unique-string-3.0.0.tgz", - "integrity": "sha512-VGXBUVwxKMBUznyffQweQABPRRW1vHZAbadFZud4pLFAqRGvv/96vafgjWFqzourzr8YonlQiPgH0YCJfawoGQ==", - "dev": true, - "dependencies": { - "crypto-random-string": "^4.0.0" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/unpipe": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/unpipe/-/unpipe-1.0.0.tgz", @@ -16134,15 +14073,6 @@ "node": ">= 0.8" } }, - "node_modules/untildify": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/untildify/-/untildify-4.0.0.tgz", - "integrity": "sha512-KK8xQ1mkzZeg9inewmFVDNkg3l5LUhoq9kN6iWYB/CC9YMG8HA+c1Q8HwDe6dEX7kErrEVNVBO3fWsVq5iDgtw==", - "dev": true, - "engines": { - "node": ">=8" - } - }, "node_modules/update-browserslist-db": { "version": "1.0.16", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.16.tgz", @@ -16173,58 +14103,6 @@ "browserslist": ">= 4.21.0" } }, - "node_modules/update-notifier": { - "version": "6.0.2", - "resolved": "https://registry.npmjs.org/update-notifier/-/update-notifier-6.0.2.tgz", - "integrity": "sha512-EDxhTEVPZZRLWYcJ4ZXjGFN0oP7qYvbXWzEgRm/Yql4dHX5wDbvh89YHP6PK1lzZJYrMtXUuZZz8XGK+U6U1og==", - "dev": true, - "dependencies": { - "boxen": "^7.0.0", - "chalk": "^5.0.1", - "configstore": "^6.0.0", - "has-yarn": "^3.0.0", - "import-lazy": "^4.0.0", - "is-ci": "^3.0.1", - "is-installed-globally": "^0.4.0", - "is-npm": "^6.0.0", - "is-yarn-global": "^0.4.0", - "latest-version": "^7.0.0", - "pupa": "^3.1.0", - "semver": "^7.3.7", - "semver-diff": "^4.0.0", - "xdg-basedir": "^5.1.0" - }, - "engines": { - "node": ">=14.16" - }, - "funding": { - "url": "https://github.com/yeoman/update-notifier?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/chalk": { - "version": "5.3.0", - "resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz", - "integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==", - "dev": true, - "engines": { - "node": "^12.17.0 || ^14.13 || >=16.0.0" - }, - "funding": { - "url": "https://github.com/chalk/chalk?sponsor=1" - } - }, - "node_modules/update-notifier/node_modules/xdg-basedir": { - "version": "5.1.0", - "resolved": "https://registry.npmjs.org/xdg-basedir/-/xdg-basedir-5.1.0.tgz", - "integrity": "sha512-GCPAHLvrIH13+c0SuacwvRYj2SxJXQ4kaVTT5xgL3kPrz56XxkF21IGhjSE1+W0aw7gpBWRGXLCPnPby6lSpmQ==", - "dev": true, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", @@ -16275,18 +14153,6 @@ "spdx-expression-parse": "^3.0.0" } }, - "node_modules/validate-npm-package-name": { - "version": "5.0.0", - "resolved": "https://registry.npmjs.org/validate-npm-package-name/-/validate-npm-package-name-5.0.0.tgz", - "integrity": "sha512-YuKoXDAhBYxY7SfOKxHBDoSyENFeW5VvIIQp2TGQuit8gpK6MnWaQelBKxso72DoxTZfZdcP3W90LqpSkgPzLQ==", - "dev": true, - "dependencies": { - "builtins": "^5.0.0" - }, - "engines": { - "node": "^14.17.0 || ^16.13.0 || >=18.0.0" - } - }, "node_modules/validator": { "version": "13.11.0", "resolved": "https://registry.npmjs.org/validator/-/validator-13.11.0.tgz", @@ -16329,6 +14195,25 @@ "node": ">=12" } }, + "node_modules/whatwg-encoding": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-3.1.1.tgz", + "integrity": "sha512-6qN4hJdMwfYBtE3YBTTHhoeuUrDBPZmbQaxWAqSALV/MeEnR5z1xd8UKud2RAkFoPkmB+hli1TZSnyi84xz1vQ==", + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/whatwg-mimetype": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-4.0.0.tgz", + "integrity": "sha512-QaKxh0eNIi2mE9p2vEdzfagOKHCcj1pJ56EEHGQOVxp8r9/iszLUUV7v89x9O1p/T+NlTM5W7jW6+cz4Fq1YVg==", + "engines": { + "node": ">=18" + } + }, "node_modules/whatwg-url": { "version": "11.0.0", "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-11.0.0.tgz", @@ -16372,13 +14257,13 @@ } }, "node_modules/which-builtin-type": { - "version": "1.1.3", - "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.3.tgz", - "integrity": "sha512-YmjsSMDBYsM1CaFiayOVT06+KJeXf0o5M/CAd4o1lTadFAtacTUM49zoYxr/oroopFDfhvN6iEcBxUyc3gvKmw==", + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/which-builtin-type/-/which-builtin-type-1.1.4.tgz", + "integrity": "sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==", "dev": true, "dependencies": { - "function.prototype.name": "^1.1.5", - "has-tostringtag": "^1.0.0", + "function.prototype.name": "^1.1.6", + "has-tostringtag": "^1.0.2", "is-async-function": "^2.0.0", "is-date-object": "^1.0.5", "is-finalizationregistry": "^1.0.2", @@ -16387,8 +14272,8 @@ "is-weakref": "^1.0.2", "isarray": "^2.0.5", "which-boxed-primitive": "^1.0.2", - "which-collection": "^1.0.1", - "which-typed-array": "^1.1.9" + "which-collection": "^1.0.2", + "which-typed-array": "^1.1.15" }, "engines": { "node": ">= 0.4" @@ -16404,15 +14289,18 @@ "dev": true }, "node_modules/which-collection": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.1.tgz", - "integrity": "sha512-W8xeTUwaln8i3K/cY1nGXzdnVZlidBcagyNFtBdD5kxnb4TvGKR7FfSIS3mYpwWS1QUCutfKz8IY8RjftB0+1A==", + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/which-collection/-/which-collection-1.0.2.tgz", + "integrity": "sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==", "dev": true, "dependencies": { - "is-map": "^2.0.1", - "is-set": "^2.0.1", - "is-weakmap": "^2.0.1", - "is-weakset": "^2.0.1" + "is-map": "^2.0.3", + "is-set": "^2.0.3", + "is-weakmap": "^2.0.2", + "is-weakset": "^2.0.3" + }, + "engines": { + "node": ">= 0.4" }, "funding": { "url": "https://github.com/sponsors/ljharb" @@ -16424,16 +14312,16 @@ "integrity": "sha512-iBdZ57RDvnOR9AGBhML2vFZf7h8vmBjhoaZqODJBFWHVtKkDmKuHai3cx5PgVMrX5YDNp27AofYbAwctSS+vhQ==" }, "node_modules/which-typed-array": { - "version": "1.1.13", - "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.13.tgz", - "integrity": "sha512-P5Nra0qjSncduVPEAr7xhoF5guty49ArDTwzJ/yNuPIbZppyRxFQsRCWrocxIY+CnMVG+qfbU2FmDKyvSGClow==", + "version": "1.1.15", + "resolved": "https://registry.npmjs.org/which-typed-array/-/which-typed-array-1.1.15.tgz", + "integrity": "sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==", "dev": true, "dependencies": { - "available-typed-arrays": "^1.0.5", - "call-bind": "^1.0.4", + "available-typed-arrays": "^1.0.7", + "call-bind": "^1.0.7", "for-each": "^0.3.3", "gopd": "^1.0.1", - "has-tostringtag": "^1.0.0" + "has-tostringtag": "^1.0.2" }, "engines": { "node": ">= 0.4" @@ -16487,6 +14375,14 @@ "wrap-ansi": "^2.0.0" } }, + "node_modules/widdershins/node_modules/entities": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-2.1.0.tgz", + "integrity": "sha512-hCx1oky9PFrJ611mf0ifBLBRW8lUUVRlFolb5gWRfIELabBlbp9xZvrqZLZAs+NxFnbfQoeGd8wDkygjg7U85w==", + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/widdershins/node_modules/find-up": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/find-up/-/find-up-3.0.0.tgz", @@ -16703,62 +14599,6 @@ "decamelize": "^1.2.0" } }, - "node_modules/wide-align": { - "version": "1.1.5", - "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", - "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", - "dev": true, - "dependencies": { - "string-width": "^1.0.2 || 2 || 3 || 4" - } - }, - "node_modules/wide-align/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true - }, - "node_modules/wide-align/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/wide-align/node_modules/strip-ansi": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/strip-ansi/-/strip-ansi-6.0.1.tgz", - "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", - "dev": true, - "dependencies": { - "ansi-regex": "^5.0.1" - }, - "engines": { - "node": ">=8" - } - }, - "node_modules/widest-line": { - "version": "4.0.1", - "resolved": "https://registry.npmjs.org/widest-line/-/widest-line-4.0.1.tgz", - "integrity": "sha512-o0cyEG0e8GPzT4iGHphIOh0cJOV8fivsXxddQasHPHfoZf1ZexrfeA21w2NaEN1RHE+fXlfISmOE8R9N3u3Qig==", - "dev": true, - "dependencies": { - "string-width": "^5.0.1" - }, - "engines": { - "node": ">=12" - }, - "funding": { - "url": "https://github.com/sponsors/sindresorhus" - } - }, "node_modules/window-size": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/window-size/-/window-size-0.1.0.tgz", @@ -16814,14 +14654,6 @@ "node": ">= 6" } }, - "node_modules/winston/node_modules/@colors/colors": { - "version": "1.6.0", - "resolved": "https://registry.npmjs.org/@colors/colors/-/colors-1.6.0.tgz", - "integrity": "sha512-Ir+AOibqzrIsL6ajt3Rz3LskB7OiMVHqltZmspbW/TJuTVuyOMirVqAkjfY6JISiLHgyNqicAC8AyHHGzNd/dA==", - "engines": { - "node": ">=0.1.90" - } - }, "node_modules/winston/node_modules/readable-stream": { "version": "3.6.2", "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", @@ -16995,9 +14827,9 @@ "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==" }, "node_modules/yaml": { - "version": "2.4.5", - "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.4.5.tgz", - "integrity": "sha512-aBx2bnqDzVOyNKfsysjA2ms5ZlnjSAW2eG3/L5G/CSujfjLJTJsEw1bGw8kCf04KodQWk1pxlGnZ56CRxiawmg==", + "version": "2.6.1", + "resolved": "https://registry.npmjs.org/yaml/-/yaml-2.6.1.tgz", + "integrity": "sha512-7r0XPzioN/Q9kXBro/XPnA6kznR73DHq+GXh5ON7ZozRO6aMjbmiBuKste2wslTFkC5d1dw0GooOCepZXJ2SAg==", "bin": { "yaml": "bin.mjs" }, @@ -17026,7 +14858,6 @@ "version": "20.2.9", "resolved": "https://registry.npmjs.org/yargs-parser/-/yargs-parser-20.2.9.tgz", "integrity": "sha512-y11nGElTIV+CT3Zv9t7VKl+Q3hTQoT9a1Qzezhhl6Rp21gJ/IVTW7Z3y9EWXhuUBC2Shnf+DX0antecpAwSP8w==", - "dev": true, "engines": { "node": ">=10" } diff --git a/package.json b/package.json index 0eeb773eb..673c9de19 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@mojaloop/central-ledger", - "version": "17.7.8", + "version": "17.8.0-snapshot.34", "description": "Central ledger hosted by a scheme to record and settle transfers", "license": "Apache-2.0", "author": "ModusBox", @@ -31,13 +31,15 @@ "pre-commit": [ "lint", "dep:check", + "audit:check", "test" ], "scripts": { "start": "npm run start:api", "start:api": "node src/api/index.js", - "watch:api": "npx nodemon src/api/index.js", "start:handlers": "node src/handlers/index.js", + "start:debug": "npm start --node-options --inspect=0.0.0.0", + "watch:api": "npx nodemon src/api/index.js", "dev": "npm run docker:stop && docker-compose -f docker-compose.yml -f docker-compose.dev.yml up --build -d", "lint": "npx standard", "lint:fix": "npx standard --fix", @@ -50,10 +52,10 @@ "test:int": "npx tape 'test/integration/**/*.test.js' ", "test:int-override": "npx tape 'test/integration-override/**/*.test.js'", "test:int:spec": "npm run test:int | npx tap-spec", - "test:xint": "npm run test:int | tap-xunit > ./test/results/xunit-integration.xml", - "test:xint-override": "npm run test:int-override | tap-xunit > ./test/results/xunit-integration-override.xml", - "test:integration": "sh ./test/scripts/test-integration.sh", - "test:functional": "sh ./test/scripts/test-functional.sh", + "test:xint": "npm run test:int | tee /dev/tty | tap-xunit > ./test/results/xunit-integration.xml", + "test:xint-override": "npm run test:int-override | tee /dev/tty | tap-xunit > ./test/results/xunit-integration-override.xml", + "test:integration": "./test/scripts/test-integration.sh", + "test:functional": "./test/scripts/test-functional.sh", "migrate": "npm run migrate:latest && npm run seed:run", "migrate:latest": "npx knex $npm_package_config_knex migrate:latest", "migrate:create": "npx knex migrate:make $npm_package_config_knex", @@ -61,7 +63,7 @@ "migrate:current": "npx knex migrate:currentVersion $npm_package_config_knex", "seed:run": "npx knex seed:run $npm_package_config_knex", "docker:build": "docker build --build-arg NODE_VERSION=\"$(cat .nvmrc)-alpine\" -t mojaloop/central-ledger:local .", - "docker:up": "docker-compose -f docker-compose.yml up", + "docker:up": ". ./docker/env.sh && docker-compose -f docker-compose.yml up -d", "docker:up:backend": "docker-compose up -d ml-api-adapter mysql mockserver kafka kowl temp_curl", "docker:up:int": "docker compose up -d kafka init-kafka objstore mysql", "docker:script:populateTestData": "sh ./test/util/scripts/populateTestData.sh", @@ -79,42 +81,45 @@ "wait-4-docker": "node ./scripts/_wait4_all.js" }, "dependencies": { - "@hapi/good": "9.0.1", - "@hapi/hapi": "21.3.10", "@hapi/basic": "7.0.2", + "@hapi/catbox-memory": "6.0.2", + "@hapi/good": "9.0.1", + "@hapi/hapi": "21.3.12", "@hapi/inert": "7.1.0", "@hapi/joi": "17.1.1", "@hapi/vision": "7.0.3", - "@hapi/catbox-memory": "6.0.2", - "@mojaloop/database-lib": "11.0.5", - "@mojaloop/central-services-error-handling": "13.0.1", + "@mojaloop/central-services-error-handling": "13.0.2", "@mojaloop/central-services-health": "15.0.0", - "@mojaloop/central-services-logger": "11.3.1", - "@mojaloop/central-services-metrics": "12.0.8", - "@mojaloop/central-services-shared": "18.3.8", - "@mojaloop/central-services-stream": "11.3.1", + "@mojaloop/central-services-logger": "11.5.1", + "@mojaloop/central-services-metrics": "12.4.2", + "@mojaloop/central-services-shared": "18.14.1", + "@mojaloop/central-services-stream": "11.4.1", + "@mojaloop/database-lib": "11.0.6", "@mojaloop/event-sdk": "14.1.1", + "@mojaloop/inter-scheme-proxy-cache-lib": "2.3.1", "@mojaloop/ml-number": "11.2.4", "@mojaloop/object-store-lib": "12.0.3", "@now-ims/hapi-now-auth": "2.1.0", - "ajv": "8.16.0", + "ajv": "8.17.1", "ajv-keywords": "5.1.0", "base64url": "3.0.1", "blipp": "4.0.2", "commander": "12.1.0", - "cron": "3.1.7", + "cron": "3.3.1", "decimal.js": "10.4.3", "docdash": "2.0.2", "event-stream": "4.0.1", "five-bells-condition": "5.0.1", - "glob": "10.4.1", + "glob": "10.4.3", + "hapi-auth-basic": "5.0.0", "hapi-auth-bearer-token": "8.0.0", - "hapi-swagger": "17.2.1", + "hapi-swagger": "17.3.2", "ilp-packet": "2.2.0", "knex": "3.1.0", "lodash": "4.17.21", "moment": "2.30.1", "mongo-uri-builder": "^4.0.0", + "parse-strings-in-object": "2.0.0", "rc": "1.2.8", "require-glob": "^4.1.0" }, @@ -122,19 +127,21 @@ "mysql": "2.18.1" }, "devDependencies": { + "@types/mock-knex": "0.4.8", "async-retry": "1.3.3", - "audit-ci": "^7.0.1", + "audit-ci": "^7.1.0", "get-port": "5.1.1", - "jsdoc": "4.0.3", + "jsdoc": "4.0.4", "jsonpath": "1.1.1", - "nodemon": "3.1.3", - "npm-check-updates": "16.14.20", - "nyc": "17.0.0", + "mock-knex": "0.4.13", + "nodemon": "3.1.9", + "npm-check-updates": "17.1.11", + "nyc": "17.1.0", "pre-commit": "1.2.2", "proxyquire": "2.1.3", "replace": "^1.2.2", "sinon": "17.0.0", - "standard": "17.1.0", + "standard": "17.1.2", "standard-version": "^9.5.0", "tap-spec": "^5.0.0", "tap-xunit": "2.4.1", diff --git a/seeds/endpointType.js b/seeds/endpointType.js index 6ac12d99c..96ea38060 100644 --- a/seeds/endpointType.js +++ b/seeds/endpointType.js @@ -25,6 +25,8 @@ 'use strict' +const { FspEndpointTypes } = require('@mojaloop/central-services-shared').Enum.EndPoints + const endpointTypes = [ { name: 'ALARM_NOTIFICATION_URL', @@ -46,6 +48,22 @@ const endpointTypes = [ name: 'FSPIOP_CALLBACK_URL_TRANSFER_ERROR', description: 'Participant callback URL to which transfer error notifications can be sent' }, + { + name: FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, + description: 'Participant callback URL to which FX quote requests can be sent' + }, + { + name: FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, + description: 'Participant callback URL to which FX transfer post can be sent' + }, + { + name: FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, + description: 'Participant callback URL to which FX transfer put can be sent' + }, + { + name: FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, + description: 'Participant callback URL to which FX transfer error notifications can be sent' + }, { name: 'NET_DEBIT_CAP_THRESHOLD_BREACH_EMAIL', description: 'Participant/Hub operator email address to which the net debit cap breach e-mail notification can be sent' diff --git a/seeds/fxParticipantCurrencyType.js b/seeds/fxParticipantCurrencyType.js new file mode 100644 index 000000000..ae4c8557c --- /dev/null +++ b/seeds/fxParticipantCurrencyType.js @@ -0,0 +1,45 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +const fxParticipantCurrencyTypes = [ + { + name: 'SOURCE', + description: 'The participant currency is the source of the currency conversion' + }, + { + name: 'TARGET', + description: 'The participant currency is the target of the currency conversion' + } +] + +exports.seed = async function (knex) { + try { + return await knex('fxParticipantCurrencyType').insert(fxParticipantCurrencyTypes).onConflict('name').ignore() + } catch (err) { + console.log(`Uploading seeds for fxParticipantCurrencyType has failed with the following error: ${err}`) + return -1000 + } +} diff --git a/seeds/fxTransferType.js b/seeds/fxTransferType.js new file mode 100644 index 000000000..47d7625bb --- /dev/null +++ b/seeds/fxTransferType.js @@ -0,0 +1,45 @@ +/***** + License + -------------- + Copyright © 2017 Bill & Melinda Gates Foundation + The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Gates Foundation organization for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +const fxTransferTypes = [ + { + name: 'PAYER_CONVERSION', + description: 'Payer side currency conversion' + }, + { + name: 'PAYEE_CONVERSION', + description: 'Payee side currency conversion' + } +] + +exports.seed = async function (knex) { + try { + return await knex('fxTransferType').insert(fxTransferTypes).onConflict('name').ignore() + } catch (err) { + console.log(`Uploading seeds for fxTransferType has failed with the following error: ${err}`) + return -1000 + } +} diff --git a/seeds/participant.js b/seeds/participant.js index 2eff87278..19885f24d 100644 --- a/seeds/participant.js +++ b/seeds/participant.js @@ -28,6 +28,7 @@ const Config = require('../src/lib/config') const participant = [ { + participantId: Config.HUB_ID, name: Config.HUB_NAME, description: 'Hub Operator', createdBy: 'seeds' @@ -36,7 +37,7 @@ const participant = [ exports.seed = async function (knex) { try { - return await knex('participant').insert(participant).onConflict('name').ignore() + return await knex('participant').insert(participant).onConflict('id').merge() } catch (err) { console.log(`Uploading seeds for participant has failed with the following error: ${err}`) return -1000 diff --git a/seeds/transferParticipantRoleType.js b/seeds/transferParticipantRoleType.js index 296493bc5..c260f0240 100644 --- a/seeds/transferParticipantRoleType.js +++ b/seeds/transferParticipantRoleType.js @@ -20,6 +20,7 @@ * Georgi Georgiev * Shashikant Hirugade + * Vijay Kumar Guthi -------------- ******/ @@ -45,6 +46,14 @@ const transferParticipantRoleTypes = [ { name: 'DFSP_POSITION', description: 'Indicates the position account' + }, + { + name: 'INITIATING_FSP', + description: 'Identifier for the FSP who is requesting a currency conversion' + }, + { + name: 'COUNTER_PARTY_FSP', + description: 'Identifier for the FXP who is performing the currency conversion' } ] diff --git a/seeds/transferState.js b/seeds/transferState.js index 8736b6c6c..4135ae33b 100644 --- a/seeds/transferState.js +++ b/seeds/transferState.js @@ -41,6 +41,11 @@ const transferStates = [ enumeration: 'RESERVED', description: 'The switch has reserved the transfer, and has been assigned to a settlement window.' }, + { + transferStateId: 'RECEIVED_FULFIL_DEPENDENT', + enumeration: 'RESERVED', + description: 'The switch has reserved the fxTransfer fulfilment.' + }, { transferStateId: 'COMMITTED', enumeration: 'COMMITTED', @@ -95,6 +100,11 @@ const transferStates = [ transferStateId: 'SETTLED', enumeration: 'SETTLED', description: 'The switch has settled the transfer.' + }, + { + transferStateId: 'RESERVED_FORWARDED', + enumeration: 'RESERVED', + description: 'The switch has forwarded the transfer to a proxy participant' } ] diff --git a/src/api/interface/swagger.json b/src/api/interface/swagger.json index cb4616082..aadb3ee69 100644 --- a/src/api/interface/swagger.json +++ b/src/api/interface/swagger.json @@ -66,6 +66,25 @@ "tags": [ "participants" ], + "parameters": [ + { + "type": ["string", "boolean", "integer", "null"], + "enum": [ + false, + "0", + "false", + "", + true, + "1", + "true", + null + ], + "description": "Filter by if participant is a proxy", + "name": "isProxy", + "in": "query", + "required": false + } + ], "responses": { "default": { "schema": { @@ -375,9 +394,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -404,9 +420,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -442,9 +455,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -663,9 +673,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -701,9 +708,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -917,9 +921,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -979,9 +980,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -1017,9 +1015,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -1062,9 +1057,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -1109,9 +1101,6 @@ "description": "Name of the participant", "minLength": 2, "maxLength": 30, - "x-format": { - "alphanum": true - }, "name": "name", "in": "path", "required": true @@ -1326,6 +1315,10 @@ "description": "Currency code", "$ref" : "#/definitions/Currency" + }, + "isProxy": { + "type": "boolean", + "description": "Is the participant a proxy" } }, "required": [ diff --git a/src/api/ledgerAccountTypes/handler.js b/src/api/ledgerAccountTypes/handler.js index ef0d6fca2..6b7277863 100644 --- a/src/api/ledgerAccountTypes/handler.js +++ b/src/api/ledgerAccountTypes/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname diff --git a/src/api/ledgerAccountTypes/routes.js b/src/api/ledgerAccountTypes/routes.js index 2ac6074eb..9d9b95f74 100644 --- a/src/api/ledgerAccountTypes/routes.js +++ b/src/api/ledgerAccountTypes/routes.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname diff --git a/src/api/metrics/handler.js b/src/api/metrics/handler.js index cf11c3f4d..5e6c7105a 100644 --- a/src/api/metrics/handler.js +++ b/src/api/metrics/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname diff --git a/src/api/metrics/plugin.js b/src/api/metrics/plugin.js index 08f264dbe..4e60b97e3 100644 --- a/src/api/metrics/plugin.js +++ b/src/api/metrics/plugin.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/api/metrics/routes.js b/src/api/metrics/routes.js index 861e09f29..11e6c0bd4 100644 --- a/src/api/metrics/routes.js +++ b/src/api/metrics/routes.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname diff --git a/src/api/participants/handler.js b/src/api/participants/handler.js index ad79e5ee2..72c8b79da 100644 --- a/src/api/participants/handler.js +++ b/src/api/participants/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname @@ -38,7 +42,7 @@ const LocalEnum = { disabled: 'disabled' } -const entityItem = ({ name, createdDate, isActive, currencyList }, ledgerAccountIds) => { +const entityItem = ({ name, createdDate, isActive, currencyList, isProxy }, ledgerAccountIds) => { const link = UrlParser.toParticipantUri(name) const accounts = currencyList.map((currentValue) => { return { @@ -58,7 +62,8 @@ const entityItem = ({ name, createdDate, isActive, currencyList }, ledgerAccount links: { self: link }, - accounts + accounts, + isProxy } } @@ -160,6 +165,9 @@ const getAll = async function (request) { const results = await ParticipantService.getAll() const ledgerAccountTypes = await Enums.getEnums('ledgerAccountType') const ledgerAccountIds = Util.transpose(ledgerAccountTypes) + if (request.query.isProxy) { + return results.map(record => entityItem(record, ledgerAccountIds)).filter(record => record.isProxy) + } return results.map(record => entityItem(record, ledgerAccountIds)) } diff --git a/src/api/participants/routes.js b/src/api/participants/routes.js index 868b29769..9bce3187e 100644 --- a/src/api/participants/routes.js +++ b/src/api/participants/routes.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname @@ -29,7 +33,7 @@ const Joi = require('joi') const currencyList = require('../../../seeds/currency.js').currencyList const tags = ['api', 'participants'] -const nameValidator = Joi.string().alphanum().min(2).max(30).required().description('Name of the participant') +const nameValidator = Joi.string().min(2).max(30).required().description('Name of the participant') const currencyValidator = Joi.string().valid(...currencyList).description('Currency code') module.exports = [ @@ -49,7 +53,7 @@ module.exports = [ tags, validate: { params: Joi.object({ - name: Joi.string().required().description('Participant name') + name: nameValidator }) } } @@ -68,7 +72,8 @@ module.exports = [ payload: Joi.object({ name: nameValidator, // password: passwordValidator, - currency: currencyValidator // , + currency: currencyValidator, + isProxy: Joi.boolean().falsy(0, '0', '').truthy(1, '1').allow(true, false, 0, 1, '0', '1', null) // emailAddress: Joi.string().email().required() }) } @@ -89,7 +94,7 @@ module.exports = [ isActive: Joi.boolean().required().description('Participant isActive boolean') }), params: Joi.object({ - name: Joi.string().required().description('Participant name') + name: nameValidator }) } } @@ -239,7 +244,7 @@ module.exports = [ type: Joi.string().required().description('Account type') // Needs a validator here }), params: Joi.object({ - name: Joi.string().required().description('Participant name') // nameValidator + name: nameValidator // nameValidator }) } } @@ -306,7 +311,7 @@ module.exports = [ description: 'Record Funds In or Out of participant account', validate: { payload: Joi.object({ - transferId: Joi.string().guid().required(), + transferId: Joi.string().pattern(/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]}$|^[0-9A-HJKMNP-TV-Z]{26}$6})$/).required(), externalReference: Joi.string().required(), action: Joi.string().required().valid('recordFundsIn', 'recordFundsOutPrepareReserve').label('action is missing or not supported'), reason: Joi.string().required(), @@ -344,7 +349,7 @@ module.exports = [ params: Joi.object({ name: nameValidator, id: Joi.number().integer().positive(), - transferId: Joi.string().guid().required() + transferId: Joi.string().pattern(/^(?:[0-9a-f]{8}-[0-9a-f]{4}-[1-7][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]}$|^[0-9A-HJKMNP-TV-Z]{26}$6})$/).required() }) } } diff --git a/src/api/root/handler.js b/src/api/root/handler.js index 17cdc6d67..c7de5483a 100644 --- a/src/api/root/handler.js +++ b/src/api/root/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname @@ -30,13 +34,23 @@ const { defaultHealthHandler } = require('@mojaloop/central-services-health') const packageJson = require('../../../package.json') const { getSubServiceHealthDatastore, - getSubServiceHealthBroker + getSubServiceHealthBroker, + getSubServiceHealthProxyCache } = require('../../lib/healthCheck/subServiceHealth') +const Config = require('../../lib/config') -const healthCheck = new HealthCheck(packageJson, [ - getSubServiceHealthDatastore, - getSubServiceHealthBroker -]) +const subServiceChecks = Config.PROXY_CACHE_CONFIG?.enabled + ? [ + getSubServiceHealthDatastore, + getSubServiceHealthBroker, + getSubServiceHealthProxyCache + ] + : [ + getSubServiceHealthDatastore, + getSubServiceHealthBroker + ] + +const healthCheck = new HealthCheck(packageJson, subServiceChecks) /** * @function getHealth diff --git a/src/api/root/routes.js b/src/api/root/routes.js index 4278fbd39..34e39b72b 100644 --- a/src/api/root/routes.js +++ b/src/api/root/routes.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/api/settlementModels/handler.js b/src/api/settlementModels/handler.js index 22cc958c5..869aaa2a3 100644 --- a/src/api/settlementModels/handler.js +++ b/src/api/settlementModels/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,9 +15,10 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . + * Gates Foundation - Name Surname diff --git a/src/api/settlementModels/routes.js b/src/api/settlementModels/routes.js index 89cf7f2f9..65e4b526f 100644 --- a/src/api/settlementModels/routes.js +++ b/src/api/settlementModels/routes.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/api/transactions/handler.js b/src/api/transactions/handler.js index b65e172ca..75d95de42 100644 --- a/src/api/transactions/handler.js +++ b/src/api/transactions/handler.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/api/transactions/routes.js b/src/api/transactions/routes.js index a26c01b67..0712f4595 100644 --- a/src/api/transactions/routes.js +++ b/src/api/transactions/routes.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/domain/bulkTransfer/index.js b/src/domain/bulkTransfer/index.js index b46c76f65..6456673ff 100644 --- a/src/domain/bulkTransfer/index.js +++ b/src/domain/bulkTransfer/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/domain/fx/cyril.js b/src/domain/fx/cyril.js new file mode 100644 index 000000000..e797c2100 --- /dev/null +++ b/src/domain/fx/cyril.js @@ -0,0 +1,462 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + * Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +const Metrics = require('@mojaloop/central-services-metrics') +const { Enum } = require('@mojaloop/central-services-shared') +const TransferModel = require('../../models/transfer/transfer') +const TransferFacade = require('../../models/transfer/facade') +const ParticipantPositionChangesModel = require('../../models/position/participantPositionChanges') +const { fxTransfer, watchList } = require('../../models/fxTransfer') +const Config = require('../../lib/config') +const ProxyCache = require('../../lib/proxyCache') + +const checkIfDeterminingTransferExistsForTransferMessage = async (payload, proxyObligation) => { + // Does this determining transfer ID appear on the watch list? + const watchListRecords = await watchList.getItemsInWatchListByDeterminingTransferId(payload.transferId) + const determiningTransferExistsInWatchList = (watchListRecords !== null && watchListRecords.length > 0) + // Create a list of participants and currencies to validate against + const participantCurrencyValidationList = [] + if (determiningTransferExistsInWatchList) { + // If there's a currency conversion before the transfer is requested, it must be the debtor who did it. + if (!proxyObligation.isCounterPartyFspProxy) { + participantCurrencyValidationList.push({ + participantName: payload.payeeFsp, + currencyId: payload.amount.currency + }) + } + } else { + // Normal transfer request or payee side currency conversion + if (!proxyObligation.isInitiatingFspProxy) { + participantCurrencyValidationList.push({ + participantName: payload.payerFsp, + currencyId: payload.amount.currency + }) + } + // If it is a normal transfer, we need to validate payeeFsp against the currency of the transfer. + // But its tricky to differentiate between normal transfer and payee side currency conversion. + if (Config.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED) { + if (!proxyObligation.isCounterPartyFspProxy) { + participantCurrencyValidationList.push({ + participantName: payload.payeeFsp, + currencyId: payload.amount.currency + }) + } + } + } + return { + determiningTransferExistsInWatchList, + watchListRecords, + participantCurrencyValidationList + } +} + +const checkIfDeterminingTransferExistsForFxTransferMessage = async (payload, proxyObligation) => { + // Does this determining transfer ID appear on the transfer list? + const transferRecord = await TransferModel.getById(payload.determiningTransferId) + const determiningTransferExistsInTransferList = (transferRecord !== null) + // We need to validate counterPartyFsp (FXP) against both source and target currencies anyway + const participantCurrencyValidationList = [ + { + participantName: payload.counterPartyFsp, + currencyId: payload.sourceAmount.currency + } + ] + // If a proxy is representing a FXP in a jurisdictional scenario, + // they would not hold a position account for the `targetAmount` currency + // for a /fxTransfer. So we skip adding this to accounts to be validated. + if (!proxyObligation.isCounterPartyFspProxy) { + participantCurrencyValidationList.push({ + participantName: payload.counterPartyFsp, + currencyId: payload.targetAmount.currency + }) + } + if (determiningTransferExistsInTransferList) { + // If there's a currency conversion which is not the first message, then it must be issued by the creditor party + participantCurrencyValidationList.push({ + participantName: payload.initiatingFsp, + currencyId: payload.targetAmount.currency + }) + } else { + // If there's a currency conversion before the transfer is requested, then it must be issued by the debtor party + participantCurrencyValidationList.push({ + participantName: payload.initiatingFsp, + currencyId: payload.sourceAmount.currency + }) + } + return { + determiningTransferExistsInTransferList, + transferRecord, + participantCurrencyValidationList + } +} + +const getParticipantAndCurrencyForTransferMessage = async (payload, determiningTransferCheckResult, proxyObligation) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_getParticipantAndCurrencyForTransferMessage', + 'fx_domain_cyril_getParticipantAndCurrencyForTransferMessage - Metrics for fx cyril', + ['success', 'determiningTransferExists'] + ).startTimer() + + let participantName, currencyId, amount + + if (determiningTransferCheckResult.determiningTransferExistsInWatchList) { + // If there's a currency conversion before the transfer is requested, it must be the debtor who did it. + // Get the FX request corresponding to this transaction ID + let fxTransferRecord + if (proxyObligation.isCounterPartyFspProxy) { + // If a proxy is representing a FXP in a jurisdictional scenario, + // they would not hold a position account for the `targetAmount` currency + // for a /fxTransfer. So we skip adding this to accounts to be validated. + fxTransferRecord = await fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer(determiningTransferCheckResult.watchListRecords[0].commitRequestId) + } else { + fxTransferRecord = await fxTransfer.getAllDetailsByCommitRequestId(determiningTransferCheckResult.watchListRecords[0].commitRequestId) + } + + // Liquidity check and reserve funds against FXP in FX target currency + participantName = fxTransferRecord.counterPartyFspName + currencyId = fxTransferRecord.targetCurrency + amount = fxTransferRecord.targetAmount + } else { + // Normal transfer request or payee side currency conversion + // Liquidity check and reserve against payer + participantName = payload.payerFsp + currencyId = payload.amount.currency + amount = payload.amount.amount + } + + histTimer({ success: true, determiningTransferExists: determiningTransferCheckResult.determiningTransferExistsInWatchList }) + return { + participantName, + currencyId, + amount + } +} + +const getParticipantAndCurrencyForFxTransferMessage = async (payload, determiningTransferCheckResult) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_getParticipantAndCurrencyForFxTransferMessage', + 'fx_domain_cyril_getParticipantAndCurrencyForFxTransferMessage - Metrics for fx cyril', + ['success', 'determiningTransferExists'] + ).startTimer() + + let participantName, currencyId, amount + + if (determiningTransferCheckResult.determiningTransferExistsInTransferList) { + // If there's a currency conversion which is not the first message, then it must be issued by the creditor party + // Liquidity check and reserve funds against FXP in FX target currency + participantName = payload.counterPartyFsp + currencyId = payload.targetAmount.currency + amount = payload.targetAmount.amount + await watchList.addToWatchList({ + commitRequestId: payload.commitRequestId, + determiningTransferId: payload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION + }) + } else { + // If there's a currency conversion before the transfer is requested, then it must be issued by the debtor party + // Liquidity check and reserve funds against requester in FX source currency + participantName = payload.initiatingFsp + currencyId = payload.sourceAmount.currency + amount = payload.sourceAmount.amount + await watchList.addToWatchList({ + commitRequestId: payload.commitRequestId, + determiningTransferId: payload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION + }) + } + + histTimer({ success: true, determiningTransferExists: determiningTransferCheckResult.determiningTransferExistsInTransferList }) + return { + participantName, + currencyId, + amount + } +} + +const processFxFulfilMessage = async (commitRequestId) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_processFxFulfilMessage', + 'fx_domain_cyril_processFxFulfilMessage - Metrics for fx cyril', + ['success'] + ).startTimer() + // Does this commitRequestId appear on the watch list? + const watchListRecord = await watchList.getItemInWatchListByCommitRequestId(commitRequestId) + if (!watchListRecord) { + throw new Error(`Commit request ID ${commitRequestId} not found in watch list`) + } + + histTimer({ success: true }) + return true +} + +/** + * @typedef {Object} PositionChangeItem + * + * @property {boolean} isFxTransferStateChange - Indicates whether the position change is related to an FX transfer. + * @property {string} [commitRequestId] - commitRequestId for the position change (only for FX transfers). + * @property {string} [transferId] - transferId for the position change (only for normal transfers). + * @property {string} notifyTo - The FSP to notify about the position change. + * @property {number} participantCurrencyId - The ID of the participant's currency involved in the position change. + * @property {number} amount - The amount of the position change, represented as a negative value. + */ +/** + * Retrieves position changes based on a list of commitRequestIds and transferIds. + * + * @param {Array} commitRequestIdList - List of commit request IDs to retrieve FX-related position changes. + * @param {Array} transferIdList - List of transfer IDs to retrieve regular transfer-related position changes. + * @returns {Promise} - A promise that resolves to an array of position change objects. + */ +const _getPositionChanges = async (commitRequestIdList, transferIdList) => { + const positionChanges = [] + for (const commitRequestId of commitRequestIdList) { + const fxRecord = await fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer(commitRequestId) + const fxPositionChanges = await ParticipantPositionChangesModel.getReservedPositionChangesByCommitRequestId(commitRequestId) + fxPositionChanges.forEach((fxPositionChange) => { + positionChanges.push({ + isFxTransferStateChange: true, + commitRequestId, + notifyTo: fxRecord.externalInitiatingFspName || fxRecord.initiatingFspName, + participantCurrencyId: fxPositionChange.participantCurrencyId, + amount: -fxPositionChange.change + }) + }) + } + + for (const transferId of transferIdList) { + const transferRecord = await TransferFacade.getById(transferId) + const transferPositionChanges = await ParticipantPositionChangesModel.getReservedPositionChangesByTransferId(transferId) + transferPositionChanges.forEach((transferPositionChange) => { + positionChanges.push({ + isFxTransferStateChange: false, + transferId, + notifyTo: transferRecord.externalPayerName || transferRecord.payerFsp, + participantCurrencyId: transferPositionChange.participantCurrencyId, + amount: -transferPositionChange.change + }) + }) + } + + return positionChanges +} + +/** + * @returns {Promise<{positionChanges: PositionChangeItem[]}>} + */ +const processFxAbortMessage = async (commitRequestId) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_processFxAbortMessage', + 'fx_domain_cyril_processFxAbortMessage - Metrics for fx cyril', + ['success'] + ).startTimer() + + // Get the fxTransfer record + const fxTransferRecord = await fxTransfer.getByCommitRequestId(commitRequestId) + // const fxTransferRecord = await fxTransfer.getAllDetailsByCommitRequestId(commitRequestId) + // In case of reference currency, there might be multiple fxTransfers associated with a transfer. + const relatedFxTransferRecords = await fxTransfer.getByDeterminingTransferId(fxTransferRecord.determiningTransferId) + + // Get position changes + const positionChanges = await _getPositionChanges(relatedFxTransferRecords.map(item => item.commitRequestId), [fxTransferRecord.determiningTransferId]) + + histTimer({ success: true }) + return { + positionChanges + } +} + +const processAbortMessage = async (transferId) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_processAbortMessage', + 'fx_domain_cyril_processAbortMessage - Metrics for fx cyril', + ['success'] + ).startTimer() + + // Get all related fxTransfers + const relatedFxTransferRecords = await fxTransfer.getByDeterminingTransferId(transferId) + + // Get position changes + const positionChanges = await _getPositionChanges(relatedFxTransferRecords.map(item => item.commitRequestId), [transferId]) + + histTimer({ success: true }) + return { + positionChanges + } +} + +const processFulfilMessage = async (transferId, payload, transfer) => { + const histTimer = Metrics.getHistogram( + 'fx_domain_cyril_processFulfilMessage', + 'fx_domain_cyril_processFulfilMessage - Metrics for fx cyril', + ['success'] + ).startTimer() + // Let's define a format for the function result + const result = { + isFx: false, + positionChanges: [], + patchNotifications: [] + } + + // Does this transferId appear on the watch list? + const watchListRecords = await watchList.getItemsInWatchListByDeterminingTransferId(transferId) + if (watchListRecords && watchListRecords.length > 0) { + result.isFx = true + + // Loop around watch list + let sendingFxpExists = false + let receivingFxpExists = false + let sendingFxpRecord = null + let receivingFxpRecord = null + for (const watchListRecord of watchListRecords) { + const fxTransferRecord = await fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer(watchListRecord.commitRequestId) + // Original Plan: If the reservation is against the FXP, then this is a conversion at the creditor. Mark FXP as receiving FXP + // The above condition is not required as we are setting the fxTransferType in the watchList beforehand + if (watchListRecord.fxTransferTypeId === Enum.Fx.FxTransferType.PAYEE_CONVERSION) { + receivingFxpExists = true + receivingFxpRecord = fxTransferRecord + // Create obligation between FXP and FX requesting party in currency of reservation + // Find out the participantCurrencyId of the initiatingFsp + // The following is hardcoded for Payer side conversion with SEND amountType. + const proxyParticipantAccountDetails = await ProxyCache.getProxyParticipantAccountDetails(fxTransferRecord.initiatingFspName, fxTransferRecord.targetCurrency) + if (proxyParticipantAccountDetails.participantCurrencyId) { + result.positionChanges.push({ + isFxTransferStateChange: false, + transferId, + participantCurrencyId: proxyParticipantAccountDetails.participantCurrencyId, + amount: -fxTransferRecord.targetAmount + }) + } + } + + // Original Plan: If the reservation is against the DFSP, then this is a conversion at the debtor. Mark FXP as sending FXP + // The above condition is not required as we are setting the fxTransferType in the watchList beforehand + if (watchListRecord.fxTransferTypeId === Enum.Fx.FxTransferType.PAYER_CONVERSION) { + sendingFxpExists = true + sendingFxpRecord = fxTransferRecord + // Create obligation between FX requesting party and FXP in currency of reservation + const proxyParticipantAccountDetails = await ProxyCache.getProxyParticipantAccountDetails(fxTransferRecord.counterPartyFspName, fxTransferRecord.sourceCurrency) + if (proxyParticipantAccountDetails.participantCurrencyId) { + result.positionChanges.push({ + isFxTransferStateChange: true, + commitRequestId: fxTransferRecord.commitRequestId, + participantCurrencyId: proxyParticipantAccountDetails.participantCurrencyId, + amount: -fxTransferRecord.sourceAmount + }) + } + result.patchNotifications.push({ + commitRequestId: watchListRecord.commitRequestId, + fxpName: fxTransferRecord.counterPartyFspName, + fulfilment: fxTransferRecord.fulfilment, + completedTimestamp: fxTransferRecord.completedTimestamp + }) + } + } + + if (!sendingFxpExists && !receivingFxpExists) { + // If there are no sending and receiving fxp, throw an error + throw new Error(`Required records not found in watch list for transfer ID ${transferId}`) + } + + if (sendingFxpExists && receivingFxpExists) { + // If we have both a sending and a receiving FXP, Create obligation between sending and receiving FXP in currency of transfer. + const proxyParticipantAccountDetails = await ProxyCache.getProxyParticipantAccountDetails(receivingFxpRecord.counterPartyFspName, receivingFxpRecord.sourceCurrency) + if (proxyParticipantAccountDetails.participantCurrencyId) { + result.positionChanges.push({ + isFxTransferStateChange: true, + commitRequestId: receivingFxpRecord.commitRequestId, + participantCurrencyId: proxyParticipantAccountDetails.participantCurrencyId, + amount: -receivingFxpRecord.sourceAmount + }) + } + } else if (sendingFxpExists) { + // If we have a sending FXP, Create obligation between FXP and creditor party to the transfer in currency of FX transfer + // Get participantCurrencyId for transfer.payeeParticipantId/transfer.payeeFsp and sendingFxpRecord.targetCurrency + const proxyParticipantAccountDetails = await ProxyCache.getProxyParticipantAccountDetails(transfer.payeeFsp, sendingFxpRecord.targetCurrency) + if (proxyParticipantAccountDetails.participantCurrencyId) { + let isPositionChange = false + if (proxyParticipantAccountDetails.inScheme) { + isPositionChange = true + } else { + // We are not expecting this. Payee participant is a proxy and have an account in the targetCurrency. + // In this case we need to check if FXP is also a proxy and have the same account as payee. + const proxyParticipantAccountDetails2 = await ProxyCache.getProxyParticipantAccountDetails(sendingFxpRecord.counterPartyFspName, sendingFxpRecord.targetCurrency) + if (!proxyParticipantAccountDetails2.inScheme && (proxyParticipantAccountDetails.participantCurrencyId !== proxyParticipantAccountDetails2.participantCurrencyId)) { + isPositionChange = true + } + } + if (isPositionChange) { + result.positionChanges.push({ + isFxTransferStateChange: false, + transferId, + participantCurrencyId: proxyParticipantAccountDetails.participantCurrencyId, + amount: -sendingFxpRecord.targetAmount + }) + } + } + } else if (receivingFxpExists) { + // If we have a receiving FXP, Create obligation between debtor party to the transfer and FXP in currency of transfer + const proxyParticipantAccountDetails = await ProxyCache.getProxyParticipantAccountDetails(receivingFxpRecord.counterPartyFspName, receivingFxpRecord.sourceCurrency) + if (proxyParticipantAccountDetails.participantCurrencyId) { + let isPositionChange = false + if (proxyParticipantAccountDetails.inScheme) { + isPositionChange = true + } else { + // We are not expecting this. FXP participant is a proxy and have an account in the sourceCurrency. + // In this case we need to check if Payer is also a proxy and have the same account as FXP. + const proxyParticipantAccountDetails2 = await ProxyCache.getProxyParticipantAccountDetails(transfer.payerFsp, receivingFxpRecord.sourceCurrency) + if (!proxyParticipantAccountDetails2.inScheme && (proxyParticipantAccountDetails.participantCurrencyId !== proxyParticipantAccountDetails2.participantCurrencyId)) { + isPositionChange = true + } + } + if (isPositionChange) { + result.positionChanges.push({ + isFxTransferStateChange: true, + commitRequestId: receivingFxpRecord.commitRequestId, + participantCurrencyId: proxyParticipantAccountDetails.participantCurrencyId, + amount: -receivingFxpRecord.sourceAmount + }) + } + } + } + } else { + // Normal transfer request, just return isFx = false + } + + histTimer({ success: true }) + return result +} + +module.exports = { + getParticipantAndCurrencyForTransferMessage, + getParticipantAndCurrencyForFxTransferMessage, + processFxFulfilMessage, + processFxAbortMessage, + processFulfilMessage, + processAbortMessage, + checkIfDeterminingTransferExistsForTransferMessage, + checkIfDeterminingTransferExistsForFxTransferMessage +} diff --git a/src/domain/fx/index.js b/src/domain/fx/index.js new file mode 100644 index 000000000..e94626711 --- /dev/null +++ b/src/domain/fx/index.js @@ -0,0 +1,81 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +/** + * @module src/domain/transfer/ + */ + +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const FxTransferModel = require('../../models/fxTransfer') +// const TransferObjectTransform = require('./transform') +const Cyril = require('./cyril') + +const handleFulfilResponse = async (transferId, payload, action, fspiopError) => { + const histTimerTransferServiceHandlePayeeResponseEnd = Metrics.getHistogram( + 'fx_domain_transfer', + 'prepare - Metrics for fx transfer domain', + ['success', 'funcName'] + ).startTimer() + + try { + await FxTransferModel.fxTransfer.saveFxFulfilResponse(transferId, payload, action, fspiopError) + const result = {} + histTimerTransferServiceHandlePayeeResponseEnd({ success: true, funcName: 'handleFulfilResponse' }) + return result + } catch (err) { + histTimerTransferServiceHandlePayeeResponseEnd({ success: false, funcName: 'handleFulfilResponse' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const forwardedFxPrepare = async (commitRequestId) => { + const histTimerTransferServicePrepareEnd = Metrics.getHistogram( + 'fx_domain_transfer', + 'prepare - Metrics for fx transfer domain', + ['success', 'funcName'] + ).startTimer() + try { + const result = await FxTransferModel.fxTransfer.updateFxPrepareReservedForwarded(commitRequestId) + histTimerTransferServicePrepareEnd({ success: true, funcName: 'forwardedFxPrepare' }) + return result + } catch (err) { + histTimerTransferServicePrepareEnd({ success: false, funcName: 'forwardedFxPrepare' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const TransferService = { + handleFulfilResponse, + forwardedFxPrepare, + getByIdLight: FxTransferModel.fxTransfer.getByIdLight, + Cyril +} + +module.exports = TransferService diff --git a/src/domain/ledgerAccountTypes/index.js b/src/domain/ledgerAccountTypes/index.js index 2535fafc5..3ad90c1c6 100644 --- a/src/domain/ledgerAccountTypes/index.js +++ b/src/domain/ledgerAccountTypes/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/domain/participant/index.js b/src/domain/participant/index.js index bbeb0cd39..e975f3027 100644 --- a/src/domain/participant/index.js +++ b/src/domain/participant/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -42,6 +45,7 @@ const KafkaProducer = require('@mojaloop/central-services-stream').Util.Producer const { randomUUID } = require('crypto') const Enum = require('@mojaloop/central-services-shared').Enum const Enums = require('../../lib/enumCached') +const { logger } = require('../../shared/logger') // Alphabetically ordered list of error texts used below const AccountInactiveErrorText = 'Account is currently set inactive' @@ -58,9 +62,12 @@ const ErrorHandler = require('@mojaloop/central-services-error-handling') const { destroyParticipantEndpointByParticipantId } = require('../../models/participant/participant') const create = async (payload) => { + const log = logger.child({ payload }) try { - return ParticipantModel.create({ name: payload.name }) + log.info('creating participant with payload') + return ParticipantModel.create({ name: payload.name, isProxy: !!payload.isProxy }) } catch (err) { + log.error('error creating participant', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -71,13 +78,16 @@ const getAll = async () => { await Promise.all(all.map(async (participant) => { participant.currencyList = await ParticipantCurrencyModel.getByParticipantId(participant.participantId) })) + logger.debug('getAll participants', { participants: all }) return all } catch (err) { + logger.error('error getting all participants', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const getById = async (id) => { + logger.debug('getting participant by id', { id }) const participant = await ParticipantModel.getById(id) if (participant) { participant.currencyList = await ParticipantCurrencyModel.getByParticipantId(participant.participantId) @@ -86,6 +96,7 @@ const getById = async (id) => { } const getByName = async (name) => { + logger.debug('getting participant by name', { name }) const participant = await ParticipantModel.getByName(name) if (participant) { participant.currencyList = await ParticipantCurrencyModel.getByParticipantId(participant.participantId) @@ -94,17 +105,23 @@ const getByName = async (name) => { } const participantExists = (participant, checkIsActive = false) => { + const log = logger.child({ participant, checkIsActive }) + log.debug('checking if participant exists') if (participant) { if (!checkIsActive || participant.isActive) { return participant } + log.warn('participant is inactive') throw ErrorHandler.Factory.createInternalServerFSPIOPError(ParticipantInactiveText) } + log.warn('participant not found') throw ErrorHandler.Factory.createInternalServerFSPIOPError(ParticipantNotFoundText) } const update = async (name, payload) => { + const log = logger.child({ name, payload }) try { + log.info('updating participant') const participant = await ParticipantModel.getByName(name) participantExists(participant) await ParticipantModel.update(participant, payload.isActive) @@ -112,38 +129,50 @@ const update = async (name, payload) => { participant.currencyList = await ParticipantCurrencyModel.getByParticipantId(participant.participantId) return participant } catch (err) { + log.error('error updating participant', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const createParticipantCurrency = async (participantId, currencyId, ledgerAccountTypeId, isActive = true) => { + const log = logger.child({ participantId, currencyId, ledgerAccountTypeId, isActive }) try { + log.info('creating participant currency') const participantCurrency = await ParticipantCurrencyModel.create(participantId, currencyId, ledgerAccountTypeId, isActive) return participantCurrency } catch (err) { + log.error('error creating participant currency', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const createHubAccount = async (participantId, currencyId, ledgerAccountTypeId) => { + const log = logger.child({ participantId, currencyId, ledgerAccountTypeId }) try { + log.info('creating hub account') const participantCurrency = await ParticipantFacade.addHubAccountAndInitPosition(participantId, currencyId, ledgerAccountTypeId) return participantCurrency } catch (err) { + log.error('error creating hub account', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const getParticipantCurrencyById = async (participantCurrencyId) => { + const log = logger.child({ participantCurrencyId }) try { + log.debug('getting participant currency by id') return await ParticipantCurrencyModel.getById(participantCurrencyId) } catch (err) { + log.error('error getting participant currency by id', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const destroyByName = async (name) => { + const log = logger.child({ name }) try { + log.debug('destroying participant by name') const participant = await ParticipantModel.getByName(name) await ParticipantLimitModel.destroyByParticipantId(participant.participantId) await ParticipantPositionModel.destroyByParticipantId(participant.participantId) @@ -151,6 +180,7 @@ const destroyByName = async (name) => { await destroyParticipantEndpointByParticipantId(participant.participantId) return await ParticipantModel.destroyByName(name) } catch (err) { + log.error('error destroying participant by name', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -174,11 +204,15 @@ const destroyByName = async (name) => { */ const addEndpoint = async (name, payload) => { + const log = logger.child({ name, payload }) try { + log.info('adding endpoint') const participant = await ParticipantModel.getByName(name) participantExists(participant) + log.info('adding endpoint for participant', { participant }) return ParticipantFacade.addEndpoint(participant.participantId, payload) } catch (err) { + log.error('error adding endpoint', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -199,11 +233,15 @@ const addEndpoint = async (name, payload) => { */ const getEndpoint = async (name, type) => { + const log = logger.child({ name, type }) try { + log.debug('getting endpoint') const participant = await ParticipantModel.getByName(name) participantExists(participant) + log.debug('getting endpoint for participant', { participant }) return ParticipantFacade.getEndpoint(participant.participantId, type) } catch (err) { + log.error('error getting endpoint', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -223,11 +261,15 @@ const getEndpoint = async (name, type) => { */ const getAllEndpoints = async (name) => { + const log = logger.child({ name }) try { + log.debug('getting all endpoints for participant name') const participant = await ParticipantModel.getByName(name) participantExists(participant) + log.debug('getting all endpoints for participant', { participant }) return ParticipantFacade.getAllEndpoints(participant.participantId) } catch (err) { + log.error('error getting all endpoints', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -245,11 +287,15 @@ const getAllEndpoints = async (name) => { */ const destroyParticipantEndpointByName = async (name) => { + const log = logger.child({ name }) try { + log.debug('destroying participant endpoint by name') const participant = await ParticipantModel.getByName(name) participantExists(participant) + log.debug('destroying participant endpoint for participant', { participant }) return ParticipantModel.destroyParticipantEndpointByParticipantId(participant.participantId) } catch (err) { + log.error('error destroying participant endpoint by name', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -278,14 +324,18 @@ const destroyParticipantEndpointByName = async (name) => { */ const addLimitAndInitialPosition = async (participantName, limitAndInitialPositionObj) => { + const log = logger.child({ participantName, limitAndInitialPositionObj }) try { + log.debug('adding limit and initial position', { participantName, limitAndInitialPositionObj }) const participant = await ParticipantFacade.getByNameAndCurrency(participantName, limitAndInitialPositionObj.currency, Enum.Accounts.LedgerAccountType.POSITION) participantExists(participant) + log.debug('adding limit and initial position for participant', { participant }) const settlementAccount = await ParticipantFacade.getByNameAndCurrency(participantName, limitAndInitialPositionObj.currency, Enum.Accounts.LedgerAccountType.SETTLEMENT) const existingLimit = await ParticipantLimitModel.getByParticipantCurrencyId(participant.participantCurrencyId) const existingPosition = await ParticipantPositionModel.getByParticipantCurrencyId(participant.participantCurrencyId) const existingSettlementPosition = await ParticipantPositionModel.getByParticipantCurrencyId(settlementAccount.participantCurrencyId) if (existingLimit || existingPosition || existingSettlementPosition) { + log.warn('participant limit or initial position already set') throw ErrorHandler.Factory.createInternalServerFSPIOPError(ParticipantInitialPositionExistsText) } const limitAndInitialPosition = Object.assign({}, limitAndInitialPositionObj, { name: participantName }) @@ -296,6 +346,7 @@ const addLimitAndInitialPosition = async (participantName, limitAndInitialPositi await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, KafkaProducer, Enum.Events.Event.Type.NOTIFICATION, Enum.Transfers.AdminNotificationActions.LIMIT_ADJUSTMENT, createLimitAdjustmentMessageProtocol(payload), Enum.Events.EventStatus.SUCCESS) return ParticipantFacade.addLimitAndInitialPosition(participant.participantCurrencyId, settlementAccount.participantCurrencyId, limitAndInitialPosition, true) } catch (err) { + log.error('error adding limit and initial position', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -313,9 +364,12 @@ const addLimitAndInitialPosition = async (participantName, limitAndInitialPositi */ const getPositionByParticipantCurrencyId = async (participantCurrencyId) => { + const log = logger.child({ participantCurrencyId }) try { + log.debug('getting position by participant currency id') return ParticipantPositionModel.getByParticipantCurrencyId(participantCurrencyId) } catch (err) { + log.error('error getting position by participant currency id', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -333,9 +387,12 @@ const getPositionByParticipantCurrencyId = async (participantCurrencyId) => { */ const getPositionChangeByParticipantPositionId = async (participantPositionId) => { + const log = logger.child({ participantPositionId }) try { + log.debug('getting position change by participant position id') return ParticipantPositionChangeModel.getByParticipantPositionId(participantPositionId) } catch (err) { + log.error('error getting position change by participant position id', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -353,11 +410,15 @@ const getPositionChangeByParticipantPositionId = async (participantPositionId) = */ const destroyParticipantPositionByNameAndCurrency = async (name, currencyId) => { + const log = logger.child({ name, currencyId }) try { + log.debug('destroying participant position by participant name and currency') const participant = await ParticipantFacade.getByNameAndCurrency(name, currencyId, Enum.Accounts.LedgerAccountType.POSITION) + log.debug('destroying participant position for participant', { participant }) participantExists(participant) return ParticipantPositionModel.destroyByParticipantCurrencyId(participant.participantCurrencyId) } catch (err) { + log.error('error destroying participant position by name and currency', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -376,11 +437,15 @@ const destroyParticipantPositionByNameAndCurrency = async (name, currencyId) => */ const destroyParticipantLimitByNameAndCurrency = async (name, currencyId) => { + const log = logger.child({ name, currencyId }) try { + log.debug('destroying participant limit by participant name and currency') const participant = await ParticipantFacade.getByNameAndCurrency(name, currencyId, Enum.Accounts.LedgerAccountType.POSITION) + log.debug('destroying participant limit for participant', { participant }) participantExists(participant) return ParticipantLimitModel.destroyByParticipantCurrencyId(participant.participantCurrencyId) } catch (err) { + log.error('error destroying participant limit by name and currency', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -403,18 +468,24 @@ const destroyParticipantLimitByNameAndCurrency = async (name, currencyId) => { */ const getLimits = async (name, { currency = null, type = null }) => { + const log = logger.child({ name, currency, type }) try { let participant if (currency != null) { + log.debug('getting limits by name and currency') participant = await ParticipantFacade.getByNameAndCurrency(name, currency, Enum.Accounts.LedgerAccountType.POSITION) + log.debug('getting limits for participant', { participant }) participantExists(participant) return ParticipantFacade.getParticipantLimitsByCurrencyId(participant.participantCurrencyId, type) } else { + log.debug('getting limits by name') participant = await ParticipantModel.getByName(name) + log.debug('getting limits for participant', { participant }) participantExists(participant) return ParticipantFacade.getParticipantLimitsByParticipantId(participant.participantId, type, Enum.Accounts.LedgerAccountType.POSITION) } } catch (err) { + log.error('error getting limits', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -434,9 +505,12 @@ const getLimits = async (name, { currency = null, type = null }) => { */ const getLimitsForAllParticipants = async ({ currency = null, type = null }) => { + const log = logger.child({ currency, type }) try { + log.debug('getting limits for all participants', { currency, type }) return ParticipantFacade.getLimitsForAllParticipants(currency, type, Enum.Accounts.LedgerAccountType.POSITION) } catch (err) { + log.error('error getting limits for all participants', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -465,15 +539,19 @@ const getLimitsForAllParticipants = async ({ currency = null, type = null }) => */ const adjustLimits = async (name, payload) => { + const log = logger.child({ name, payload }) try { + log.debug('adjusting limits') const { limit, currency } = payload const participant = await ParticipantFacade.getByNameAndCurrency(name, currency, Enum.Accounts.LedgerAccountType.POSITION) + log.debug('adjusting limits for participant', { participant }) participantExists(participant) const result = await ParticipantFacade.adjustLimits(participant.participantCurrencyId, limit) payload.name = name await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, KafkaProducer, Enum.Events.Event.Type.NOTIFICATION, Enum.Transfers.AdminNotificationActions.LIMIT_ADJUSTMENT, createLimitAdjustmentMessageProtocol(payload), Enum.Events.EventStatus.SUCCESS) return result } catch (err) { + log.error('error adjusting limits', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -546,9 +624,12 @@ const createLimitAdjustmentMessageProtocol = (payload, action = Enum.Transfers.A */ const getPositions = async (name, query) => { + const log = logger.child({ name, query }) try { + log.debug('getting positions') if (query.currency) { const participant = await ParticipantFacade.getByNameAndCurrency(name, query.currency, Enum.Accounts.LedgerAccountType.POSITION) + log.debug('getting positions for participant', { participant }) participantExists(participant) const result = await PositionFacade.getByNameAndCurrency(name, Enum.Accounts.LedgerAccountType.POSITION, query.currency) // TODO this function only takes a max of 3 params, this has 4 let position = {} @@ -559,9 +640,11 @@ const getPositions = async (name, query) => { changedDate: result[0].changedDate } } + log.debug('found positions for participant', { participant, position }) return position } else { const participant = await ParticipantModel.getByName(name) + log.debug('getting positions for participant', { participant }) participantExists(participant) const result = await await PositionFacade.getByNameAndCurrency(name, Enum.Accounts.LedgerAccountType.POSITION) const positions = [] @@ -574,16 +657,21 @@ const getPositions = async (name, query) => { }) }) } + log.debug('found positions for participant', { participant, positions }) return positions } } catch (err) { + log.error('error getting positions', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const getAccounts = async (name, query) => { + const log = logger.child({ name, query }) try { + log.debug('getting accounts') const participant = await ParticipantModel.getByName(name) + log.debug('getting accounts for participant', { participant }) participantExists(participant) const result = await PositionFacade.getAllByNameAndCurrency(name, query.currency) const positions = [] @@ -600,18 +688,24 @@ const getAccounts = async (name, query) => { }) }) } + log.debug('found accounts for participant', { participant, positions }) return positions } catch (err) { + log.error('error getting accounts', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const updateAccount = async (payload, params, enums) => { + const log = logger.child({ payload, params, enums }) try { + log.debug('updating account') const { name, id } = params const participant = await ParticipantModel.getByName(name) + log.debug('updating account for participant', { participant }) participantExists(participant) const account = await ParticipantCurrencyModel.getById(id) + log.debug('updating account for participant', { participant, account }) if (!account) { throw ErrorHandler.Factory.createInternalServerFSPIOPError(AccountNotFoundErrorText) } else if (account.participantId !== participant.participantId) { @@ -621,22 +715,29 @@ const updateAccount = async (payload, params, enums) => { } return await ParticipantCurrencyModel.update(id, payload.isActive) } catch (err) { + log.error('error updating account', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const getLedgerAccountTypeName = async (name) => { + const log = logger.child({ name }) try { + log.debug('getting ledger account type by name') return await LedgerAccountTypeModel.getLedgerAccountByName(name) } catch (err) { + log.error('error getting ledger account type by name', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const getParticipantAccount = async (accountParams) => { + const log = logger.child({ accountParams }) try { + log.debug('getting participant account by params') return await ParticipantCurrencyModel.findOneByParams(accountParams) } catch (err) { + log.error('error getting participant account by params', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -690,7 +791,9 @@ const setPayerPayeeFundsInOut = (fspName, payload, enums) => { } const recordFundsInOut = async (payload, params, enums) => { + const log = logger.child({ payload, params, enums }) try { + log.debug('recording funds in/out') const { name, id, transferId } = params const participant = await ParticipantModel.getByName(name) const currency = (payload.amount && payload.amount.currency) || null @@ -699,6 +802,7 @@ const recordFundsInOut = async (payload, params, enums) => { participantExists(participant, checkIsActive) const accounts = await ParticipantFacade.getAllAccountsByNameAndCurrency(name, currency, isAccountActive) const accountMatched = accounts[accounts.map(account => account.participantCurrencyId).findIndex(i => i === id)] + log.debug('recording funds in/out for participant account', { participant, accountMatched }) if (!accountMatched) { throw ErrorHandler.Factory.createInternalServerFSPIOPError(ParticipantAccountCurrencyMismatchText) } else if (!accountMatched.accountIsActive) { @@ -714,6 +818,7 @@ const recordFundsInOut = async (payload, params, enums) => { } return await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, KafkaProducer, Enum.Events.Event.Type.ADMIN, Enum.Events.Event.Action.TRANSFER, messageProtocol, Enum.Events.EventStatus.SUCCESS) } catch (err) { + log.error('error recording funds in/out', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -722,17 +827,21 @@ const validateHubAccounts = async (currency) => { const ledgerAccountTypes = await Enums.getEnums('ledgerAccountType') const hubReconciliationAccountExists = await ParticipantCurrencyModel.hubAccountExists(currency, ledgerAccountTypes.HUB_RECONCILIATION) if (!hubReconciliationAccountExists) { + logger.error('Hub reconciliation account for the specified currency does not exist') throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.ADD_PARTY_INFO_ERROR, 'Hub reconciliation account for the specified currency does not exist') } const hubMlnsAccountExists = await ParticipantCurrencyModel.hubAccountExists(currency, ledgerAccountTypes.HUB_MULTILATERAL_SETTLEMENT) if (!hubMlnsAccountExists) { + logger.error('Hub multilateral net settlement account for the specified currency does not exist') throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.ADD_PARTY_INFO_ERROR, 'Hub multilateral net settlement account for the specified currency does not exist') } return true } const createAssociatedParticipantAccounts = async (currency, ledgerAccountTypeId, trx) => { + const log = logger.child({ currency, ledgerAccountTypeId }) try { + log.info('creating associated participant accounts') const nonHubParticipantWithCurrencies = await ParticipantFacade.getAllNonHubParticipantsWithCurrencies(trx) const participantCurrencies = nonHubParticipantWithCurrencies.map(item => ({ @@ -760,6 +869,7 @@ const createAssociatedParticipantAccounts = async (currency, ledgerAccountTypeId } await ParticipantPositionModel.createParticipantPositionRecords(participantPositionRecords, trx) } catch (err) { + log.error('error creating associated participant accounts', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } diff --git a/src/domain/position/abort.js b/src/domain/position/abort.js new file mode 100644 index 000000000..9489a4f67 --- /dev/null +++ b/src/domain/position/abort.js @@ -0,0 +1,249 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ +const { Enum } = require('@mojaloop/central-services-shared') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Config = require('../../lib/config') +const Utility = require('@mojaloop/central-services-shared').Util +const MLNumber = require('@mojaloop/ml-number') +const Logger = require('@mojaloop/central-services-logger') + +/** + * @function processPositionAbortBin + * + * @async + * @description This is the domain function to process a bin of abort / fx-abort messages of a single participant account. + * + * @param {array} abortBins - an array containing abort / fx-abort action bins + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. + * @param {object} transferInfoList - object with transfer id keys and transfer info values. Used to pass transfer info to domain function. + * @param {boolean} changePositions - whether to change positions or not + * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedTransferStateChanges, accumulatedTransferStates, resultMessages, limitAlarms or throws an error if failed + */ +const processPositionAbortBin = async ( + abortBins, + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + accumulatedFxTransferStates, + isFx, + changePositions = true + } +) => { + const transferStateChanges = [] + const participantPositionChanges = [] + const resultMessages = [] + const followupMessages = [] + const fxTransferStateChanges = [] + const accumulatedTransferStatesCopy = Object.assign({}, accumulatedTransferStates) + const accumulatedFxTransferStatesCopy = Object.assign({}, accumulatedFxTransferStates) + let runningPosition = new MLNumber(accumulatedPositionValue) + + if (abortBins && abortBins.length > 0) { + for (const binItem of abortBins) { + Logger.isDebugEnabled && Logger.debug(`processPositionAbortBin::binItem: ${JSON.stringify(binItem.message.value)}`) + if (isFx) { + // If the transfer is not in `RECEIVED_ERROR`, a position fx-abort message was incorrectly published. + // i.e Something has gone extremely wrong. + if (accumulatedFxTransferStates[binItem.message.value.content.uriParams.id] !== Enum.Transfers.TransferInternalState.RECEIVED_ERROR) { + throw ErrorHandler.Factory.createInternalServerFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.message) + } + } else { + // If the transfer is not in `RECEIVED_ERROR`, a position abort message was incorrectly published. + // i.e Something has gone extremely wrong. + if (accumulatedTransferStates[binItem.message.value.content.uriParams.id] !== Enum.Transfers.TransferInternalState.RECEIVED_ERROR) { + throw ErrorHandler.Factory.createInternalServerFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.message) + } + } + + const cyrilResult = binItem.message.value.content.context?.cyrilResult + if (!cyrilResult || !cyrilResult.positionChanges || cyrilResult.positionChanges.length === 0) { + throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR) + } + + // Handle position movements + // Iterate through positionChanges and handle each position movement, mark as done and publish a position-commit kafka message again for the next item + // Find out the first item to be processed + const positionChangeIndex = cyrilResult.positionChanges.findIndex(positionChange => !positionChange.isDone) + const positionChangeToBeProcessed = cyrilResult.positionChanges[positionChangeIndex] + if (positionChangeToBeProcessed.isFxTransferStateChange) { + const { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChangeFx(runningPosition, positionChangeToBeProcessed.amount, positionChangeToBeProcessed.commitRequestId, accumulatedPositionReservedValue) + runningPosition = updatedRunningPosition + participantPositionChanges.push(participantPositionChange) + fxTransferStateChanges.push(fxTransferStateChange) + accumulatedFxTransferStatesCopy[positionChangeToBeProcessed.commitRequestId] = transferStateId + } else { + const { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChange(runningPosition, positionChangeToBeProcessed.amount, positionChangeToBeProcessed.transferId, accumulatedPositionReservedValue) + runningPosition = updatedRunningPosition + participantPositionChanges.push(participantPositionChange) + transferStateChanges.push(transferStateChange) + accumulatedTransferStatesCopy[positionChangeToBeProcessed.transferId] = transferStateId + } + binItem.result = { success: true } + const from = binItem.message.value.from + cyrilResult.positionChanges[positionChangeIndex].isDone = true + const nextIndex = cyrilResult.positionChanges.findIndex(positionChange => !positionChange.isDone) + if (nextIndex === -1) { + // All position changes are done, we need to inform all the participants about the abort + // Construct a list of messages excluding the original message as it will notified anyway + for (const positionChange of cyrilResult.positionChanges) { + if (positionChange.isFxTransferStateChange) { + // Construct notification message for fx transfer state change + const resultMessage = _constructAbortResultMessage(binItem, positionChange.commitRequestId, from, positionChange.notifyTo) + resultMessages.push({ binItem, message: resultMessage }) + } else { + // Construct notification message for transfer state change + const resultMessage = _constructAbortResultMessage(binItem, positionChange.transferId, from, positionChange.notifyTo) + resultMessages.push({ binItem, message: resultMessage }) + } + } + } else { + // There are still position changes to be processed + // Send position-commit kafka message again for the next item + const participantCurrencyId = cyrilResult.positionChanges[nextIndex].participantCurrencyId + // const followupMessage = _constructTransferAbortFollowupMessage(binItem, transferId, payerFsp, payeeFsp, transfer) + // Pass down the context to the followup message with mutated cyrilResult + const followupMessage = { ...binItem.message.value } + // followupMessage.content.context = binItem.message.value.content.context + followupMessages.push({ binItem, messageKey: participantCurrencyId.toString(), message: followupMessage }) + } + } + } + + return { + accumulatedPositionValue: changePositions ? runningPosition.toNumber() : accumulatedPositionValue, + accumulatedTransferStates: accumulatedTransferStatesCopy, // finalized transfer state after fulfil processing + accumulatedPositionReservedValue, // not used but kept for consistency + accumulatedTransferStateChanges: transferStateChanges, // transfer state changes to be persisted in order + accumulatedFxTransferStates: accumulatedFxTransferStatesCopy, // finalized fx transfer state after fulfil processing + accumulatedFxTransferStateChanges: fxTransferStateChanges, // fx transfer state changes to be persisted in order + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order + notifyMessages: resultMessages, // array of objects containing bin item and result message. {binItem, message} + followupMessages // array of objects containing bin item, message key and followup message. {binItem, messageKey, message} + } +} + +const _constructAbortResultMessage = (binItem, id, from, notifyTo) => { + let apiErrorCode = ErrorHandler.Enums.FSPIOPErrorCodes.PAYEE_REJECTION + let fromCalculated = from + if (binItem.message?.value.metadata.event.action === Enum.Events.Event.Action.FX_ABORT_VALIDATION || binItem.message?.value.metadata.event.action === Enum.Events.Event.Action.ABORT_VALIDATION) { + fromCalculated = Config.HUB_NAME + apiErrorCode = ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR + } + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + apiErrorCode, + null, + null, + null, + null + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + // Create metadata for the message + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + id, + Enum.Kafka.Topics.POSITION, + binItem.message?.value.metadata.event.action, // This will be replaced anyway in Kafka.produceGeneralMessage function + state + ) + const resultMessage = Utility.StreamingProtocol.createMessage( + id, + notifyTo, + fromCalculated, + metadata, + binItem.message.value.content.headers, // Headers don't really matter here. ml-api-adapter will ignore them and create their own. + fspiopError, + { id }, + 'application/json' + ) + + return resultMessage +} + +const _handleParticipantPositionChange = (runningPosition, transferAmount, transferId, accumulatedPositionReservedValue) => { + const transferStateId = Enum.Transfers.TransferInternalState.ABORTED_ERROR + const updatedRunningPosition = new MLNumber(runningPosition.add(transferAmount).toFixed(Config.AMOUNT.SCALE)) + + const participantPositionChange = { + transferId, // Need to delete this in bin processor while updating transferStateChangeId + transferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + // Construct transfer state change object + const transferStateChange = { + transferId, + transferStateId, + reason: null + } + return { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } +} + +const _handleParticipantPositionChangeFx = (runningPosition, transferAmount, commitRequestId, accumulatedPositionReservedValue) => { + const transferStateId = Enum.Transfers.TransferInternalState.ABORTED_ERROR + // Amounts in `transferParticipant` for the payee are stored as negative values + const updatedRunningPosition = new MLNumber(runningPosition.add(transferAmount).toFixed(Config.AMOUNT.SCALE)) + + const participantPositionChange = { + commitRequestId, // Need to delete this in bin processor while updating fxTransferStateChangeId + fxTransferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + const fxTransferStateChange = { + commitRequestId, + transferStateId, + reason: null + } + return { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } +} + +module.exports = { + processPositionAbortBin +} diff --git a/src/domain/position/binProcessor.js b/src/domain/position/binProcessor.js index 39816764b..1b16d6ff6 100644 --- a/src/domain/position/binProcessor.js +++ b/src/domain/position/binProcessor.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -24,7 +24,6 @@ * INFITX - Vijay Kumar Guthi - - Steven Oderayi -------------- ******/ @@ -34,7 +33,12 @@ const Logger = require('@mojaloop/central-services-logger') const BatchPositionModel = require('../../models/position/batch') const BatchPositionModelCached = require('../../models/position/batchCached') const PositionPrepareDomain = require('./prepare') +const PositionFxPrepareDomain = require('./fx-prepare') const PositionFulfilDomain = require('./fulfil') +const PositionFxFulfilDomain = require('./fx-fulfil') +const PositionTimeoutReservedDomain = require('./timeout-reserved') +const PositionFxTimeoutReservedDomain = require('./fx-timeout-reserved') +const PositionAbortDomain = require('./abort') const SettlementModelCached = require('../../models/settlement/settlementModelCached') const Enum = require('@mojaloop/central-services-shared').Enum const ErrorHandler = require('@mojaloop/central-services-error-handling') @@ -52,75 +56,29 @@ const participantFacade = require('../../models/participant/facade') * @returns {results} - Returns a list of bins with results or throws an error if failed */ const processBins = async (bins, trx) => { - const transferIdList = [] - const reservedActionTransferIdList = [] - await iterateThroughBins(bins, (_accountID, action, item) => { - if (item.decodedPayload?.transferId) { - transferIdList.push(item.decodedPayload.transferId) - // get transferId from uriParams for fulfil messages - } else if (item.message?.value?.content?.uriParams?.id) { - transferIdList.push(item.message.value.content.uriParams.id) - if (action === Enum.Events.Event.Action.RESERVE) { - reservedActionTransferIdList.push(item.message.value.content.uriParams.id) - } - } - }) + let notifyMessages = [] + let followupMessages = [] + let limitAlarms = [] + + // Get transferIdList, reservedActionTransferIdList and commitRequestId for actions PREPARE, FX_PREPARE, FX_RESERVE, COMMIT and RESERVE + const { transferIdList, reservedActionTransferIdList, commitRequestIdList } = await _getTransferIdList(bins) + // Pre fetch latest transferStates for all the transferIds in the account-bin - const latestTransferStateChanges = await BatchPositionModel.getLatestTransferStateChangesByTransferIdList(trx, transferIdList) - const latestTransferStates = {} - for (const key in latestTransferStateChanges) { - latestTransferStates[key] = latestTransferStateChanges[key].transferStateId - } + const latestTransferStates = await _fetchLatestTransferStates(trx, transferIdList) - const accountIds = Object.keys(bins) + // Pre fetch latest fxTransferStates for all the commitRequestIds in the account-bin + const latestFxTransferStates = await _fetchLatestFxTransferStates(trx, commitRequestIdList) - // Pre fetch all settlement accounts corresponding to the position accounts - // Get all participantIdMap for the accountIds - const participantCurrencyIds = await BatchPositionModelCached.getParticipantCurrencyByIds(trx, accountIds) + const accountIds = [...Object.keys(bins).filter(accountId => accountId !== '0')] - // Validate that participantCurrencyIds exist for each of the accountIds - // i.e every unique accountId has a corresponding entry in participantCurrencyIds - const participantIdsHavingCurrencyIdsList = [...new Set(participantCurrencyIds.map(item => item.participantCurrencyId))] - const allAccountIdsHaveParticipantCurrencyIds = accountIds.every(accountId => { - return participantIdsHavingCurrencyIdsList.includes(Number(accountId)) - }) - if (!allAccountIdsHaveParticipantCurrencyIds) { - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, 'Not all accountIds have corresponding participantCurrencyIds') - } + // Get all participantIdMap for the accountIds + const participantCurrencyIds = await _getParticipantCurrencyIds(trx, accountIds) + // Pre fetch all settlement accounts corresponding to the position accounts const allSettlementModels = await SettlementModelCached.getAll() // Construct objects participantIdMap, accountIdMap and currencyIdMap - const participantIdMap = {} - const accountIdMap = {} - const currencyIdMap = {} - for (const item of participantCurrencyIds) { - const { participantId, currencyId, participantCurrencyId } = item - if (!participantIdMap[participantId]) { - participantIdMap[participantId] = {} - } - if (!currencyIdMap[currencyId]) { - currencyIdMap[currencyId] = { - settlementModel: _getSettlementModelForCurrency(currencyId, allSettlementModels) - } - } - participantIdMap[participantId][currencyId] = participantCurrencyId - accountIdMap[participantCurrencyId] = { participantId, currencyId } - } - - // Get all participantCurrencyIds for the participantIdMap - const allParticipantCurrencyIds = await BatchPositionModelCached.getParticipantCurrencyByParticipantIds(trx, Object.keys(participantIdMap)) - const settlementCurrencyIds = [] - for (const pc of allParticipantCurrencyIds) { - const correspondingParticipantCurrencyId = participantIdMap[pc.participantId][pc.currencyId] - if (correspondingParticipantCurrencyId) { - const settlementModel = currencyIdMap[pc.currencyId].settlementModel - if (pc.ledgerAccountTypeId === settlementModel.settlementAccountTypeId) { - settlementCurrencyIds.push(pc) - accountIdMap[correspondingParticipantCurrencyId].settlementCurrencyId = pc.participantCurrencyId - } - } - } + const { settlementCurrencyIds, accountIdMap } = await _constructRequiredMaps(participantCurrencyIds, allSettlementModels, trx) // Pre fetch all position account balances for the account-bin and acquire lock on position const positions = await BatchPositionModel.getPositionsByAccountIdsForUpdate(trx, [ @@ -135,15 +93,21 @@ const processBins = async (bins, trx) => { Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE ) + // Fetch all RESERVED participantPositionChanges associated with a commitRequestId + // These will contain the value that was reserved for the fxTransfer + // We will use these values to revert the position on timeouts + const fetchedReservedPositionChangesByCommitRequestIds = + await BatchPositionModel.getReservedPositionChangesByCommitRequestIds( + trx, + commitRequestIdList + ) + // Pre fetch transfers for all reserve action fulfils const reservedActionTransfers = await BatchPositionModel.getTransferByIdsForReserve( trx, reservedActionTransferIdList ) - let notifyMessages = [] - let limitAlarms = [] - // For each account-bin in the list for (const accountID in bins) { const accountBin = bins[accountID] @@ -152,57 +116,211 @@ const processBins = async (bins, trx) => { array2.every((element) => array1.includes(element)) // If non-prepare/non-commit action found, log error // We need to remove this once we implement all the actions - if (!isSubset(['prepare', 'commit', 'reserve'], actions)) { - Logger.isErrorEnabled && Logger.error('Only prepare/commit actions are allowed in a batch') - // throw new Error('Only prepare action is allowed in a batch') + const allowedActions = [ + Enum.Events.Event.Action.PREPARE, + Enum.Events.Event.Action.FX_PREPARE, + Enum.Events.Event.Action.COMMIT, + Enum.Events.Event.Action.RESERVE, + Enum.Events.Event.Action.FX_RESERVE, + Enum.Events.Event.Action.TIMEOUT_RESERVED, + Enum.Events.Event.Action.FX_TIMEOUT_RESERVED, + Enum.Events.Event.Action.ABORT, + Enum.Events.Event.Action.FX_ABORT, + Enum.Events.Event.Action.ABORT_VALIDATION, + Enum.Events.Event.Action.FX_ABORT_VALIDATION + ] + if (!isSubset(allowedActions, actions)) { + Logger.isErrorEnabled && Logger.error(`Only ${allowedActions.join()} are allowed in a batch`) } - const settlementParticipantPosition = positions[accountIdMap[accountID].settlementCurrencyId].value - const settlementModel = currencyIdMap[accountIdMap[accountID].currencyId].settlementModel + let settlementParticipantPosition = 0 + let participantLimit = null - // Story #3657: The following SQL query/lookup can be optimized for performance - const participantLimit = await participantFacade.getParticipantLimitByParticipantCurrencyLimit( - accountIdMap[accountID].participantId, - accountIdMap[accountID].currencyId, - Enum.Accounts.LedgerAccountType.POSITION, - Enum.Accounts.ParticipantLimitType.NET_DEBIT_CAP - ) // Initialize accumulated values // These values will be passed across various actions in the bin - let accumulatedPositionValue = positions[accountID].value - let accumulatedPositionReservedValue = positions[accountID].reservedValue + let accumulatedPositionValue = 0 + let accumulatedPositionReservedValue = 0 let accumulatedTransferStates = latestTransferStates + let accumulatedFxTransferStates = latestFxTransferStates let accumulatedTransferStateChanges = [] + let accumulatedFxTransferStateChanges = [] let accumulatedPositionChanges = [] + let changePositions = false + + if (accountID !== '0') { + settlementParticipantPosition = positions[accountIdMap[accountID].settlementCurrencyId].value + + // Story #3657: The following SQL query/lookup can be optimized for performance + participantLimit = await participantFacade.getParticipantLimitByParticipantCurrencyLimit( + accountIdMap[accountID].participantId, + accountIdMap[accountID].currencyId, + Enum.Accounts.LedgerAccountType.POSITION, + Enum.Accounts.ParticipantLimitType.NET_DEBIT_CAP + ) + accumulatedPositionValue = positions[accountID].value + accumulatedPositionReservedValue = positions[accountID].reservedValue + + changePositions = true + } + + // ========== FX_FULFIL ========== + // If fulfil action found then call processPositionPrepareBin function + // We don't need to change the position for FX transfers. All the position changes happen when actual transfer is done + const fxFulfilActionResult = await PositionFxFulfilDomain.processPositionFxFulfilBin( + accountBin[Enum.Events.Event.Action.FX_RESERVE], + { + accumulatedFxTransferStates + } + ) + + // ========== FX_TIMEOUT ========== + // If fx-timeout-reserved action found then call processPositionTimeoutReserveBin function + const fxTimeoutReservedActionResult = await PositionFxTimeoutReservedDomain.processPositionFxTimeoutReservedBin( + accountBin[Enum.Events.Event.Action.FX_TIMEOUT_RESERVED], + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedFxTransferStates, + fetchedReservedPositionChangesByCommitRequestIds, + changePositions + } + ) + + // Update accumulated values + accumulatedPositionValue = fxTimeoutReservedActionResult.accumulatedPositionValue + accumulatedPositionReservedValue = fxTimeoutReservedActionResult.accumulatedPositionReservedValue + accumulatedFxTransferStates = fxTimeoutReservedActionResult.accumulatedFxTransferStates + // Append accumulated arrays + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(fxTimeoutReservedActionResult.accumulatedFxTransferStateChanges) + accumulatedPositionChanges = accumulatedPositionChanges.concat(fxTimeoutReservedActionResult.accumulatedPositionChanges) + notifyMessages = notifyMessages.concat(fxTimeoutReservedActionResult.notifyMessages) + + // Update accumulated values + accumulatedFxTransferStates = fxFulfilActionResult.accumulatedFxTransferStates + // Append accumulated arrays + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(fxFulfilActionResult.accumulatedFxTransferStateChanges) + notifyMessages = notifyMessages.concat(fxFulfilActionResult.notifyMessages) + + // ========== FULFIL ========== // If fulfil action found then call processPositionPrepareBin function const fulfilActionResult = await PositionFulfilDomain.processPositionFulfilBin( [accountBin.commit, accountBin.reserve], - accumulatedPositionValue, - accumulatedPositionReservedValue, - accumulatedTransferStates, - latestTransferInfoByTransferId, - reservedActionTransfers + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList: latestTransferInfoByTransferId, + reservedActionTransfers, + changePositions + } ) // Update accumulated values accumulatedPositionValue = fulfilActionResult.accumulatedPositionValue accumulatedPositionReservedValue = fulfilActionResult.accumulatedPositionReservedValue accumulatedTransferStates = fulfilActionResult.accumulatedTransferStates + accumulatedFxTransferStates = fulfilActionResult.accumulatedFxTransferStates // Append accumulated arrays accumulatedTransferStateChanges = accumulatedTransferStateChanges.concat(fulfilActionResult.accumulatedTransferStateChanges) + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(fulfilActionResult.accumulatedFxTransferStateChanges) accumulatedPositionChanges = accumulatedPositionChanges.concat(fulfilActionResult.accumulatedPositionChanges) notifyMessages = notifyMessages.concat(fulfilActionResult.notifyMessages) + followupMessages = followupMessages.concat(fulfilActionResult.followupMessages) + + // ========== ABORT ========== + // If abort action found then call processPositionAbortBin function + const abortReservedActionResult = await PositionAbortDomain.processPositionAbortBin( + [ + ...(accountBin[Enum.Events.Event.Action.ABORT] || []), + ...(accountBin[Enum.Events.Event.Action.ABORT_VALIDATION] || []) + ], + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + accumulatedFxTransferStates, + isFx: false, + changePositions + } + ) + + // Update accumulated values + accumulatedPositionValue = abortReservedActionResult.accumulatedPositionValue + accumulatedPositionReservedValue = abortReservedActionResult.accumulatedPositionReservedValue + accumulatedTransferStates = abortReservedActionResult.accumulatedTransferStates + accumulatedFxTransferStates = abortReservedActionResult.accumulatedFxTransferStates + // Append accumulated arrays + accumulatedTransferStateChanges = accumulatedTransferStateChanges.concat(abortReservedActionResult.accumulatedTransferStateChanges) + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(abortReservedActionResult.accumulatedFxTransferStateChanges) + accumulatedPositionChanges = accumulatedPositionChanges.concat(abortReservedActionResult.accumulatedPositionChanges) + notifyMessages = notifyMessages.concat(abortReservedActionResult.notifyMessages) + followupMessages = followupMessages.concat(abortReservedActionResult.followupMessages) + + // ========== FX_ABORT ========== + // If abort action found then call processPositionAbortBin function + const fxAbortReservedActionResult = await PositionAbortDomain.processPositionAbortBin( + [ + ...(accountBin[Enum.Events.Event.Action.FX_ABORT] || []), + ...(accountBin[Enum.Events.Event.Action.FX_ABORT_VALIDATION] || []) + ], + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + accumulatedFxTransferStates, + isFx: true, + changePositions + } + ) + + // Update accumulated values + accumulatedPositionValue = fxAbortReservedActionResult.accumulatedPositionValue + accumulatedPositionReservedValue = fxAbortReservedActionResult.accumulatedPositionReservedValue + accumulatedTransferStates = fxAbortReservedActionResult.accumulatedTransferStates + accumulatedFxTransferStates = fxAbortReservedActionResult.accumulatedFxTransferStates + // Append accumulated arrays + accumulatedTransferStateChanges = accumulatedTransferStateChanges.concat(fxAbortReservedActionResult.accumulatedTransferStateChanges) + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(fxAbortReservedActionResult.accumulatedFxTransferStateChanges) + accumulatedPositionChanges = accumulatedPositionChanges.concat(fxAbortReservedActionResult.accumulatedPositionChanges) + notifyMessages = notifyMessages.concat(fxAbortReservedActionResult.notifyMessages) + followupMessages = followupMessages.concat(fxAbortReservedActionResult.followupMessages) + + // ========== TIMEOUT_RESERVED ========== + // If timeout-reserved action found then call processPositionTimeoutReserveBin function + const timeoutReservedActionResult = await PositionTimeoutReservedDomain.processPositionTimeoutReservedBin( + accountBin[Enum.Events.Event.Action.TIMEOUT_RESERVED], + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + transferInfoList: latestTransferInfoByTransferId, + changePositions + } + ) + + // Update accumulated values + accumulatedPositionValue = timeoutReservedActionResult.accumulatedPositionValue + accumulatedPositionReservedValue = timeoutReservedActionResult.accumulatedPositionReservedValue + accumulatedTransferStates = timeoutReservedActionResult.accumulatedTransferStates + // Append accumulated arrays + accumulatedTransferStateChanges = accumulatedTransferStateChanges.concat(timeoutReservedActionResult.accumulatedTransferStateChanges) + accumulatedPositionChanges = accumulatedPositionChanges.concat(timeoutReservedActionResult.accumulatedPositionChanges) + notifyMessages = notifyMessages.concat(timeoutReservedActionResult.notifyMessages) + // ========== PREPARE ========== // If prepare action found then call processPositionPrepareBin function const prepareActionResult = await PositionPrepareDomain.processPositionPrepareBin( accountBin.prepare, - accumulatedPositionValue, - accumulatedPositionReservedValue, - accumulatedTransferStates, - settlementParticipantPosition, - settlementModel, - participantLimit + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + settlementParticipantPosition, + participantLimit, + changePositions + } ) // Update accumulated values @@ -214,22 +332,63 @@ const processBins = async (bins, trx) => { accumulatedPositionChanges = accumulatedPositionChanges.concat(prepareActionResult.accumulatedPositionChanges) notifyMessages = notifyMessages.concat(prepareActionResult.notifyMessages) - // Update accumulated position values by calling a facade function - await BatchPositionModel.updateParticipantPosition(trx, positions[accountID].participantPositionId, accumulatedPositionValue, accumulatedPositionReservedValue) + // ========== FX_PREPARE ========== + // If fx-prepare action found then call processPositionFxPrepareBin function + const fxPrepareActionResult = await PositionFxPrepareDomain.processFxPositionPrepareBin( + accountBin[Enum.Events.Event.Action.FX_PREPARE], + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedFxTransferStates, + settlementParticipantPosition, + participantLimit, + changePositions + } + ) + + // Update accumulated values + accumulatedPositionValue = fxPrepareActionResult.accumulatedPositionValue + accumulatedPositionReservedValue = fxPrepareActionResult.accumulatedPositionReservedValue + accumulatedFxTransferStates = fxPrepareActionResult.accumulatedFxTransferStates + // Append accumulated arrays + accumulatedFxTransferStateChanges = accumulatedFxTransferStateChanges.concat(fxPrepareActionResult.accumulatedFxTransferStateChanges) + accumulatedPositionChanges = accumulatedPositionChanges.concat(fxPrepareActionResult.accumulatedPositionChanges) + notifyMessages = notifyMessages.concat(fxPrepareActionResult.notifyMessages) + + // ========== CONSOLIDATION ========== + + if (changePositions) { + // Update accumulated position values by calling a facade function + await BatchPositionModel.updateParticipantPosition(trx, positions[accountID].participantPositionId, accumulatedPositionValue, accumulatedPositionReservedValue) + } // Bulk insert accumulated transferStateChanges by calling a facade function await BatchPositionModel.bulkInsertTransferStateChanges(trx, accumulatedTransferStateChanges) + // Bulk insert accumulated fxTransferStateChanges by calling a facade function + await BatchPositionModel.bulkInsertFxTransferStateChanges(trx, accumulatedFxTransferStateChanges) // Bulk get the transferStateChangeIds for transferids using select whereIn const fetchedTransferStateChanges = await BatchPositionModel.getLatestTransferStateChangesByTransferIdList(trx, accumulatedTransferStateChanges.map(item => item.transferId)) - // Mutate accumulated positionChanges with transferStateChangeIds - for (const positionChange of accumulatedPositionChanges) { - positionChange.transferStateChangeId = fetchedTransferStateChanges[positionChange.transferId].transferStateChangeId - positionChange.participantPositionId = positions[accountID].participantPositionId - delete positionChange.transferId + // Bulk get the fxTransferStateChangeIds for commitRequestId using select whereIn + const fetchedFxTransferStateChanges = await BatchPositionModel.getLatestFxTransferStateChangesByCommitRequestIdList(trx, accumulatedFxTransferStateChanges.map(item => item.commitRequestId)) + + if (changePositions) { + // Mutate accumulated positionChanges with transferStateChangeIds and fxTransferStateChangeIds + for (const positionChange of accumulatedPositionChanges) { + if (positionChange.transferId) { + positionChange.transferStateChangeId = fetchedTransferStateChanges[positionChange.transferId].transferStateChangeId + delete positionChange.transferId + } else if (positionChange.commitRequestId) { + positionChange.fxTransferStateChangeId = fetchedFxTransferStateChanges[positionChange.commitRequestId].fxTransferStateChangeId + delete positionChange.commitRequestId + } + positionChange.participantPositionId = positions[accountID].participantPositionId + positionChange.participantCurrencyId = accountID + } + + // Bulk insert accumulated positionChanges by calling a facade function + await BatchPositionModel.bulkInsertParticipantPositionChanges(trx, accumulatedPositionChanges) } - // Bulk insert accumulated positionChanges by calling a facade function - await BatchPositionModel.bulkInsertParticipantPositionChanges(trx, accumulatedPositionChanges) limitAlarms = limitAlarms.concat(prepareActionResult.limitAlarms) } @@ -237,6 +396,7 @@ const processBins = async (bins, trx) => { // Return results return { notifyMessages, + followupMessages, limitAlarms } } @@ -285,6 +445,108 @@ const _getSettlementModelForCurrency = (currencyId, allSettlementModels) => { return settlementModels.find(sm => sm.ledgerAccountTypeId === Enum.Accounts.LedgerAccountType.POSITION) } +const _getTransferIdList = async (bins) => { + const transferIdList = [] + const reservedActionTransferIdList = [] + const commitRequestIdList = [] + await iterateThroughBins(bins, (_accountID, action, item) => { + if (action === Enum.Events.Event.Action.PREPARE) { + transferIdList.push(item.decodedPayload.transferId) + } else if (action === Enum.Events.Event.Action.FULFIL) { + transferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.COMMIT) { + transferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.RESERVE) { + transferIdList.push(item.message.value.content.uriParams.id) + reservedActionTransferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.TIMEOUT_RESERVED) { + transferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.FX_PREPARE) { + commitRequestIdList.push(item.decodedPayload.commitRequestId) + } else if (action === Enum.Events.Event.Action.FX_RESERVE) { + commitRequestIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.FX_TIMEOUT_RESERVED) { + commitRequestIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.ABORT) { + transferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.FX_ABORT) { + commitRequestIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.ABORT_VALIDATION) { + transferIdList.push(item.message.value.content.uriParams.id) + } else if (action === Enum.Events.Event.Action.FX_ABORT_VALIDATION) { + commitRequestIdList.push(item.message.value.content.uriParams.id) + } + }) + return { transferIdList, reservedActionTransferIdList, commitRequestIdList } +} + +const _fetchLatestTransferStates = async (trx, transferIdList) => { + const latestTransferStateChanges = await BatchPositionModel.getLatestTransferStateChangesByTransferIdList(trx, transferIdList) + const latestTransferStates = {} + for (const key in latestTransferStateChanges) { + latestTransferStates[key] = latestTransferStateChanges[key].transferStateId + } + return latestTransferStates +} + +const _fetchLatestFxTransferStates = async (trx, commitRequestIdList) => { + const latestFxTransferStateChanges = await BatchPositionModel.getLatestFxTransferStateChangesByCommitRequestIdList(trx, commitRequestIdList) + const latestFxTransferStates = {} + for (const key in latestFxTransferStateChanges) { + latestFxTransferStates[key] = latestFxTransferStateChanges[key].transferStateId + } + return latestFxTransferStates +} + +const _getParticipantCurrencyIds = async (trx, accountIds) => { + const participantCurrencyIds = await BatchPositionModelCached.getParticipantCurrencyByIds(trx, accountIds) + + // Validate that participantCurrencyIds exist for each of the accountIds + // i.e every unique accountId has a corresponding entry in participantCurrencyIds + const participantIdsHavingCurrencyIdsList = [...new Set(participantCurrencyIds.map(item => item.participantCurrencyId))] + const allAccountIdsHaveParticipantCurrencyIds = accountIds.every(accountId => { + return participantIdsHavingCurrencyIdsList.includes(Number(accountId)) + }) + if (!allAccountIdsHaveParticipantCurrencyIds) { + throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, 'Not all accountIds have corresponding participantCurrencyIds') + } + return participantCurrencyIds +} + +const _constructRequiredMaps = async (participantCurrencyIds, allSettlementModels, trx) => { + const participantIdMap = {} + const accountIdMap = {} + const currencyIdMap = {} + for (const item of participantCurrencyIds) { + const { participantId, currencyId, participantCurrencyId } = item + if (!participantIdMap[participantId]) { + participantIdMap[participantId] = {} + } + if (!currencyIdMap[currencyId]) { + currencyIdMap[currencyId] = { + settlementModel: _getSettlementModelForCurrency(currencyId, allSettlementModels) + } + } + participantIdMap[participantId][currencyId] = participantCurrencyId + accountIdMap[participantCurrencyId] = { participantId, currencyId } + } + + // Get all participantCurrencyIds for the participantIdMap + const allParticipantCurrencyIds = await BatchPositionModelCached.getParticipantCurrencyByParticipantIds(trx, Object.keys(participantIdMap)) + const settlementCurrencyIds = [] + for (const pc of allParticipantCurrencyIds) { + const correspondingParticipantCurrencyId = participantIdMap[pc.participantId][pc.currencyId] + if (correspondingParticipantCurrencyId) { + const settlementModel = currencyIdMap[pc.currencyId].settlementModel + if (pc.ledgerAccountTypeId === settlementModel.settlementAccountTypeId) { + settlementCurrencyIds.push(pc) + accountIdMap[correspondingParticipantCurrencyId].settlementCurrencyId = pc.participantCurrencyId + } + } + } + return { settlementCurrencyIds, accountIdMap, currencyIdMap } +} + module.exports = { processBins, iterateThroughBins diff --git a/src/domain/position/fulfil.js b/src/domain/position/fulfil.js index 6877eaf93..59a87b18a 100644 --- a/src/domain/position/fulfil.js +++ b/src/domain/position/fulfil.js @@ -1,3 +1,38 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + const { Enum } = require('@mojaloop/central-services-shared') const ErrorHandler = require('@mojaloop/central-services-error-handling') const Config = require('../../lib/config') @@ -13,149 +48,290 @@ const TransferObjectTransform = require('../../domain/transfer/transform') * @description This is the domain function to process a bin of position-fulfil messages of a single participant account. * * @param {array} commitReserveFulfilBins - an array containing commit and reserve action bins - * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing - * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency - * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. - * @param {object} transferInfoList - object with transfer id keys and transfer info values. Used to pass transfer info to domain function. + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. + * @param {object} transferInfoList - object with transfer id keys and transfer info values. Used to pass transfer info to domain function. + * @param {boolean} changePositions - whether to change positions or not * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedTransferStateChanges, accumulatedTransferStates, resultMessages, limitAlarms or throws an error if failed */ const processPositionFulfilBin = async ( commitReserveFulfilBins, - accumulatedPositionValue, - accumulatedPositionReservedValue, - accumulatedTransferStates, - transferInfoList, - reservedActionTransfers + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers, + changePositions = true + } ) => { const transferStateChanges = [] + const fxTransferStateChanges = [] const participantPositionChanges = [] const resultMessages = [] + const followupMessages = [] const accumulatedTransferStatesCopy = Object.assign({}, accumulatedTransferStates) + const accumulatedFxTransferStatesCopy = Object.assign({}, accumulatedFxTransferStates) let runningPosition = new MLNumber(accumulatedPositionValue) for (const binItems of commitReserveFulfilBins) { if (binItems && binItems.length > 0) { for (const binItem of binItems) { - let transferStateId - let reason - let resultMessage const transferId = binItem.message.value.content.uriParams.id const payeeFsp = binItem.message.value.from const payerFsp = binItem.message.value.to const transfer = binItem.decodedPayload - Logger.isDebugEnabled && Logger.debug(`processPositionFulfilBin::transfer:processingMessage: ${JSON.stringify(transfer)}`) - Logger.isDebugEnabled && Logger.debug(`accumulatedTransferStates: ${JSON.stringify(accumulatedTransferStates)}`) + // Inform payee dfsp if transfer is not in RECEIVED_FULFIL state, skip making any transfer state changes if (accumulatedTransferStates[transferId] !== Enum.Transfers.TransferInternalState.RECEIVED_FULFIL) { - // forward same headers from the prepare message, except the content-length header - // set destination to payeefsp and source to switch - const headers = { ...binItem.message.value.content.headers } - headers[Enum.Http.Headers.FSPIOP.DESTINATION] = payeeFsp - headers[Enum.Http.Headers.FSPIOP.SOURCE] = Enum.Http.Headers.FSPIOP.SWITCH.value - delete headers['content-length'] - - const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( - `Invalid State: ${accumulatedTransferStates[transferId]} - expected: ${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL}` - ).toApiErrorObject(Config.ERROR_HANDLING) - const state = Utility.StreamingProtocol.createEventState( - Enum.Events.EventStatus.FAILURE.status, - fspiopError.errorInformation.errorCode, - fspiopError.errorInformation.errorDescription - ) - - const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( - transferId, - Enum.Kafka.Topics.NOTIFICATION, - Enum.Events.Event.Action.FULFIL, - state - ) - - resultMessage = Utility.StreamingProtocol.createMessage( - transferId, - payeeFsp, - Enum.Http.Headers.FSPIOP.SWITCH.value, - metadata, - headers, - fspiopError, - { id: transferId }, - 'application/json' - ) + const resultMessage = _handleIncorrectTransferState(binItem, payeeFsp, transferId, accumulatedTransferStates) + resultMessages.push({ binItem, message: resultMessage }) } else { - const transferInfo = transferInfoList[transferId] - - // forward same headers from the prepare message, except the content-length header - const headers = { ...binItem.message.value.content.headers } - delete headers['content-length'] - - const state = Utility.StreamingProtocol.createEventState( - Enum.Events.EventStatus.SUCCESS.status, - null, - null - ) - const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( - transferId, - Enum.Kafka.Topics.TRANSFER, - Enum.Events.Event.Action.COMMIT, - state - ) - - resultMessage = Utility.StreamingProtocol.createMessage( - transferId, - payerFsp, - payeeFsp, - metadata, - headers, - transfer, - { id: transferId }, - 'application/json' - ) - - if (binItem.message.value.metadata.event.action === Enum.Events.Event.Action.RESERVE) { - resultMessage.content.payload = TransferObjectTransform.toFulfil( - reservedActionTransfers[transferId] - ) - } - - transferStateId = Enum.Transfers.TransferState.COMMITTED - // Amounts in `transferParticipant` for the payee are stored as negative values - runningPosition = new MLNumber(runningPosition.add(transferInfo.amount).toFixed(Config.AMOUNT.SCALE)) - - const participantPositionChange = { - transferId, // Need to delete this in bin processor while updating transferStateChangeId - transferStateChangeId: null, // Need to update this in bin processor while executing queries - value: runningPosition.toNumber(), - reservedValue: accumulatedPositionReservedValue + Logger.isDebugEnabled && Logger.debug(`processPositionFulfilBin::transfer:processingMessage: ${JSON.stringify(transfer)}`) + Logger.isDebugEnabled && Logger.debug(`accumulatedTransferStates: ${JSON.stringify(accumulatedTransferStates)}`) + const cyrilResult = binItem.message.value.content.context?.cyrilResult + if (cyrilResult && cyrilResult.isFx) { + // This is FX transfer + // Handle position movements + // Iterate through positionChanges and handle each position movement, mark as done and publish a position-commit kafka message again for the next item + // Find out the first item to be processed + const positionChangeIndex = cyrilResult.positionChanges.findIndex(positionChange => !positionChange.isDone) + const positionChangeToBeProcessed = cyrilResult.positionChanges[positionChangeIndex] + let transferStateIdCopy + if (positionChangeToBeProcessed.isFxTransferStateChange) { + const { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChangeFx(runningPosition, positionChangeToBeProcessed.amount, positionChangeToBeProcessed.commitRequestId, accumulatedPositionReservedValue) + transferStateIdCopy = transferStateId + runningPosition = updatedRunningPosition + participantPositionChanges.push(participantPositionChange) + fxTransferStateChanges.push(fxTransferStateChange) + accumulatedFxTransferStatesCopy[positionChangeToBeProcessed.commitRequestId] = transferStateId + const patchMessages = _constructPatchNotificationResultMessage( + binItem, + cyrilResult + ) + for (const patchMessage of patchMessages) { + resultMessages.push({ binItem, message: patchMessage }) + } + } else { + const { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChange(runningPosition, positionChangeToBeProcessed.amount, positionChangeToBeProcessed.transferId, accumulatedPositionReservedValue) + transferStateIdCopy = transferStateId + runningPosition = updatedRunningPosition + participantPositionChanges.push(participantPositionChange) + transferStateChanges.push(transferStateChange) + accumulatedTransferStatesCopy[positionChangeToBeProcessed.transferId] = transferStateId + } + binItem.result = { success: true } + cyrilResult.positionChanges[positionChangeIndex].isDone = true + const nextIndex = cyrilResult.positionChanges.findIndex(positionChange => !positionChange.isDone) + if (nextIndex === -1) { + // All position changes are done + const resultMessage = _constructTransferFulfilResultMessage(binItem, transferId, payerFsp, payeeFsp, transfer, reservedActionTransfers, transferStateIdCopy) + resultMessages.push({ binItem, message: resultMessage }) + } else { + // There are still position changes to be processed + // Send position-commit kafka message again for the next item + const participantCurrencyId = cyrilResult.positionChanges[nextIndex].participantCurrencyId + const followupMessage = _constructTransferFulfilResultMessage(binItem, transferId, payerFsp, payeeFsp, transfer, reservedActionTransfers, transferStateIdCopy) + // Pass down the context to the followup message with mutated cyrilResult + followupMessage.content.context = binItem.message.value.content.context + followupMessages.push({ binItem, messageKey: participantCurrencyId.toString(), message: followupMessage }) + } + } else { + const transferAmount = transferInfoList[transferId].amount + const { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChange(runningPosition, transferAmount, transferId, accumulatedPositionReservedValue) + runningPosition = updatedRunningPosition + binItem.result = { success: true } + participantPositionChanges.push(participantPositionChange) + transferStateChanges.push(transferStateChange) + accumulatedTransferStatesCopy[transferId] = transferStateId + const resultMessage = _constructTransferFulfilResultMessage(binItem, transferId, payerFsp, payeeFsp, transfer, reservedActionTransfers, transferStateId) + resultMessages.push({ binItem, message: resultMessage }) } - participantPositionChanges.push(participantPositionChange) - binItem.result = { success: true } - } - - resultMessages.push({ binItem, message: resultMessage }) - - if (transferStateId) { - const transferStateChange = { - transferId, - transferStateId, - reason - } - transferStateChanges.push(transferStateChange) - Logger.isDebugEnabled && Logger.debug(`processPositionFulfilBin::transferStateChange: ${JSON.stringify(transferStateChange)}`) - - accumulatedTransferStatesCopy[transferId] = transferStateId - Logger.isDebugEnabled && Logger.debug(`processPositionFulfilBin::accumulatedTransferStatesCopy:finalizedTransferState ${JSON.stringify(transferStateId)}`) } } } } return { - accumulatedPositionValue: runningPosition.toNumber(), + accumulatedPositionValue: changePositions ? runningPosition.toNumber() : accumulatedPositionValue, accumulatedTransferStates: accumulatedTransferStatesCopy, // finalized transfer state after fulfil processing + accumulatedFxTransferStates: accumulatedFxTransferStatesCopy, // finalized transfer state after fx fulfil processing accumulatedPositionReservedValue, // not used but kept for consistency accumulatedTransferStateChanges: transferStateChanges, // transfer state changes to be persisted in order - accumulatedPositionChanges: participantPositionChanges, // participant position changes to be persisted in order - notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} + accumulatedFxTransferStateChanges: fxTransferStateChanges, // fx-transfer state changes to be persisted in order + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order + notifyMessages: resultMessages, // array of objects containing bin item and result message. {binItem, message} + followupMessages // array of objects containing bin item, message key and followup message. {binItem, messageKey, message} + } +} + +const _handleIncorrectTransferState = (binItem, payeeFsp, transferId, accumulatedTransferStates) => { + // forward same headers from the prepare message, except the content-length header + // set destination to payeefsp and source to switch + const headers = { ...binItem.message.value.content.headers } + headers[Enum.Http.Headers.FSPIOP.DESTINATION] = payeeFsp + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME + delete headers['content-length'] + + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( + `Invalid State: ${accumulatedTransferStates[transferId]} - expected: ${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL}` + ).toApiErrorObject(Config.ERROR_HANDLING) + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + transferId, + Enum.Kafka.Topics.NOTIFICATION, + Enum.Events.Event.Action.FULFIL, + state + ) + + return Utility.StreamingProtocol.createMessage( + transferId, + payeeFsp, + Config.HUB_NAME, + metadata, + headers, + fspiopError, + { id: transferId }, + 'application/json' + ) +} + +const _constructTransferFulfilResultMessage = (binItem, transferId, payerFsp, payeeFsp, transfer, reservedActionTransfers, transferStateId) => { + // forward same headers from the prepare message, except the content-length header + const headers = { ...binItem.message.value.content.headers } + delete headers['content-length'] + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.SUCCESS.status, + null, + null + ) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + transferId, + Enum.Kafka.Topics.TRANSFER, + Enum.Events.Event.Action.COMMIT, + state + ) + + const resultMessage = Utility.StreamingProtocol.createMessage( + transferId, + payerFsp, + payeeFsp, + metadata, + headers, + transfer, + { id: transferId }, + 'application/json' + ) + + if (binItem.message.value.metadata.event.action === Enum.Events.Event.Action.RESERVE) { + resultMessage.content.payload = TransferObjectTransform.toFulfil( + reservedActionTransfers[transferId] + ) + resultMessage.content.payload.transferState = transferStateId + } + return resultMessage +} + +const _constructPatchNotificationResultMessage = (binItem, cyrilResult) => { + const messages = [] + const patchNotifications = cyrilResult.patchNotifications + for (const patchNotification of patchNotifications) { + const commitRequestId = patchNotification.commitRequestId + const fxpName = patchNotification.fxpName + const fulfilment = patchNotification.fulfilment + const completedTimestamp = patchNotification.completedTimestamp + const headers = { + ...binItem.message.value.content.headers, + 'fspiop-source': Config.HUB_NAME, + 'fspiop-destination': fxpName + } + + const fulfil = { + conversionState: Enum.Transfers.TransferState.COMMITTED, + fulfilment, + completedTimestamp + } + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.SUCCESS.status, + null, + null + ) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + commitRequestId, + Enum.Kafka.Topics.TRANSFER, + Enum.Events.Event.Action.FX_NOTIFY, + state + ) + + const resultMessage = Utility.StreamingProtocol.createMessage( + commitRequestId, + fxpName, + Config.HUB_NAME, + metadata, + headers, + fulfil, + { id: commitRequestId }, + 'application/json' + ) + + messages.push(resultMessage) + } + return messages +} + +const _handleParticipantPositionChange = (runningPosition, transferAmount, transferId, accumulatedPositionReservedValue) => { + const transferStateId = Enum.Transfers.TransferState.COMMITTED + // Amounts in `transferParticipant` for the payee are stored as negative values + const updatedRunningPosition = new MLNumber(runningPosition.add(transferAmount).toFixed(Config.AMOUNT.SCALE)) + + const participantPositionChange = { + transferId, // Need to delete this in bin processor while updating transferStateChangeId + transferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + const transferStateChange = { + transferId, + transferStateId, + reason: undefined + } + return { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } +} + +const _handleParticipantPositionChangeFx = (runningPosition, transferAmount, commitRequestId, accumulatedPositionReservedValue) => { + const transferStateId = Enum.Transfers.TransferState.COMMITTED + // Amounts in `transferParticipant` for the payee are stored as negative values + const updatedRunningPosition = new MLNumber(runningPosition.add(transferAmount).toFixed(Config.AMOUNT.SCALE)) + + const participantPositionChange = { + commitRequestId, // Need to delete this in bin processor while updating fxTransferStateChangeId + fxTransferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + const fxTransferStateChange = { + commitRequestId, + transferStateId, + reason: null } + return { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } } module.exports = { diff --git a/src/domain/position/fx-fulfil.js b/src/domain/position/fx-fulfil.js new file mode 100644 index 000000000..5c9226822 --- /dev/null +++ b/src/domain/position/fx-fulfil.js @@ -0,0 +1,173 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const { Enum } = require('@mojaloop/central-services-shared') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Config = require('../../lib/config') +const Utility = require('@mojaloop/central-services-shared').Util +const Logger = require('@mojaloop/central-services-logger') + +/** + * @function processPositionFxFulfilBin + * + * @async + * @description This is the domain function to process a bin of position-fx-fulfil messages of a single participant account. + * + * @param {array} binItems - an array of objects that contain a position fx reserve message and its span. {message, span} + * @param {object} options + * @param {object} accumulatedFxTransferStates - object with fx transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. + * @returns {object} - Returns an object containing accumulatedFxTransferStateChanges, accumulatedFxTransferStates, resultMessages, limitAlarms or throws an error if failed + */ +const processPositionFxFulfilBin = async ( + binItems, + { + accumulatedFxTransferStates + } +) => { + const fxTransferStateChanges = [] + const resultMessages = [] + const accumulatedFxTransferStatesCopy = Object.assign({}, accumulatedFxTransferStates) + + if (binItems && binItems.length > 0) { + for (const binItem of binItems) { + let transferStateId + let reason + let resultMessage + const commitRequestId = binItem.message.value.content.uriParams.id + const counterPartyFsp = binItem.message.value.from + const initiatingFsp = binItem.message.value.to + const fxTransfer = binItem.decodedPayload + Logger.isDebugEnabled && Logger.debug(`processPositionFxFulfilBin::fxTransfer:processingMessage: ${JSON.stringify(fxTransfer)}`) + Logger.isDebugEnabled && Logger.debug(`accumulatedFxTransferStates: ${JSON.stringify(accumulatedFxTransferStates)}`) + Logger.isDebugEnabled && Logger.debug(`accumulatedFxTransferStates[commitRequestId]: ${accumulatedFxTransferStates[commitRequestId]}`) + // Inform sender if transfer is not in RECEIVED_FULFIL_DEPENDENT state, skip making any transfer state changes + if (accumulatedFxTransferStates[commitRequestId] !== Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT) { + // forward same headers from the request, except the content-length header + // set destination to counterPartyFsp and source to switch + const headers = { ...binItem.message.value.content.headers } + headers[Enum.Http.Headers.FSPIOP.DESTINATION] = counterPartyFsp + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME + delete headers['content-length'] + + // There is no such logic in the fulfil handler. + transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED + reason = 'FxFulfil in incorrect state' + + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( + `Invalid State: ${accumulatedFxTransferStates[commitRequestId]} - expected: ${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT}` + ).toApiErrorObject(Config.ERROR_HANDLING) + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + commitRequestId, + Enum.Kafka.Topics.NOTIFICATION, + Enum.Events.Event.Action.FX_FULFIL, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + commitRequestId, + counterPartyFsp, + Config.HUB_NAME, + metadata, + headers, + fspiopError, + { id: commitRequestId }, + 'application/json' + ) + } else { + // forward same headers from the prepare message, except the content-length header + const headers = { ...binItem.message.value.content.headers } + delete headers['content-length'] + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.SUCCESS.status, + null, + null + ) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + commitRequestId, + Enum.Kafka.Topics.TRANSFER, + Enum.Events.Event.Action.COMMIT, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + commitRequestId, + initiatingFsp, + counterPartyFsp, + metadata, + headers, + fxTransfer, + { id: commitRequestId }, + 'application/json' + ) + + // No need to change the transfer state here for success case. + + binItem.result = { success: true } + } + + resultMessages.push({ binItem, message: resultMessage }) + + if (transferStateId) { + const fxTransferStateChange = { + commitRequestId, + transferStateId, + reason + } + fxTransferStateChanges.push(fxTransferStateChange) + Logger.isDebugEnabled && Logger.debug(`processPositionFxFulfilBin::fxTransferStateChange: ${JSON.stringify(fxTransferStateChange)}`) + + accumulatedFxTransferStatesCopy[commitRequestId] = transferStateId + Logger.isDebugEnabled && Logger.debug(`processPositionFxFulfilBin::accumulatedTransferStatesCopy:finalizedFxTransferState ${JSON.stringify(transferStateId)}`) + } + } + } + + return { + accumulatedFxTransferStates: accumulatedFxTransferStatesCopy, // finalized fx transfer state after fx-fulfil processing + accumulatedFxTransferStateChanges: fxTransferStateChanges, // fx transfer state changes to be persisted in order + notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} + } +} + +module.exports = { + processPositionFxFulfilBin +} diff --git a/src/domain/position/fx-prepare.js b/src/domain/position/fx-prepare.js new file mode 100644 index 000000000..f5741f5ba --- /dev/null +++ b/src/domain/position/fx-prepare.js @@ -0,0 +1,315 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const { Enum } = require('@mojaloop/central-services-shared') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Config = require('../../lib/config') +const Utility = require('@mojaloop/central-services-shared').Util +const MLNumber = require('@mojaloop/ml-number') +const Logger = require('@mojaloop/central-services-logger') + +/** + * @function processFxPositionPrepareBin + * + * @async + * @description This is the domain function to process a bin of position-prepare messages of a single participant account. + * + * @param {array} binItems - an array of objects that contain a position prepare message and its span. {message, decodedPayload, span} + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedFxTransferStates - object with fx commit request id keys and fx transfer state id values. Used to check if fx transfer is in correct state for processing. Clone and update states for output. + * @param {number} settlementParticipantPosition - position value of the participants settlement account + * @param {object} participantLimit - participant limit object for the currency + * @param {boolean} changePositions - whether to change positions or not + * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedFxTransferStateChanges, accumulatedTransferStates, resultMessages, limitAlarms or throws an error if failed + */ +const processFxPositionPrepareBin = async ( + binItems, + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedFxTransferStates, + settlementParticipantPosition, + participantLimit, + changePositions = true + } +) => { + const fxTransferStateChanges = [] + const participantPositionChanges = [] + const resultMessages = [] + const limitAlarms = [] + const accumulatedFxTransferStatesCopy = Object.assign({}, accumulatedFxTransferStates) + + let currentPosition = new MLNumber(accumulatedPositionValue) + let liquidityCover = 0 + let availablePositionBasedOnLiquidityCover = 0 + let availablePositionBasedOnPayerLimit = 0 + + if (changePositions) { + const reservedPosition = new MLNumber(accumulatedPositionReservedValue) + const effectivePosition = new MLNumber(currentPosition.add(reservedPosition).toFixed(Config.AMOUNT.SCALE)) + const payerLimit = new MLNumber(participantLimit.value) + liquidityCover = new MLNumber(settlementParticipantPosition).multiply(-1) + availablePositionBasedOnLiquidityCover = new MLNumber(liquidityCover.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) + Logger.isInfoEnabled && Logger.info(`processFxPositionPrepareBin::availablePositionBasedOnLiquidityCover: ${availablePositionBasedOnLiquidityCover}`) + availablePositionBasedOnPayerLimit = new MLNumber(payerLimit.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::availablePositionBasedOnPayerLimit: ${availablePositionBasedOnPayerLimit}`) + } + + if (binItems && binItems.length > 0) { + for (const binItem of binItems) { + let transferStateId + let reason + let resultMessage + const fxTransfer = binItem.decodedPayload + const cyrilResult = binItem.message.value.content.context.cyrilResult + const transferAmount = fxTransfer.targetAmount.currency === cyrilResult.currencyId ? fxTransfer.targetAmount.amount : fxTransfer.sourceAmount.amount + + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::transfer:processingMessage: ${JSON.stringify(fxTransfer)}`) + + // Check if fxTransfer is in correct state for processing, produce an internal error message + if (accumulatedFxTransferStates[fxTransfer.commitRequestId] !== Enum.Transfers.TransferInternalState.RECEIVED_PREPARE) { + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::transferState: ${accumulatedFxTransferStates[fxTransfer.commitRequestId]} !== ${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`) + + transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED + reason = 'FxTransfer in incorrect state' + + // forward same headers from the prepare message, except the content-length header + // set destination to initiatingFsp and source to switch + const headers = { ...binItem.message.value.content.headers } + headers[Enum.Http.Headers.FSPIOP.DESTINATION] = fxTransfer.initiatingFsp + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME + delete headers['content-length'] + + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + fxTransfer.commitRequestId, + Enum.Kafka.Topics.NOTIFICATION, + Enum.Events.Event.Action.FX_PREPARE, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + fxTransfer.commitRequestId, + fxTransfer.initiatingFsp, + Config.HUB_NAME, + metadata, + headers, + fspiopError, + { id: fxTransfer.commitRequestId }, + 'application/json' + ) + + binItem.result = { success: false } + + // Check if payer has insufficient liquidity, produce an error message and abort transfer + } else if (changePositions && availablePositionBasedOnLiquidityCover.toNumber() < transferAmount) { + transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED + reason = ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_FSP_INSUFFICIENT_LIQUIDITY.message + + // forward same headers from the prepare message, except the content-length header + // set destination to payerfsp and source to switch + const headers = { ...binItem.message.value.content.headers } + headers[Enum.Http.Headers.FSPIOP.DESTINATION] = fxTransfer.initiatingFsp + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME + delete headers['content-length'] + + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_FSP_INSUFFICIENT_LIQUIDITY + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + fxTransfer.commitRequestId, + Enum.Kafka.Topics.NOTIFICATION, + Enum.Events.Event.Action.FX_PREPARE, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + fxTransfer.commitRequestId, + fxTransfer.initiatingFsp, + Config.HUB_NAME, + metadata, + headers, + fspiopError, + { id: fxTransfer.commitRequestId }, + 'application/json' + ) + + binItem.result = { success: false } + + // Check if payer has surpassed their limit, produce an error message and abort transfer + } else if (changePositions && availablePositionBasedOnPayerLimit.toNumber() < transferAmount) { + transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED + reason = ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_LIMIT_ERROR.message + + // forward same headers from the prepare message, except the content-length header + // set destination to payerfsp and source to switch + const headers = { ...binItem.message.value.content.headers } + headers[Enum.Http.Headers.FSPIOP.DESTINATION] = fxTransfer.initiatingFsp + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME + delete headers['content-length'] + + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_LIMIT_ERROR + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + fxTransfer.commitRequestId, + Enum.Kafka.Topics.NOTIFICATION, + Enum.Events.Event.Action.FX_PREPARE, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + fxTransfer.commitRequestId, + fxTransfer.initiatingFsp, + Config.HUB_NAME, + metadata, + headers, + fspiopError, + { id: fxTransfer.commitRequestId }, + 'application/json' + ) + + binItem.result = { success: false } + + // Payer has sufficient liquidity and limit + } else { + transferStateId = Enum.Transfers.TransferInternalState.RESERVED + + if (changePositions) { + currentPosition = currentPosition.add(transferAmount) + availablePositionBasedOnLiquidityCover = availablePositionBasedOnLiquidityCover.add(transferAmount) + availablePositionBasedOnPayerLimit = availablePositionBasedOnPayerLimit.add(transferAmount) + const participantPositionChange = { + commitRequestId: fxTransfer.commitRequestId, // Need to delete this in bin processor while updating fxTransferStateChangeId + fxTransferStateChangeId: null, // Need to update this in bin processor while executing queries + value: currentPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + participantPositionChanges.push(participantPositionChange) + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::participantPositionChange: ${JSON.stringify(participantPositionChange)}`) + } + + // forward same headers from the prepare message, except the content-length header + const headers = { ...binItem.message.value.content.headers } + delete headers['content-length'] + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.SUCCESS.status, + null, + null + ) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + fxTransfer.commitRequestId, + Enum.Kafka.Topics.TRANSFER, + Enum.Events.Event.Action.FX_PREPARE, + state + ) + + resultMessage = Utility.StreamingProtocol.createMessage( + fxTransfer.commitRequestId, + fxTransfer.counterPartyFsp, + fxTransfer.initiatingFsp, + metadata, + headers, + fxTransfer, + {}, + 'application/json' + ) + + binItem.result = { success: true } + } + + resultMessages.push({ binItem, message: resultMessage }) + + if (changePositions) { + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::limitAlarm: ${currentPosition.toNumber()} > ${liquidityCover.multiply(participantLimit.thresholdAlarmPercentage)}`) + if (currentPosition.toNumber() > liquidityCover.multiply(participantLimit.thresholdAlarmPercentage).toNumber()) { + limitAlarms.push(participantLimit) + } + } + + const fxTransferStateChange = { + commitRequestId: fxTransfer.commitRequestId, + transferStateId, + reason + } + fxTransferStateChanges.push(fxTransferStateChange) + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::fxTransferStateChange: ${JSON.stringify(fxTransferStateChange)}`) + + accumulatedFxTransferStatesCopy[fxTransfer.commitRequestId] = transferStateId + Logger.isDebugEnabled && Logger.debug(`processFxPositionPrepareBin::accumulatedTransferStatesCopy:finalizedTransferState ${JSON.stringify(transferStateId)}`) + } + } + + return { + accumulatedPositionValue: changePositions ? currentPosition.toNumber() : accumulatedPositionValue, + accumulatedFxTransferStates: accumulatedFxTransferStatesCopy, // finalized transfer state after prepare processing + accumulatedPositionReservedValue, // not used but kept for consistency + accumulatedFxTransferStateChanges: fxTransferStateChanges, // fx-transfer state changes to be persisted in order + limitAlarms, // array of participant limits that have been breached + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order + notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} + } +} + +module.exports = { + processFxPositionPrepareBin +} diff --git a/src/domain/position/fx-timeout-reserved.js b/src/domain/position/fx-timeout-reserved.js new file mode 100644 index 000000000..e6b854158 --- /dev/null +++ b/src/domain/position/fx-timeout-reserved.js @@ -0,0 +1,194 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const { Enum } = require('@mojaloop/central-services-shared') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Config = require('../../lib/config') +const Utility = require('@mojaloop/central-services-shared').Util +const MLNumber = require('@mojaloop/ml-number') +const Logger = require('@mojaloop/central-services-logger') + +/** + * @function processPositionFxTimeoutReservedBin + * + * @async + * @description This is the domain function to process a bin of timeout-reserved messages of a single participant account. + * + * @param {array} fxTimeoutReservedBins - an array containing timeout-reserved action bins + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedFxTransferStates - object with commitRequest id keys and fxTransfer state id values. Used to check if fxTransfer is in correct state for processing. Clone and update states for output. + * @param {object} transferInfoList - object with transfer id keys and transfer info values. Used to pass transfer info to domain function. + * @param {boolean} changePositions - whether to change positions or not + * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedTransferStateChanges, accumulatedFxTransferStates, resultMessages, limitAlarms or throws an error if failed + */ +const processPositionFxTimeoutReservedBin = async ( + fxTimeoutReservedBins, + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedFxTransferStates, + fetchedReservedPositionChangesByCommitRequestIds, + changePositions = true + } +) => { + const fxTransferStateChanges = [] + const participantPositionChanges = [] + const resultMessages = [] + const accumulatedFxTransferStatesCopy = Object.assign({}, accumulatedFxTransferStates) + let runningPosition = new MLNumber(accumulatedPositionValue) + // Position action FX_RESERVED_TIMEOUT event messages are keyed with payer account id. + // We need to revert the payer's position for the source currency amount of the fxTransfer. + // We need to notify the payee of the timeout. + if (fxTimeoutReservedBins && fxTimeoutReservedBins.length > 0) { + for (const binItem of fxTimeoutReservedBins) { + Logger.isDebugEnabled && Logger.debug(`processPositionFxTimeoutReservedBin::binItem: ${JSON.stringify(binItem.message.value)}`) + const participantAccountId = binItem.message.key.toString() + const commitRequestId = binItem.message.value.content.uriParams.id + const counterPartyFsp = binItem.message.value.to + const initiatingFsp = binItem.message.value.from + + // If the transfer is not in `RESERVED_TIMEOUT`, a position fx-timeout-reserved message was incorrectly published. + // i.e Something has gone extremely wrong. + if (accumulatedFxTransferStates[commitRequestId] !== Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { + throw ErrorHandler.Factory.createInternalServerFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.message) + } else { + Logger.isDebugEnabled && Logger.debug(`accumulatedFxTransferStates: ${JSON.stringify(accumulatedFxTransferStates)}`) + + const transferAmount = fetchedReservedPositionChangesByCommitRequestIds[commitRequestId][participantAccountId].change + + // Construct payee notification message + const resultMessage = _constructFxTimeoutReservedResultMessage( + binItem, + commitRequestId, + counterPartyFsp, + initiatingFsp + ) + Logger.isDebugEnabled && Logger.debug(`processPositionFxTimeoutReservedBin::resultMessage: ${JSON.stringify(resultMessage)}`) + + // Revert payer's position for the amount of the transfer + const { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChange(runningPosition, transferAmount, commitRequestId, accumulatedPositionReservedValue) + Logger.isDebugEnabled && Logger.debug(`processPositionFxTimeoutReservedBin::participantPositionChange: ${JSON.stringify(participantPositionChange)}`) + runningPosition = updatedRunningPosition + binItem.result = { success: true } + participantPositionChanges.push(participantPositionChange) + fxTransferStateChanges.push(fxTransferStateChange) + accumulatedFxTransferStatesCopy[commitRequestId] = transferStateId + resultMessages.push({ binItem, message: resultMessage }) + } + } + } + + return { + accumulatedPositionValue: changePositions ? runningPosition.toNumber() : accumulatedPositionValue, + accumulatedFxTransferStates: accumulatedFxTransferStatesCopy, // finalized transfer state after fx fulfil processing + accumulatedPositionReservedValue, // not used but kept for consistency + accumulatedFxTransferStateChanges: fxTransferStateChanges, // fx-transfer state changes to be persisted in order + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order + notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} + } +} + +const _constructFxTimeoutReservedResultMessage = (binItem, commitRequestId, counterPartyFsp, initiatingFsp) => { + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payer and payee of the timeout. + // As long as the `to` and `from` message values are the payer and payee, + // and the action is `timeout-reserved`, the ml-api-adapter will notify both. + // Create a FSPIOPError object for timeout payee notification + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED, + null, + null, + null, + null + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + // Create metadata for the message, associating the payee notification + // with the position event fx-timeout-reserved action + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + commitRequestId, + Enum.Kafka.Topics.POSITION, + Enum.Events.Event.Action.FX_TIMEOUT_RESERVED, + state + ) + const resultMessage = Utility.StreamingProtocol.createMessage( + commitRequestId, + counterPartyFsp, + initiatingFsp, + metadata, + binItem.message.value.content.headers, // Headers don't really matter here. ml-api-adapter will ignore them and create their own. + fspiopError, + { id: commitRequestId }, + 'application/json' + ) + + return resultMessage +} + +const _handleParticipantPositionChange = (runningPosition, transferAmount, commitRequestId, accumulatedPositionReservedValue) => { + const transferStateId = Enum.Transfers.TransferInternalState.EXPIRED_RESERVED + // Revert payer's position for the amount of the transfer + const updatedRunningPosition = new MLNumber(runningPosition.subtract(transferAmount).toFixed(Config.AMOUNT.SCALE)) + Logger.isDebugEnabled && Logger.debug(`processPositionFxTimeoutReservedBin::_handleParticipantPositionChange::updatedRunningPosition: ${updatedRunningPosition.toString()}`) + Logger.isDebugEnabled && Logger.debug(`processPositionFxTimeoutReservedBin::_handleParticipantPositionChange::transferAmount: ${transferAmount}`) + // Construct participant position change object + const participantPositionChange = { + commitRequestId, // Need to delete this in bin processor while updating transferStateChangeId + transferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + // Construct transfer state change object + const fxTransferStateChange = { + commitRequestId, + transferStateId, + reason: ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message + } + return { participantPositionChange, fxTransferStateChange, transferStateId, updatedRunningPosition } +} + +module.exports = { + processPositionFxTimeoutReservedBin +} diff --git a/src/domain/position/index.js b/src/domain/position/index.js index a1039dee8..5adb0235a 100644 --- a/src/domain/position/index.js +++ b/src/domain/position/index.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -23,6 +23,7 @@ - Name Surname * Shashikant Hirugade + * Vijay Kumar Guthi -------------- ******/ diff --git a/src/domain/position/prepare.js b/src/domain/position/prepare.js index 3f6df96c4..1aaab95bc 100644 --- a/src/domain/position/prepare.js +++ b/src/domain/position/prepare.js @@ -1,9 +1,44 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + const { Enum } = require('@mojaloop/central-services-shared') const ErrorHandler = require('@mojaloop/central-services-error-handling') -const Config = require('../../lib/config') const Utility = require('@mojaloop/central-services-shared').Util const MLNumber = require('@mojaloop/ml-number') const Logger = require('@mojaloop/central-services-logger') +const Config = require('../../lib/config') /** * @function processPositionPrepareBin @@ -11,23 +46,27 @@ const Logger = require('@mojaloop/central-services-logger') * @async * @description This is the domain function to process a bin of position-prepare messages of a single participant account. * - * @param {array} binItems - an array of objects that contain a position prepare message and its span. {message, span} - * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing - * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency - * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. - * @param {number} settlementParticipantPosition - position value of the participants settlement account - * @param {object} settlementModel - settlement model object for the currency - * @param {object} participantLimit - participant limit object for the currency + * @param {array} binItems - an array of objects that contain a position prepare message and its span. {message, decodedPayload, span} + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. + * @param {number} settlementParticipantPosition - position value of the participants settlement account + * @param {object} settlementModel - settlement model object for the currency + * @param {object} participantLimit - participant limit object for the currency + * @param {boolean} changePositions - whether to change positions or not * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedTransferStateChanges, accumulatedTransferStates, resultMessages, limitAlarms or throws an error if failed */ const processPositionPrepareBin = async ( binItems, - accumulatedPositionValue, - accumulatedPositionReservedValue, - accumulatedTransferStates, - settlementParticipantPosition, - settlementModel, - participantLimit + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + settlementParticipantPosition, + participantLimit, + changePositions = true + } ) => { const transferStateChanges = [] const participantPositionChanges = [] @@ -36,14 +75,20 @@ const processPositionPrepareBin = async ( const accumulatedTransferStatesCopy = Object.assign({}, accumulatedTransferStates) let currentPosition = new MLNumber(accumulatedPositionValue) - const reservedPosition = new MLNumber(accumulatedPositionReservedValue) - const effectivePosition = new MLNumber(currentPosition.add(reservedPosition).toFixed(Config.AMOUNT.SCALE)) - const liquidityCover = new MLNumber(settlementParticipantPosition).multiply(-1) - const payerLimit = new MLNumber(participantLimit.value) - let availablePositionBasedOnLiquidityCover = new MLNumber(liquidityCover.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) - Logger.isInfoEnabled && Logger.info(`processPositionPrepareBin::availablePositionBasedOnLiquidityCover: ${availablePositionBasedOnLiquidityCover}`) - let availablePositionBasedOnPayerLimit = new MLNumber(payerLimit.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) - Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::availablePositionBasedOnPayerLimit: ${availablePositionBasedOnPayerLimit}`) + let liquidityCover = 0 + let availablePositionBasedOnLiquidityCover = 0 + let availablePositionBasedOnPayerLimit = 0 + + if (changePositions) { + const reservedPosition = new MLNumber(accumulatedPositionReservedValue) + const effectivePosition = new MLNumber(currentPosition.add(reservedPosition).toFixed(Config.AMOUNT.SCALE)) + const payerLimit = new MLNumber(participantLimit.value) + liquidityCover = new MLNumber(settlementParticipantPosition).multiply(-1) + availablePositionBasedOnLiquidityCover = new MLNumber(liquidityCover.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) + Logger.isInfoEnabled && Logger.info(`processPositionPrepareBin::availablePositionBasedOnLiquidityCover: ${availablePositionBasedOnLiquidityCover}`) + availablePositionBasedOnPayerLimit = new MLNumber(payerLimit.subtract(effectivePosition).toFixed(Config.AMOUNT.SCALE)) + Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::availablePositionBasedOnPayerLimit: ${availablePositionBasedOnPayerLimit}`) + } if (binItems && binItems.length > 0) { for (const binItem of binItems) { @@ -51,6 +96,9 @@ const processPositionPrepareBin = async ( let reason let resultMessage const transfer = binItem.decodedPayload + const cyrilResult = binItem.message.value.content.context?.cyrilResult + const transferAmount = cyrilResult ? cyrilResult.amount : transfer.amount.amount + Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::transfer:processingMessage: ${JSON.stringify(transfer)}`) // Check if transfer is in correct state for processing, produce an internal error message @@ -64,7 +112,7 @@ const processPositionPrepareBin = async ( // set destination to payerfsp and source to switch const headers = { ...binItem.message.value.content.headers } headers[Enum.Http.Headers.FSPIOP.DESTINATION] = transfer.payerFsp - headers[Enum.Http.Headers.FSPIOP.SOURCE] = Enum.Http.Headers.FSPIOP.SWITCH.value + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME delete headers['content-length'] const fspiopError = ErrorHandler.Factory.createFSPIOPError( @@ -87,7 +135,7 @@ const processPositionPrepareBin = async ( resultMessage = Utility.StreamingProtocol.createMessage( transfer.transferId, transfer.payerFsp, - Enum.Http.Headers.FSPIOP.SWITCH.value, + Config.HUB_NAME, metadata, headers, fspiopError, @@ -98,7 +146,7 @@ const processPositionPrepareBin = async ( binItem.result = { success: false } // Check if payer has insufficient liquidity, produce an error message and abort transfer - } else if (availablePositionBasedOnLiquidityCover.toNumber() < transfer.amount.amount) { + } else if (changePositions && availablePositionBasedOnLiquidityCover.toNumber() < transferAmount) { transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED reason = ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_FSP_INSUFFICIENT_LIQUIDITY.message @@ -106,7 +154,7 @@ const processPositionPrepareBin = async ( // set destination to payerfsp and source to switch const headers = { ...binItem.message.value.content.headers } headers[Enum.Http.Headers.FSPIOP.DESTINATION] = transfer.payerFsp - headers[Enum.Http.Headers.FSPIOP.SOURCE] = Enum.Http.Headers.FSPIOP.SWITCH.value + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME delete headers['content-length'] const fspiopError = ErrorHandler.Factory.createFSPIOPError( @@ -129,7 +177,7 @@ const processPositionPrepareBin = async ( resultMessage = Utility.StreamingProtocol.createMessage( transfer.transferId, transfer.payerFsp, - Enum.Http.Headers.FSPIOP.SWITCH.value, + Config.HUB_NAME, metadata, headers, fspiopError, @@ -140,7 +188,7 @@ const processPositionPrepareBin = async ( binItem.result = { success: false } // Check if payer has surpassed their limit, produce an error message and abort transfer - } else if (availablePositionBasedOnPayerLimit.toNumber() < transfer.amount.amount) { + } else if (changePositions && availablePositionBasedOnPayerLimit.toNumber() < transferAmount) { transferStateId = Enum.Transfers.TransferInternalState.ABORTED_REJECTED reason = ErrorHandler.Enums.FSPIOPErrorCodes.PAYER_LIMIT_ERROR.message @@ -148,7 +196,7 @@ const processPositionPrepareBin = async ( // set destination to payerfsp and source to switch const headers = { ...binItem.message.value.content.headers } headers[Enum.Http.Headers.FSPIOP.DESTINATION] = transfer.payerFsp - headers[Enum.Http.Headers.FSPIOP.SOURCE] = Enum.Http.Headers.FSPIOP.SWITCH.value + headers[Enum.Http.Headers.FSPIOP.SOURCE] = Config.HUB_NAME delete headers['content-length'] const fspiopError = ErrorHandler.Factory.createFSPIOPError( @@ -171,7 +219,7 @@ const processPositionPrepareBin = async ( resultMessage = Utility.StreamingProtocol.createMessage( transfer.transferId, transfer.payerFsp, - Enum.Http.Headers.FSPIOP.SWITCH.value, + Config.HUB_NAME, metadata, headers, fspiopError, @@ -181,12 +229,25 @@ const processPositionPrepareBin = async ( binItem.result = { success: false } - // Payer has sufficient liquidity and limit + // Payer has sufficient liquidity and limit or positions are not being changed } else { transferStateId = Enum.Transfers.TransferState.RESERVED - currentPosition = currentPosition.add(transfer.amount.amount) - availablePositionBasedOnLiquidityCover = availablePositionBasedOnLiquidityCover.add(transfer.amount.amount) - availablePositionBasedOnPayerLimit = availablePositionBasedOnPayerLimit.add(transfer.amount.amount) + if (changePositions) { + currentPosition = currentPosition.add(transferAmount) + + availablePositionBasedOnLiquidityCover = availablePositionBasedOnLiquidityCover.add(transferAmount) + availablePositionBasedOnPayerLimit = availablePositionBasedOnPayerLimit.add(transferAmount) + + const participantPositionChange = { + transferId: transfer.transferId, // Need to delete this in bin processor while updating transferStateChangeId + transferStateChangeId: null, // Need to update this in bin processor while executing queries + value: currentPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + participantPositionChanges.push(participantPositionChange) + Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::participantPositionChange: ${JSON.stringify(participantPositionChange)}`) + } // forward same headers from the prepare message, except the content-length header const headers = { ...binItem.message.value.content.headers } @@ -215,19 +276,18 @@ const processPositionPrepareBin = async ( 'application/json' ) - const participantPositionChange = { - transferId: transfer.transferId, // Need to delete this in bin processor while updating transferStateChangeId - transferStateChangeId: null, // Need to update this in bin processor while executing queries - value: currentPosition.toNumber(), - reservedValue: accumulatedPositionReservedValue - } - participantPositionChanges.push(participantPositionChange) - Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::participantPositionChange: ${JSON.stringify(participantPositionChange)}`) binItem.result = { success: true } } resultMessages.push({ binItem, message: resultMessage }) + if (changePositions) { + Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::limitAlarm: ${currentPosition.toNumber()} > ${liquidityCover.multiply(participantLimit.thresholdAlarmPercentage)}`) + if (currentPosition.toNumber() > liquidityCover.multiply(participantLimit.thresholdAlarmPercentage).toNumber()) { + limitAlarms.push(participantLimit) + } + } + const transferStateChange = { transferId: transfer.transferId, transferStateId, @@ -236,23 +296,18 @@ const processPositionPrepareBin = async ( transferStateChanges.push(transferStateChange) Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::transferStateChange: ${JSON.stringify(transferStateChange)}`) - Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::limitAlarm: ${currentPosition.toNumber()} > ${liquidityCover.multiply(participantLimit.thresholdAlarmPercentage)}`) - if (currentPosition.toNumber() > liquidityCover.multiply(participantLimit.thresholdAlarmPercentage).toNumber()) { - limitAlarms.push(participantLimit) - } - accumulatedTransferStatesCopy[transfer.transferId] = transferStateId Logger.isDebugEnabled && Logger.debug(`processPositionPrepareBin::accumulatedTransferStatesCopy:finalizedTransferState ${JSON.stringify(transferStateId)}`) } } return { - accumulatedPositionValue: currentPosition.toNumber(), + accumulatedPositionValue: changePositions ? currentPosition.toNumber() : accumulatedPositionValue, accumulatedTransferStates: accumulatedTransferStatesCopy, // finalized transfer state after prepare processing accumulatedPositionReservedValue, // not used but kept for consistency accumulatedTransferStateChanges: transferStateChanges, // transfer state changes to be persisted in order limitAlarms, // array of participant limits that have been breached - accumulatedPositionChanges: participantPositionChanges, // participant position changes to be persisted in order + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} } } diff --git a/src/domain/position/timeout-reserved.js b/src/domain/position/timeout-reserved.js new file mode 100644 index 000000000..3b52ef9ab --- /dev/null +++ b/src/domain/position/timeout-reserved.js @@ -0,0 +1,197 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const { Enum } = require('@mojaloop/central-services-shared') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Config = require('../../lib/config') +const Utility = require('@mojaloop/central-services-shared').Util +const MLNumber = require('@mojaloop/ml-number') +const Logger = require('@mojaloop/central-services-logger') + +/** + * @function processPositionTimeoutReservedBin + * + * @async + * @description This is the domain function to process a bin of timeout-reserved messages of a single participant account. + * + * @param {array} timeoutReservedBins - an array containing timeout-reserved action bins + * @param {object} options + * @param {number} accumulatedPositionValue - value of position accumulated so far from previous bin processing + * @param {number} accumulatedPositionReservedValue - value of position reserved accumulated so far, not used but kept for consistency + * @param {object} accumulatedTransferStates - object with transfer id keys and transfer state id values. Used to check if transfer is in correct state for processing. Clone and update states for output. + * @param {object} transferInfoList - object with transfer id keys and transfer info values. Used to pass transfer info to domain function. + * @param {boolean} changePositions - whether to change positions or not + * @returns {object} - Returns an object containing accumulatedPositionValue, accumulatedPositionReservedValue, accumulatedTransferStateChanges, accumulatedTransferStates, resultMessages, limitAlarms or throws an error if failed + */ +const processPositionTimeoutReservedBin = async ( + timeoutReservedBins, + { + accumulatedPositionValue, + accumulatedPositionReservedValue, + accumulatedTransferStates, + transferInfoList, + changePositions = true + } +) => { + const transferStateChanges = [] + const participantPositionChanges = [] + const resultMessages = [] + const accumulatedTransferStatesCopy = Object.assign({}, accumulatedTransferStates) + let runningPosition = new MLNumber(accumulatedPositionValue) + // Position action RESERVED_TIMEOUT event messages are keyed either with the + // payer's account id or an fxp target currency account of an associated fxTransfer. + // We need to revert the payer's/fxp's position for the amount of the transfer. + // The payer and payee are notified from the singular NOTIFICATION event RESERVED_TIMEOUT action + if (timeoutReservedBins && timeoutReservedBins.length > 0) { + for (const binItem of timeoutReservedBins) { + Logger.isDebugEnabled && Logger.debug(`processPositionTimeoutReservedBin::binItem: ${JSON.stringify(binItem.message.value)}`) + const transferId = binItem.message.value.content.uriParams.id + const payeeFsp = binItem.message.value.to + const payerFsp = binItem.message.value.from + + // If the transfer is not in `RESERVED_TIMEOUT`, a position timeout-reserved message was incorrectly published. + // i.e Something has gone extremely wrong. + if (accumulatedTransferStates[transferId] !== Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { + throw ErrorHandler.Factory.createInternalServerFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.message) + } else { + Logger.isDebugEnabled && Logger.debug(`accumulatedTransferStates: ${JSON.stringify(accumulatedTransferStates)}`) + + const transferAmount = transferInfoList[transferId].amount + + // Construct notification message + const resultMessage = _constructTimeoutReservedResultMessage( + binItem, + transferId, + payeeFsp, + payerFsp + ) + Logger.isDebugEnabled && Logger.debug(`processPositionTimeoutReservedBin::resultMessage: ${JSON.stringify(resultMessage)}`) + + // Revert payer's or fxp's position for the amount of the transfer + const { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } = + _handleParticipantPositionChange(runningPosition, transferAmount, transferId, accumulatedPositionReservedValue) + Logger.isDebugEnabled && Logger.debug(`processPositionTimeoutReservedBin::participantPositionChange: ${JSON.stringify(participantPositionChange)}`) + runningPosition = updatedRunningPosition + binItem.result = { success: true } + participantPositionChanges.push(participantPositionChange) + transferStateChanges.push(transferStateChange) + accumulatedTransferStatesCopy[transferId] = transferStateId + resultMessages.push({ binItem, message: resultMessage }) + } + } + } + + return { + accumulatedPositionValue: changePositions ? runningPosition.toNumber() : accumulatedPositionValue, + accumulatedTransferStates: accumulatedTransferStatesCopy, // finalized transfer state after fulfil processing + accumulatedPositionReservedValue, // not used but kept for consistency + accumulatedTransferStateChanges: transferStateChanges, // transfer state changes to be persisted in order + accumulatedPositionChanges: changePositions ? participantPositionChanges : [], // participant position changes to be persisted in order + notifyMessages: resultMessages // array of objects containing bin item and result message. {binItem, message} + } +} + +const _constructTimeoutReservedResultMessage = (binItem, transferId, payeeFsp, payerFsp) => { + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payer and payee of the timeout. + // As long as the `to` and `from` message values are the payer and payee, + // and the action is `timeout-reserved`, the ml-api-adapter will notify both. + // Create a FSPIOPError object for timeout payee notification + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED, + null, + null, + null, + null + ).toApiErrorObject(Config.ERROR_HANDLING) + + const state = Utility.StreamingProtocol.createEventState( + Enum.Events.EventStatus.FAILURE.status, + fspiopError.errorInformation.errorCode, + fspiopError.errorInformation.errorDescription + ) + + // Create metadata for the message, associating the payee notification + // with the position event timeout-reserved action + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent( + transferId, + Enum.Kafka.Topics.POSITION, + Enum.Events.Event.Action.TIMEOUT_RESERVED, + state + ) + const resultMessage = Utility.StreamingProtocol.createMessage( + transferId, + payeeFsp, + payerFsp, + metadata, + binItem.message.value.content.headers, // Headers don't really matter here. ml-api-adapter will ignore them and create their own. + fspiopError, + { id: transferId }, + 'application/json' + ) + + return resultMessage +} + +const _handleParticipantPositionChange = (runningPosition, transferAmount, transferId, accumulatedPositionReservedValue) => { + // NOTE: The transfer info amount is pulled from the payee records in a batch `SELECT` query. + // And will have a negative value. We add that value to the payer's(in regular transfer) or fxp's(in fx transfer) position + // to revert the position for the amount of the transfer. + const transferStateId = Enum.Transfers.TransferInternalState.EXPIRED_RESERVED + // Revert payer's or fxp's position for the amount of the transfer + const updatedRunningPosition = new MLNumber(runningPosition.add(transferAmount).toFixed(Config.AMOUNT.SCALE)) + Logger.isDebugEnabled && Logger.debug(`processPositionTimeoutReservedBin::_handleParticipantPositionChange::updatedRunningPosition: ${updatedRunningPosition.toString()}`) + Logger.isDebugEnabled && Logger.debug(`processPositionTimeoutReservedBin::_handleParticipantPositionChange::transferAmount: ${transferAmount}`) + // Construct participant position change object + const participantPositionChange = { + transferId, // Need to delete this in bin processor while updating transferStateChangeId + transferStateChangeId: null, // Need to update this in bin processor while executing queries + value: updatedRunningPosition.toNumber(), + change: transferAmount, + reservedValue: accumulatedPositionReservedValue + } + + // Construct transfer state change object + const transferStateChange = { + transferId, + transferStateId, + reason: ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message + } + return { participantPositionChange, transferStateChange, transferStateId, updatedRunningPosition } +} + +module.exports = { + processPositionTimeoutReservedBin +} diff --git a/src/domain/settlement/index.js b/src/domain/settlement/index.js index 9cd9896cd..795de5918 100644 --- a/src/domain/settlement/index.js +++ b/src/domain/settlement/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/domain/timeout/index.js b/src/domain/timeout/index.js index ec1251d69..744bb8817 100644 --- a/src/domain/timeout/index.js +++ b/src/domain/timeout/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -30,7 +33,9 @@ const SegmentModel = require('../../models/misc/segment') const TransferTimeoutModel = require('../../models/transfer/transferTimeout') +const FxTransferTimeoutModel = require('../../models/fxTransfer/fxTransferTimeout') const TransferStateChangeModel = require('../../models/transfer/transferStateChange') +const FxTransferStateChangeModel = require('../../models/fxTransfer/stateChange') const TransferFacade = require('../../models/transfer/facade') const getTimeoutSegment = async () => { @@ -43,24 +48,46 @@ const getTimeoutSegment = async () => { return result } +const getFxTimeoutSegment = async () => { + const params = { + segmentType: 'timeout', + enumeration: 0, + tableName: 'fxTransferStateChange' + } + const result = await SegmentModel.getByParams(params) + return result +} + const cleanupTransferTimeout = async () => { const result = await TransferTimeoutModel.cleanup() return result } +const cleanupFxTransferTimeout = async () => { + const result = await FxTransferTimeoutModel.cleanup() + return result +} + const getLatestTransferStateChange = async () => { const result = await TransferStateChangeModel.getLatest() return result } -const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax) => { - const result = await TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax) +const getLatestFxTransferStateChange = async () => { + const result = await FxTransferStateChangeModel.getLatest() return result } +const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax, fxSegmentId, fxIntervalMin, fxIntervalMax) => { + return TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax, fxSegmentId, fxIntervalMin, fxIntervalMax) +} + module.exports = { getTimeoutSegment, + getFxTimeoutSegment, cleanupTransferTimeout, + cleanupFxTransferTimeout, getLatestTransferStateChange, + getLatestFxTransferStateChange, timeoutExpireReserved } diff --git a/src/domain/transactions/index.js b/src/domain/transactions/index.js index 2dc1e0e71..8cd67140e 100644 --- a/src/domain/transactions/index.js +++ b/src/domain/transactions/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/domain/transfer/index.js b/src/domain/transfer/index.js index b8cfe7d53..8cf47b99f 100644 --- a/src/domain/transfer/index.js +++ b/src/domain/transfer/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,6 +32,8 @@ * @module src/domain/transfer/ */ +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') const TransferFacade = require('../../models/transfer/facade') const TransferModel = require('../../models/transfer/transfer') const TransferStateChangeModel = require('../../models/transfer/transferStateChange') @@ -36,19 +41,17 @@ const TransferErrorModel = require('../../models/transfer/transferError') const TransferDuplicateCheckModel = require('../../models/transfer/transferDuplicateCheck') const TransferFulfilmentDuplicateCheckModel = require('../../models/transfer/transferFulfilmentDuplicateCheck') const TransferErrorDuplicateCheckModel = require('../../models/transfer/transferErrorDuplicateCheck') -const TransferObjectTransform = require('./transform') const TransferError = require('../../models/transfer/transferError') -const ErrorHandler = require('@mojaloop/central-services-error-handling') -const Metrics = require('@mojaloop/central-services-metrics') +const TransferObjectTransform = require('./transform') -const prepare = async (payload, stateReason = null, hasPassedValidation = true) => { +const prepare = async (payload, stateReason = null, hasPassedValidation = true, determiningTransferCheckResult, proxyObligation) => { const histTimerTransferServicePrepareEnd = Metrics.getHistogram( 'domain_transfer', 'prepare - Metrics for transfer domain', ['success', 'funcName'] ).startTimer() try { - const result = await TransferFacade.saveTransferPrepared(payload, stateReason, hasPassedValidation) + const result = await TransferFacade.saveTransferPrepared(payload, stateReason, hasPassedValidation, determiningTransferCheckResult, proxyObligation) histTimerTransferServicePrepareEnd({ success: true, funcName: 'prepare' }) return result } catch (err) { @@ -57,6 +60,22 @@ const prepare = async (payload, stateReason = null, hasPassedValidation = true) } } +const forwardedPrepare = async (transferId) => { + const histTimerTransferServicePrepareEnd = Metrics.getHistogram( + 'domain_transfer', + 'prepare - Metrics for transfer domain', + ['success', 'funcName'] + ).startTimer() + try { + const result = await TransferFacade.updatePrepareReservedForwarded(transferId) + histTimerTransferServicePrepareEnd({ success: true, funcName: 'forwardedPrepare' }) + return result + } catch (err) { + histTimerTransferServicePrepareEnd({ success: false, funcName: 'forwardedPrepare' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + const handlePayeeResponse = async (transferId, payload, action, fspiopError) => { const histTimerTransferServiceHandlePayeeResponseEnd = Metrics.getHistogram( 'domain_transfer', @@ -104,6 +123,7 @@ const TransferService = { prepare, handlePayeeResponse, logTransferError, + forwardedPrepare, getTransferErrorByTransferId: TransferErrorModel.getByTransferId, getTransferById: TransferModel.getById, getById: TransferFacade.getById, diff --git a/src/domain/transfer/transform.js b/src/domain/transfer/transform.js index 6e6fbd8a0..320f54d51 100644 --- a/src/domain/transfer/transform.js +++ b/src/domain/transfer/transform.js @@ -110,17 +110,30 @@ const transformExtensionList = (extensionList) => { }) } -const transformTransferToFulfil = (transfer) => { +const transformTransferToFulfil = (transfer, isFx) => { try { + if (!transfer || Object.keys(transfer).length === 0) { + throw new Error('transformTransferToFulfil: transfer is required') + } + const result = { - completedTimestamp: transfer.completedTimestamp, - transferState: transfer.transferStateEnumeration + completedTimestamp: transfer.completedTimestamp + } + if (isFx) { + result.conversionState = transfer.fxTransferStateEnumeration + } else { + result.transferState = transfer.transferStateEnumeration } + if (transfer.fulfilment !== '0') result.fulfilment = transfer.fulfilment - const extension = transformExtensionList(transfer.extensionList) - if (extension.length > 0) { - result.extensionList = { extension } + + if (transfer.extensionList) { + const extension = transformExtensionList(transfer.extensionList) + if (extension.length > 0 && !isFx) { + result.extensionList = { extension } + } } + return Util.omitNil(result) } catch (err) { throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `Unable to transform to fulfil response: ${err}`) diff --git a/src/handlers/admin/handler.js b/src/handlers/admin/handler.js index a18f7c39b..656ec28b7 100644 --- a/src/handlers/admin/handler.js +++ b/src/handlers/admin/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -63,10 +63,8 @@ const createRecordFundsInOut = async (payload, transactionTimestamp, enums) => { try { await TransferService.reconciliationTransferPrepare(payload, transactionTimestamp, enums, trx) await TransferService.reconciliationTransferReserve(payload, transactionTimestamp, enums, trx) - await trx.commit } catch (err) { Logger.isErrorEnabled && Logger.error(err) - await trx.rollback throw ErrorHandler.Factory.reformatFSPIOPError(err) } }) diff --git a/src/handlers/api/plugin.js b/src/handlers/api/plugin.js index b6cef9a56..df97f6a88 100644 --- a/src/handlers/api/plugin.js +++ b/src/handlers/api/plugin.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/handlers/api/routes.js b/src/handlers/api/routes.js index 25e28a04c..91168ee9d 100644 --- a/src/handlers/api/routes.js +++ b/src/handlers/api/routes.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/handlers/bulk/fulfil/handler.js b/src/handlers/bulk/fulfil/handler.js index 1a94f3b45..6fa4b25b8 100644 --- a/src/handlers/bulk/fulfil/handler.js +++ b/src/handlers/bulk/fulfil/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -110,7 +110,7 @@ const bulkFulfil = async (error, messages) => { Logger.isErrorEnabled && Logger.error(Util.breadcrumb(location, `callbackErrorModified--${actionLetter}2`)) const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST) const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } @@ -134,7 +134,7 @@ const bulkFulfil = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } try { @@ -240,7 +240,7 @@ const bulkFulfil = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorGeneric--${actionLetter}8`)) @@ -248,7 +248,7 @@ const bulkFulfil = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: validationFspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: validationFspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw validationFspiopError } } catch (err) { @@ -293,7 +293,7 @@ const sendIndividualTransfer = async (message, messageId, kafkaTopic, headers, p value: Util.StreamingProtocol.createMessage(messageId, headers[Enum.Http.Headers.FSPIOP.DESTINATION], headers[Enum.Http.Headers.FSPIOP.SOURCE], metadata, headers, dataUri, { id: transferId }) } params = { message: msg, kafkaTopic, consumer: Consumer, producer: Producer } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) } diff --git a/src/handlers/bulk/get/handler.js b/src/handlers/bulk/get/handler.js index 571d55c36..a9f2a1666 100644 --- a/src/handlers/bulk/get/handler.js +++ b/src/handlers/bulk/get/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -88,7 +88,7 @@ const getBulkTransfer = async (error, messages) => { if (!(await Validator.validateParticipantByName(message.value.from)).isValid) { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `breakParticipantDoesntExist--${actionLetter}1`)) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } @@ -97,7 +97,7 @@ const getBulkTransfer = async (error, messages) => { if (!bulkTransferLight) { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorBulkTransferNotFound--${actionLetter}3`)) const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.BULK_TRANSFER_ID_NOT_FOUND, 'Provided Bulk Transfer ID was not found on the server.') - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } // The SD says this should be 404 response which I think will not be constent with single transfers @@ -106,7 +106,7 @@ const getBulkTransfer = async (error, messages) => { if (![participants.payeeFsp, participants.payerFsp].includes(message.value.from)) { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNotBulkTransferParticipant--${actionLetter}2`)) const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } const isPayeeRequest = participants.payeeFsp === message.value.from @@ -129,9 +129,9 @@ const getBulkTransfer = async (error, messages) => { } message.value.content.payload = payload if (fspiopError) { - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) } else { - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) } histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true diff --git a/src/handlers/bulk/index.js b/src/handlers/bulk/index.js index 6f129ceb0..1ad6340de 100644 --- a/src/handlers/bulk/index.js +++ b/src/handlers/bulk/index.js @@ -2,8 +2,8 @@ * @file This registers all handlers for the central-ledger API License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -16,7 +16,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/handlers/bulk/prepare/handler.js b/src/handlers/bulk/prepare/handler.js index 6dedb551e..967ea76eb 100644 --- a/src/handlers/bulk/prepare/handler.js +++ b/src/handlers/bulk/prepare/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -145,15 +145,15 @@ const bulkPrepare = async (error, messages) => { params.message.value.content.payload = payload params.message.value.content.uriParams = { id: bulkTransferId } if (fspiopError) { - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) } else { - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) } return true } else { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'inProgress')) Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `ignore--${actionLetter}3`)) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, hubName: Config.HUB_NAME }) return true } } @@ -165,7 +165,7 @@ const bulkPrepare = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } @@ -183,7 +183,7 @@ const bulkPrepare = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } try { @@ -212,7 +212,7 @@ const bulkPrepare = async (error, messages) => { } params = { message: msg, kafkaTopic, consumer: Consumer, producer: Producer } const eventDetail = { functionality: Enum.Events.Event.Type.PREPARE, action: Enum.Events.Event.Action.BULK_PREPARE } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) } } catch (err) { // handle individual transfers streaming error @@ -221,7 +221,7 @@ const bulkPrepare = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } } else { // handle validation failure @@ -257,7 +257,7 @@ const bulkPrepare = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } // produce validation error callback notification to payer @@ -266,7 +266,7 @@ const bulkPrepare = async (error, messages) => { const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action } params.message.value.content.uriParams = { id: bulkTransferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: validationFspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: validationFspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw validationFspiopError } } catch (err) { diff --git a/src/handlers/bulk/processing/handler.js b/src/handlers/bulk/processing/handler.js index 1c2bf42dd..7369e45de 100644 --- a/src/handlers/bulk/processing/handler.js +++ b/src/handlers/bulk/processing/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -32,7 +32,6 @@ const Logger = require('@mojaloop/central-services-logger') const BulkTransferService = require('../../../domain/bulkTransfer') const Util = require('@mojaloop/central-services-shared').Util -const Kafka = require('@mojaloop/central-services-shared').Util.Kafka const Producer = require('@mojaloop/central-services-stream').Util.Producer const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const Enum = require('@mojaloop/central-services-shared').Enum @@ -41,6 +40,8 @@ const Config = require('../../../lib/config') const decodePayload = require('@mojaloop/central-services-shared').Util.StreamingProtocol.decodePayload const BulkTransferModels = require('@mojaloop/object-store-lib').Models.BulkTransfer const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Kafka = Util.Kafka +const HeaderValidation = Util.HeaderValidation const location = { module: 'BulkProcessingHandler', method: '', path: '' } // var object used as pointer @@ -295,7 +296,7 @@ const bulkProcessing = async (error, messages) => { }) const metadata = Util.StreamingProtocol.createMetadataWithCorrelatedEvent(params.message.value.metadata.event.id, params.message.value.metadata.type, params.message.value.metadata.action, Enum.Events.EventStatus.SUCCESS) params.message.value = Util.StreamingProtocol.createMessage(params.message.value.id, payeeBulkResponse.destination, payeeBulkResponse.headers[Enum.Http.Headers.FSPIOP.SOURCE], metadata, payeeBulkResponse.headers, payload) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } else { @@ -310,7 +311,7 @@ const bulkProcessing = async (error, messages) => { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `bulkFulfil--${actionLetter}3`)) const participants = await BulkTransferService.getParticipantsById(bulkTransferInfo.bulkTransferId) const normalizedKeys = Object.keys(headers).reduce((keys, k) => { keys[k.toLowerCase()] = k; return keys }, {}) - const payeeBulkResponseHeaders = Util.Headers.transformHeaders(headers, { httpMethod: headers[normalizedKeys[Enum.Http.Headers.FSPIOP.HTTP_METHOD]], sourceFsp: Enum.Http.Headers.FSPIOP.SWITCH.value, destinationFsp: participants.payeeFsp }) + const payeeBulkResponseHeaders = Util.Headers.transformHeaders(headers, { httpMethod: headers[normalizedKeys[Enum.Http.Headers.FSPIOP.HTTP_METHOD]], sourceFsp: Config.HUB_NAME, destinationFsp: participants.payeeFsp, hubNameRegex: HeaderValidation.getHubNameRegex(Config.HUB_NAME) }) delete payeeBulkResponseHeaders[normalizedKeys[Enum.Http.Headers.FSPIOP.SIGNATURE]] const payerBulkResponse = Object.assign({}, { messageId: message.value.id, headers: Util.clone(headers) }, getBulkTransferByIdResult.payerBulkTransfer) const payeeBulkResponse = Object.assign({}, { messageId: message.value.id, headers: payeeBulkResponseHeaders }, getBulkTransferByIdResult.payeeBulkTransfer) @@ -344,13 +345,13 @@ const bulkProcessing = async (error, messages) => { payerParams.message.value = Util.StreamingProtocol.createMessage(params.message.value.id, participants.payerFsp, payerBulkResponse.headers[normalizedKeys[Enum.Http.Headers.FSPIOP.SOURCE]], payerMetadata, payerBulkResponse.headers, payerPayload) const payeeMetadata = Util.StreamingProtocol.createMetadataWithCorrelatedEvent(params.message.value.metadata.event.id, payeeParams.message.value.metadata.type, payeeParams.message.value.metadata.action, Enum.Events.EventStatus.SUCCESS) - payeeParams.message.value = Util.StreamingProtocol.createMessage(params.message.value.id, participants.payeeFsp, Enum.Http.Headers.FSPIOP.SWITCH.value, payeeMetadata, payeeBulkResponse.headers, payeePayload) + payeeParams.message.value = Util.StreamingProtocol.createMessage(params.message.value.id, participants.payeeFsp, Config.HUB_NAME, payeeMetadata, payeeBulkResponse.headers, payeePayload) if ([Enum.Events.Event.Action.BULK_TIMEOUT_RECEIVED, Enum.Events.Event.Action.BULK_TIMEOUT_RESERVED].includes(action)) { eventDetail.action = Enum.Events.Event.Action.BULK_COMMIT } else if ([Enum.Events.Event.Action.BULK_ABORT].includes(action)) { eventDetail.action = Enum.Events.Event.Action.BULK_ABORT } - await Kafka.proceed(Config.KAFKA_CONFIG, payerParams, { consumerCommit, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, payerParams, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) await Kafka.proceed(Config.KAFKA_CONFIG, payeeParams, { consumerCommit, eventDetail }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) @@ -359,7 +360,7 @@ const bulkProcessing = async (error, messages) => { const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED, null, null, null, payload.extensionList) eventDetail.action = Enum.Events.Event.Action.BULK_ABORT params.message.value.content.uriParams.id = bulkTransferInfo.bulkTransferId - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, hubName: Config.HUB_NAME }) throw fspiopError } else { // TODO: For the following (Internal Server Error) scenario a notification is produced for each individual transfer. @@ -367,7 +368,7 @@ const bulkProcessing = async (error, messages) => { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `invalidEventTypeOrAction--${actionLetter}4`)) const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event action:(${action}) and/or type:(${eventType})`).toApiErrorObject(Config.ERROR_HANDLING) const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action: Enum.Events.Event.Action.BULK_PROCESSING } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError, eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } diff --git a/src/handlers/bulk/shared/validator.js b/src/handlers/bulk/shared/validator.js index a54b039ff..cc888cfc2 100644 --- a/src/handlers/bulk/shared/validator.js +++ b/src/handlers/bulk/shared/validator.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -95,7 +95,7 @@ const validateFspiopSourceAndDestination = async (payload, headers) => { // Due to the Bulk [Design Considerations](https://docs.mojaloop.io/technical/central-bulk-transfers/#_2-design-considerations), // it is possible that the Switch may send a POST Request to the Payee FSP with the Source Header containing "Switch", // and the Payee FSP thus responding with a PUT Callback and destination header containing the same value (Switch). - (headers[Enum.Http.Headers.FSPIOP.DESTINATION] === Enum.Http.Headers.FSPIOP.SWITCH.value) + (headers[Enum.Http.Headers.FSPIOP.DESTINATION] === Config.HUB_NAME) ) ) diff --git a/src/handlers/index.js b/src/handlers/index.js index ee1a6a706..9acaa19f7 100644 --- a/src/handlers/index.js +++ b/src/handlers/index.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/handlers/positions/handler.js b/src/handlers/positions/handler.js index 17feba7ea..d3c12a9ea 100644 --- a/src/handlers/positions/handler.js +++ b/src/handlers/positions/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -43,6 +43,7 @@ const EventSdk = require('@mojaloop/event-sdk') const TransferService = require('../../domain/transfer') const TransferObjectTransform = require('../../domain/transfer/transform') const PositionService = require('../../domain/position') +const participantFacade = require('../../models/participant/facade') const SettlementModelCached = require('../../models/settlement/settlementModelCached') const Utility = require('@mojaloop/central-services-shared').Util const Kafka = require('@mojaloop/central-services-shared').Util.Kafka @@ -113,6 +114,7 @@ const positions = async (error, messages) => { Logger.isErrorEnabled && Logger.error(fspiopError) throw fspiopError } + const kafkaTopic = message.topic Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, { method: 'positions' })) @@ -158,7 +160,7 @@ const positions = async (error, messages) => { const { transferState, fspiopError } = prepareMessage if (transferState.transferStateId === Enum.Transfers.TransferState.RESERVED) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, `payer--${actionLetter}1`)) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId, action }) return true } else { @@ -166,17 +168,18 @@ const positions = async (error, messages) => { const responseFspiopError = fspiopError || ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR) const fspiopApiError = responseFspiopError.toApiErrorObject(Config.ERROR_HANDLING) await TransferService.logTransferError(transferId, fspiopApiError.errorInformation.errorCode, fspiopApiError.errorInformation.errorDescription) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopApiError, eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopApiError, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw responseFspiopError } } } else if (eventType === Enum.Events.Event.Type.POSITION && [Enum.Events.Event.Action.COMMIT, Enum.Events.Event.Action.RESERVE, Enum.Events.Event.Action.BULK_COMMIT].includes(action)) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, { path: 'commit' })) const transferInfo = await TransferService.getTransferInfoToChangePosition(transferId, Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP, Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + const participantCurrency = await participantFacade.getByIDAndCurrency(transferInfo.participantId, transferInfo.currencyId, Enum.Accounts.LedgerAccountType.POSITION) if (transferInfo.transferStateId !== Enum.Transfers.TransferInternalState.RECEIVED_FULFIL) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, `validationFailed::notReceivedFulfilState1--${actionLetter}3`)) const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid State: ${transferInfo.transferStateId} - expected: ${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL}`) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } else { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, `payee--${actionLetter}4`)) @@ -185,18 +188,19 @@ const positions = async (error, messages) => { transferId: transferInfo.transferId, transferStateId: Enum.Transfers.TransferState.COMMITTED } - await PositionService.changeParticipantPosition(transferInfo.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) + await PositionService.changeParticipantPosition(participantCurrency.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) if (action === Enum.Events.Event.Action.RESERVE) { const transfer = await TransferService.getById(transferInfo.transferId) message.value.content.payload = TransferObjectTransform.toFulfil(transfer) } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId, action }) return true } } else if (eventType === Enum.Events.Event.Type.POSITION && [Enum.Events.Event.Action.REJECT, Enum.Events.Event.Action.ABORT, Enum.Events.Event.Action.ABORT_VALIDATION, Enum.Events.Event.Action.BULK_ABORT].includes(action)) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, { path: action })) const transferInfo = await TransferService.getTransferInfoToChangePosition(transferId, Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + const participantCurrency = await participantFacade.getByIDAndCurrency(transferInfo.participantId, transferInfo.currencyId, Enum.Accounts.LedgerAccountType.POSITION) let transferStateId if (action === Enum.Events.Event.Action.REJECT) { @@ -212,14 +216,15 @@ const positions = async (error, messages) => { transferStateId, reason: transferInfo.reason } - await PositionService.changeParticipantPosition(transferInfo.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail }) + await PositionService.changeParticipantPosition(participantCurrency.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId, action }) return true } else if (eventType === Enum.Events.Event.Type.POSITION && [Enum.Events.Event.Action.TIMEOUT_RESERVED, Enum.Events.Event.Action.BULK_TIMEOUT_RESERVED].includes(action)) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, { path: 'timeout' })) span.setTags({ transactionId: transferId }) const transferInfo = await TransferService.getTransferInfoToChangePosition(transferId, Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + const participantCurrency = await participantFacade.getByIDAndCurrency(transferInfo.participantId, transferInfo.currencyId, Enum.Accounts.LedgerAccountType.POSITION) if (transferInfo.transferStateId !== Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, `validationFailed::notReceivedFulfilState2--${actionLetter}6`)) throw ErrorHandler.Factory.createInternalServerFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.message) @@ -231,16 +236,24 @@ const positions = async (error, messages) => { transferStateId: Enum.Transfers.TransferInternalState.EXPIRED_RESERVED, reason: ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message } - await PositionService.changeParticipantPosition(transferInfo.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) + await PositionService.changeParticipantPosition(participantCurrency.participantCurrencyId, isReversal, transferInfo.amount, transferStateChange) const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED, null, null, null, payload.extensionList) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail }) + await Kafka.proceed( + Config.KAFKA_CONFIG, + params, + { + consumerCommit, + fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), + eventDetail, + hubName: Config.HUB_NAME + }) throw fspiopError } } else { Logger.isInfoEnabled && Logger.info(Utility.breadcrumb(location, `invalidEventTypeOrAction--${actionLetter}8`)) const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event action:(${action}) and/or type:(${eventType})`) const eventDetail = { functionality: Enum.Events.Event.Type.NOTIFICATION, action: Enum.Events.Event.Action.POSITION } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) throw fspiopError } } catch (err) { diff --git a/src/handlers/positions/handlerBatch.js b/src/handlers/positions/handlerBatch.js index cc706b3ca..627e0bae9 100644 --- a/src/handlers/positions/handlerBatch.js +++ b/src/handlers/positions/handlerBatch.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -48,7 +48,6 @@ const { randomUUID } = require('crypto') const ErrorHandler = require('@mojaloop/central-services-error-handling') const BatchPositionModel = require('../../models/position/batch') const decodePayload = require('@mojaloop/central-services-shared').Util.StreamingProtocol.decodePayload - const consumerCommit = true /** @@ -89,7 +88,7 @@ const positions = async (error, messages) => { // Iterate through consumedMessages const bins = {} const lastPerPartition = {} - for (const message of consumedMessages) { + await Promise.all(consumedMessages.map(message => { const histTimerMsgEnd = Metrics.getHistogram( 'transfer_position', 'Process a prepare transfer message', @@ -104,9 +103,10 @@ const positions = async (error, messages) => { binId }) + const accountID = message.key.toString() + // Assign message to account-bin by accountID and child action-bin by action // (References to the messages to be stored in bins, no duplication of messages) - const accountID = message.key.toString() const action = message.value.metadata.event.action const accountBin = bins[accountID] || (bins[accountID] = {}) const actionBin = accountBin[action] || (accountBin[action] = []) @@ -126,39 +126,67 @@ const positions = async (error, messages) => { lastPerPartition[message.partition] = message } - await span.audit(message, EventSdk.AuditEventAction.start) - } + return span.audit(message, EventSdk.AuditEventAction.start) + })) - // Start DB Transaction - const trx = await BatchPositionModel.startDbTransaction() + // Start DB Transaction if there are any bins to process + const trx = !!Object.keys(bins).length && await BatchPositionModel.startDbTransaction() try { - // Call Bin Processor with the list of account-bins and trx - const result = await BinProcessor.processBins(bins, trx) - - // If Bin Processor processed bins successfully, commit Kafka offset - // Commit the offset of last message in the array - for (const message of Object.values(lastPerPartition)) { - const params = { message, kafkaTopic: message.topic, consumer: Consumer } - // We are using Kafka.proceed() to just commit the offset of the last message in the array - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit }) - } + if (trx) { + // Call Bin Processor with the list of account-bins and trx + const result = await BinProcessor.processBins(bins, trx) + + // If Bin Processor processed bins successfully, commit Kafka offset + // Commit the offset of last message in the array + for (const message of Object.values(lastPerPartition)) { + const params = { message, kafkaTopic: message.topic, consumer: Consumer } + // We are using Kafka.proceed() to just commit the offset of the last message in the array + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, hubName: Config.HUB_NAME }) + } - // Commit DB transaction - await trx.commit() + // Commit DB transaction + await trx.commit() - // Loop through results and produce notification messages and audit messages - for (const item of result.notifyMessages) { - // Produce notification message and audit message - const action = item.binItem.message?.value.metadata.event.action - const eventStatus = item?.message.metadata.event.state.status === Enum.Events.EventStatus.SUCCESS.status ? Enum.Events.EventStatus.SUCCESS : Enum.Events.EventStatus.FAILURE - await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Events.Event.Type.NOTIFICATION, action, item.message, eventStatus, null, item.binItem.span) + // Loop through results and produce notification messages and audit messages + await Promise.all(result.notifyMessages.map(item => { + // Produce notification message and audit message + // NOTE: Not sure why we're checking the binItem for the action vs the message + // that is being created. + // Handled FX_NOTIFY differently so as not to break existing functionality. + let action + if (item?.message.metadata.event.action !== Enum.Events.Event.Action.FX_NOTIFY) { + action = item.binItem.message?.value.metadata.event.action + } else { + action = item.message.metadata.event.action + } + const eventStatus = item?.message.metadata.event.state.status === Enum.Events.EventStatus.SUCCESS.status ? Enum.Events.EventStatus.SUCCESS : Enum.Events.EventStatus.FAILURE + return Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Events.Event.Type.NOTIFICATION, action, item.message, eventStatus, null, item.binItem.span) + }).concat( + // Loop through followup messages and produce position messages for further processing of the transfer + result.followupMessages.map(item => { + // Produce position message and audit message + const action = item.binItem.message?.value.metadata.event.action + const eventStatus = item?.message.metadata.event.state.status === Enum.Events.EventStatus.SUCCESS.status ? Enum.Events.EventStatus.SUCCESS : Enum.Events.EventStatus.FAILURE + return Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Events.Event.Type.POSITION, + action, + item.message, + eventStatus, + item.messageKey, + item.binItem.span, + Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.COMMIT + ) + }) + )) } histTimerEnd({ success: true }) } catch (err) { // If Bin Processor returns failure // - Rollback DB transaction - await trx.rollback() + await trx?.rollback() // - Audit Error for each message const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) diff --git a/src/handlers/register.js b/src/handlers/register.js index ae89f1394..092685932 100644 --- a/src/handlers/register.js +++ b/src/handlers/register.js @@ -2,8 +2,8 @@ * @file This registers all handlers for the central-ledger API License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -16,7 +16,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -97,7 +97,8 @@ module.exports = { }, timeouts: { registerAllHandlers: TimeoutHandlers.registerAllHandlers, - registerTimeoutHandler: TimeoutHandlers.registerTimeoutHandler + registerTimeoutHandler: TimeoutHandlers.registerTimeoutHandler, + registerFxTimeoutHandler: TimeoutHandlers.registerFxTimeoutHandler }, admin: { registerAdminHandlers: AdminHandlers.registerAllHandlers diff --git a/src/handlers/timeouts/handler.js b/src/handlers/timeouts/handler.js index 0bd1b2e86..edc7c79ca 100644 --- a/src/handlers/timeouts/handler.js +++ b/src/handlers/timeouts/handler.js @@ -1,23 +1,23 @@ /***** -License --------------- -Copyright © 2017 Bill & Melinda Gates Foundation -The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at - -http://www.apache.org/licenses/LICENSE-2.0 - -Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - -Contributors --------------- -This is the official list of the Mojaloop project contributors for this file. -Names of the original copyright holders (individuals or organizations) -should be listed with a '*' in the first column. People who have -contributed from an organization can be listed under the organization -that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have - their names indented and be marked with a '-'. Email address can be added - optionally within square brackets . + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . * Gates Foundation - Name Surname @@ -35,20 +35,206 @@ that actually holds the copyright for their contributions (see the */ const CronJob = require('cron').CronJob -const Config = require('../../lib/config') -const TimeoutService = require('../../domain/timeout') const Enum = require('@mojaloop/central-services-shared').Enum -const Kafka = require('@mojaloop/central-services-shared').Util.Kafka -const Producer = require('@mojaloop/central-services-stream').Util.Producer const Utility = require('@mojaloop/central-services-shared').Util +const Producer = require('@mojaloop/central-services-stream').Util.Producer const ErrorHandler = require('@mojaloop/central-services-error-handling') const EventSdk = require('@mojaloop/event-sdk') -const resourceVersions = require('@mojaloop/central-services-shared').Util.resourceVersions -const Logger = require('@mojaloop/central-services-logger') + +const Config = require('../../lib/config') +const TimeoutService = require('../../domain/timeout') +const { logger } = require('../../shared/logger') + +const { Kafka, resourceVersions } = Utility +const { Action, Type } = Enum.Events.Event + let timeoutJob let isRegistered let running = false +/** + * Processes timedOut transfers + * + * @param {TimedOutTransfer[]} transferTimeoutList + * @returns {Promise} + */ +const _processTimedOutTransfers = async (transferTimeoutList) => { + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED).toApiErrorObject(Config.ERROR_HANDLING) + if (!Array.isArray(transferTimeoutList)) { + transferTimeoutList = [ + { ...transferTimeoutList } + ] + } + + for (const TT of transferTimeoutList) { + const span = EventSdk.Tracer.createSpan('cl_transfer_timeout') + try { + const state = Utility.StreamingProtocol.createEventState(Enum.Events.EventStatus.FAILURE.status, fspiopError.errorInformation.errorCode, fspiopError.errorInformation.errorDescription) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent(TT.transferId, Enum.Kafka.Topics.NOTIFICATION, Action.TIMEOUT_RECEIVED, state) + const destination = TT.externalPayerName || TT.payerFsp + const source = TT.externalPayeeName || TT.payeeFsp + const headers = Utility.Http.SwitchDefaultHeaders(destination, Enum.Http.HeaderResources.TRANSFERS, Config.HUB_NAME, resourceVersions[Enum.Http.HeaderResources.TRANSFERS].contentVersion) + const message = Utility.StreamingProtocol.createMessage(TT.transferId, destination, source, metadata, headers, fspiopError, { id: TT.transferId }, `application/vnd.interoperability.${Enum.Http.HeaderResources.TRANSFERS}+json;version=${resourceVersions[Enum.Http.HeaderResources.TRANSFERS].contentVersion}`) + + span.setTags(Utility.EventFramework.getTransferSpanTags({ payload: message.content.payload, headers }, Type.TRANSFER, Action.TIMEOUT_RECEIVED)) + await span.audit({ + state, + metadata, + headers, + message + }, EventSdk.AuditEventAction.start) + + if (TT.bulkTransferId === null) { // regular transfer + if (TT.transferStateId === Enum.Transfers.TransferInternalState.EXPIRED_PREPARED) { + message.from = Config.HUB_NAME + // event & type set above when `const metadata` is initialized to NOTIFICATION / TIMEOUT_RECEIVED + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.NOTIFICATION, + Action.TIMEOUT_RECEIVED, + message, + state, + null, + span + ) + } else if (TT.transferStateId === Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { + message.metadata.event.type = Type.POSITION + message.metadata.event.action = Action.TIMEOUT_RESERVED + // Key position timeouts with payer account id + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.POSITION, + Action.TIMEOUT_RESERVED, + message, + state, + TT.effectedParticipantCurrencyId?.toString(), + span, + Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.TIMEOUT_RESERVED + ) + } + } else { // individual transfer from a bulk + if (TT.transferStateId === Enum.Transfers.TransferInternalState.EXPIRED_PREPARED) { + message.from = Config.HUB_NAME + message.metadata.event.type = Type.BULK_PROCESSING + message.metadata.event.action = Action.BULK_TIMEOUT_RECEIVED + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.BULK_PROCESSING, + Action.BULK_TIMEOUT_RECEIVED, + message, + state, + null, + span + ) + } else if (TT.transferStateId === Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { + message.metadata.event.type = Type.POSITION + message.metadata.event.action = Action.BULK_TIMEOUT_RESERVED + // Key position timeouts with payer account id + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.POSITION, + Action.BULK_TIMEOUT_RESERVED, + message, + state, + TT.payerParticipantCurrencyId?.toString(), + span + ) + } + } + } catch (err) { + logger.error('error in _processTimedOutTransfers:', err) + const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) + const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) + await span.error(fspiopError, state) + await span.finish(fspiopError.message, state) + throw fspiopError + } finally { + if (!span.isFinished) { + await span.finish() + } + } + } +} + +/** + * Processes timedOut fxTransfers + * + * @param {TimedOutFxTransfer[]} fxTransferTimeoutList + * @returns {Promise} + */ +const _processFxTimedOutTransfers = async (fxTransferTimeoutList) => { + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED).toApiErrorObject(Config.ERROR_HANDLING) + if (!Array.isArray(fxTransferTimeoutList)) { + fxTransferTimeoutList = [ + { ...fxTransferTimeoutList } + ] + } + for (const fTT of fxTransferTimeoutList) { + const span = EventSdk.Tracer.createSpan('cl_fx_transfer_timeout') + try { + const state = Utility.StreamingProtocol.createEventState(Enum.Events.EventStatus.FAILURE.status, fspiopError.errorInformation.errorCode, fspiopError.errorInformation.errorDescription) + const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent(fTT.commitRequestId, Enum.Kafka.Topics.NOTIFICATION, Action.TIMEOUT_RECEIVED, state) + const destination = fTT.externalInitiatingFspName || fTT.initiatingFsp + const source = fTT.externalCounterPartyFspName || fTT.counterPartyFsp + const headers = Utility.Http.SwitchDefaultHeaders(destination, Enum.Http.HeaderResources.FX_TRANSFERS, Config.HUB_NAME, resourceVersions[Enum.Http.HeaderResources.FX_TRANSFERS].contentVersion) + const message = Utility.StreamingProtocol.createMessage(fTT.commitRequestId, destination, source, metadata, headers, fspiopError, { id: fTT.commitRequestId }, `application/vnd.interoperability.${Enum.Http.HeaderResources.FX_TRANSFERS}+json;version=${resourceVersions[Enum.Http.HeaderResources.FX_TRANSFERS].contentVersion}`) + + span.setTags(Utility.EventFramework.getTransferSpanTags({ payload: message.content.payload, headers }, Type.FX_TRANSFER, Action.TIMEOUT_RECEIVED)) + await span.audit({ + state, + metadata, + headers, + message + }, EventSdk.AuditEventAction.start) + + if (fTT.transferStateId === Enum.Transfers.TransferInternalState.EXPIRED_PREPARED) { + message.from = Config.HUB_NAME + // event & type set above when `const metadata` is initialized to NOTIFICATION / TIMEOUT_RECEIVED + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.NOTIFICATION, + Action.FX_TIMEOUT_RESERVED, + message, + state, + null, + span + ) + } else if (fTT.transferStateId === Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { + message.metadata.event.type = Type.POSITION + message.metadata.event.action = Action.FX_TIMEOUT_RESERVED + // Key position timeouts with payer account id + await Kafka.produceGeneralMessage( + Config.KAFKA_CONFIG, + Producer, + Enum.Kafka.Topics.POSITION, + Action.FX_TIMEOUT_RESERVED, + message, + state, + fTT.effectedParticipantCurrencyId?.toString(), + span, + Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.FX_TIMEOUT_RESERVED + ) + } + } catch (err) { + logger.error('error in _processFxTimedOutTransfers:', err) + const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) + const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) + await span.error(fspiopError, state) + await span.finish(fspiopError.message, state) + throw fspiopError + } finally { + if (!span.isFinished) { + await span.finish() + } + } + } +} + /** * @function TransferTimeoutHandler * @@ -70,73 +256,31 @@ const timeout = async () => { const segmentId = timeoutSegment ? timeoutSegment.segmentId : 0 const cleanup = await TimeoutService.cleanupTransferTimeout() const latestTransferStateChange = await TimeoutService.getLatestTransferStateChange() + + const fxTimeoutSegment = await TimeoutService.getFxTimeoutSegment() const intervalMax = (latestTransferStateChange && parseInt(latestTransferStateChange.transferStateChangeId)) || 0 - const result = await TimeoutService.timeoutExpireReserved(segmentId, intervalMin, intervalMax) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED).toApiErrorObject(Config.ERROR_HANDLING) - if (!Array.isArray(result)) { - result[0] = result - } - for (let i = 0; i < result.length; i++) { - const span = EventSdk.Tracer.createSpan('cl_transfer_timeout') - try { - const state = Utility.StreamingProtocol.createEventState(Enum.Events.EventStatus.FAILURE.status, fspiopError.errorInformation.errorCode, fspiopError.errorInformation.errorDescription) - const metadata = Utility.StreamingProtocol.createMetadataWithCorrelatedEvent(result[i].transferId, Enum.Kafka.Topics.NOTIFICATION, Enum.Events.Event.Action.TIMEOUT_RECEIVED, state) - const headers = Utility.Http.SwitchDefaultHeaders(result[i].payerFsp, Enum.Http.HeaderResources.TRANSFERS, Enum.Http.Headers.FSPIOP.SWITCH.value, resourceVersions[Enum.Http.HeaderResources.TRANSFERS].contentVersion) - const message = Utility.StreamingProtocol.createMessage(result[i].transferId, result[i].payeeFsp, result[i].payerFsp, metadata, headers, fspiopError, { id: result[i].transferId }, `application/vnd.interoperability.${Enum.Http.HeaderResources.TRANSFERS}+json;version=${resourceVersions[Enum.Http.HeaderResources.TRANSFERS].contentVersion}`) - span.setTags(Utility.EventFramework.getTransferSpanTags({ payload: message.content.payload, headers }, Enum.Events.Event.Type.TRANSFER, Enum.Events.Event.Action.TIMEOUT_RECEIVED)) - await span.audit({ - state, - metadata, - headers, - message - }, EventSdk.AuditEventAction.start) - if (result[i].bulkTransferId === null) { // regular transfer - if (result[i].transferStateId === Enum.Transfers.TransferInternalState.EXPIRED_PREPARED) { - message.to = message.from - message.from = Enum.Http.Headers.FSPIOP.SWITCH.value - // event & type set above when `const metadata` is initialized to NOTIFICATION / TIMEOUT_RECEIVED - await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Kafka.Topics.NOTIFICATION, Enum.Events.Event.Action.TIMEOUT_RECEIVED, message, state, null, span) - } else if (result[i].transferStateId === Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { - message.metadata.event.type = Enum.Events.Event.Type.POSITION - message.metadata.event.action = Enum.Events.Event.Action.TIMEOUT_RESERVED - // Key position timeouts with payer account id - await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Kafka.Topics.POSITION, Enum.Events.Event.Action.TIMEOUT_RESERVED, message, state, result[i].payerParticipantCurrencyId?.toString(), span) - } - } else { // individual transfer from a bulk - if (result[i].transferStateId === Enum.Transfers.TransferInternalState.EXPIRED_PREPARED) { - message.to = message.from - message.from = Enum.Http.Headers.FSPIOP.SWITCH.value - message.metadata.event.type = Enum.Events.Event.Type.BULK_PROCESSING - message.metadata.event.action = Enum.Events.Event.Action.BULK_TIMEOUT_RECEIVED - await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Kafka.Topics.BULK_PROCESSING, Enum.Events.Event.Action.BULK_TIMEOUT_RECEIVED, message, state, null, span) - } else if (result[i].transferStateId === Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT) { - message.metadata.event.type = Enum.Events.Event.Type.POSITION - message.metadata.event.action = Enum.Events.Event.Action.BULK_TIMEOUT_RESERVED - // Key position timeouts with payer account id - await Kafka.produceGeneralMessage(Config.KAFKA_CONFIG, Producer, Enum.Kafka.Topics.POSITION, Enum.Events.Event.Action.BULK_TIMEOUT_RESERVED, message, state, result[i].payerParticipantCurrencyId?.toString(), span) - } - } - } catch (err) { - Logger.isErrorEnabled && Logger.error(err) - const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) - const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) - await span.error(fspiopError, state) - await span.finish(fspiopError.message, state) - throw fspiopError - } finally { - if (!span.isFinished) { - await span.finish() - } - } - } + const fxIntervalMin = fxTimeoutSegment ? fxTimeoutSegment.value : 0 + const fxSegmentId = fxTimeoutSegment ? fxTimeoutSegment.segmentId : 0 + const fxCleanup = await TimeoutService.cleanupFxTransferTimeout() + const latestFxTransferStateChange = await TimeoutService.getLatestFxTransferStateChange() + const fxIntervalMax = (latestFxTransferStateChange && parseInt(latestFxTransferStateChange.fxTransferStateChangeId)) || 0 + + const { transferTimeoutList, fxTransferTimeoutList } = await TimeoutService.timeoutExpireReserved(segmentId, intervalMin, intervalMax, fxSegmentId, fxIntervalMin, fxIntervalMax) + transferTimeoutList && await _processTimedOutTransfers(transferTimeoutList) + fxTransferTimeoutList && await _processFxTimedOutTransfers(fxTransferTimeoutList) + return { intervalMin, cleanup, intervalMax, - result + fxIntervalMin, + fxCleanup, + fxIntervalMax, + transferTimeoutList, + fxTransferTimeoutList } } catch (err) { - Logger.isErrorEnabled && Logger.error(err) + logger.error('error in timeout:', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } finally { running = false @@ -192,7 +336,7 @@ const registerTimeoutHandler = async () => { await timeoutJob.start() return true } catch (err) { - Logger.isErrorEnabled && Logger.error(err) + logger.error('error in registerTimeoutHandler:', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -212,7 +356,7 @@ const registerAllHandlers = async () => { } return true } catch (err) { - Logger.isErrorEnabled && Logger.error(err) + logger.error('error in registerAllHandlers:', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } diff --git a/src/handlers/transfers/FxFulfilService.js b/src/handlers/transfers/FxFulfilService.js new file mode 100644 index 000000000..9e7839733 --- /dev/null +++ b/src/handlers/transfers/FxFulfilService.js @@ -0,0 +1,390 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +/* eslint-disable space-before-function-paren */ +const { Enum, Util } = require('@mojaloop/central-services-shared') +const cyril = require('../../domain/fx/cyril') +const TransferObjectTransform = require('../../domain/transfer/transform') +const fspiopErrorFactory = require('../../shared/fspiopErrorFactory') +const ErrorHandler = require('@mojaloop/central-services-error-handling') + +const { Type, Action } = Enum.Events.Event +const { SOURCE, DESTINATION } = Enum.Http.Headers.FSPIOP +const { TransferState, TransferInternalState } = Enum.Transfers + +const consumerCommit = true +const fromSwitch = true + +class FxFulfilService { + // #state = null + + constructor(deps) { + this.log = deps.log + this.Config = deps.Config + this.Comparators = deps.Comparators + this.Validator = deps.Validator + this.FxTransferModel = deps.FxTransferModel + this.Kafka = deps.Kafka + this.params = deps.params + this.cyril = deps.cyril || cyril + this.transform = deps.transform || TransferObjectTransform + } + + async getFxTransferDetails(commitRequestId, functionality) { + const fxTransfer = await this.FxTransferModel.fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer(commitRequestId) + + if (!fxTransfer) { + const fspiopError = fspiopErrorFactory.fxTransferNotFound() + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: Action.FX_RESERVE + } + this.log.warn('fxTransfer not found', { commitRequestId, eventDetail, apiFSPIOPError }) + + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + throw fspiopError + } + + this.log.debug('fxTransfer is found', { fxTransfer }) + return fxTransfer + } + + async validateHeaders({ transfer, headers, payload }) { + let fspiopError = null + + if (!transfer.counterPartyFspIsProxy && (headers[SOURCE]?.toLowerCase() !== transfer.counterPartyFspName.toLowerCase())) { + fspiopError = fspiopErrorFactory.fxHeaderSourceValidationError() + } + if (!transfer.initiatingFspIsProxy && (headers[DESTINATION]?.toLowerCase() !== transfer.initiatingFspName.toLowerCase())) { + fspiopError = fspiopErrorFactory.fxHeaderDestinationValidationError() + } + + if (fspiopError) { + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality: Type.POSITION, + action: Action.FX_ABORT_VALIDATION + } + this.log.warn('headers validation error', { eventDetail, apiFSPIOPError }) + + // Lets handle the abort validation and change the fxTransfer state to reflect this + await this.FxTransferModel.fxTransfer.saveFxFulfilResponse(transfer.commitRequestId, payload, eventDetail.action, apiFSPIOPError) + + await this._handleAbortValidation(transfer, apiFSPIOPError, eventDetail) + throw fspiopError + } + } + + async _handleAbortValidation(fxTransfer, apiFSPIOPError, eventDetail) { + const cyrilResult = await this.cyril.processFxAbortMessage(fxTransfer.commitRequestId) + + this.params.message.value.content.context = { + ...this.params.message.value.content.context, + cyrilResult + } + if (cyrilResult.positionChanges.length > 0) { + const participantCurrencyId = cyrilResult.positionChanges[0].participantCurrencyId + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch, + toDestination: fxTransfer.externalInitiatingFspName || fxTransfer.initiatingFspName, + messageKey: participantCurrencyId.toString(), + topicNameOverride: this.Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.FX_ABORT + }) + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('Invalid cyril result') + throw fspiopError + } + } + + async getDuplicateCheckResult({ commitRequestId, payload, action }) { + const { duplicateCheck } = this.FxTransferModel + const isFxTransferError = action === Action.FX_ABORT + + const getDuplicateFn = isFxTransferError + ? duplicateCheck.getFxTransferErrorDuplicateCheck + : duplicateCheck.getFxTransferFulfilmentDuplicateCheck + const saveHashFn = isFxTransferError + ? duplicateCheck.saveFxTransferErrorDuplicateCheck + : duplicateCheck.saveFxTransferFulfilmentDuplicateCheck + + return this.Comparators.duplicateCheckComparator( + commitRequestId, + payload, + getDuplicateFn, + saveHashFn + ) + } + + async checkDuplication({ dupCheckResult, transfer, functionality, action, type }) { + const transferStateEnum = transfer?.transferStateEnumeration + this.log.info('fxTransfer checkDuplication...', { dupCheckResult, action, transferStateEnum }) + + if (!dupCheckResult.hasDuplicateId) { + this.log.debug('No duplication found') + return false + } + + if (!dupCheckResult.hasDuplicateHash) { + // ERROR: We've seen fxTransfer of this ID before, but it's message hash doesn't match the previous message hash. + const fspiopError = fspiopErrorFactory.noFxDuplicateHash() + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: action === Action.FX_ABORT ? Action.FX_ABORT_DUPLICATE : Action.FX_FULFIL_DUPLICATE + } + this.log.warn('callbackErrorModified - no hasDuplicateHash', { eventDetail, apiFSPIOPError }) + + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + throw fspiopError + } + + // This is a duplicate message for a fxTransfer that is already in a finalized state + // respond as if we received a GET /fxTransfers/{ID} from the client + if ([TransferState.COMMITTED, TransferState.ABORTED].includes(transferStateEnum)) { + this.params.message.value.content.payload = this.transform.toFulfil(transfer) + const eventDetail = { + functionality, + action: action === Action.FX_ABORT ? Action.FX_ABORT_DUPLICATE : Action.FX_FULFIL_DUPLICATE + } + this.log.info('eventDetail:', { eventDetail }) + await this.kafkaProceed({ consumerCommit, eventDetail, fromSwitch }) + return true + } + + if ([TransferState.RECEIVED, TransferState.RESERVED].includes(transferStateEnum)) { + this.log.info('state: RECEIVED or RESERVED') + await this.kafkaProceed({ consumerCommit }) + // this code doesn't publish any message to kafka, coz we don't provide eventDetail: + // https://github.com/mojaloop/central-services-shared/blob/main/src/util/kafka/index.js#L315 + return true + } + + // Error scenario - fxTransfer.transferStateEnumeration is in some invalid state + const fspiopError = fspiopErrorFactory.invalidFxTransferState({ transferStateEnum, action, type }) + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: Action.FX_RESERVE + } + this.log.warn('callbackErrorInvalidTransferStateEnum', { eventDetail, apiFSPIOPError }) + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + + return true + } + + async validateEventType(type, functionality) { + if (type !== Type.FULFIL) { + const fspiopError = fspiopErrorFactory.invalidEventType(type) + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: Action.FX_RESERVE + } + this.log.warn('callbackErrorInvalidEventType', { type, eventDetail, apiFSPIOPError }) + + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + throw fspiopError + } + this.log.debug('validateEventType is passed', { type, functionality }) + } + + async validateFulfilment(fxTransfer, payload) { + const isValid = this.validateFulfilCondition(payload.fulfilment, fxTransfer.ilpCondition) + + if (!isValid) { + const fspiopError = fspiopErrorFactory.fxInvalidFulfilment() + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality: Type.POSITION, + action: Action.FX_ABORT_VALIDATION + } + this.log.warn('callbackErrorInvalidFulfilment', { eventDetail, apiFSPIOPError, fxTransfer, payload }) + await this.FxTransferModel.fxTransfer.saveFxFulfilResponse(fxTransfer.commitRequestId, payload, eventDetail.action, apiFSPIOPError) + + await this._handleAbortValidation(fxTransfer, apiFSPIOPError, eventDetail) + throw fspiopError + } + + this.log.info('fulfilmentCheck passed successfully', { isValid }) + return isValid + } + + async validateTransferState(transfer, functionality) { + if (transfer.transferState !== TransferInternalState.RESERVED && + transfer.transferState !== TransferInternalState.RESERVED_FORWARDED) { + const fspiopError = fspiopErrorFactory.fxTransferNonReservedState() + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: Action.FX_RESERVE + } + this.log.warn('callbackErrorNonReservedState', { eventDetail, apiFSPIOPError, transfer }) + + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + throw fspiopError + } + this.log.debug('validateTransferState is passed') + return true + } + + async validateExpirationDate(transfer, functionality) { + if (transfer.expirationDate <= new Date(Util.Time.getUTCString(new Date()))) { + const fspiopError = fspiopErrorFactory.fxTransferExpired() + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality, + action: Action.FX_RESERVE + } + this.log.warn('callbackErrorTransferExpired', { eventDetail, apiFSPIOPError }) + + await this.kafkaProceed({ + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + fromSwitch + }) + throw fspiopError + } + } + + async processFxAbort({ transfer, payload, action }) { + const fspiopError = fspiopErrorFactory.fromErrorInformation(payload.errorInformation) + const apiFSPIOPError = fspiopError.toApiErrorObject(this.Config.ERROR_HANDLING) + const eventDetail = { + functionality: Type.POSITION, + action // FX_ABORT + } + this.log.warn('FX_ABORT case', { eventDetail, apiFSPIOPError }) + + await this.FxTransferModel.fxTransfer.saveFxFulfilResponse(transfer.commitRequestId, payload, action, apiFSPIOPError) + const cyrilResult = await this.cyril.processFxAbortMessage(transfer.commitRequestId) + + this.params.message.value.content.context = { + ...this.params.message.value.content.context, + cyrilResult + } + if (cyrilResult.positionChanges.length > 0) { + const participantCurrencyId = cyrilResult.positionChanges[0].participantCurrencyId + await this.kafkaProceed({ + consumerCommit, + eventDetail, + messageKey: participantCurrencyId.toString(), + topicNameOverride: this.Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.FX_ABORT + }) + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('Invalid cyril result') + throw fspiopError + } + return true + } + + async processFxFulfil({ transfer, payload, action }) { + await this.FxTransferModel.fxTransfer.saveFxFulfilResponse(transfer.commitRequestId, payload, action) + await this.cyril.processFxFulfilMessage(transfer.commitRequestId) + const eventDetail = { + functionality: Type.POSITION, + action + } + this.log.info('handle fxFulfilResponse', { eventDetail }) + + await this.kafkaProceed({ + consumerCommit, + eventDetail, + messageKey: transfer.counterPartyFspSourceParticipantCurrencyId.toString(), + topicNameOverride: this.Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.COMMIT + }) + return true + } + + async kafkaProceed(kafkaOpts) { + return this.Kafka.proceed(this.Config.KAFKA_CONFIG, this.params, { + ...kafkaOpts, + hubName: this.Config.HUB_NAME + }) + } + + validateFulfilCondition(fulfilment, condition) { + try { + const isValid = fulfilment && this.Validator.validateFulfilCondition(fulfilment, condition) + this.log.debug('validateFulfilCondition result:', { isValid, fulfilment, condition }) + return isValid + } catch (err) { + this.log.warn(`validateFulfilCondition error: ${err?.message}`, { fulfilment, condition }) + return false + } + } + + static decodeKafkaMessage(message) { + if (!message?.value) { + throw TypeError('Invalid message format!') + } + const payload = Util.StreamingProtocol.decodePayload(message.value.content.payload) + const { headers } = message.value.content + const { type, action } = message.value.metadata.event + const commitRequestId = message.value.content.uriParams.id + + return Object.freeze({ + payload, + headers, + type, + action, + commitRequestId, + kafkaTopic: message.topic + }) + } +} + +module.exports = FxFulfilService diff --git a/src/handlers/transfers/createRemittanceEntity.js b/src/handlers/transfers/createRemittanceEntity.js new file mode 100644 index 000000000..609c7f6a8 --- /dev/null +++ b/src/handlers/transfers/createRemittanceEntity.js @@ -0,0 +1,141 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const fxTransferModel = require('../../models/fxTransfer') +const TransferService = require('../../domain/transfer') +const cyril = require('../../domain/fx/cyril') +const { logger } = require('../../shared/logger') + +/** @import { ProxyObligation } from './prepare.js' */ + +// abstraction on transfer and fxTransfer +const createRemittanceEntity = (isFx) => { + return { + isFx, + + async getDuplicate (id) { + return isFx + ? fxTransferModel.duplicateCheck.getFxTransferDuplicateCheck(id) + : TransferService.getTransferDuplicateCheck(id) + }, + async saveDuplicateHash (id, hash) { + return isFx + ? fxTransferModel.duplicateCheck.saveFxTransferDuplicateCheck(id, hash) + : TransferService.saveTransferDuplicateCheck(id, hash) + }, + + /** + * Saves prepare transfer/fxTransfer details to DB. + * + * @param {Object} payload - Message payload. + * @param {string | null} reason - Validation failure reasons. + * @param {Boolean} isValid - isValid. + * @param {DeterminingTransferCheckResult} determiningTransferCheckResult - The determining transfer check result. + * @param {ProxyObligation} proxyObligation - The proxy obligation + * @returns {Promise} + */ + async savePreparedRequest ( + payload, + reason, + isValid, + determiningTransferCheckResult, + proxyObligation + ) { + return isFx + ? fxTransferModel.fxTransfer.savePreparedRequest( + payload, + reason, + isValid, + determiningTransferCheckResult, + proxyObligation + ) + : TransferService.prepare( + payload, + reason, + isValid, + determiningTransferCheckResult, + proxyObligation + ) + }, + + async getByIdLight (id) { + return isFx + ? fxTransferModel.fxTransfer.getByIdLight(id) + : TransferService.getByIdLight(id) + }, + + /** + * @typedef {Object} DeterminingTransferCheckResult + * + * @property {boolean} determiningTransferExists - Indicates if the determining transfer exists. + * @property {Array<{participantName, currencyId}>} participantCurrencyValidationList - List of validations for participant currencies. + * @property {Object} [transferRecord] - Determining transfer for the FX transfer (optional). + * @property {Array} [watchListRecords] - Records from fxWatchList-table for the transfer (optional). + */ + /** + * Checks if a determining transfer exists based on the payload and proxy obligation. + * The function determines which method to use based on whether it is an FX transfer. + * + * @param {Object} payload - The payload data required for the transfer check. + * @param {ProxyObligation} proxyObligation - The proxy obligation details. + * @returns {DeterminingTransferCheckResult} determiningTransferCheckResult + */ + async checkIfDeterminingTransferExists (payload, proxyObligation) { + const result = isFx + ? await cyril.checkIfDeterminingTransferExistsForFxTransferMessage(payload, proxyObligation) + : await cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, proxyObligation) + + logger.debug('cyril determiningTransferCheckResult:', { result }) + return result + }, + + async getPositionParticipant (payload, determiningTransferCheckResult, proxyObligation) { + const result = isFx + ? await cyril.getParticipantAndCurrencyForFxTransferMessage(payload, determiningTransferCheckResult) + : await cyril.getParticipantAndCurrencyForTransferMessage(payload, determiningTransferCheckResult, proxyObligation) + + logger.debug('cyril getPositionParticipant result:', { result }) + return result + }, + + async logTransferError (id, errorCode, errorDescription) { + return isFx + ? fxTransferModel.stateChange.logTransferError(id, errorCode, errorDescription) + : TransferService.logTransferError(id, errorCode, errorDescription) + } + } +} + +module.exports = createRemittanceEntity diff --git a/src/handlers/transfers/dto.js b/src/handlers/transfers/dto.js new file mode 100644 index 000000000..1f1edcd41 --- /dev/null +++ b/src/handlers/transfers/dto.js @@ -0,0 +1,53 @@ +const { Util, Enum } = require('@mojaloop/central-services-shared') +const { PROM_METRICS } = require('../../shared/constants') + +const { decodePayload } = Util.StreamingProtocol +const { Action, Type } = Enum.Events.Event + +const prepareInputDto = (error, messages) => { + if (error || !messages) { + return { + error, + metric: PROM_METRICS.transferPrepare() + } + } + + const message = Array.isArray(messages) ? messages[0] : messages + if (!message) throw new Error('No input kafka message') + + const payload = decodePayload(message.value.content.payload) + const isFx = !payload.transferId + + const { action } = message.value.metadata.event + const isForwarded = [Action.FORWARDED, Action.FX_FORWARDED].includes(action) + const isPrepare = [Action.PREPARE, Action.FX_PREPARE, Action.FORWARDED, Action.FX_FORWARDED].includes(action) + + const actionLetter = isPrepare + ? Enum.Events.ActionLetter.prepare + : (action === Action.BULK_PREPARE + ? Enum.Events.ActionLetter.bulkPrepare + : Enum.Events.ActionLetter.unknown) + + const functionality = isPrepare + ? Type.NOTIFICATION + : (action === Action.BULK_PREPARE + ? Type.BULK_PROCESSING + : Enum.Events.ActionLetter.unknown) + + return { + message, + payload, + action, + functionality, + isFx, + isForwarded, + ID: payload.transferId || payload.commitRequestId || message.value.id, + headers: message.value.content.headers, + metric: PROM_METRICS.transferPrepare(isFx, isForwarded), + actionLetter // just for logging + } +} + +module.exports = { + prepareInputDto +} diff --git a/src/handlers/transfers/handler.js b/src/handlers/transfers/handler.js index c0e85c388..1eff070ea 100644 --- a/src/handlers/transfers/handler.js +++ b/src/handlers/transfers/handler.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -40,214 +40,85 @@ const Logger = require('@mojaloop/central-services-logger') const EventSdk = require('@mojaloop/event-sdk') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const { Enum, Util } = require('@mojaloop/central-services-shared') +const { Consumer, Producer } = require('@mojaloop/central-services-stream').Util + +const { logger } = require('../../shared/logger') +const { ERROR_MESSAGES } = require('../../shared/constants') +const Config = require('../../lib/config') const TransferService = require('../../domain/transfer') -const Util = require('@mojaloop/central-services-shared').Util -const Kafka = require('@mojaloop/central-services-shared').Util.Kafka -const Producer = require('@mojaloop/central-services-stream').Util.Producer -const Consumer = require('@mojaloop/central-services-stream').Util.Consumer +const FxService = require('../../domain/fx') +const FxTransferModel = require('../../models/fxTransfer') +const TransferObjectTransform = require('../../domain/transfer/transform') +const Participant = require('../../domain/participant') const Validator = require('./validator') -const Enum = require('@mojaloop/central-services-shared').Enum +const FxFulfilService = require('./FxFulfilService') + +// particular handlers +const { prepare } = require('./prepare') + +const { Kafka, Comparators } = Util const TransferState = Enum.Transfers.TransferState const TransferEventType = Enum.Events.Event.Type const TransferEventAction = Enum.Events.Event.Action -const TransferObjectTransform = require('../../domain/transfer/transform') -const Metrics = require('@mojaloop/central-services-metrics') -const Config = require('../../lib/config') const decodePayload = Util.StreamingProtocol.decodePayload -const Comparators = require('@mojaloop/central-services-shared').Util.Comparators -const ErrorHandler = require('@mojaloop/central-services-error-handling') -const Participant = require('../../domain/participant') const consumerCommit = true const fromSwitch = true -/** - * @function TransferPrepareHandler - * - * @async - * @description This is the consumer callback function that gets registered to a topic. This then gets a list of messages, - * we will only ever use the first message in non batch processing. We then break down the message into its payload and - * begin validating the payload. Once the payload is validated successfully it will be written to the database to - * the relevant tables. If the validation fails it is still written to the database for auditing purposes but with an - * INVALID status. For any duplicate requests we will send appropriate callback based on the transfer state and the hash validation - * - * Validator.validatePrepare called to validate the payload of the message - * TransferService.getById called to get the details of the existing transfer - * TransferObjectTransform.toTransfer called to transform the transfer object - * TransferService.prepare called and creates new entries in transfer tables for successful prepare transfer - * TransferService.logTransferError called to log the invalid request - * - * @param {error} error - error thrown if something fails within Kafka - * @param {array} messages - a list of messages to consume for the relevant topic - * - * @returns {object} - Returns a boolean: true if successful, or throws and error if failed - */ -const prepare = async (error, messages) => { - const location = { module: 'PrepareHandler', method: '', path: '' } - const histTimerEnd = Metrics.getHistogram( - 'transfer_prepare', - 'Consume a prepare transfer message from the kafka topic and process it accordingly', - ['success', 'fspId'] - ).startTimer() +const fulfil = async (error, messages) => { if (error) { - histTimerEnd({ success: false, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) throw ErrorHandler.Factory.reformatFSPIOPError(error) } - let message = {} + let message if (Array.isArray(messages)) { message = messages[0] } else { message = messages } - const parentSpanService = 'cl_transfer_prepare' const contextFromMessage = EventSdk.Tracer.extractContextFromMessage(message.value) - const span = EventSdk.Tracer.createChildSpanFromContext(parentSpanService, contextFromMessage) + const span = EventSdk.Tracer.createChildSpanFromContext('cl_transfer_fulfil', contextFromMessage) try { - const payload = decodePayload(message.value.content.payload) - const headers = message.value.content.headers - const action = message.value.metadata.event.action - const transferId = payload.transferId - span.setTags({ transactionId: transferId }) await span.audit(message, EventSdk.AuditEventAction.start) - const kafkaTopic = message.topic - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { method: 'prepare' })) - - const actionLetter = action === TransferEventAction.PREPARE - ? Enum.Events.ActionLetter.prepare - : (action === TransferEventAction.BULK_PREPARE - ? Enum.Events.ActionLetter.bulkPrepare - : Enum.Events.ActionLetter.unknown) - - let functionality = action === TransferEventAction.PREPARE - ? TransferEventType.NOTIFICATION - : (action === TransferEventAction.BULK_PREPARE - ? TransferEventType.BULK_PROCESSING - : Enum.Events.ActionLetter.unknown) - const params = { message, kafkaTopic, decodedPayload: payload, span, consumer: Consumer, producer: Producer } - - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'dupCheck' })) - const histTimerDuplicateCheckEnd = Metrics.getHistogram( - 'handler_transfers', - 'prepare_duplicateCheckComparator - Metrics for transfer handler', - ['success', 'funcName'] - ).startTimer() - - const { hasDuplicateId, hasDuplicateHash } = await Comparators.duplicateCheckComparator(transferId, payload, TransferService.getTransferDuplicateCheck, TransferService.saveTransferDuplicateCheck) - histTimerDuplicateCheckEnd({ success: true, funcName: 'prepare_duplicateCheckComparator' }) - if (hasDuplicateId && hasDuplicateHash) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'handleResend')) - const transfer = await TransferService.getByIdLight(transferId) - const transferStateEnum = transfer && transfer.transferStateEnumeration - const eventDetail = { functionality, action: TransferEventAction.PREPARE_DUPLICATE } - if ([TransferState.COMMITTED, TransferState.ABORTED].includes(transferStateEnum)) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'finalized')) - if (action === TransferEventAction.PREPARE) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callback--${actionLetter}1`)) - message.value.content.payload = TransferObjectTransform.toFulfil(transfer) - message.value.content.uriParams = { id: transferId } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch }) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } else if (action === TransferEventAction.BULK_PREPARE) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `validationError1--${actionLetter}2`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST, 'Individual transfer prepare duplicate') - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } - } else { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'inProgress')) - if (action === TransferEventAction.BULK_PREPARE) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `validationError2--${actionLetter}4`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST, 'Individual transfer prepare duplicate') - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } else { // action === TransferEventAction.PREPARE - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `ignore--${actionLetter}3`)) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit }) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } - } - } else if (hasDuplicateId && !hasDuplicateHash) { - Logger.isErrorEnabled && Logger.error(Util.breadcrumb(location, `callbackErrorModified1--${actionLetter}5`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST) - const eventDetail = { functionality, action } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } else { // !hasDuplicateId - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) - if (validationPassed) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'validationPassed' })) - try { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'saveTransfer')) - await TransferService.prepare(payload) - } catch (err) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInternal1--${actionLetter}6`)) - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) - const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err, ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR) - const eventDetail = { functionality, action: TransferEventAction.PREPARE } - /** - * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) - * HOWTO: Stop execution at the `TransferService.prepare`, stop mysql, - * continue execution to catch block, start mysql - */ - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic1--${actionLetter}7`)) - functionality = TransferEventType.POSITION - const eventDetail = { functionality, action } - // Key position prepare message with payer account id - const payerAccount = await Participant.getAccountByNameAndCurrency(payload.payerFsp, payload.amount.currency, Enum.Accounts.LedgerAccountType.POSITION) - // We route bulk-prepare and prepare messages differently based on the topic configured for it. - // Note: The batch handler does not currently support bulk-prepare messages, only prepare messages are supported. - // Therefore, it is necessary to check the action to determine the topic to route to. - const topicNameOverride = - action === TransferEventAction.BULK_PREPARE - ? Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.BULK_PREPARE - : Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.PREPARE - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, messageKey: payerAccount.participantCurrencyId.toString(), topicNameOverride }) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } else { - Logger.isErrorEnabled && Logger.error(Util.breadcrumb(location, { path: 'validationFailed' })) - try { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'saveInvalidRequest')) - await TransferService.prepare(payload, reasons.toString(), false) - } catch (err) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInternal2--${actionLetter}8`)) - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) - const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err, ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR) - const eventDetail = { functionality, action: TransferEventAction.PREPARE } - /** - * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) - * HOWTO: For regular transfers this branch may be triggered by sending - * a transfer in a currency not supported by either dfsp and also stopping - * mysql at `TransferService.prepare` and starting it after entring catch. - * Not sure if it will work for bulk, because of the BulkPrepareHandler. - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorGeneric--${actionLetter}9`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, reasons.toString()) - await TransferService.logTransferError(transferId, ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR.code, reasons.toString()) - const eventDetail = { functionality, action } - /** - * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) - * HOWTO: For regular transfers this branch may be triggered by sending - * a tansfer in a currency not supported by either dfsp. Not sure if it - * will be triggered for bulk, because of the BulkPrepareHandler. - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError + const action = message.value.metadata.event.action + + const functionality = (() => { + switch (action) { + case TransferEventAction.COMMIT: + case TransferEventAction.FX_COMMIT: + case TransferEventAction.RESERVE: + case TransferEventAction.FX_RESERVE: + case TransferEventAction.REJECT: + case TransferEventAction.FX_REJECT: + case TransferEventAction.ABORT: + case TransferEventAction.FX_ABORT: + return TransferEventType.NOTIFICATION + case TransferEventAction.BULK_COMMIT: + case TransferEventAction.BULK_ABORT: + return TransferEventType.BULK_PROCESSING + default: return Enum.Events.ActionLetter.unknown } + })() + logger.info('FulfilHandler start:', { action, functionality }) + + const fxActions = [ + TransferEventAction.FX_COMMIT, + TransferEventAction.FX_RESERVE, + TransferEventAction.FX_REJECT, + TransferEventAction.FX_ABORT, + TransferEventAction.FX_FORWARDED + ] + + if (fxActions.includes(action)) { + return await processFxFulfilMessage(message, functionality, span) + } else { + return await processFulfilMessage(message, functionality, span) } } catch (err) { - histTimerEnd({ success: false, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + logger.error(`error in FulfilHandler: ${err?.message}`, { err }) const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}--P0`) const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) await span.error(fspiopError, state) await span.finish(fspiopError.message, state) @@ -259,107 +130,82 @@ const prepare = async (error, messages) => { } } -const fulfil = async (error, messages) => { +const processFulfilMessage = async (message, functionality, span) => { const location = { module: 'FulfilHandler', method: '', path: '' } const histTimerEnd = Metrics.getHistogram( 'transfer_fulfil', 'Consume a fulfil transfer message from the kafka topic and process it accordingly', ['success', 'fspId'] ).startTimer() - if (error) { - throw ErrorHandler.Factory.reformatFSPIOPError(error) - } - let message = {} - if (Array.isArray(messages)) { - message = messages[0] - } else { - message = messages - } - const contextFromMessage = EventSdk.Tracer.extractContextFromMessage(message.value) - const span = EventSdk.Tracer.createChildSpanFromContext('cl_transfer_fulfil', contextFromMessage) - try { - await span.audit(message, EventSdk.AuditEventAction.start) - const payload = decodePayload(message.value.content.payload) - const headers = message.value.content.headers - const type = message.value.metadata.event.type - const action = message.value.metadata.event.action - const transferId = message.value.content.uriParams.id - const kafkaTopic = message.topic - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { method: `fulfil:${action}` })) - - const actionLetter = (() => { - switch (action) { - case TransferEventAction.COMMIT: return Enum.Events.ActionLetter.commit - case TransferEventAction.RESERVE: return Enum.Events.ActionLetter.reserve - case TransferEventAction.REJECT: return Enum.Events.ActionLetter.reject - case TransferEventAction.ABORT: return Enum.Events.ActionLetter.abort - case TransferEventAction.BULK_COMMIT: return Enum.Events.ActionLetter.bulkCommit - case TransferEventAction.BULK_ABORT: return Enum.Events.ActionLetter.bulkAbort - default: return Enum.Events.ActionLetter.unknown - } - })() - - const functionality = (() => { - switch (action) { - case TransferEventAction.COMMIT: - case TransferEventAction.RESERVE: - case TransferEventAction.REJECT: - case TransferEventAction.ABORT: - return TransferEventType.NOTIFICATION - case TransferEventAction.BULK_COMMIT: - case TransferEventAction.BULK_ABORT: - return TransferEventType.BULK_PROCESSING - default: return Enum.Events.ActionLetter.unknown - } - })() - // fulfil-specific declarations - const isTransferError = action === TransferEventAction.ABORT - const params = { message, kafkaTopic, decodedPayload: payload, span, consumer: Consumer, producer: Producer } + const payload = decodePayload(message.value.content.payload) + const headers = message.value.content.headers + const type = message.value.metadata.event.type + const action = message.value.metadata.event.action + const transferId = message.value.content.uriParams.id + const kafkaTopic = message.topic + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { method: `fulfil:${action}` })) - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'getById' })) - - // We fail early and silently to allow timeout handler abort transfer - // if 'RESERVED' transfer state is sent in with v1.0 content-type - if (headers['content-type'].split('=')[1] === '1.0' && payload.transferState === TransferState.RESERVED) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `failSilentlyforReservedStateWith1.0ContentType--${actionLetter}0`)) - const errorMessage = 'action "RESERVE" is not allowed in fulfil handler for v1.0 clients.' - Logger.isErrorEnabled && Logger.error(errorMessage) - !!span && span.error(errorMessage) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true + const actionLetter = (() => { + switch (action) { + case TransferEventAction.COMMIT: return Enum.Events.ActionLetter.commit + case TransferEventAction.RESERVE: return Enum.Events.ActionLetter.reserve + case TransferEventAction.REJECT: return Enum.Events.ActionLetter.reject + case TransferEventAction.ABORT: return Enum.Events.ActionLetter.abort + case TransferEventAction.BULK_COMMIT: return Enum.Events.ActionLetter.bulkCommit + case TransferEventAction.BULK_ABORT: return Enum.Events.ActionLetter.bulkAbort + default: return Enum.Events.ActionLetter.unknown } + })() + + // We fail early and silently to allow timeout handler abort transfer + // if 'RESERVED' transfer state is sent in with v1.0 content-type + if (headers['content-type'].split('=')[1] === '1.0' && payload.transferState === TransferState.RESERVED) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `failSilentlyforReservedStateWith1.0ContentType--${actionLetter}0`)) + const errorMessage = 'action "RESERVE" is not allowed in fulfil handler for v1.0 clients.' + Logger.isErrorEnabled && Logger.error(errorMessage) + !!span && span.error(errorMessage) + return true + } - const transfer = await TransferService.getById(transferId) - const transferStateEnum = transfer && transfer.transferStateEnumeration - - // List of valid actions that Source & Destination headers should be checked - const validActionsForRouteValidations = [ - TransferEventAction.COMMIT, - TransferEventAction.RESERVE, - TransferEventAction.REJECT, - TransferEventAction.ABORT - ] - - if (!transfer) { - Logger.isErrorEnabled && Logger.error(Util.breadcrumb(location, `callbackInternalServerErrorNotFound--${actionLetter}1`)) - const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('transfer not found') - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) - * HOWTO: The list of individual transfers being committed should contain - * non-existing transferId - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - - // Lets validate FSPIOP Source & Destination Headers - } else if ( - validActionsForRouteValidations.includes(action) && // Lets only check headers for specific actions that need checking (i.e. bulk should not since its already done elsewhere) - ( - (headers[Enum.Http.Headers.FSPIOP.SOURCE] && (headers[Enum.Http.Headers.FSPIOP.SOURCE].toLowerCase() !== transfer.payeeFsp.toLowerCase())) || - (headers[Enum.Http.Headers.FSPIOP.DESTINATION] && (headers[Enum.Http.Headers.FSPIOP.DESTINATION].toLowerCase() !== transfer.payerFsp.toLowerCase())) - ) + // fulfil-specific declarations + const isTransferError = action === TransferEventAction.ABORT + const params = { message, kafkaTopic, decodedPayload: payload, span, consumer: Consumer, producer: Producer } + + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'getById' })) + + const transfer = await TransferService.getById(transferId) + const transferStateEnum = transfer && transfer.transferStateEnumeration + + // List of valid actions that Source & Destination headers should be checked + const validActionsForRouteValidations = [ + TransferEventAction.COMMIT, + TransferEventAction.RESERVE, + TransferEventAction.REJECT, + TransferEventAction.ABORT + ] + + if (!transfer) { + Logger.isErrorEnabled && Logger.error(Util.breadcrumb(location, `callbackInternalServerErrorNotFound--${actionLetter}1`)) + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('transfer not found') + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) + * HOWTO: The list of individual transfers being committed should contain + * non-existing transferId + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + + // Lets validate FSPIOP Source & Destination Headers + // In interscheme scenario, we store proxy fsp id in transferParticipant table and hence we can't compare that data with fspiop headers in fulfil + } else if ( + validActionsForRouteValidations.includes(action) // Lets only check headers for specific actions that need checking (i.e. bulk should not since its already done elsewhere) + ) { + // Check if the payerFsp and payeeFsp are proxies and if they are, skip validating headers + if ( + (headers[Enum.Http.Headers.FSPIOP.SOURCE] && !transfer.payeeIsProxy && (headers[Enum.Http.Headers.FSPIOP.SOURCE].toLowerCase() !== transfer.payeeFsp.toLowerCase())) || + (headers[Enum.Http.Headers.FSPIOP.DESTINATION] && !transfer.payerIsProxy && (headers[Enum.Http.Headers.FSPIOP.DESTINATION].toLowerCase() !== transfer.payerFsp.toLowerCase())) ) { /** * If fulfilment request is coming from a source not matching transfer payee fsp or destination not matching transfer payer fsp, @@ -370,19 +216,22 @@ const fulfil = async (error, messages) => { let fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'FSP does not match one of the fsp-id\'s associated with a transfer on the Fulfil callback response') // Lets make the error specific if the PayeeFSP IDs do not match - if (headers[Enum.Http.Headers.FSPIOP.SOURCE].toLowerCase() !== transfer.payeeFsp.toLowerCase()) { + if (!transfer.payeeIsProxy && (headers[Enum.Http.Headers.FSPIOP.SOURCE].toLowerCase() !== transfer.payeeFsp.toLowerCase())) { fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, `${Enum.Http.Headers.FSPIOP.SOURCE} does not match payee fsp on the Fulfil callback response`) } // Lets make the error specific if the PayerFSP IDs do not match - if (headers[Enum.Http.Headers.FSPIOP.DESTINATION].toLowerCase() !== transfer.payerFsp.toLowerCase()) { + if (!transfer.payerIsProxy && (headers[Enum.Http.Headers.FSPIOP.DESTINATION].toLowerCase() !== transfer.payerFsp.toLowerCase())) { fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, `${Enum.Http.Headers.FSPIOP.DESTINATION} does not match payer fsp on the Fulfil callback response`) } const apiFSPIOPError = fspiopError.toApiErrorObject(Config.ERROR_HANDLING) // Set the event details to map to an ABORT_VALIDATION event targeted to the Position Handler - const eventDetail = { functionality: TransferEventType.POSITION, action: TransferEventAction.ABORT_VALIDATION } + const eventDetail = { + functionality: TransferEventType.POSITION, + action: TransferEventAction.ABORT_VALIDATION + } // Lets handle the abort validation and change the transfer state to reflect this const transferAbortResult = await TransferService.handlePayeeResponse(transferId, payload, TransferEventAction.ABORT_VALIDATION, apiFSPIOPError) @@ -397,7 +246,7 @@ const fulfil = async (error, messages) => { // Publish message to Position Handler // Key position abort with payer account id const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: apiFSPIOPError, eventDetail, fromSwitch, toDestination: transfer.payerFsp, messageKey: payerAccount.participantCurrencyId.toString() }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: apiFSPIOPError, eventDetail, fromSwitch, toDestination: transfer.payerFsp, messageKey: payerAccount.participantCurrencyId.toString(), hubName: Config.HUB_NAME }) /** * Send patch notification callback to original payee fsp if they asked for a a patch response. @@ -427,319 +276,468 @@ const fulfil = async (error, messages) => { } } message.value.content.payload = reservedAbortedPayload - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail: reserveAbortedEventDetail, fromSwitch: true, toDestination: transfer.payeeFsp }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail: reserveAbortedEventDetail, fromSwitch: true, toDestination: transfer.payeeFsp, hubName: Config.HUB_NAME }) } throw apiFSPIOPError } - // If execution continues after this point we are sure transfer exists and source matches payee fsp - - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'dupCheck' })) - const histTimerDuplicateCheckEnd = Metrics.getHistogram( - 'handler_transfers', - 'fulfil_duplicateCheckComparator - Metrics for transfer handler', - ['success', 'funcName'] - ).startTimer() - - let dupCheckResult - if (!isTransferError) { - dupCheckResult = await Comparators.duplicateCheckComparator(transferId, payload, TransferService.getTransferFulfilmentDuplicateCheck, TransferService.saveTransferFulfilmentDuplicateCheck) - } else { - dupCheckResult = await Comparators.duplicateCheckComparator(transferId, payload, TransferService.getTransferErrorDuplicateCheck, TransferService.saveTransferErrorDuplicateCheck) - } - const { hasDuplicateId, hasDuplicateHash } = dupCheckResult - histTimerDuplicateCheckEnd({ success: true, funcName: 'fulfil_duplicateCheckComparator' }) - if (hasDuplicateId && hasDuplicateHash) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'handleResend')) - - // This is a duplicate message for a transfer that is already in a finalized state - // respond as if we received a GET /transfers/{ID} from the client - if (transferStateEnum === TransferState.COMMITTED || transferStateEnum === TransferState.ABORTED) { - message.value.content.payload = TransferObjectTransform.toFulfil(transfer) - const eventDetail = { functionality, action } - if (action !== TransferEventAction.RESERVE) { - if (!isTransferError) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackFinalized2--${actionLetter}3`)) - eventDetail.action = TransferEventAction.FULFIL_DUPLICATE - /** - * HOWTO: During bulk fulfil use an individualTransfer from a previous bulk fulfil - */ - } else { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackFinalized3--${actionLetter}4`)) - eventDetail.action = TransferEventAction.ABORT_DUPLICATE - } - } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch }) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } + } + // If execution continues after this point we are sure transfer exists and source matches payee fsp - if (transferStateEnum === TransferState.RECEIVED || transferStateEnum === TransferState.RESERVED) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `inProgress2--${actionLetter}5`)) - /** - * HOWTO: Nearly impossible to trigger for bulk - an individual transfer from a bulk needs to be triggered - * for processing in order to have the fulfil duplicate hash recorded. While it is still in RESERVED state - * the individual transfer needs to be requested by another bulk fulfil request! - * - * TODO: find a way to trigger this code branch and handle it at BulkProcessingHandler (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd }) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'dupCheck' })) + const histTimerDuplicateCheckEnd = Metrics.getHistogram( + 'handler_transfers', + 'fulfil_duplicateCheckComparator - Metrics for transfer handler', + ['success', 'funcName'] + ).startTimer() - // Error scenario - transfer.transferStateEnumeration is in some invalid state - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidTransferStateEnum--${actionLetter}6`)) - const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( - `Invalid transferStateEnumeration:(${transferStateEnum}) for event action:(${action}) and type:(${type})`).toApiErrorObject(Config.ERROR_HANDLING) - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * HOWTO: Impossible to trigger for individual transfer in a bulk? (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError, eventDetail, fromSwitch }) + let dupCheckResult + if (!isTransferError) { + dupCheckResult = await Comparators.duplicateCheckComparator(transferId, payload, TransferService.getTransferFulfilmentDuplicateCheck, TransferService.saveTransferFulfilmentDuplicateCheck) + } else { + dupCheckResult = await Comparators.duplicateCheckComparator(transferId, payload, TransferService.getTransferErrorDuplicateCheck, TransferService.saveTransferErrorDuplicateCheck) + } + const { hasDuplicateId, hasDuplicateHash } = dupCheckResult + histTimerDuplicateCheckEnd({ success: true, funcName: 'fulfil_duplicateCheckComparator' }) + if (hasDuplicateId && hasDuplicateHash) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, 'handleResend')) + + // This is a duplicate message for a transfer that is already in a finalized state + // respond as if we received a GET /transfers/{ID} from the client + if (transferStateEnum === TransferState.COMMITTED || transferStateEnum === TransferState.ABORTED) { + message.value.content.payload = TransferObjectTransform.toFulfil(transfer) + const eventDetail = { functionality, action } + if (action !== TransferEventAction.RESERVE) { + if (!isTransferError) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackFinalized2--${actionLetter}3`)) + eventDetail.action = TransferEventAction.FULFIL_DUPLICATE + /** + * HOWTO: During bulk fulfil use an individualTransfer from a previous bulk fulfil + */ + } else { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackFinalized3--${actionLetter}4`)) + eventDetail.action = TransferEventAction.ABORT_DUPLICATE + } + } + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } - // ERROR: We have seen a transfer of this ID before, but it's message hash doesn't match - // the previous message hash. - if (hasDuplicateId && !hasDuplicateHash) { - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST) - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorModified2--${actionLetter}7`)) - let action = TransferEventAction.FULFIL_DUPLICATE - if (isTransferError) { - action = TransferEventAction.ABORT_DUPLICATE - } - + if (transferStateEnum === TransferState.RECEIVED || transferStateEnum === TransferState.RESERVED) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `inProgress2--${actionLetter}5`)) /** - * HOWTO: During bulk fulfil use an individualTransfer from a previous bulk fulfil, - * but use different fulfilment value. + * HOWTO: Nearly impossible to trigger for bulk - an individual transfer from a bulk needs to be triggered + * for processing in order to have the fulfil duplicate hash recorded. While it is still in RESERVED state + * the individual transfer needs to be requested by another bulk fulfil request! + * + * TODO: find a way to trigger this code branch and handle it at BulkProcessingHandler (not in scope of #967) */ - const eventDetail = { functionality, action } - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd, hubName: Config.HUB_NAME }) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return true } - // Transfer is not a duplicate, or message hasn't been changed. + // Error scenario - transfer.transferStateEnumeration is in some invalid state + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidTransferStateEnum--${actionLetter}6`)) + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( + `Invalid transferStateEnumeration:(${transferStateEnum}) for event action:(${action}) and type:(${type})`).toApiErrorObject(Config.ERROR_HANDLING) + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * HOWTO: Impossible to trigger for individual transfer in a bulk? (not in scope of #967) + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return true + } - if (type !== TransferEventType.FULFIL) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidEventType--${actionLetter}15`)) - const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event type:(${type})`) - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * TODO: BulkProcessingHandler (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError + // ERROR: We have seen a transfer of this ID before, but it's message hash doesn't match + // the previous message hash. + if (hasDuplicateId && !hasDuplicateHash) { + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorModified2--${actionLetter}7`)) + let action = TransferEventAction.FULFIL_DUPLICATE + if (isTransferError) { + action = TransferEventAction.ABORT_DUPLICATE } - const validActions = [ - TransferEventAction.COMMIT, - TransferEventAction.RESERVE, - TransferEventAction.REJECT, - TransferEventAction.ABORT, - TransferEventAction.BULK_COMMIT, - TransferEventAction.BULK_ABORT - ] - if (!validActions.includes(action)) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidEventAction--${actionLetter}15`)) - const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event action:(${action}) and/or type:(${type})`) - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * TODO: BulkProcessingHandler (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } + /** + * HOWTO: During bulk fulfil use an individualTransfer from a previous bulk fulfil, + * but use different fulfilment value. + */ + const eventDetail = { functionality, action } + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } - Util.breadcrumb(location, { path: 'validationCheck' }) - if (payload.fulfilment && !Validator.validateFulfilCondition(payload.fulfilment, transfer.condition)) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidFulfilment--${actionLetter}9`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'invalid fulfilment') - const apiFSPIOPError = fspiopError.toApiErrorObject(Config.ERROR_HANDLING) - await TransferService.handlePayeeResponse(transferId, payload, action, apiFSPIOPError) - const eventDetail = { functionality: TransferEventType.POSITION, action: TransferEventAction.ABORT_VALIDATION } - /** - * TODO: BulkProcessingHandler (not in scope of #967) The individual transfer is ABORTED by notification is never sent. - */ - // Key position validation abort with payer account id - const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: apiFSPIOPError, eventDetail, messageKey: payerAccount.participantCurrencyId.toString() }) + // Transfer is not a duplicate, or message hasn't been changed. + + if (type !== TransferEventType.FULFIL) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidEventType--${actionLetter}15`)) + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event type:(${type})`) + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * TODO: BulkProcessingHandler (not in scope of #967) + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } - // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE - if (action === TransferEventAction.RESERVE) { - // Get the updated transfer now that completedTimestamp will be different - // TODO: should we just modify TransferService.handlePayeeResponse to - // return the completed timestamp? Or is it safer to go back to the DB here? - const transferAbortResult = await TransferService.getById(transferId) - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}1`)) - const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } + const validActions = [ + TransferEventAction.COMMIT, + TransferEventAction.RESERVE, + TransferEventAction.REJECT, + TransferEventAction.ABORT, + TransferEventAction.BULK_COMMIT, + TransferEventAction.BULK_ABORT + ] + if (!validActions.includes(action)) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidEventAction--${actionLetter}15`)) + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError(`Invalid event action:(${action}) and/or type:(${type})`) + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * TODO: BulkProcessingHandler (not in scope of #967) + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } - // Extract error information - const errorCode = apiFSPIOPError && apiFSPIOPError.errorInformation && apiFSPIOPError.errorInformation.errorCode - const errorDescription = apiFSPIOPError && apiFSPIOPError.errorInformation && apiFSPIOPError.errorInformation.errorDescription + Util.breadcrumb(location, { path: 'validationCheck' }) + if (payload.fulfilment && !Validator.validateFulfilCondition(payload.fulfilment, transfer.condition)) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorInvalidFulfilment--${actionLetter}9`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'invalid fulfilment') + const apiFSPIOPError = fspiopError.toApiErrorObject(Config.ERROR_HANDLING) + await TransferService.handlePayeeResponse(transferId, payload, TransferEventAction.ABORT_VALIDATION, apiFSPIOPError) + const eventDetail = { functionality: TransferEventType.POSITION, action: TransferEventAction.ABORT_VALIDATION } + /** + * TODO: BulkProcessingHandler (not in scope of #967) The individual transfer is ABORTED by notification is never sent. + */ + // Key position validation abort with payer account id + + const cyrilResult = await FxService.Cyril.processAbortMessage(transferId) + + params.message.value.content.context = { + ...params.message.value.content.context, + cyrilResult + } + if (cyrilResult.positionChanges.length > 0) { + const participantCurrencyId = cyrilResult.positionChanges[0].participantCurrencyId + await Kafka.proceed( + Config.KAFKA_CONFIG, + params, + { + consumerCommit, + fspiopError: apiFSPIOPError, + eventDetail, + messageKey: participantCurrencyId.toString(), + topicNameOverride: Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.ABORT, + hubName: Config.HUB_NAME + } + ) + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('Invalid cyril result') + throw fspiopError + } - // TODO: This should be handled by a PATCH /transfers/{id}/error callback in the future FSPIOP v1.2 specification, and instead we should just send the FSPIOP-Error instead! Ref: https://github.com/mojaloop/mojaloop-specification/issues/106. - const reservedAbortedPayload = { - transferId: transferAbortResult && transferAbortResult.id, - completedTimestamp: transferAbortResult && transferAbortResult.completedTimestamp && (new Date(Date.parse(transferAbortResult.completedTimestamp))).toISOString(), - transferState: TransferState.ABORTED, - extensionList: { // lets add the extension list to handle the limitation of the FSPIOP v1.1 specification by adding the error cause... - extension: [ - { - key: 'cause', - value: `${errorCode}: ${errorDescription}` - } - ] - } + // const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + // await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: apiFSPIOPError, eventDetail, messageKey: payerAccount.participantCurrencyId.toString(), hubName: Config.HUB_NAME }) + + // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE + if (action === TransferEventAction.RESERVE) { + // Get the updated transfer now that completedTimestamp will be different + const transferAbortResult = await TransferService.getById(transferId) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}1`)) + const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } + + // Extract error information + const errorCode = apiFSPIOPError && apiFSPIOPError.errorInformation && apiFSPIOPError.errorInformation.errorCode + const errorDescription = apiFSPIOPError && apiFSPIOPError.errorInformation && apiFSPIOPError.errorInformation.errorDescription + + // TODO: This should be handled by a PATCH /transfers/{id}/error callback in the future FSPIOP v1.2 specification, and instead we should just send the FSPIOP-Error instead! Ref: https://github.com/mojaloop/mojaloop-specification/issues/106. + const reservedAbortedPayload = { + transferId: transferAbortResult && transferAbortResult.id, + completedTimestamp: transferAbortResult && transferAbortResult.completedTimestamp && (new Date(Date.parse(transferAbortResult.completedTimestamp))).toISOString(), + transferState: TransferState.ABORTED, + extensionList: { // lets add the extension list to handle the limitation of the FSPIOP v1.1 specification by adding the error cause... + extension: [ + { + key: 'cause', + value: `${errorCode}: ${errorDescription}` + } + ] } - message.value.content.payload = reservedAbortedPayload - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true, toDestination: transfer.payeeFsp }) } - throw fspiopError + message.value.content.payload = reservedAbortedPayload + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true, toDestination: transfer.payeeFsp, hubName: Config.HUB_NAME }) } + throw fspiopError + } - if (transfer.transferState !== TransferState.RESERVED) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNonReservedState--${actionLetter}10`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'non-RESERVED transfer state') - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * TODO: BulkProcessingHandler (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + if (transfer.transferState !== Enum.Transfers.TransferInternalState.RESERVED && + transfer.transferState !== Enum.Transfers.TransferInternalState.RESERVED_FORWARDED + ) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNonReservedState--${actionLetter}10`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'non-RESERVED transfer state') + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * TODO: BulkProcessingHandler (not in scope of #967) + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + + // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE + if (action === TransferEventAction.RESERVE) { + // Get the updated transfer now that completedTimestamp will be different + const transferAborted = await TransferService.getById(transferId) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}2`)) + const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } + const reservedAbortedPayload = { + transferId: transferAborted.id, + completedTimestamp: Util.Time.getUTCString(new Date(transferAborted.completedTimestamp)), + transferState: TransferState.ABORTED + } + message.value.content.payload = reservedAbortedPayload + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true, toDestination: transfer.payeeFsp, hubName: Config.HUB_NAME }) + } + throw fspiopError + } - // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE - if (action === TransferEventAction.RESERVE) { - // Get the updated transfer now that completedTimestamp will be different - // TODO: should we just modify TransferService.handlePayeeResponse to - // return the completed timestamp? Or is it safer to go back to the DB here? - const transferAborted = await TransferService.getById(transferId) // TODO: remove this once it can be tested - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}2`)) - const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } - const reservedAbortedPayload = { - transferId: transferAborted.id, - completedTimestamp: Util.Time.getUTCString(new Date(transferAborted.completedTimestamp)), // TODO: remove this once it can be tested - transferState: TransferState.ABORTED - } - message.value.content.payload = reservedAbortedPayload - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true, toDestination: transfer.payeeFsp }) + if (transfer.expirationDate <= new Date(Util.Time.getUTCString(new Date()))) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorTransferExpired--${actionLetter}11`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED) + const eventDetail = { functionality, action: TransferEventAction.COMMIT } + /** + * TODO: BulkProcessingHandler (not in scope of #967) + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + + // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE + if (action === TransferEventAction.RESERVE) { + // Get the updated transfer now that completedTimestamp will be different + const transferAborted = await TransferService.getById(transferId) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}3`)) + const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } + const reservedAbortedPayload = { + transferId: transferAborted.id, + completedTimestamp: Util.Time.getUTCString(new Date(transferAborted.completedTimestamp)), + transferState: TransferState.ABORTED } - throw fspiopError + message.value.content.payload = reservedAbortedPayload + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true, hubName: Config.HUB_NAME }) } + throw fspiopError + } - if (transfer.expirationDate <= new Date(Util.Time.getUTCString(new Date()))) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorTransferExpired--${actionLetter}11`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED) - const eventDetail = { functionality, action: TransferEventAction.COMMIT } - /** - * TODO: BulkProcessingHandler (not in scope of #967) - */ - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + // Validations Succeeded - process the fulfil + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'validationPassed' })) + switch (action) { + case TransferEventAction.COMMIT: + case TransferEventAction.RESERVE: + case TransferEventAction.BULK_COMMIT: { + let topicNameOverride + if (action === TransferEventAction.COMMIT) { + topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.COMMIT + } else if (action === TransferEventAction.RESERVE) { + topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.RESERVE + } else if (action === TransferEventAction.BULK_COMMIT) { + topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.BULK_COMMIT + } - // emit an extra message - RESERVED_ABORTED if action === TransferEventAction.RESERVE - if (action === TransferEventAction.RESERVE) { - // Get the updated transfer now that completedTimestamp will be different - // TODO: should we just modify TransferService.handlePayeeResponse to - // return the completed timestamp? Or is it safer to go back to the DB here? - const transferAborted = await TransferService.getById(transferId) - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackReservedAborted--${actionLetter}3`)) - const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.RESERVED_ABORTED } - const reservedAbortedPayload = { - transferId: transferAborted.id, - completedTimestamp: Util.Time.getUTCString(new Date(transferAborted.completedTimestamp)), - transferState: TransferState.ABORTED + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic2--${actionLetter}12`)) + await TransferService.handlePayeeResponse(transferId, payload, action) + const eventDetail = { functionality: TransferEventType.POSITION, action } + // Key position fulfil message with payee account id + const cyrilResult = await FxService.Cyril.processFulfilMessage(transferId, payload, transfer) + if (cyrilResult.isFx) { + // const payeeAccount = await Participant.getAccountByNameAndCurrency(transfer.payeeFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + params.message.value.content.context = { + ...params.message.value.content.context, + cyrilResult } - message.value.content.payload = reservedAbortedPayload - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch: true }) + if (cyrilResult.positionChanges.length > 0) { + const participantCurrencyId = cyrilResult.positionChanges[0].participantCurrencyId + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, messageKey: participantCurrencyId.toString(), topicNameOverride, hubName: Config.HUB_NAME }) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + } else { + histTimerEnd({ success: false, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('Invalid cyril result') + throw fspiopError + } + } else { + const payeeAccount = await Participant.getAccountByNameAndCurrency(transfer.payeeFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, messageKey: payeeAccount.participantCurrencyId.toString(), topicNameOverride, hubName: Config.HUB_NAME }) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + } + return true + } + case TransferEventAction.REJECT: { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic3--${actionLetter}13`)) + const errorMessage = 'action REJECT is not allowed into fulfil handler' + Logger.isErrorEnabled && Logger.error(errorMessage) + !!span && span.error(errorMessage) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return true + } + case TransferEventAction.BULK_ABORT: { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic4--${actionLetter}14`)) + let fspiopError + const eInfo = payload.errorInformation + try { // handle only valid errorCodes provided by the payee + fspiopError = ErrorHandler.Factory.createFSPIOPErrorFromErrorInformation(eInfo) + } catch (err) { + Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) + fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'API specification undefined errorCode') + await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) + const eventDetail = { functionality: TransferEventType.POSITION, action } + // Key position abort with payer account id + const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, messageKey: payerAccount.participantCurrencyId.toString(), hubName: Config.HUB_NAME }) + throw fspiopError } + await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) + const eventDetail = { functionality: TransferEventType.POSITION, action } + // Key position abort with payer account id + const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, messageKey: payerAccount.participantCurrencyId.toString(), hubName: Config.HUB_NAME }) throw fspiopError } - - // Validations Succeeded - process the fulfil - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { path: 'validationPassed' })) - switch (action) { - case TransferEventAction.COMMIT: - case TransferEventAction.RESERVE: - case TransferEventAction.BULK_COMMIT: { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic2--${actionLetter}12`)) - await TransferService.handlePayeeResponse(transferId, payload, action) + case TransferEventAction.ABORT: { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic4--${actionLetter}14`)) + let fspiopError + const eInfo = payload.errorInformation + try { // handle only valid errorCodes provided by the payee + fspiopError = ErrorHandler.Factory.createFSPIOPErrorFromErrorInformation(eInfo) + } catch (err) { + Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) + fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'API specification undefined errorCode') + await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) const eventDetail = { functionality: TransferEventType.POSITION, action } - // Key position fulfil message with payee account id - let topicNameOverride - if (action === TransferEventAction.COMMIT) { - topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.COMMIT - } else if (action === TransferEventAction.RESERVE) { - topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.RESERVE - } else if (action === TransferEventAction.BULK_COMMIT) { - topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.BULK_COMMIT - } - const payeeAccount = await Participant.getAccountByNameAndCurrency(transfer.payeeFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + // Key position abort with payer account id + const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, messageKey: payerAccount.participantCurrencyId.toString(), hubName: Config.HUB_NAME }) + throw fspiopError + } + await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) + const eventDetail = { functionality: TransferEventType.POSITION, action } + const cyrilResult = await FxService.Cyril.processAbortMessage(transferId) + + params.message.value.content.context = { + ...params.message.value.content.context, + cyrilResult + } + if (cyrilResult.positionChanges.length > 0) { + const participantCurrencyId = cyrilResult.positionChanges[0].participantCurrencyId await Kafka.proceed( Config.KAFKA_CONFIG, params, { consumerCommit, + fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, - messageKey: payeeAccount.participantCurrencyId.toString(), - topicNameOverride + messageKey: participantCurrencyId.toString(), + topicNameOverride: Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.ABORT, + hubName: Config.HUB_NAME } ) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } - // TODO: why do we let this logic get this far? Why not remove it from validActions array above? - case TransferEventAction.REJECT: { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic3--${actionLetter}13`)) - const errorMessage = 'action REJECT is not allowed into fulfil handler' - Logger.isErrorEnabled && Logger.error(errorMessage) - !!span && span.error(errorMessage) - histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - return true - } - // TODO: why do we let this logic get this far? Why not remove it from validActions array above? - case TransferEventAction.ABORT: - case TransferEventAction.BULK_ABORT: - default: { // action === TransferEventAction.ABORT || action === TransferEventAction.BULK_ABORT // error-callback request to be processed - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `positionTopic4--${actionLetter}14`)) - let fspiopError - const eInfo = payload.errorInformation - try { // handle only valid errorCodes provided by the payee - fspiopError = ErrorHandler.Factory.createFSPIOPErrorFromErrorInformation(eInfo) - } catch (err) { - /** - * TODO: Handling of out-of-range errorCodes is to be introduced to the ml-api-adapter, - * so that such requests are rejected right away, instead of aborting the transfer here. - */ - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}`) - fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.VALIDATION_ERROR, 'API specification undefined errorCode') - await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) - const eventDetail = { functionality: TransferEventType.POSITION, action } - // Key position abort with payer account id - const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, messageKey: payerAccount.participantCurrencyId.toString() }) - throw fspiopError - } - await TransferService.handlePayeeResponse(transferId, payload, action, fspiopError.toApiErrorObject(Config.ERROR_HANDLING)) - const eventDetail = { functionality: TransferEventType.POSITION, action } - // Key position abort with payer account id - const payerAccount = await Participant.getAccountByNameAndCurrency(transfer.payerFsp, transfer.currency, Enum.Accounts.LedgerAccountType.POSITION) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, messageKey: payerAccount.participantCurrencyId.toString() }) - // TODO(2556): I don't think we should emit an extra notification here - // this is the case where the Payee sent an ABORT, so we don't need to tell them to abort + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError('Invalid cyril result') throw fspiopError } } - } catch (err) { - histTimerEnd({ success: false, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) - const fspiopError = ErrorHandler.Factory.reformatFSPIOPError(err) - Logger.isErrorEnabled && Logger.error(`${Util.breadcrumb(location)}::${err.message}--F0`) - const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) - await span.error(fspiopError, state) - await span.finish(fspiopError.message, state) + } +} + +const processFxFulfilMessage = async (message, functionality, span) => { + const histTimerEnd = Metrics.getHistogram( + 'fx_transfer_fulfil', + 'Consume a fx fulfil transfer message from the kafka topic and process it accordingly', + ['success', 'fspId'] + ).startTimer() + + const { + payload, + headers, + type, + action, + commitRequestId, + kafkaTopic + } = FxFulfilService.decodeKafkaMessage(message) + + const log = logger.child({ commitRequestId, type, action }) + log.info('processFxFulfilMessage start...', { payload }) + + const params = { + message, + kafkaTopic, + span, + decodedPayload: payload, + consumer: Consumer, + producer: Producer + } + + const fxFulfilService = new FxFulfilService({ + log, Config, Comparators, Validator, FxTransferModel, Kafka, params + }) + + // Validate event type + await fxFulfilService.validateEventType(type, functionality) + + // Validate action + const validActions = [ + TransferEventAction.FX_RESERVE, + TransferEventAction.FX_COMMIT, + // TransferEventAction.FX_REJECT, + TransferEventAction.FX_ABORT, + TransferEventAction.FX_FORWARDED + ] + if (!validActions.includes(action)) { + const errorMessage = ERROR_MESSAGES.fxActionIsNotAllowed(action) + log.error(errorMessage) + span?.error(errorMessage) + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true - } finally { - if (!span.isFinished) { - await span.finish() + } + + const transfer = await fxFulfilService.getFxTransferDetails(commitRequestId, functionality) + await fxFulfilService.validateHeaders({ transfer, headers, payload }) + + // If execution continues after this point we are sure fxTransfer exists and source matches payee fsp + const histTimerDuplicateCheckEnd = Metrics.getHistogram( + 'fx_handler_transfers', + 'fxFulfil_duplicateCheckComparator - Metrics for fxTransfer handler', + ['success', 'funcName'] + ).startTimer() + + const dupCheckResult = await fxFulfilService.getDuplicateCheckResult({ commitRequestId, payload }) + histTimerDuplicateCheckEnd({ success: true, funcName: 'fxFulfil_duplicateCheckComparator' }) + + const isDuplicate = await fxFulfilService.checkDuplication({ dupCheckResult, transfer, functionality, action, type }) + if (isDuplicate) { + log.info('fxTransfer duplication detected, skip further processing') + histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return true + } + + // Transfer is not a duplicate, or message hasn't been changed. + + payload.fulfilment && await fxFulfilService.validateFulfilment(transfer, payload) + await fxFulfilService.validateTransferState(transfer, functionality) + await fxFulfilService.validateExpirationDate(transfer, functionality) + + log.info('Validations Succeeded - process the fxFulfil...') + + switch (action) { + case TransferEventAction.FX_RESERVE: + case TransferEventAction.FX_COMMIT: { + const success = await fxFulfilService.processFxFulfil({ transfer, payload, action }) + log.info('fxFulfil handling is done', { success }) + histTimerEnd({ success, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return success + } + case TransferEventAction.FX_ABORT: { + const success = await fxFulfilService.processFxAbort({ transfer, payload, action }) + log.info('fxAbort handling is done', { success }) + histTimerEnd({ success, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) + return true } } } @@ -769,46 +767,66 @@ const getTransfer = async (error, messages) => { } else { message = messages } + const action = message.value.metadata.event.action + const isFx = action === TransferEventAction.FX_GET const contextFromMessage = EventSdk.Tracer.extractContextFromMessage(message.value) const span = EventSdk.Tracer.createChildSpanFromContext('cl_transfer_get', contextFromMessage) try { await span.audit(message, EventSdk.AuditEventAction.start) const metadata = message.value.metadata const action = metadata.event.action - const transferId = message.value.content.uriParams.id + const transferIdOrCommitRequestId = message.value.content.uriParams.id const kafkaTopic = message.topic Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, { method: `getTransfer:${action}` })) const actionLetter = Enum.Events.ActionLetter.get const params = { message, kafkaTopic, span, consumer: Consumer, producer: Producer } - const eventDetail = { functionality: TransferEventType.NOTIFICATION, action: TransferEventAction.GET } + const eventDetail = { functionality: TransferEventType.NOTIFICATION, action } Util.breadcrumb(location, { path: 'validationFailed' }) if (!await Validator.validateParticipantByName(message.value.from)) { Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `breakParticipantDoesntExist--${actionLetter}1`)) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, histTimerEnd, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } - const transfer = await TransferService.getByIdLight(transferId) - if (!transfer) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorTransferNotFound--${actionLetter}3`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_ID_NOT_FOUND, 'Provided Transfer ID was not found on the server.') - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError - } - if (!await Validator.validateParticipantTransferId(message.value.from, transferId)) { - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNotTransferParticipant--${actionLetter}2`)) - const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) - throw fspiopError + if (isFx) { + const fxTransfer = await FxTransferModel.fxTransfer.getByIdLight(transferIdOrCommitRequestId) + if (!fxTransfer) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorTransferNotFound--${actionLetter}3`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_ID_NOT_FOUND, 'Provided commitRequest ID was not found on the server.') + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } + if (!await Validator.validateParticipantForCommitRequestId(message.value.from, transferIdOrCommitRequestId)) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNotFxTransferParticipant--${actionLetter}2`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } + Util.breadcrumb(location, { path: 'validationPassed' }) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackMessage--${actionLetter}4`)) + message.value.content.payload = TransferObjectTransform.toFulfil(fxTransfer, true) + } else { + const transfer = await TransferService.getByIdLight(transferIdOrCommitRequestId) + if (!transfer) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorTransferNotFound--${actionLetter}3`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_ID_NOT_FOUND, 'Provided Transfer ID was not found on the server.') + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } + if (!await Validator.validateParticipantTransferId(message.value.from, transferIdOrCommitRequestId)) { + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackErrorNotTransferParticipant--${actionLetter}2`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.CLIENT_ERROR) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + throw fspiopError + } + Util.breadcrumb(location, { path: 'validationPassed' }) + Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackMessage--${actionLetter}4`)) + message.value.content.payload = TransferObjectTransform.toFulfil(transfer) } - // ============================================================================================ - Util.breadcrumb(location, { path: 'validationPassed' }) - Logger.isInfoEnabled && Logger.info(Util.breadcrumb(location, `callbackMessage--${actionLetter}4`)) - message.value.content.payload = TransferObjectTransform.toFulfil(transfer) - await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch }) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) histTimerEnd({ success: true, fspId: Config.INSTRUMENTATION_METRICS_LABELS.fspId }) return true } catch (err) { @@ -836,13 +854,14 @@ const getTransfer = async (error, messages) => { */ const registerPrepareHandler = async () => { try { - const prepareHandler = { - command: prepare, - topicName: Kafka.transformGeneralTopicName(Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, TransferEventType.TRANSFER, TransferEventAction.PREPARE), - config: Kafka.getKafkaConfig(Config.KAFKA_CONFIG, Enum.Kafka.Config.CONSUMER, TransferEventType.TRANSFER.toUpperCase(), TransferEventAction.PREPARE.toUpperCase()) - } - prepareHandler.config.rdkafkaConf['client.id'] = prepareHandler.topicName - await Consumer.createHandler(prepareHandler.topicName, prepareHandler.config, prepareHandler.command) + const { TRANSFER } = TransferEventType + const { PREPARE } = TransferEventAction + + const topicName = Kafka.transformGeneralTopicName(Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, TRANSFER, PREPARE) + const consumeConfig = Kafka.getKafkaConfig(Config.KAFKA_CONFIG, Enum.Kafka.Config.CONSUMER, TRANSFER.toUpperCase(), PREPARE.toUpperCase()) + consumeConfig.rdkafkaConf['client.id'] = topicName + + await Consumer.createHandler(topicName, consumeConfig, prepare) return true } catch (err) { Logger.isErrorEnabled && Logger.error(err) diff --git a/src/handlers/transfers/prepare.js b/src/handlers/transfers/prepare.js new file mode 100644 index 000000000..15d3fdb7c --- /dev/null +++ b/src/handlers/transfers/prepare.js @@ -0,0 +1,582 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const EventSdk = require('@mojaloop/event-sdk') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const { Enum, Util } = require('@mojaloop/central-services-shared') +const { Consumer, Producer } = require('@mojaloop/central-services-stream').Util + +const { logger } = require('../../shared/logger') +const Config = require('../../lib/config') +const TransferObjectTransform = require('../../domain/transfer/transform') +const Participant = require('../../domain/participant') + +const createRemittanceEntity = require('./createRemittanceEntity') +const Validator = require('./validator') +const dto = require('./dto') +const TransferService = require('../../domain/transfer/index') +const ProxyCache = require('../../lib/proxyCache') +const FxTransferService = require('../../domain/fx/index') + +const { Kafka, Comparators } = Util +const { TransferState, TransferInternalState } = Enum.Transfers +const { Action, Type } = Enum.Events.Event +const { FSPIOPErrorCodes } = ErrorHandler.Enums +const { createFSPIOPError, reformatFSPIOPError } = ErrorHandler.Factory +const { fspId } = Config.INSTRUMENTATION_METRICS_LABELS + +const consumerCommit = true +const fromSwitch = true +const proxyEnabled = Config.PROXY_CACHE_CONFIG.enabled + +const proceedForwardErrorMessage = async ({ fspiopError, isFx, params }) => { + const eventDetail = { + functionality: Type.NOTIFICATION, + action: isFx ? Action.FX_FORWARDED : Action.FORWARDED + } + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + fspiopError, + eventDetail, + consumerCommit + }) + logger.warn('proceedForwardErrorMessage is done', { fspiopError, eventDetail }) +} + +// think better name +const forwardPrepare = async ({ isFx, params, ID }) => { + if (isFx) { + const fxTransfer = await FxTransferService.getByIdLight(ID) + if (!fxTransfer) { + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + FSPIOPErrorCodes.ID_NOT_FOUND, + 'Forwarded fxTransfer could not be found.' + ).toApiErrorObject(Config.ERROR_HANDLING) + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payerFsp and proxy of the error. + // As long as the `to` and `from` message values are the fsp and fxp, + // and the action is `fx-forwarded`, the ml-api-adapter will notify both. + await proceedForwardErrorMessage({ fspiopError, isFx, params }) + return true + } + + if (fxTransfer.fxTransferState === TransferInternalState.RESERVED) { + await FxTransferService.forwardedFxPrepare(ID) + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( + `Invalid State: ${fxTransfer.fxTransferState} - expected: ${TransferInternalState.RESERVED}` + ).toApiErrorObject(Config.ERROR_HANDLING) + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payerFsp and proxy of the error. + // As long as the `to` and `from` message values are the fsp and fxp, + // and the action is `fx-forwarded`, the ml-api-adapter will notify both. + await proceedForwardErrorMessage({ fspiopError, isFx, params }) + } + } else { + const transfer = await TransferService.getById(ID) + if (!transfer) { + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + FSPIOPErrorCodes.ID_NOT_FOUND, + 'Forwarded transfer could not be found.' + ).toApiErrorObject(Config.ERROR_HANDLING) + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payerFsp and proxy of the error. + // As long as the `to` and `from` message values are the payer and payee, + // and the action is `forwarded`, the ml-api-adapter will notify both. + await proceedForwardErrorMessage({ fspiopError, isFx, params }) + return true + } + + if (transfer.transferState === TransferInternalState.RESERVED) { + await TransferService.forwardedPrepare(ID) + } else { + const fspiopError = ErrorHandler.Factory.createInternalServerFSPIOPError( + `Invalid State: ${transfer.transferState} - expected: ${TransferInternalState.RESERVED}` + ).toApiErrorObject(Config.ERROR_HANDLING) + // IMPORTANT: This singular message is taken by the ml-api-adapter and used to + // notify the payerFsp and proxy of the error. + // As long as the `to` and `from` message values are the payer and payee, + // and the action is `forwarded`, the ml-api-adapter will notify both. + await proceedForwardErrorMessage({ fspiopError, isFx, params }) + } + } + + return true +} + +/** @import { ProxyOrParticipant } from '#src/lib/proxyCache.js' */ +/** + * @typedef {Object} ProxyObligation + * @property {boolean} isFx - Is FX transfer. + * @property {Object} payloadClone - A clone of the original payload. + * @property {ProxyOrParticipant} initiatingFspProxyOrParticipantId - initiating FSP: proxy or participant. + * @property {ProxyOrParticipant} counterPartyFspProxyOrParticipantId - counterparty FSP: proxy or participant. + * @property {boolean} isInitiatingFspProxy - initiatingFsp.(!inScheme && proxyId !== null). + * @property {boolean} isCounterPartyFspProxy - counterPartyFsp.(!inScheme && proxyId !== null). + */ + +/** + * Calculates proxyObligation. + * @returns {ProxyObligation} proxyObligation + */ +const calculateProxyObligation = async ({ payload, isFx, params, functionality, action }) => { + const proxyObligation = { + isFx, + payloadClone: { ...payload }, + isInitiatingFspProxy: false, + isCounterPartyFspProxy: false, + initiatingFspProxyOrParticipantId: null, + counterPartyFspProxyOrParticipantId: null + } + + if (proxyEnabled) { + const [initiatingFsp, counterPartyFsp] = isFx ? [payload.initiatingFsp, payload.counterPartyFsp] : [payload.payerFsp, payload.payeeFsp] + + // We need to double check the following validation logic incase of payee side currency conversion + const payeeFspLookupOptions = isFx + ? null + : { + validateCurrencyAccounts: true, + accounts: [ + { currency: payload.amount.currency, accountType: Enum.Accounts.LedgerAccountType.POSITION } + ] + } + + ;[proxyObligation.initiatingFspProxyOrParticipantId, proxyObligation.counterPartyFspProxyOrParticipantId] = await Promise.all([ + ProxyCache.getFSPProxy(initiatingFsp), + ProxyCache.getFSPProxy(counterPartyFsp, payeeFspLookupOptions) + ]) + logger.debug('Prepare proxy cache lookup results', { + initiatingFsp, + counterPartyFsp, + initiatingFspProxyOrParticipantId: proxyObligation.initiatingFspProxyOrParticipantId, + counterPartyFspProxyOrParticipantId: proxyObligation.counterPartyFspProxyOrParticipantId + }) + + proxyObligation.isInitiatingFspProxy = !proxyObligation.initiatingFspProxyOrParticipantId.inScheme && + proxyObligation.initiatingFspProxyOrParticipantId.proxyId !== null + proxyObligation.isCounterPartyFspProxy = !proxyObligation.counterPartyFspProxyOrParticipantId.inScheme && + proxyObligation.counterPartyFspProxyOrParticipantId.proxyId !== null + + if (isFx) { + proxyObligation.payloadClone.initiatingFsp = !proxyObligation.initiatingFspProxyOrParticipantId?.inScheme && + proxyObligation.initiatingFspProxyOrParticipantId?.proxyId + ? proxyObligation.initiatingFspProxyOrParticipantId.proxyId + : payload.initiatingFsp + proxyObligation.payloadClone.counterPartyFsp = !proxyObligation.counterPartyFspProxyOrParticipantId?.inScheme && + proxyObligation.counterPartyFspProxyOrParticipantId?.proxyId + ? proxyObligation.counterPartyFspProxyOrParticipantId.proxyId + : payload.counterPartyFsp + } else { + proxyObligation.payloadClone.payerFsp = !proxyObligation.initiatingFspProxyOrParticipantId?.inScheme && + proxyObligation.initiatingFspProxyOrParticipantId?.proxyId + ? proxyObligation.initiatingFspProxyOrParticipantId.proxyId + : payload.payerFsp + proxyObligation.payloadClone.payeeFsp = !proxyObligation.counterPartyFspProxyOrParticipantId?.inScheme && + proxyObligation.counterPartyFspProxyOrParticipantId?.proxyId + ? proxyObligation.counterPartyFspProxyOrParticipantId.proxyId + : payload.payeeFsp + } + + // If either debtor participant or creditor participant aren't in the scheme and have no proxy representative, then throw an error. + if ((proxyObligation.initiatingFspProxyOrParticipantId.inScheme === false && proxyObligation.initiatingFspProxyOrParticipantId.proxyId === null) || + (proxyObligation.counterPartyFspProxyOrParticipantId.inScheme === false && proxyObligation.counterPartyFspProxyOrParticipantId.proxyId === null)) { + const fspiopError = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.ID_NOT_FOUND, + `Payer proxy or payee proxy not found: initiatingFsp: ${initiatingFsp} counterPartyFsp: ${counterPartyFsp}` + ).toApiErrorObject(Config.ERROR_HANDLING) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + consumerCommit, + fspiopError, + eventDetail: { functionality, action }, + fromSwitch, + hubName: Config.HUB_NAME + }) + throw fspiopError + } + } + + return proxyObligation +} + +const checkDuplication = async ({ payload, isFx, ID, location }) => { + const funcName = 'prepare_duplicateCheckComparator' + const histTimerDuplicateCheckEnd = Metrics.getHistogram( + 'handler_transfers', + `${funcName} - Metrics for transfer handler`, + ['success', 'funcName'] + ).startTimer() + + const remittance = createRemittanceEntity(isFx) + const { hasDuplicateId, hasDuplicateHash } = await Comparators.duplicateCheckComparator( + ID, + payload, + remittance.getDuplicate, + remittance.saveDuplicateHash + ) + + logger.info(Util.breadcrumb(location, { path: funcName }), { hasDuplicateId, hasDuplicateHash, isFx, ID }) + histTimerDuplicateCheckEnd({ success: true, funcName }) + + return { hasDuplicateId, hasDuplicateHash } +} + +const processDuplication = async ({ + duplication, isFx, ID, functionality, action, actionLetter, params, location +}) => { + if (!duplication.hasDuplicateId) return + + let error + if (!duplication.hasDuplicateHash) { + logger.warn(Util.breadcrumb(location, `callbackErrorModified1--${actionLetter}5`)) + error = createFSPIOPError(FSPIOPErrorCodes.MODIFIED_REQUEST) + } else if (action === Action.BULK_PREPARE) { + logger.info(Util.breadcrumb(location, `validationError1--${actionLetter}2`)) + error = createFSPIOPError('Individual transfer prepare duplicate') + } + + if (error) { + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + consumerCommit, + fspiopError: error.toApiErrorObject(Config.ERROR_HANDLING), + eventDetail: { functionality, action }, + fromSwitch, + hubName: Config.HUB_NAME + }) + throw error + } + logger.info(Util.breadcrumb(location, 'handleResend')) + + const transfer = await createRemittanceEntity(isFx) + .getByIdLight(ID) + + const finalizedState = [TransferState.COMMITTED, TransferState.ABORTED, TransferState.RESERVED] + const isFinalized = + finalizedState.includes(transfer?.transferStateEnumeration) || + finalizedState.includes(transfer?.fxTransferStateEnumeration) + const isPrepare = [Action.PREPARE, Action.FX_PREPARE, Action.FORWARDED, Action.FX_FORWARDED].includes(action) + + let eventDetail = { functionality, action: Action.PREPARE_DUPLICATE } + if (isFinalized) { + if (isPrepare) { + logger.info(Util.breadcrumb(location, `finalized callback--${actionLetter}1`)) + params.message.value.content.payload = TransferObjectTransform.toFulfil(transfer, isFx) + params.message.value.content.uriParams = { id: ID } + const action = isFx ? Action.FX_PREPARE_DUPLICATE : Action.PREPARE_DUPLICATE + eventDetail = { functionality, action } + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, eventDetail, fromSwitch, hubName: Config.HUB_NAME }) + } else if (action === Action.BULK_PREPARE) { + logger.info(Util.breadcrumb(location, `validationError1--${actionLetter}2`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST, 'Individual transfer prepare duplicate') + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + throw fspiopError + } + } else { + logger.info(Util.breadcrumb(location, 'inProgress')) + if (action === Action.BULK_PREPARE) { + logger.info(Util.breadcrumb(location, `validationError2--${actionLetter}4`)) + const fspiopError = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.MODIFIED_REQUEST, 'Individual transfer prepare duplicate') + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit, fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), eventDetail, fromSwitch }) + throw fspiopError + } else { // action === TransferEventAction.PREPARE + logger.info(Util.breadcrumb(location, `ignore--${actionLetter}3`)) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { consumerCommit }) + return true + } + } + + return true +} + +const savePreparedRequest = async ({ + validationPassed, + reasons, + payload, + isFx, + functionality, + params, + location, + determiningTransferCheckResult, + proxyObligation +}) => { + const logMessage = Util.breadcrumb(location, 'savePreparedRequest') + try { + logger.info(logMessage, { validationPassed, reasons }) + const reason = validationPassed ? null : reasons.toString() + await createRemittanceEntity(isFx) + .savePreparedRequest( + payload, + reason, + validationPassed, + determiningTransferCheckResult, + proxyObligation + ) + } catch (err) { + logger.error(`${logMessage} error:`, err) + const fspiopError = reformatFSPIOPError(err, FSPIOPErrorCodes.INTERNAL_SERVER_ERROR) + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + consumerCommit, + fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), + eventDetail: { functionality, action: Action.PREPARE }, + fromSwitch, + hubName: Config.HUB_NAME + }) + throw fspiopError + } +} + +const definePositionParticipant = async ({ isFx, payload, determiningTransferCheckResult, proxyObligation }) => { + const cyrilResult = await createRemittanceEntity(isFx) + .getPositionParticipant(payload, determiningTransferCheckResult, proxyObligation) + + let messageKey + // On a proxied transfer prepare if there is a corresponding fx transfer `getPositionParticipant` + // should return the fxp's proxy as the participantName since the fxp proxy would be saved as the counterPartyFsp + // in the prior fx transfer prepare. + // Following interscheme rules, if the debtor(fxTransfer FXP) and the creditor(transfer payee) are + // represented by the same proxy, no position adjustment is needed. + let isSameProxy = false + // Only check transfers that have a related fxTransfer + if (determiningTransferCheckResult?.watchListRecords?.length > 0) { + const counterPartyParticipantFXPProxy = cyrilResult.participantName + isSameProxy = counterPartyParticipantFXPProxy && proxyObligation?.counterPartyFspProxyOrParticipantId?.proxyId + ? counterPartyParticipantFXPProxy === proxyObligation.counterPartyFspProxyOrParticipantId.proxyId + : false + } + if (isSameProxy) { + messageKey = '0' + } else { + const account = await Participant.getAccountByNameAndCurrency( + cyrilResult.participantName, + cyrilResult.currencyId, + Enum.Accounts.LedgerAccountType.POSITION + ) + messageKey = account.participantCurrencyId.toString() + } + logger.info('prepare positionParticipant details:', { messageKey, isSameProxy, cyrilResult }) + + return { + messageKey, + cyrilResult + } +} + +const sendPositionPrepareMessage = async ({ + isFx, + action, + params, + determiningTransferCheckResult, + proxyObligation +}) => { + const eventDetail = { + functionality: Type.POSITION, + action + } + + const { messageKey, cyrilResult } = await definePositionParticipant({ + payload: proxyObligation.payloadClone, + isFx, + determiningTransferCheckResult, + proxyObligation + }) + + params.message.value.content.context = { + ...params.message.value.content.context, + cyrilResult + } + // We route fx-prepare, bulk-prepare and prepare messages differently based on the topic configured for it. + // Note: The batch handler does not currently support bulk-prepare messages, only prepare messages are supported. + // And non batch processing is not supported for fx-prepare messages. + // Therefore, it is necessary to check the action to determine the topic to route to. + let topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.PREPARE + if (action === Action.BULK_PREPARE) { + topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.BULK_PREPARE + } else if (action === Action.FX_PREPARE) { + topicNameOverride = Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP?.POSITION?.FX_PREPARE + } + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + consumerCommit, + eventDetail, + messageKey, + topicNameOverride, + hubName: Config.HUB_NAME + }) + + return true +} + +/** + * @function TransferPrepareHandler + * + * @async + * @description This is the consumer callback function that gets registered to a topic. This then gets a list of messages, + * we will only ever use the first message in non batch processing. We then break down the message into its payload and + * begin validating the payload. Once the payload is validated successfully it will be written to the database to + * the relevant tables. If the validation fails it is still written to the database for auditing purposes but with an + * INVALID status. For any duplicate requests we will send appropriate callback based on the transfer state and the hash validation + * + * Validator.validatePrepare called to validate the payload of the message + * TransferService.getById called to get the details of the existing transfer + * TransferObjectTransform.toTransfer called to transform the transfer object + * TransferService.prepare called and creates new entries in transfer tables for successful prepare transfer + * TransferService.logTransferError called to log the invalid request + * + * @param {error} error - error thrown if something fails within Kafka + * @param {array} messages - a list of messages to consume for the relevant topic + * + * @returns {object} - Returns a boolean: true if successful, or throws and error if failed + */ +const prepare = async (error, messages) => { + const location = { module: 'PrepareHandler', method: '', path: '' } + const input = dto.prepareInputDto(error, messages) + + const histTimerEnd = Metrics.getHistogram( + input.metric, + `Consume a ${input.metric} message from the kafka topic and process it accordingly`, + ['success', 'fspId'] + ).startTimer() + if (error) { + histTimerEnd({ success: false, fspId }) + throw reformatFSPIOPError(error) + } + + const { + message, payload, isFx, ID, headers, action, actionLetter, functionality, isForwarded + } = input + + const contextFromMessage = EventSdk.Tracer.extractContextFromMessage(message.value) + const span = EventSdk.Tracer.createChildSpanFromContext(`cl_${input.metric}`, contextFromMessage) + + try { + span.setTags({ transactionId: ID }) + await span.audit(message, EventSdk.AuditEventAction.start) + logger.info(Util.breadcrumb(location, { method: 'prepare' })) + + const params = { + message, + kafkaTopic: message.topic, + decodedPayload: payload, + span, + consumer: Consumer, + producer: Producer + } + + if (proxyEnabled && isForwarded) { + const isOk = await forwardPrepare({ isFx, params, ID }) + logger.info('forwardPrepare message is processed', { isOk, isFx, ID }) + return isOk + } + + const proxyObligation = await calculateProxyObligation({ + payload, isFx, params, functionality, action + }) + + const duplication = await checkDuplication({ payload, isFx, ID, location }) + if (duplication.hasDuplicateId) { + const success = await processDuplication({ + duplication, isFx, ID, functionality, action, actionLetter, params, location + }) + histTimerEnd({ success, fspId }) + return success + } + + const determiningTransferCheckResult = await createRemittanceEntity(isFx) + .checkIfDeterminingTransferExists(proxyObligation.payloadClone, proxyObligation) + + const { validationPassed, reasons } = await Validator.validatePrepare( + payload, + headers, + isFx, + determiningTransferCheckResult, + proxyObligation + ) + + await savePreparedRequest({ + validationPassed, + reasons, + payload, + isFx, + functionality, + params, + location, + determiningTransferCheckResult, + proxyObligation + }) + + if (!validationPassed) { + logger.warn(Util.breadcrumb(location, { path: 'validationFailed' })) + const fspiopError = createFSPIOPError(FSPIOPErrorCodes.VALIDATION_ERROR, reasons.toString()) + await createRemittanceEntity(isFx) + .logTransferError(ID, FSPIOPErrorCodes.VALIDATION_ERROR.code, reasons.toString()) + /** + * TODO: BULK-Handle at BulkProcessingHandler (not in scope of #967) + * HOWTO: For regular transfers this branch may be triggered by sending + * a transfer in a currency not supported by either dfsp. Not sure if it + * will be triggered for bulk, because of the BulkPrepareHandler. + */ + await Kafka.proceed(Config.KAFKA_CONFIG, params, { + consumerCommit, + fspiopError: fspiopError.toApiErrorObject(Config.ERROR_HANDLING), + eventDetail: { functionality, action }, + fromSwitch, + hubName: Config.HUB_NAME + }) + throw fspiopError + } + + logger.info(Util.breadcrumb(location, `positionTopic1--${actionLetter}7`)) + const success = await sendPositionPrepareMessage({ + isFx, action, params, determiningTransferCheckResult, proxyObligation + }) + + histTimerEnd({ success, fspId }) + return success + } catch (err) { + histTimerEnd({ success: false, fspId }) + const fspiopError = reformatFSPIOPError(err) + logger.error(`${Util.breadcrumb(location)}::${err.message}`, err) + const state = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, fspiopError.apiErrorCode.code, fspiopError.apiErrorCode.message) + await span.error(fspiopError, state) + await span.finish(fspiopError.message, state) + return true + } finally { + if (!span.isFinished) { + await span.finish() + } + } +} + +module.exports = { + prepare, + forwardPrepare, + calculateProxyObligation, + checkDuplication, + processDuplication, + savePreparedRequest, + definePositionParticipant, + sendPositionPrepareMessage +} diff --git a/src/handlers/transfers/validator.js b/src/handlers/transfers/validator.js index e4d928115..b2d958c01 100644 --- a/src/handlers/transfers/validator.js +++ b/src/handlers/transfers/validator.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -42,6 +42,9 @@ const Decimal = require('decimal.js') const Config = require('../../lib/config') const Participant = require('../../domain/participant') const Transfer = require('../../domain/transfer') +const FxTransferModel = require('../../models/fxTransfer') +// const TransferStateChangeModel = require('../../models/transfer/transferStateChange') +const FxTransferStateChangeModel = require('../../models/fxTransfer/stateChange') const CryptoConditions = require('../../cryptoConditions') const Crypto = require('crypto') const base64url = require('base64url') @@ -87,9 +90,9 @@ const validatePositionAccountByNameAndCurrency = async function (participantName return validationPassed } -const validateDifferentDfsp = (payload) => { +const validateDifferentDfsp = (payerFsp, payeeFsp) => { if (!Config.ENABLE_ON_US_TRANSFERS) { - const isPayerAndPayeeDifferent = (payload.payerFsp.toLowerCase() !== payload.payeeFsp.toLowerCase()) + const isPayerAndPayeeDifferent = (payerFsp.toLowerCase() !== payeeFsp.toLowerCase()) if (!isPayerAndPayeeDifferent) { reasons.push('Payer FSP and Payee FSP should be different, unless on-us tranfers are allowed by the Scheme') return false @@ -98,8 +101,8 @@ const validateDifferentDfsp = (payload) => { return true } -const validateFspiopSourceMatchesPayer = (payload, headers) => { - const matched = (headers && headers['fspiop-source'] && headers['fspiop-source'] === payload.payerFsp) +const validateFspiopSourceMatchesPayer = (payer, headers) => { + const matched = (headers && headers['fspiop-source'] && headers['fspiop-source'] === payer) if (!matched) { reasons.push('FSPIOP-Source header should match Payer') return false @@ -185,7 +188,11 @@ const validateConditionAndExpiration = async (payload) => { return true } -const validatePrepare = async (payload, headers) => { +const isAmountValid = (payload, isFx) => isFx + ? validateAmount(payload.sourceAmount) && validateAmount(payload.targetAmount) + : validateAmount(payload.amount) + +const validatePrepare = async (payload, headers, isFx = false, determiningTransferCheckResult, proxyObligation) => { const histTimerValidatePrepareEnd = Metrics.getHistogram( 'handlers_transfer_validator', 'validatePrepare - Metrics for transfer handler', @@ -199,15 +206,53 @@ const validatePrepare = async (payload, headers) => { validationPassed = false return { validationPassed, reasons } } - validationPassed = (validateFspiopSourceMatchesPayer(payload, headers) && - await validateParticipantByName(payload.payerFsp) && - await validatePositionAccountByNameAndCurrency(payload.payerFsp, payload.amount.currency) && - await validateParticipantByName(payload.payeeFsp) && - await validatePositionAccountByNameAndCurrency(payload.payeeFsp, payload.amount.currency) && - validateAmount(payload.amount) && - await validateConditionAndExpiration(payload) && - validateDifferentDfsp(payload)) + + const initiatingFsp = isFx ? payload.initiatingFsp : payload.payerFsp + const counterPartyFsp = isFx ? payload.counterPartyFsp : payload.payeeFsp + + // Check if determining transfers are failed + if (determiningTransferCheckResult.watchListRecords && determiningTransferCheckResult.watchListRecords.length > 0) { + // Iterate through determiningTransferCheckResult.watchListRecords + for (const watchListRecord of determiningTransferCheckResult.watchListRecords) { + if (isFx) { + throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.NOT_IMPLEMENTED) + } else { + // Check the transfer state of commitRequestId + const latestFxTransferStateChange = await FxTransferStateChangeModel.getByCommitRequestId(watchListRecord.commitRequestId) + if (latestFxTransferStateChange.transferStateId !== Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT) { + reasons.push('Related FX Transfer is not fulfilled') + validationPassed = false + return { validationPassed, reasons } + } + } + } + } + + // Skip usual validation if preparing a proxy transfer or fxTransfer + if (!(proxyObligation?.isInitiatingFspProxy || proxyObligation?.isCounterPartyFspProxy)) { + validationPassed = ( + validateFspiopSourceMatchesPayer(initiatingFsp, headers) && + isAmountValid(payload, isFx) && + await validateParticipantByName(initiatingFsp) && + await validateParticipantByName(counterPartyFsp) && + await validateConditionAndExpiration(payload) && + validateDifferentDfsp(initiatingFsp, counterPartyFsp) + ) + } else { + validationPassed = true + } + + // validate participant accounts from determiningTransferCheckResult + if (validationPassed && determiningTransferCheckResult) { + for (const participantCurrency of determiningTransferCheckResult.participantCurrencyValidationList) { + if (!await validatePositionAccountByNameAndCurrency(participantCurrency.participantName, participantCurrency.currencyId)) { + validationPassed = false + break // Exit the loop if validation fails + } + } + } histTimerValidatePrepareEnd({ success: true, funcName: 'validatePrepare' }) + return { validationPassed, reasons @@ -241,11 +286,21 @@ const validateParticipantTransferId = async function (participantName, transferI return validationPassed } +const validateParticipantForCommitRequestId = async function (participantName, commitRequestId) { + const fxTransferParticipants = await FxTransferModel.fxTransfer.getFxTransferParticipant(participantName, commitRequestId) + let validationPassed = false + if (Array.isArray(fxTransferParticipants) && fxTransferParticipants.length > 0) { + validationPassed = true + } + return validationPassed +} + module.exports = { validatePrepare, validateById, validateFulfilCondition, validateParticipantByName, reasons, - validateParticipantTransferId + validateParticipantTransferId, + validateParticipantForCommitRequestId } diff --git a/src/lib/cache.js b/src/lib/cache.js index 839ca0a77..d559fc23f 100644 --- a/src/lib/cache.js +++ b/src/lib/cache.js @@ -74,7 +74,7 @@ const initCache = async function () { } const destroyCache = async function () { - catboxMemoryClient.stop() + catboxMemoryClient?.stop() catboxMemoryClient = null } diff --git a/src/lib/config.js b/src/lib/config.js index 5442a4a67..5c9e95526 100644 --- a/src/lib/config.js +++ b/src/lib/config.js @@ -1,4 +1,4 @@ -const RC = require('rc')('CLEDG', require('../../config/default.json')) +const RC = require('parse-strings-in-object')(require('rc')('CLEDG', require('../../config/default.json'))) module.exports = { HOSTNAME: RC.HOSTNAME.replace(/\/$/, ''), @@ -9,8 +9,8 @@ module.exports = { MONGODB_USER: RC.MONGODB.USER, MONGODB_PASSWORD: RC.MONGODB.PASSWORD, MONGODB_DATABASE: RC.MONGODB.DATABASE, - MONGODB_DEBUG: (RC.MONGODB.DEBUG === true || RC.MONGODB.DEBUG === 'true'), - MONGODB_DISABLED: (RC.MONGODB.DISABLED === true || RC.MONGODB.DISABLED === 'true'), + MONGODB_DEBUG: RC.MONGODB.DEBUG === true, + MONGODB_DISABLED: RC.MONGODB.DISABLED === true, AMOUNT: RC.AMOUNT, EXPIRES_TIMEOUT: RC.EXPIRES_TIMEOUT, ERROR_HANDLING: RC.ERROR_HANDLING, @@ -23,6 +23,7 @@ module.exports = { HANDLERS_TIMEOUT_TIMEXP: RC.HANDLERS.TIMEOUT.TIMEXP, HANDLERS_TIMEOUT_TIMEZONE: RC.HANDLERS.TIMEOUT.TIMEZONE, CACHE_CONFIG: RC.CACHE, + PROXY_CACHE_CONFIG: RC.PROXY_CACHE, KAFKA_CONFIG: RC.KAFKA, PARTICIPANT_INITIAL_POSITION: RC.PARTICIPANT_INITIAL_POSITION, RUN_MIGRATIONS: !RC.MIGRATIONS.DISABLED, @@ -69,5 +70,7 @@ module.exports = { debug: RC.DATABASE.DEBUG }, API_DOC_ENDPOINTS_ENABLED: RC.API_DOC_ENDPOINTS_ENABLED || false, + // If this is set to true, payee side currency conversion will not be allowed due to a limitation in the current implementation + PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED: (RC.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED === true || RC.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED === 'true'), SETTLEMENT_MODELS: RC.SETTLEMENT_MODELS } diff --git a/src/lib/db.js b/src/lib/db.js index c49361960..fcde740c8 100644 --- a/src/lib/db.js +++ b/src/lib/db.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/lib/enum.js b/src/lib/enum.js index da51ceb02..bbdd27180 100644 --- a/src/lib/enum.js +++ b/src/lib/enum.js @@ -2,8 +2,8 @@ * @file This registers all handlers for the central-ledger API License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -16,7 +16,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/lib/enumCached.js b/src/lib/enumCached.js index 6d8667fb7..7a54d6df9 100644 --- a/src/lib/enumCached.js +++ b/src/lib/enumCached.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/lib/healthCheck/subServiceHealth.js b/src/lib/healthCheck/subServiceHealth.js index 2ddc59591..ceeed0a5c 100644 --- a/src/lib/healthCheck/subServiceHealth.js +++ b/src/lib/healthCheck/subServiceHealth.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -26,7 +29,7 @@ const { statusEnum, serviceName } = require('@mojaloop/central-services-shared').HealthCheck.HealthCheckEnums const Logger = require('@mojaloop/central-services-logger') const Consumer = require('@mojaloop/central-services-stream').Util.Consumer - +const ProxyCache = require('../proxyCache') const MigrationLockModel = require('../../models/misc/migrationLock') /** @@ -82,7 +85,17 @@ const getSubServiceHealthDatastore = async () => { } } +const getSubServiceHealthProxyCache = async () => { + const proxyCache = ProxyCache.getCache() + const healthCheck = await proxyCache.healthCheck() + return { + name: 'proxyCache', + status: healthCheck ? statusEnum.OK : statusEnum.DOWN + } +} + module.exports = { getSubServiceHealthBroker, - getSubServiceHealthDatastore + getSubServiceHealthDatastore, + getSubServiceHealthProxyCache } diff --git a/src/lib/proxyCache.js b/src/lib/proxyCache.js new file mode 100644 index 000000000..7000a6702 --- /dev/null +++ b/src/lib/proxyCache.js @@ -0,0 +1,166 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +'use strict' +const { createProxyCache } = require('@mojaloop/inter-scheme-proxy-cache-lib') +const { Enum } = require('@mojaloop/central-services-shared') +const ParticipantService = require('../../src/domain/participant') +const Config = require('./config.js') +const { logger } = require('../../src/shared/logger') + +let proxyCache + +const init = () => { + const { type, proxyConfig } = Config.PROXY_CACHE_CONFIG + proxyCache = createProxyCache(type, proxyConfig) +} + +const connect = async () => { + return !proxyCache?.isConnected && getCache().connect() +} + +const disconnect = async () => { + proxyCache?.isConnected && await proxyCache.disconnect() + proxyCache = null +} + +const reset = async () => { + await disconnect() + proxyCache = null +} + +const getCache = () => { + if (!proxyCache) { + init() + } + return proxyCache +} + +/** + * @typedef {Object} ProxyOrParticipant - An object containing the inScheme status, proxyId and FSP name + * + * @property {boolean} inScheme - Is FSP in the scheme. + * @property {string|null} proxyId - Proxy, associated with the FSP, if FSP is not in the scheme. + * @property {string} name - FSP name. + */ + +/** + * Checks if dfspId is in scheme or proxy. + * + * @param {string} dfspId - The DFSP ID to check. + * @param {Object} [options] - { validateCurrencyAccounts: boolean, accounts: [ { currency: string, accountType: Enum.Accounts.LedgerAccountType } ] } + * @returns {ProxyOrParticipant} proxyOrParticipant details + */ +const getFSPProxy = async (dfspId, options = null) => { + logger.debug('Checking if dfspId is in scheme or proxy', { dfspId }) + const participant = await ParticipantService.getByName(dfspId) + let inScheme = !!participant + + if (inScheme && options?.validateCurrencyAccounts) { + logger.debug('Checking if participant currency accounts are active', { dfspId, options, participant }) + let accountsAreActive = false + for (const account of options.accounts) { + accountsAreActive = participant.currencyList.some((currAccount) => { + return ( + currAccount.currencyId === account.currency && + currAccount.ledgerAccountTypeId === account.accountType && + currAccount.isActive === 1 + ) + }) + if (!accountsAreActive) break + } + inScheme = accountsAreActive + } + + return { + inScheme, + proxyId: !participant ? await getCache().lookupProxyByDfspId(dfspId) : null, + name: dfspId + } +} + +const checkSameCreditorDebtorProxy = async (debtorDfspId, creditorDfspId) => { + logger.debug('Checking if debtorDfspId and creditorDfspId are using the same proxy', { debtorDfspId, creditorDfspId }) + const [debtorProxyId, creditorProxyId] = await Promise.all([ + getCache().lookupProxyByDfspId(debtorDfspId), + getCache().lookupProxyByDfspId(creditorDfspId) + ]) + return debtorProxyId && creditorProxyId ? debtorProxyId === creditorProxyId : false +} + +const getProxyParticipantAccountDetails = async (fspName, currency) => { + logger.debug('Getting account details for fspName and currency', { fspName, currency }) + const proxyLookupResult = await getFSPProxy(fspName) + if (proxyLookupResult.inScheme) { + const participantCurrency = await ParticipantService.getAccountByNameAndCurrency( + fspName, + currency, + Enum.Accounts.LedgerAccountType.POSITION + ) + logger.debug("Account details for fspName's currency", { fspName, currency, participantCurrency }) + return { + inScheme: true, + participantCurrencyId: participantCurrency?.participantCurrencyId || null + } + } else { + if (proxyLookupResult.proxyId) { + const participantCurrency = await ParticipantService.getAccountByNameAndCurrency( + proxyLookupResult.proxyId, + currency, + Enum.Accounts.LedgerAccountType.POSITION + ) + logger.debug('Account details for proxy\'s currency', { proxyId: proxyLookupResult.proxyId, currency, participantCurrency }) + return { + inScheme: false, + participantCurrencyId: participantCurrency?.participantCurrencyId || null + } + } + logger.debug('No proxy found for fspName', { fspName }) + return { + inScheme: false, + participantCurrencyId: null + } + } +} + +module.exports = { + reset, // for testing + connect, + disconnect, + getCache, + getFSPProxy, + getProxyParticipantAccountDetails, + checkSameCreditorDebtorProxy +} diff --git a/src/lib/urlParser.js b/src/lib/urlParser.js index a8393795e..f962ffba4 100644 --- a/src/lib/urlParser.js +++ b/src/lib/urlParser.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransfer.js b/src/models/bulkTransfer/bulkTransfer.js index 7254d4077..9e48bca43 100644 --- a/src/models/bulkTransfer/bulkTransfer.js +++ b/src/models/bulkTransfer/bulkTransfer.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransferAssociation.js b/src/models/bulkTransfer/bulkTransferAssociation.js index aeea76292..b563303c6 100644 --- a/src/models/bulkTransfer/bulkTransferAssociation.js +++ b/src/models/bulkTransfer/bulkTransferAssociation.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransferDuplicateCheck.js b/src/models/bulkTransfer/bulkTransferDuplicateCheck.js index 7cb850038..cf9d81afc 100644 --- a/src/models/bulkTransfer/bulkTransferDuplicateCheck.js +++ b/src/models/bulkTransfer/bulkTransferDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransferExtension.js b/src/models/bulkTransfer/bulkTransferExtension.js index 7919ba1e6..f9a6110dc 100644 --- a/src/models/bulkTransfer/bulkTransferExtension.js +++ b/src/models/bulkTransfer/bulkTransferExtension.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransferFulfilmentDuplicateCheck.js b/src/models/bulkTransfer/bulkTransferFulfilmentDuplicateCheck.js index d022df307..67fdab2fd 100644 --- a/src/models/bulkTransfer/bulkTransferFulfilmentDuplicateCheck.js +++ b/src/models/bulkTransfer/bulkTransferFulfilmentDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/bulkTransferStateChange.js b/src/models/bulkTransfer/bulkTransferStateChange.js index ccadfb7d3..de1bca1d7 100644 --- a/src/models/bulkTransfer/bulkTransferStateChange.js +++ b/src/models/bulkTransfer/bulkTransferStateChange.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/bulkTransfer/facade.js b/src/models/bulkTransfer/facade.js index 1dc71c90f..558d456eb 100644 --- a/src/models/bulkTransfer/facade.js +++ b/src/models/bulkTransfer/facade.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -51,25 +54,19 @@ const saveBulkTransferReceived = async (payload, participants, stateReason = nul const knex = await Db.getKnex() return await knex.transaction(async (trx) => { - try { - await knex('bulkTransfer').transacting(trx).insert(bulkTransferRecord) - if (payload.extensionList && payload.extensionList.extension) { - const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { - return { - bulkTransferId: payload.bulkTransferId, - key: ext.key, - value: ext.value - } - }) - await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) - } - await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) - await trx.commit - return state - } catch (err) { - await trx.rollback - throw err + await knex('bulkTransfer').transacting(trx).insert(bulkTransferRecord) + if (payload.extensionList && payload.extensionList.extension) { + const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { + return { + bulkTransferId: payload.bulkTransferId, + key: ext.key, + value: ext.value + } + }) + await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) } + await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) + return state }) } catch (err) { Logger.isErrorEnabled && Logger.error(err) @@ -95,26 +92,20 @@ const saveBulkTransferProcessing = async (payload, stateReason = null, isValid = const knex = await Db.getKnex() return await knex.transaction(async (trx) => { - try { - await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) - if (payload.extensionList && payload.extensionList.extension) { - const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { - return { - bulkTransferId: payload.bulkTransferId, - isFulfilment: true, - key: ext.key, - value: ext.value - } - }) - await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) - } - await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) - await trx.commit - return state - } catch (err) { - await trx.rollback - throw err + await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) + if (payload.extensionList && payload.extensionList.extension) { + const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { + return { + bulkTransferId: payload.bulkTransferId, + isFulfilment: true, + key: ext.key, + value: ext.value + } + }) + await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) } + await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) + return state }) } catch (err) { Logger.isErrorEnabled && Logger.error(err) @@ -138,33 +129,27 @@ const saveBulkTransferErrorProcessing = async (payload, stateReason = null, isVa const knex = await Db.getKnex() return await knex.transaction(async (trx) => { - try { - await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) - if (payload.errorInformation.extensionList && payload.errorInformation.extensionList.extension) { - const bulkTransferExtensionsRecordList = payload.errorInformation.extensionList.extension.map(ext => { - return { - bulkTransferId: payload.bulkTransferId, - isFulfilment: true, - key: ext.key, - value: ext.value - } - }) - await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) - } - const returnedInsertIds = await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord).returning('bulkTransferStateChangeId') - const bulkTransferStateChangeId = returnedInsertIds[0] - const bulkTransferErrorRecord = { - bulkTransferStateChangeId, - errorCode: payload.errorInformation.errorCode, - errorDescription: payload.errorInformation.errorDescription - } - await knex('bulkTransferError').transacting(trx).insert(bulkTransferErrorRecord) - await trx.commit - return state - } catch (err) { - await trx.rollback - throw err + await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) + if (payload.errorInformation.extensionList && payload.errorInformation.extensionList.extension) { + const bulkTransferExtensionsRecordList = payload.errorInformation.extensionList.extension.map(ext => { + return { + bulkTransferId: payload.bulkTransferId, + isFulfilment: true, + key: ext.key, + value: ext.value + } + }) + await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) + } + const returnedInsertIds = await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord).returning('bulkTransferStateChangeId') + const bulkTransferStateChangeId = returnedInsertIds[0] + const bulkTransferErrorRecord = { + bulkTransferStateChangeId, + errorCode: payload.errorInformation.errorCode, + errorDescription: payload.errorInformation.errorDescription } + await knex('bulkTransferError').transacting(trx).insert(bulkTransferErrorRecord) + return state }) } catch (err) { Logger.isErrorEnabled && Logger.error(err) @@ -188,26 +173,20 @@ const saveBulkTransferAborting = async (payload, stateReason = null) => { const knex = await Db.getKnex() return await knex.transaction(async (trx) => { - try { - await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) - if (payload.extensionList && payload.extensionList.extension) { - const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { - return { - bulkTransferId: payload.bulkTransferId, - isFulfilment: true, - key: ext.key, - value: ext.value - } - }) - await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) - } - await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) - await trx.commit - return state - } catch (err) { - await trx.rollback - throw err + await knex('bulkTransferFulfilment').transacting(trx).insert(bulkTransferFulfilmentRecord) + if (payload.extensionList && payload.extensionList.extension) { + const bulkTransferExtensionsRecordList = payload.extensionList.extension.map(ext => { + return { + bulkTransferId: payload.bulkTransferId, + isFulfilment: true, + key: ext.key, + value: ext.value + } + }) + await knex.batchInsert('bulkTransferExtension', bulkTransferExtensionsRecordList).transacting(trx) } + await knex('bulkTransferStateChange').transacting(trx).insert(bulkTransferStateChangeRecord) + return state }) } catch (err) { Logger.isErrorEnabled && Logger.error(err) diff --git a/src/models/bulkTransfer/individualTransfer.js b/src/models/bulkTransfer/individualTransfer.js index 87a2d3a54..d211eb6af 100644 --- a/src/models/bulkTransfer/individualTransfer.js +++ b/src/models/bulkTransfer/individualTransfer.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/fxTransfer/duplicateCheck.js b/src/models/fxTransfer/duplicateCheck.js new file mode 100644 index 000000000..deda85c93 --- /dev/null +++ b/src/models/fxTransfer/duplicateCheck.js @@ -0,0 +1,188 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const Db = require('../../lib/db') +const { logger } = require('../../shared/logger') +const { TABLE_NAMES } = require('../../shared/constants') + +const histName = 'model_fx_transfer' + +const getOneByCommitRequestId = async ({ commitRequestId, table, queryName }) => { + const histTimerEnd = Metrics.getHistogram( + histName, + `${queryName} - Metrics for fxTransfer duplicate check model`, + ['success', 'queryName'] + ).startTimer() + logger.debug('get duplicate record', { commitRequestId, table, queryName }) + + try { + const result = await Db.from(table).findOne({ commitRequestId }) + histTimerEnd({ success: true, queryName }) + return result + } catch (err) { + histTimerEnd({ success: false, queryName }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const saveCommitRequestIdAndHash = async ({ commitRequestId, hash, table, queryName }) => { + const histTimerEnd = Metrics.getHistogram( + histName, + `${queryName} - Metrics for fxTransfer duplicate check model`, + ['success', 'queryName'] + ).startTimer() + logger.debug('save duplicate record', { commitRequestId, hash, table }) + + try { + const result = await Db.from(table).insert({ commitRequestId, hash }) + histTimerEnd({ success: true, queryName }) + return result + } catch (err) { + histTimerEnd({ success: false, queryName }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +/** + * @function GetTransferDuplicateCheck + * + * @async + * @description This retrieves the fxTransferDuplicateCheck table record if present + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * + * @returns {object} - Returns the record from fxTransferDuplicateCheck table, or throws an error if failed + */ +const getFxTransferDuplicateCheck = async (commitRequestId) => { + const table = TABLE_NAMES.fxTransferDuplicateCheck + const queryName = `${table}_getFxTransferDuplicateCheck` + return getOneByCommitRequestId({ commitRequestId, table, queryName }) +} + +/** + * @function SaveTransferDuplicateCheck + * + * @async + * @description This inserts a record into fxTransferDuplicateCheck table + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * @param {string} hash - the hash of the fxTransfer request payload + * + * @returns {integer} - Returns the database id of the inserted row, or throws an error if failed + */ +const saveFxTransferDuplicateCheck = async (commitRequestId, hash) => { + const table = TABLE_NAMES.fxTransferDuplicateCheck + const queryName = `${table}_saveFxTransferDuplicateCheck` + return saveCommitRequestIdAndHash({ commitRequestId, hash, table, queryName }) +} + +/** + * @function getFxTransferErrorDuplicateCheck + * + * @async + * @description This retrieves the fxTransferErrorDuplicateCheck table record if present + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * + * @returns {object} - Returns the record from fxTransferDuplicateCheck table, or throws an error if failed + */ +const getFxTransferErrorDuplicateCheck = async (commitRequestId) => { + const table = TABLE_NAMES.fxTransferErrorDuplicateCheck + const queryName = `${table}_getFxTransferErrorDuplicateCheck` + return getOneByCommitRequestId({ commitRequestId, table, queryName }) +} + +/** + * @function saveFxTransferErrorDuplicateCheck + * + * @async + * @description This inserts a record into fxTransferErrorDuplicateCheck table + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * @param {string} hash - the hash of the fxTransfer request payload + * + * @returns {integer} - Returns the database id of the inserted row, or throws an error if failed + */ +const saveFxTransferErrorDuplicateCheck = async (commitRequestId, hash) => { + const table = TABLE_NAMES.fxTransferErrorDuplicateCheck + const queryName = `${table}_saveFxTransferErrorDuplicateCheck` + return saveCommitRequestIdAndHash({ commitRequestId, hash, table, queryName }) +} + +/** + * @function getFxTransferFulfilmentDuplicateCheck + * + * @async + * @description This retrieves the fxTransferFulfilmentDuplicateCheck table record if present + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * + * @returns {object} - Returns the record from fxTransferFulfilmentDuplicateCheck table, or throws an error if failed + */ +const getFxTransferFulfilmentDuplicateCheck = async (commitRequestId) => { + const table = TABLE_NAMES.fxTransferFulfilmentDuplicateCheck + const queryName = `${table}_getFxTransferFulfilmentDuplicateCheck` + return getOneByCommitRequestId({ commitRequestId, table, queryName }) +} + +/** + * @function saveFxTransferFulfilmentDuplicateCheck + * + * @async + * @description This inserts a record into fxTransferFulfilmentDuplicateCheck table + * + * @param {string} commitRequestId - the fxTransfer commitRequestId + * @param {string} hash - the hash of the fxTransfer request payload + * + * @returns {integer} - Returns the database id of the inserted row, or throws an error if failed + */ +const saveFxTransferFulfilmentDuplicateCheck = async (commitRequestId, hash) => { + const table = TABLE_NAMES.fxTransferFulfilmentDuplicateCheck + const queryName = `${table}_saveFxTransferFulfilmentDuplicateCheck` + return saveCommitRequestIdAndHash({ commitRequestId, hash, table, queryName }) +} + +module.exports = { + getFxTransferDuplicateCheck, + saveFxTransferDuplicateCheck, + + getFxTransferErrorDuplicateCheck, + saveFxTransferErrorDuplicateCheck, + + getFxTransferFulfilmentDuplicateCheck, + saveFxTransferFulfilmentDuplicateCheck +} diff --git a/src/models/fxTransfer/fxTransfer.js b/src/models/fxTransfer/fxTransfer.js new file mode 100644 index 000000000..324ce7408 --- /dev/null +++ b/src/models/fxTransfer/fxTransfer.js @@ -0,0 +1,594 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const Metrics = require('@mojaloop/central-services-metrics') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const { Enum, Util } = require('@mojaloop/central-services-shared') +const Time = require('@mojaloop/central-services-shared').Util.Time +const TransferEventAction = Enum.Events.Event.Action + +const { logger } = require('../../shared/logger') +const { TABLE_NAMES } = require('../../shared/constants') +const Db = require('../../lib/db') +const participant = require('../participant/facade') +const ParticipantCachedModel = require('../participant/participantCached') +const TransferExtensionModel = require('./fxTransferExtension') + +const { TransferInternalState } = Enum.Transfers + +const UnsupportedActionText = 'Unsupported action' + +const getByCommitRequestId = async (commitRequestId) => { + logger.debug('get fxTransfer by commitRequestId:', { commitRequestId }) + return Db.from(TABLE_NAMES.fxTransfer).findOne({ commitRequestId }) +} + +const getByDeterminingTransferId = async (determiningTransferId) => { + logger.debug('get fxTransfers by determiningTransferId:', { determiningTransferId }) + return Db.from(TABLE_NAMES.fxTransfer).find({ determiningTransferId }) +} + +const saveFxTransfer = async (record) => { + logger.debug('save fxTransfer record:', { record }) + return Db.from(TABLE_NAMES.fxTransfer).insert(record) +} + +const getByIdLight = async (id) => { + try { + /** @namespace Db.fxTransfer **/ + return await Db.from(TABLE_NAMES.fxTransfer).query(async (builder) => { + return builder + .where({ 'fxTransfer.commitRequestId': id }) + .leftJoin('fxTransferStateChange AS tsc', 'tsc.commitRequestId', 'fxTransfer.commitRequestId') + .leftJoin('transferState AS ts', 'ts.transferStateId', 'tsc.transferStateId') + .leftJoin('fxTransferFulfilment AS tf', 'tf.commitRequestId', 'fxTransfer.commitRequestId') + .select( + 'fxTransfer.*', + 'tsc.fxTransferStateChangeId', + 'tsc.transferStateId AS fxTransferState', + 'ts.enumeration AS fxTransferStateEnumeration', + 'ts.description as fxTransferStateDescription', + 'tsc.reason AS reason', + 'tsc.createdDate AS completedTimestamp', + 'fxTransfer.ilpCondition AS condition', + 'tf.ilpFulfilment AS fulfilment' + ) + .orderBy('tsc.fxTransferStateChangeId', 'desc') + .first() + }) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getAllDetailsByCommitRequestId = async (commitRequestId) => { + try { + /** @namespace Db.fxTransfer **/ + return await Db.from('fxTransfer').query(async (builder) => { + const transferResult = await builder + .where({ + 'fxTransfer.commitRequestId': commitRequestId, + 'tprt1.name': 'INITIATING_FSP', + 'tprt2.name': 'COUNTER_PARTY_FSP', + 'tprt3.name': 'COUNTER_PARTY_FSP', + 'fpct1.name': 'SOURCE', + 'fpct2.name': 'TARGET' + }) + // INITIATING_FSP + .innerJoin('fxTransferParticipant AS tp1', 'tp1.commitRequestId', 'fxTransfer.commitRequestId') + .innerJoin('transferParticipantRoleType AS tprt1', 'tprt1.transferParticipantRoleTypeId', 'tp1.transferParticipantRoleTypeId') + .innerJoin('participant AS da', 'da.participantId', 'tp1.participantId') + // COUNTER_PARTY_FSP SOURCE currency + .innerJoin('fxTransferParticipant AS tp21', 'tp21.commitRequestId', 'fxTransfer.commitRequestId') + .innerJoin('transferParticipantRoleType AS tprt2', 'tprt2.transferParticipantRoleTypeId', 'tp21.transferParticipantRoleTypeId') + .innerJoin('fxParticipantCurrencyType AS fpct1', 'fpct1.fxParticipantCurrencyTypeId', 'tp21.fxParticipantCurrencyTypeId') + .innerJoin('participant AS ca', 'ca.participantId', 'tp21.participantId') + .leftJoin('participantCurrency AS pc21', 'pc21.participantCurrencyId', 'tp21.participantCurrencyId') + // COUNTER_PARTY_FSP TARGET currency + .innerJoin('fxTransferParticipant AS tp22', 'tp22.commitRequestId', 'fxTransfer.commitRequestId') + .innerJoin('transferParticipantRoleType AS tprt3', 'tprt3.transferParticipantRoleTypeId', 'tp22.transferParticipantRoleTypeId') + .innerJoin('fxParticipantCurrencyType AS fpct2', 'fpct2.fxParticipantCurrencyTypeId', 'tp22.fxParticipantCurrencyTypeId') + // .innerJoin('participantCurrency AS pc22', 'pc22.participantCurrencyId', 'tp22.participantCurrencyId') + // OTHER JOINS + .leftJoin('fxTransferStateChange AS tsc', 'tsc.commitRequestId', 'fxTransfer.commitRequestId') + .leftJoin('transferState AS ts', 'ts.transferStateId', 'tsc.transferStateId') + .leftJoin('fxTransferFulfilment AS tf', 'tf.commitRequestId', 'fxTransfer.commitRequestId') + // .leftJoin('transferError as te', 'te.commitRequestId', 'transfer.commitRequestId') // currently transferError.transferId is PK ensuring one error per transferId + .select( + 'fxTransfer.*', + 'da.participantId AS initiatingFspParticipantId', + 'da.name AS initiatingFspName', + 'da.isProxy AS initiatingFspIsProxy', + // 'pc21.participantCurrencyId AS counterPartyFspSourceParticipantCurrencyId', + // 'pc22.participantCurrencyId AS counterPartyFspTargetParticipantCurrencyId', + 'tp21.participantCurrencyId AS counterPartyFspSourceParticipantCurrencyId', + 'tp22.participantCurrencyId AS counterPartyFspTargetParticipantCurrencyId', + 'ca.participantId AS counterPartyFspParticipantId', + 'ca.name AS counterPartyFspName', + 'ca.isProxy AS counterPartyFspIsProxy', + 'tsc.fxTransferStateChangeId', + 'tsc.transferStateId AS transferState', + 'tsc.reason AS reason', + 'tsc.createdDate AS completedTimestamp', + 'ts.enumeration as transferStateEnumeration', + 'ts.description as transferStateDescription', + 'tf.ilpFulfilment AS fulfilment' + ) + .orderBy('tsc.fxTransferStateChangeId', 'desc') + .first() + if (transferResult) { + transferResult.extensionList = await TransferExtensionModel.getByCommitRequestId(commitRequestId) + if (transferResult.errorCode && transferResult.transferStateEnumeration === Enum.Transfers.TransferState.ABORTED) { + if (!transferResult.extensionList) transferResult.extensionList = [] + transferResult.extensionList.push({ + key: 'cause', + value: `${transferResult.errorCode}: ${transferResult.errorDescription}`.substr(0, 128) + }) + } + transferResult.isTransferReadModel = true + } + return transferResult + }) + } catch (err) { + logger.warn('error in getAllDetailsByCommitRequestId', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +// For proxied fxTransfers and transfers in a regional and jurisdictional scenario, proxy participants +// are not expected to have a target currency account, so we need a slightly altered version of the above function. +const getAllDetailsByCommitRequestIdForProxiedFxTransfer = async (commitRequestId) => { + try { + /** @namespace Db.fxTransfer **/ + return await Db.from('fxTransfer').query(async (builder) => { + const transferResult = await builder + .where({ + 'fxTransfer.commitRequestId': commitRequestId, + 'tprt1.name': 'INITIATING_FSP', + 'tprt2.name': 'COUNTER_PARTY_FSP', + 'fpct1.name': 'SOURCE' + }) + // INITIATING_FSP + .innerJoin('fxTransferParticipant AS tp1', 'tp1.commitRequestId', 'fxTransfer.commitRequestId') + .leftJoin('externalParticipant AS ep1', 'ep1.externalParticipantId', 'tp1.externalParticipantId') + .innerJoin('transferParticipantRoleType AS tprt1', 'tprt1.transferParticipantRoleTypeId', 'tp1.transferParticipantRoleTypeId') + .innerJoin('participant AS da', 'da.participantId', 'tp1.participantId') + // COUNTER_PARTY_FSP SOURCE currency + .innerJoin('fxTransferParticipant AS tp21', 'tp21.commitRequestId', 'fxTransfer.commitRequestId') + .leftJoin('externalParticipant AS ep2', 'ep2.externalParticipantId', 'tp21.externalParticipantId') + .innerJoin('transferParticipantRoleType AS tprt2', 'tprt2.transferParticipantRoleTypeId', 'tp21.transferParticipantRoleTypeId') + .innerJoin('fxParticipantCurrencyType AS fpct1', 'fpct1.fxParticipantCurrencyTypeId', 'tp21.fxParticipantCurrencyTypeId') + .innerJoin('participant AS ca', 'ca.participantId', 'tp21.participantId') + .leftJoin('participantCurrency AS pc21', 'pc21.participantCurrencyId', 'tp21.participantCurrencyId') + // .innerJoin('participantCurrency AS pc22', 'pc22.participantCurrencyId', 'tp22.participantCurrencyId') + // OTHER JOINS + .leftJoin('fxTransferStateChange AS tsc', 'tsc.commitRequestId', 'fxTransfer.commitRequestId') + .leftJoin('transferState AS ts', 'ts.transferStateId', 'tsc.transferStateId') + .leftJoin('fxTransferFulfilment AS tf', 'tf.commitRequestId', 'fxTransfer.commitRequestId') + // .leftJoin('transferError as te', 'te.commitRequestId', 'transfer.commitRequestId') // currently transferError.transferId is PK ensuring one error per transferId + .select( + 'fxTransfer.*', + 'da.participantId AS initiatingFspParticipantId', + 'da.name AS initiatingFspName', + 'da.isProxy AS initiatingFspIsProxy', + // 'pc21.participantCurrencyId AS counterPartyFspSourceParticipantCurrencyId', + // 'pc22.participantCurrencyId AS counterPartyFspTargetParticipantCurrencyId', + 'tp21.participantCurrencyId AS counterPartyFspSourceParticipantCurrencyId', + 'ca.participantId AS counterPartyFspParticipantId', + 'ca.name AS counterPartyFspName', + 'ca.isProxy AS counterPartyFspIsProxy', + 'tsc.fxTransferStateChangeId', + 'tsc.transferStateId AS transferState', + 'tsc.reason AS reason', + 'tsc.createdDate AS completedTimestamp', + 'ts.enumeration as transferStateEnumeration', + 'ts.description as transferStateDescription', + 'tf.ilpFulfilment AS fulfilment', + 'ep1.name AS externalInitiatingFspName', + 'ep2.name AS externalCounterPartyFspName' + ) + .orderBy('tsc.fxTransferStateChangeId', 'desc') + .first() + + if (transferResult) { + transferResult.extensionList = await TransferExtensionModel.getByCommitRequestId(commitRequestId) + if (transferResult.errorCode && transferResult.transferStateEnumeration === Enum.Transfers.TransferState.ABORTED) { + if (!transferResult.extensionList) transferResult.extensionList = [] + transferResult.extensionList.push({ + key: 'cause', + value: `${transferResult.errorCode}: ${transferResult.errorDescription}`.substr(0, 128) + }) + } + transferResult.isTransferReadModel = true + } + return transferResult + }) + } catch (err) { + logger.warn('error in getAllDetailsByCommitRequestIdForProxiedFxTransfer', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getParticipant = async (name, currency) => + participant.getByNameAndCurrency(name, currency, Enum.Accounts.LedgerAccountType.POSITION) + +/** + * Saves prepare fxTransfer details to DB. + * + * @param {Object} payload - Message payload. + * @param {string | null} stateReason - Validation failure reasons. + * @param {Boolean} hasPassedValidation - Is fxTransfer prepare validation passed. + * @param {DeterminingTransferCheckResult} determiningTransferCheckResult - Determining transfer check result. + * @param {ProxyObligation} proxyObligation - The proxy obligation + * @returns {Promise} + */ +const savePreparedRequest = async ( + payload, + stateReason, + hasPassedValidation, + determiningTransferCheckResult, + proxyObligation +) => { + const histTimerSaveFxTransferEnd = Metrics.getHistogram( + 'model_fx_transfer', + 'facade_saveFxTransferPrepared - Metrics for transfer model', + ['success', 'queryName'] + ).startTimer() + + // Substitute out of scheme participants with their proxy representatives + const initiatingFsp = proxyObligation.isInitiatingFspProxy + ? proxyObligation.initiatingFspProxyOrParticipantId.proxyId + : payload.initiatingFsp + const counterPartyFsp = proxyObligation.isCounterPartyFspProxy + ? proxyObligation.counterPartyFspProxyOrParticipantId.proxyId + : payload.counterPartyFsp + + // If creditor(counterPartyFsp) is a proxy in a jurisdictional scenario, + // they would not hold a position account for the target currency, + // so we skip adding records of the target currency for the creditor. + try { + const [initiatingParticipant, counterParticipant1, counterParticipant2] = await Promise.all([ + ParticipantCachedModel.getByName(initiatingFsp), + getParticipant(counterPartyFsp, payload.sourceAmount.currency), + !proxyObligation.isCounterPartyFspProxy ? getParticipant(counterPartyFsp, payload.targetAmount.currency) : null + ]) + + const fxTransferRecord = { + commitRequestId: payload.commitRequestId, + determiningTransferId: payload.determiningTransferId, + sourceAmount: payload.sourceAmount.amount, + sourceCurrency: payload.sourceAmount.currency, + targetAmount: payload.targetAmount.amount, + targetCurrency: payload.targetAmount.currency, + ilpCondition: payload.condition, + expirationDate: Util.Time.getUTCString(new Date(payload.expiration)) + } + + const fxTransferStateChangeRecord = { + commitRequestId: payload.commitRequestId, + transferStateId: hasPassedValidation ? TransferInternalState.RECEIVED_PREPARE : TransferInternalState.INVALID, + reason: stateReason, + createdDate: Util.Time.getUTCString(new Date()) + } + + const initiatingParticipantRecord = { + commitRequestId: payload.commitRequestId, + participantId: initiatingParticipant.participantId, + participantCurrencyId: null, + amount: payload.sourceAmount.amount, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.INITIATING_FSP, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE + } + if (proxyObligation.isInitiatingFspProxy) { + initiatingParticipantRecord.externalParticipantId = await participant + .getExternalParticipantIdByNameOrCreate(proxyObligation.initiatingFspProxyOrParticipantId) + } + + const counterPartyParticipantRecord1 = { + commitRequestId: payload.commitRequestId, + participantId: counterParticipant1.participantId, + participantCurrencyId: counterParticipant1.participantCurrencyId, + amount: -payload.sourceAmount.amount, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.COUNTER_PARTY_FSP, + fxParticipantCurrencyTypeId: Enum.Fx.FxParticipantCurrencyType.SOURCE, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE + } + if (proxyObligation.isCounterPartyFspProxy) { + counterPartyParticipantRecord1.externalParticipantId = await participant + .getExternalParticipantIdByNameOrCreate(proxyObligation.counterPartyFspProxyOrParticipantId) + } + + let counterPartyParticipantRecord2 = null + if (!proxyObligation.isCounterPartyFspProxy) { + counterPartyParticipantRecord2 = { + commitRequestId: payload.commitRequestId, + participantId: counterParticipant2.participantId, + participantCurrencyId: counterParticipant2.participantCurrencyId, + amount: -payload.targetAmount.amount, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.COUNTER_PARTY_FSP, + fxParticipantCurrencyTypeId: Enum.Fx.FxParticipantCurrencyType.TARGET, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE + } + } + + const knex = await Db.getKnex() + if (hasPassedValidation) { + const histTimerSaveTranferTransactionValidationPassedEnd = Metrics.getHistogram( + 'model_fx_transfer', + 'facade_saveFxTransferPrepared_transaction - Metrics for transfer model', + ['success', 'queryName'] + ).startTimer() + return await knex.transaction(async (trx) => { + try { + await knex(TABLE_NAMES.fxTransfer).transacting(trx).insert(fxTransferRecord) + await knex(TABLE_NAMES.fxTransferParticipant).transacting(trx).insert(initiatingParticipantRecord) + await knex(TABLE_NAMES.fxTransferParticipant).transacting(trx).insert(counterPartyParticipantRecord1) + if (!proxyObligation.isCounterPartyFspProxy) { + await knex(TABLE_NAMES.fxTransferParticipant).transacting(trx).insert(counterPartyParticipantRecord2) + } + initiatingParticipantRecord.name = payload.initiatingFsp + counterPartyParticipantRecord1.name = payload.counterPartyFsp + if (!proxyObligation.isCounterPartyFspProxy) { + counterPartyParticipantRecord2.name = payload.counterPartyFsp + } + + await knex(TABLE_NAMES.fxTransferStateChange).transacting(trx).insert(fxTransferStateChangeRecord) + histTimerSaveTranferTransactionValidationPassedEnd({ success: true, queryName: 'facade_saveFxTransferPrepared_transaction' }) + } catch (err) { + histTimerSaveTranferTransactionValidationPassedEnd({ success: false, queryName: 'facade_saveFxTransferPrepared_transaction' }) + throw err + } + }) + } else { + const queryName = 'facade_saveFxTransferPrepared_no_validation' + const histTimerNoValidationEnd = Metrics.getHistogram( + 'model_fx_transfer', + `${queryName} - Metrics for fxTransfer model`, + ['success', 'queryName'] + ).startTimer() + await knex(TABLE_NAMES.fxTransfer).insert(fxTransferRecord) + + try { + await knex(TABLE_NAMES.fxTransferParticipant).insert(initiatingParticipantRecord) + } catch (err) { + logger.warn(`Payer fxTransferParticipant insert error: ${err.message}`) + histTimerNoValidationEnd({ success: false, queryName }) + } + + try { + await knex(TABLE_NAMES.fxTransferParticipant).insert(counterPartyParticipantRecord1) + if (!proxyObligation.isCounterPartyFspProxy) { + await knex(TABLE_NAMES.fxTransferParticipant).insert(counterPartyParticipantRecord2) + } + } catch (err) { + histTimerNoValidationEnd({ success: false, queryName }) + logger.warn(`Payee fxTransferParticipant insert error: ${err.message}`) + } + initiatingParticipantRecord.name = payload.initiatingFsp + counterPartyParticipantRecord1.name = payload.counterPartyFsp + if (!proxyObligation.isCounterPartyFspProxy) { + counterPartyParticipantRecord2.name = payload.counterPartyFsp + } + + try { + await knex(TABLE_NAMES.fxTransferStateChange).insert(fxTransferStateChangeRecord) + histTimerNoValidationEnd({ success: true, queryName }) + } catch (err) { + logger.warn(`fxTransferStateChange insert error: ${err.message}`) + histTimerNoValidationEnd({ success: false, queryName }) + } + } + histTimerSaveFxTransferEnd({ success: true, queryName: 'transfer_model_facade_saveTransferPrepared' }) + } catch (err) { + logger.warn('error in savePreparedRequest', err) + histTimerSaveFxTransferEnd({ success: false, queryName: 'transfer_model_facade_saveTransferPrepared' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const saveFxFulfilResponse = async (commitRequestId, payload, action, fspiopError) => { + const histTimerSaveFulfilResponseEnd = Metrics.getHistogram( + 'fx_model_transfer', + 'facade_saveFxFulfilResponse - Metrics for fxTransfer model', + ['success', 'queryName'] + ).startTimer() + + let state + let isFulfilment = false + let isError = false + // const errorCode = fspiopError && fspiopError.errorInformation && fspiopError.errorInformation.errorCode + const errorDescription = fspiopError && fspiopError.errorInformation && fspiopError.errorInformation.errorDescription + let extensionList + switch (action) { + case TransferEventAction.FX_COMMIT: + case TransferEventAction.FX_RESERVE: + case TransferEventAction.FX_FORWARDED: + state = TransferInternalState.RECEIVED_FULFIL_DEPENDENT + extensionList = payload && payload.extensionList + isFulfilment = true + break + case TransferEventAction.FX_REJECT: + state = TransferInternalState.RECEIVED_REJECT + extensionList = payload && payload.extensionList + isFulfilment = true + break + + case TransferEventAction.FX_ABORT_VALIDATION: + case TransferEventAction.FX_ABORT: + state = TransferInternalState.RECEIVED_ERROR + extensionList = payload && payload.errorInformation && payload.errorInformation.extensionList + isError = true + break + default: + throw ErrorHandler.Factory.createInternalServerFSPIOPError(UnsupportedActionText) + } + const completedTimestamp = Time.getUTCString((payload.completedTimestamp && new Date(payload.completedTimestamp)) || new Date()) + const transactionTimestamp = Time.getUTCString(new Date()) + const result = { + savePayeeTransferResponseExecuted: false + } + + const fxTransferFulfilmentRecord = { + commitRequestId, + ilpFulfilment: payload.fulfilment || null, + completedDate: completedTimestamp, + isValid: !fspiopError, + settlementWindowId: null, + createdDate: transactionTimestamp + } + let fxTransferExtensionRecordsList = [] + if (extensionList && extensionList.extension) { + fxTransferExtensionRecordsList = extensionList.extension.map(ext => { + return { + commitRequestId, + key: ext.key, + value: ext.value, + isFulfilment, + isError + } + }) + } + const fxTransferStateChangeRecord = { + commitRequestId, + transferStateId: state, + reason: errorDescription, + createdDate: transactionTimestamp + } + + try { + /** @namespace Db.getKnex **/ + const knex = await Db.getKnex() + const histTFxFulfilResponseValidationPassedEnd = Metrics.getHistogram( + 'model_transfer', + 'facade_saveTransferPrepared_transaction - Metrics for transfer model', + ['success', 'queryName'] + ).startTimer() + + await knex.transaction(async (trx) => { + try { + if (!fspiopError && [TransferEventAction.FX_COMMIT, TransferEventAction.FX_RESERVE].includes(action)) { + const res = await Db.from('settlementWindow').query(builder => { + return builder + .leftJoin('settlementWindowStateChange AS swsc', 'swsc.settlementWindowStateChangeId', 'settlementWindow.currentStateChangeId') + .select( + 'settlementWindow.settlementWindowId', + 'swsc.settlementWindowStateId as state', + 'swsc.reason as reason', + 'settlementWindow.createdDate as createdDate', + 'swsc.createdDate as changedDate' + ) + .where('swsc.settlementWindowStateId', 'OPEN') + .orderBy('changedDate', 'desc') + }) + fxTransferFulfilmentRecord.settlementWindowId = res[0].settlementWindowId + logger.debug('saveFxFulfilResponse::settlementWindowId') + } + if (isFulfilment) { + await knex('fxTransferFulfilment').transacting(trx).insert(fxTransferFulfilmentRecord) + result.fxTransferFulfilmentRecord = fxTransferFulfilmentRecord + logger.debug('saveFxFulfilResponse::fxTransferFulfilment') + } + if (fxTransferExtensionRecordsList.length > 0) { + await knex('fxTransferExtension').transacting(trx).insert(fxTransferExtensionRecordsList) + result.fxTransferExtensionRecordsList = fxTransferExtensionRecordsList + logger.debug('saveFxFulfilResponse::transferExtensionRecordsList') + } + await knex('fxTransferStateChange').transacting(trx).insert(fxTransferStateChangeRecord) + result.fxTransferStateChangeRecord = fxTransferStateChangeRecord + logger.debug('saveFxFulfilResponse::fxTransferStateChange') + histTFxFulfilResponseValidationPassedEnd({ success: true, queryName: 'facade_saveFxFulfilResponse_transaction' }) + result.savePayeeTransferResponseExecuted = true + logger.debug('saveFxFulfilResponse::success') + } catch (err) { + histTFxFulfilResponseValidationPassedEnd({ success: false, queryName: 'facade_saveFxFulfilResponse_transaction' }) + logger.error('saveFxFulfilResponse::failure') + throw err + } + }) + histTimerSaveFulfilResponseEnd({ success: true, queryName: 'facade_saveFulfilResponse' }) + return result + } catch (err) { + logger.warn('error in saveFxFulfilResponse', err) + histTimerSaveFulfilResponseEnd({ success: false, queryName: 'facade_saveFulfilResponse' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const updateFxPrepareReservedForwarded = async function (commitRequestId) { + try { + const knex = await Db.getKnex() + return await knex('fxTransferStateChange') + .insert({ + commitRequestId, + transferStateId: TransferInternalState.RESERVED_FORWARDED, + reason: null, + createdDate: Time.getUTCString(new Date()) + }) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getFxTransferParticipant = async (participantName, commitRequestId) => { + try { + return Db.from('participant').query(async (builder) => { + return builder + .where({ + 'ftp.commitRequestId': commitRequestId, + 'participant.name': participantName, + 'participant.isActive': 1 + }) + .innerJoin('fxTransferParticipant AS ftp', 'ftp.participantId', 'participant.participantId') + .select( + 'ftp.*' + ) + }) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +module.exports = { + getByCommitRequestId, + getByDeterminingTransferId, + getByIdLight, + getAllDetailsByCommitRequestId, + getAllDetailsByCommitRequestIdForProxiedFxTransfer, + getFxTransferParticipant, + savePreparedRequest, + saveFxFulfilResponse, + saveFxTransfer, + updateFxPrepareReservedForwarded +} diff --git a/src/models/fxTransfer/fxTransferError.js b/src/models/fxTransfer/fxTransferError.js new file mode 100644 index 000000000..8568a8c73 --- /dev/null +++ b/src/models/fxTransfer/fxTransferError.js @@ -0,0 +1,56 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +/** + * @module src/models/transfer/transferError/ + */ + +const Db = require('../../lib/db') +const Logger = require('@mojaloop/central-services-logger') + +const getByCommitRequestId = async (id) => { + try { + const fxTransferError = await Db.from('fxTransferError').query(async (builder) => { + const result = builder + .where({ commitRequestId: id }) + .select('*') + .first() + return result + }) + fxTransferError.errorCode = fxTransferError.errorCode.toString() + return fxTransferError + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + +module.exports = { + getByCommitRequestId +} diff --git a/src/models/fxTransfer/fxTransferExtension.js b/src/models/fxTransfer/fxTransferExtension.js new file mode 100644 index 000000000..58081b1dc --- /dev/null +++ b/src/models/fxTransfer/fxTransferExtension.js @@ -0,0 +1,44 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Infitx + - Kalin Krustev + -------------- + ******/ + +'use strict' + +const Db = require('../../lib/db') +const ErrorHandler = require('@mojaloop/central-services-error-handling') + +const getByCommitRequestId = async (commitRequestId, isFulfilment = false, isError = false) => { + try { + return await Db.from('fxTransferExtension').find({ commitRequestId, isFulfilment, isError }) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +module.exports = { + getByCommitRequestId +} diff --git a/src/models/fxTransfer/fxTransferTimeout.js b/src/models/fxTransfer/fxTransferTimeout.js new file mode 100644 index 000000000..0c71ab135 --- /dev/null +++ b/src/models/fxTransfer/fxTransferTimeout.js @@ -0,0 +1,71 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +const Db = require('../../lib/db') +const Logger = require('@mojaloop/central-services-logger') +const Enum = require('@mojaloop/central-services-shared').Enum +const TS = Enum.Transfers.TransferInternalState + +const cleanup = async () => { + Logger.isDebugEnabled && Logger.debug('cleanup fxTransferTimeout') + try { + const knex = await Db.getKnex() + + const ttIdList = await Db.from('fxTransferTimeout').query(async (builder) => { + const b = await builder + .whereIn('tsc.transferStateId', [`${TS.RECEIVED_FULFIL}`, `${TS.COMMITTED}`, `${TS.FAILED}`, `${TS.RESERVED_TIMEOUT}`, + `${TS.RECEIVED_REJECT}`, `${TS.EXPIRED_PREPARED}`, `${TS.EXPIRED_RESERVED}`, `${TS.ABORTED_REJECTED}`, `${TS.ABORTED_ERROR}`]) + .innerJoin( + knex('fxTransferTimeout AS tt1') + .select('tsc1.commitRequestId') + .max('tsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferStateChange AS tsc1', 'tsc1.commitRequestId', 'tt1.commitRequestId') + .groupBy('tsc1.commitRequestId').as('ts'), 'ts.commitRequestId', 'fxTransferTimeout.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS tsc', 'tsc.fxTransferStateChangeId', 'ts.maxFxTransferStateChangeId') + .select('fxTransferTimeout.fxTransferTimeoutId') + return b + }) + + await Db.from('fxTransferTimeout').query(async (builder) => { + const b = await builder + .whereIn('fxTransferTimeoutId', ttIdList.map(elem => elem.fxTransferTimeoutId)) + .del() + return b + }) + return ttIdList + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + +module.exports = { + cleanup +} diff --git a/src/models/fxTransfer/index.js b/src/models/fxTransfer/index.js new file mode 100644 index 000000000..526024f58 --- /dev/null +++ b/src/models/fxTransfer/index.js @@ -0,0 +1,50 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const duplicateCheck = require('./duplicateCheck') +const fxTransfer = require('./fxTransfer') +const stateChange = require('./stateChange') +const watchList = require('./watchList') +const fxTransferTimeout = require('./fxTransferTimeout') +const fxTransferError = require('./fxTransferError') + +module.exports = { + duplicateCheck, + fxTransfer, + stateChange, + watchList, + fxTransferTimeout, + fxTransferError +} diff --git a/src/models/fxTransfer/stateChange.js b/src/models/fxTransfer/stateChange.js new file mode 100644 index 000000000..8c1283788 --- /dev/null +++ b/src/models/fxTransfer/stateChange.js @@ -0,0 +1,81 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const TransferError = require('../../models/transfer/transferError') +const Db = require('../../lib/db') +const { TABLE_NAMES } = require('../../shared/constants') +const { logger } = require('../../shared/logger') + +const table = TABLE_NAMES.fxTransferStateChange + +const getByCommitRequestId = async (id) => { + return await Db.from(table).query(async (builder) => { + return builder + .where({ 'fxTransferStateChange.commitRequestId': id }) + .select('fxTransferStateChange.*') + .orderBy('fxTransferStateChangeId', 'desc') + .first() + }) +} + +const logTransferError = async (id, errorCode, errorDescription) => { + try { + const stateChange = await getByCommitRequestId(id) + return TransferError.insert(id, stateChange?.fxTransferStateChangeId, errorCode, errorDescription) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getLatest = async () => { + try { + return await Db.from('fxTransferStateChange').query(async (builder) => { + return builder + .select('fxTransferStateChangeId') + .orderBy('fxTransferStateChangeId', 'desc') + .first() + }) + } catch (err) { + logger.error('getLatest::fxTransferStateChange', err) + throw err + } +} + +module.exports = { + getByCommitRequestId, + logTransferError, + getLatest +} diff --git a/src/models/fxTransfer/watchList.js b/src/models/fxTransfer/watchList.js new file mode 100644 index 000000000..1e057d6a1 --- /dev/null +++ b/src/models/fxTransfer/watchList.js @@ -0,0 +1,52 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + * Vijay Kumar Guthi + -------------- + ******/ + +'use strict' + +const Db = require('../../lib/db') +const { TABLE_NAMES } = require('../../shared/constants') +const { logger } = require('../../shared/logger') + +const getItemInWatchListByCommitRequestId = async (commitRequestId) => { + logger.debug(`get item in watch list (commitRequestId=${commitRequestId})`) + return Db.from(TABLE_NAMES.fxWatchList).findOne({ commitRequestId }) +} + +const getItemsInWatchListByDeterminingTransferId = async (determiningTransferId) => { + logger.debug(`get item in watch list (determiningTransferId=${determiningTransferId})`) + return Db.from(TABLE_NAMES.fxWatchList).find({ determiningTransferId }) +} + +const addToWatchList = async (record) => { + logger.debug('add to fx watch list', record) + return Db.from(TABLE_NAMES.fxWatchList).insert(record) +} + +module.exports = { + getItemInWatchListByCommitRequestId, + getItemsInWatchListByDeterminingTransferId, + addToWatchList +} diff --git a/src/models/ilpPackets/ilpPacket.js b/src/models/ilpPackets/ilpPacket.js index 358763bb7..ac03b9b13 100644 --- a/src/models/ilpPackets/ilpPacket.js +++ b/src/models/ilpPackets/ilpPacket.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/ledgerAccountType/ledgerAccountType.js b/src/models/ledgerAccountType/ledgerAccountType.js index 4b2795473..4b151093c 100644 --- a/src/models/ledgerAccountType/ledgerAccountType.js +++ b/src/models/ledgerAccountType/ledgerAccountType.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -35,25 +38,19 @@ const ErrorHandler = require('@mojaloop/central-services-error-handling') exports.getLedgerAccountByName = async (name, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { const ledgerAccountType = await knex('ledgerAccountType') .select() .where('name', name) .transacting(trx) - if (doCommit) { - await trx.commit - } return ledgerAccountType.length > 0 ? ledgerAccountType[0] : null } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -66,25 +63,19 @@ exports.getLedgerAccountByName = async (name, trx = null) => { exports.getLedgerAccountsByName = async (names, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { const ledgerAccountTypes = await knex('ledgerAccountType') .select('name') .whereIn('name', names) .transacting(trx) - if (doCommit) { - await trx.commit - } return ledgerAccountTypes } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -97,7 +88,7 @@ exports.getLedgerAccountsByName = async (names, trx = null) => { exports.bulkInsert = async (records, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { await knex('ledgerAccountType') .insert(records) @@ -107,19 +98,13 @@ exports.bulkInsert = async (records, trx = null) => { .from('ledgerAccountType') .whereIn('name', recordsNames) .transacting(trx) - if (doCommit) { - await trx.commit - } return createdIds.map(record => record.ledgerAccountTypeId) } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -131,7 +116,7 @@ exports.bulkInsert = async (records, trx = null) => { exports.create = async (name, description, isActive, isSettleable, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { await knex('ledgerAccountType') .insert({ @@ -145,19 +130,13 @@ exports.create = async (name, description, isActive, isSettleable, trx = null) = .from('ledgerAccountType') .where('name', name) .transacting(trx) - if (doCommit) { - await trx.commit - } return createdId[0].ledgerAccountTypeId } catch (err) { - if (doCommit) { - await trx.rollback() - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } diff --git a/src/models/misc/migrationLock.js b/src/models/misc/migrationLock.js index e482c1443..7f00dc14b 100644 --- a/src/models/misc/migrationLock.js +++ b/src/models/misc/migrationLock.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/misc/segment.js b/src/models/misc/segment.js index 60250ae5a..0bf946c40 100644 --- a/src/models/misc/segment.js +++ b/src/models/misc/segment.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -26,7 +29,6 @@ const Db = require('../../lib/db') const ErrorHandler = require('@mojaloop/central-services-error-handling') -// const Logger = require('@mojaloop/central-services-logger') const getByParams = async (params) => { try { diff --git a/src/models/participant/externalParticipant.js b/src/models/participant/externalParticipant.js new file mode 100644 index 000000000..6fc6b7038 --- /dev/null +++ b/src/models/participant/externalParticipant.js @@ -0,0 +1,98 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Db = require('../../lib/db') +const { logger } = require('../../shared/logger') +const { TABLE_NAMES, DB_ERROR_CODES } = require('../../shared/constants') + +const TABLE = TABLE_NAMES.externalParticipant +const ID_FIELD = 'externalParticipantId' + +const log = logger.child(`DB#${TABLE}`) + +const create = async ({ name, proxyId }) => { + try { + const result = await Db.from(TABLE).insert({ name, proxyId }) + log.debug('create result:', { result }) + return result + } catch (err) { + if (err.code === DB_ERROR_CODES.duplicateEntry) { + log.warn('duplicate entry for externalParticipant. Skip inserting', { name, proxyId }) + return null + } + log.error('error in create', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getAll = async (options = {}) => { + try { + const result = await Db.from(TABLE).find({}, options) + log.debug('getAll result:', { result }) + return result + } catch (err) /* istanbul ignore next */ { + log.error('error in getAll:', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getOneBy = async (criteria, options) => { + try { + const result = await Db.from(TABLE).findOne(criteria, options) + log.debug('getOneBy result:', { criteria, result }) + return result + } catch (err) /* istanbul ignore next */ { + log.error('error in getOneBy:', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} +const getById = async (id, options = {}) => getOneBy({ [ID_FIELD]: id }, options) +const getByName = async (name, options = {}) => getOneBy({ name }, options) + +const destroyBy = async (criteria) => { + try { + const result = await Db.from(TABLE).destroy(criteria) + log.debug('destroyBy result:', { criteria, result }) + return result + } catch (err) /* istanbul ignore next */ { + log.error('error in destroyBy', err) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} +const destroyById = async (id) => destroyBy({ [ID_FIELD]: id }) +const destroyByName = async (name) => destroyBy({ name }) + +module.exports = { + create, + getAll, + getById, + getByName, + destroyById, + destroyByName +} diff --git a/src/models/participant/externalParticipantCached.js b/src/models/participant/externalParticipantCached.js new file mode 100644 index 000000000..d83d94805 --- /dev/null +++ b/src/models/participant/externalParticipantCached.js @@ -0,0 +1,151 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const cache = require('../../lib/cache') +const externalParticipantModel = require('./externalParticipant') + +let cacheClient +let epAllCacheKey + +const buildUnifiedCachedData = (allExternalParticipants) => { + // build indexes - optimization for byId and byName access + const indexById = {} + const indexByName = {} + + allExternalParticipants.forEach(({ createdDate, ...ep }) => { + indexById[ep.externalParticipantId] = ep + indexByName[ep.name] = ep + }) + + // build unified structure - indexes + data + return { + indexById, + indexByName, + allExternalParticipants + } +} + +const getExternalParticipantsCached = async () => { + const queryName = 'model_getExternalParticipantsCached' + const histTimer = Metrics.getHistogram( + 'model_externalParticipant', + `${queryName} - Metrics for externalParticipant model`, + ['success', 'queryName', 'hit'] + ).startTimer() + + let cachedParticipants = cacheClient.get(epAllCacheKey) + const hit = !!cachedParticipants + + if (!cachedParticipants) { + const allParticipants = await externalParticipantModel.getAll() + cachedParticipants = buildUnifiedCachedData(allParticipants) + cacheClient.set(epAllCacheKey, cachedParticipants) + } else { + // unwrap participants list from catbox structure + cachedParticipants = cachedParticipants.item + } + histTimer({ success: true, queryName, hit }) + + return cachedParticipants +} + +/* + Public API +*/ +const initialize = () => { + /* Register as cache client */ + const cacheClientMeta = { + id: 'externalParticipants', + preloadCache: getExternalParticipantsCached + } + + cacheClient = cache.registerCacheClient(cacheClientMeta) + epAllCacheKey = cacheClient.createKey('all') +} + +const invalidateCache = async () => { + cacheClient.drop(epAllCacheKey) +} + +const getById = async (id) => { + try { + const cachedParticipants = await getExternalParticipantsCached() + return cachedParticipants.indexById[id] + } catch (err) /* istanbul ignore next */ { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getByName = async (name) => { + try { + const cachedParticipants = await getExternalParticipantsCached() + return cachedParticipants.indexByName[name] + } catch (err) /* istanbul ignore next */ { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const getAll = async () => { + try { + const cachedParticipants = await getExternalParticipantsCached() + return cachedParticipants.allExternalParticipants + } catch (err) /* istanbul ignore next */ { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +const withInvalidate = (theFunctionName) => { + return async (...args) => { + try { + const result = await externalParticipantModel[theFunctionName](...args) + await invalidateCache() + return result + } catch (err) /* istanbul ignore next */ { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } + } +} + +const create = withInvalidate('create') +const destroyById = withInvalidate('destroyById') +const destroyByName = withInvalidate('destroyByName') + +module.exports = { + initialize, + invalidateCache, + + getAll, + getById, + getByName, + + create, + destroyById, + destroyByName +} diff --git a/src/models/participant/facade.js b/src/models/participant/facade.js index cf68cc666..9059c29a5 100644 --- a/src/models/participant/facade.js +++ b/src/models/participant/facade.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,17 +31,20 @@ * @module src/models/participant/facade/ */ -const Db = require('../../lib/db') const Time = require('@mojaloop/central-services-shared').Util.Time +const { Enum } = require('@mojaloop/central-services-shared') const ErrorHandler = require('@mojaloop/central-services-error-handling') const Metrics = require('@mojaloop/central-services-metrics') + +const Db = require('../../lib/db') const Cache = require('../../lib/cache') const ParticipantModelCached = require('../../models/participant/participantCached') const ParticipantCurrencyModelCached = require('../../models/participant/participantCurrencyCached') const ParticipantLimitCached = require('../../models/participant/participantLimitCached') +const externalParticipantModelCached = require('../../models/participant/externalParticipantCached') const Config = require('../../lib/config') const SettlementModelModel = require('../settlement/settlementModel') -const { Enum } = require('@mojaloop/central-services-shared') +const { logger } = require('../../shared/logger') const getByNameAndCurrency = async (name, currencyId, ledgerAccountTypeId, isCurrencyActive) => { const histTimerParticipantGetByNameAndCurrencyEnd = Metrics.getHistogram( @@ -106,6 +112,72 @@ const getByNameAndCurrency = async (name, currencyId, ledgerAccountTypeId, isCur } } +const getByIDAndCurrency = async (participantId, currencyId, ledgerAccountTypeId, isCurrencyActive) => { + const histTimerParticipantGetByIDAndCurrencyEnd = Metrics.getHistogram( + 'model_participant', + 'facade_getByIDAndCurrency - Metrics for participant model', + ['success', 'queryName'] + ).startTimer() + + try { + let participant + if (Cache.isCacheEnabled()) { + /* Cached version - fetch data from Models (which we trust are cached) */ + /* find paricipant by ID */ + participant = await ParticipantModelCached.getById(participantId) + if (participant) { + /* use the paricipant id and incoming params to prepare the filter */ + const searchFilter = { + participantId, + currencyId, + ledgerAccountTypeId + } + if (isCurrencyActive !== undefined) { + searchFilter.isActive = isCurrencyActive + } + + /* find the participantCurrency by prepared filter */ + const participantCurrency = await ParticipantCurrencyModelCached.findOneByParams(searchFilter) + + if (participantCurrency) { + /* mix requested data from participantCurrency */ + participant.participantCurrencyId = participantCurrency.participantCurrencyId + participant.currencyId = participantCurrency.currencyId + participant.currencyIsActive = participantCurrency.isActive + } + } + } else { + /* Non-cached version - direct call to DB */ + participant = await Db.from('participant').query(async (builder) => { + let b = builder + .where({ 'participant.participantId': participantId }) + .andWhere({ 'pc.currencyId': currencyId }) + .andWhere({ 'pc.ledgerAccountTypeId': ledgerAccountTypeId }) + .innerJoin('participantCurrency AS pc', 'pc.participantId', 'participant.participantId') + .select( + 'participant.*', + 'pc.participantCurrencyId', + 'pc.currencyId', + 'pc.isActive AS currencyIsActive' + ) + .first() + + if (isCurrencyActive !== undefined) { + b = b.andWhere({ 'pc.isActive': isCurrencyActive }) + } + return b + }) + } + + histTimerParticipantGetByIDAndCurrencyEnd({ success: true, queryName: 'facade_getByIDAndCurrency' }) + + return participant + } catch (err) { + histTimerParticipantGetByIDAndCurrencyEnd({ success: false, queryName: 'facade_getByIDAndCurrency' }) + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + const getParticipantLimitByParticipantIdAndCurrencyId = async (participantId, currencyId, ledgerAccountTypeId) => { try { return await Db.from('participant').query(async (builder) => { @@ -259,34 +331,30 @@ const addEndpoint = async (participantId, endpoint) => { try { const knex = Db.getKnex() return knex.transaction(async trx => { - try { - const endpointType = await knex('endpointType').where({ name: endpoint.type, isActive: 1 }).select('endpointTypeId').first() - // let endpointType = await trx.first('endpointTypeId').from('endpointType').where({ 'name': endpoint.type, 'isActive': 1 }) + const endpointType = await knex('endpointType').where({ + name: endpoint.type, + isActive: 1 + }).select('endpointTypeId').first() - const existingEndpoint = await knex('participantEndpoint').transacting(trx).forUpdate().select('*') - .where({ - participantId, - endpointTypeId: endpointType.endpointTypeId, - isActive: 1 - }) - if (Array.isArray(existingEndpoint) && existingEndpoint.length > 0) { - await knex('participantEndpoint').transacting(trx).update({ isActive: 0 }).where('participantEndpointId', existingEndpoint[0].participantEndpointId) - } - const newEndpoint = { + const existingEndpoint = await knex('participantEndpoint').transacting(trx).forUpdate().select('*') + .where({ participantId, endpointTypeId: endpointType.endpointTypeId, - value: endpoint.value, - isActive: 1, - createdBy: 'unknown' - } - const result = await knex('participantEndpoint').transacting(trx).insert(newEndpoint) - newEndpoint.participantEndpointId = result[0] - await trx.commit - return newEndpoint - } catch (err) { - await trx.rollback - throw err + isActive: 1 + }) + if (Array.isArray(existingEndpoint) && existingEndpoint.length > 0) { + await knex('participantEndpoint').transacting(trx).update({ isActive: 0 }).where('participantEndpointId', existingEndpoint[0].participantEndpointId) + } + const newEndpoint = { + participantId, + endpointTypeId: endpointType.endpointTypeId, + value: endpoint.value, + isActive: 1, + createdBy: 'unknown' } + const result = await knex('participantEndpoint').transacting(trx).insert(newEndpoint) + newEndpoint.participantEndpointId = result[0] + return newEndpoint }) } catch (err) { throw ErrorHandler.Factory.reformatFSPIOPError(err) @@ -413,73 +481,67 @@ const addLimitAndInitialPosition = async (participantCurrencyId, settlementAccou try { const knex = Db.getKnex() return knex.transaction(async trx => { - try { - const limitType = await knex('participantLimitType').where({ name: limitPositionObj.limit.type, isActive: 1 }).select('participantLimitTypeId').first() - const participantLimit = { - participantCurrencyId, - participantLimitTypeId: limitType.participantLimitTypeId, - value: limitPositionObj.limit.value, - isActive: 1, - createdBy: 'unknown' - } - const result = await knex('participantLimit').transacting(trx).insert(participantLimit) - participantLimit.participantLimitId = result[0] - - const allSettlementModels = await SettlementModelModel.getAll() - const settlementModels = allSettlementModels.filter(model => model.currencyId === limitPositionObj.currency) - if (Array.isArray(settlementModels) && settlementModels.length > 0) { - for (const settlementModel of settlementModels) { - const positionAccount = await getByNameAndCurrency(limitPositionObj.name, limitPositionObj.currency, settlementModel.ledgerAccountTypeId) - const settlementAccount = await getByNameAndCurrency(limitPositionObj.name, limitPositionObj.currency, settlementModel.settlementAccountTypeId) - - const participantPosition = { - participantCurrencyId: positionAccount.participantCurrencyId, - value: (settlementModel.ledgerAccountTypeId === Enum.Accounts.LedgerAccountType.POSITION ? limitPositionObj.initialPosition : 0), - reservedValue: 0 - } - await knex('participantPosition').transacting(trx).insert(participantPosition) + const limitType = await knex('participantLimitType').where({ name: limitPositionObj.limit.type, isActive: 1 }).select('participantLimitTypeId').first() + const participantLimit = { + participantCurrencyId, + participantLimitTypeId: limitType.participantLimitTypeId, + value: limitPositionObj.limit.value, + isActive: 1, + createdBy: 'unknown' + } + const result = await knex('participantLimit').transacting(trx).insert(participantLimit) + participantLimit.participantLimitId = result[0] + + const allSettlementModels = await SettlementModelModel.getAll() + const settlementModels = allSettlementModels.filter(model => model.currencyId === limitPositionObj.currency) + if (Array.isArray(settlementModels) && settlementModels.length > 0) { + for (const settlementModel of settlementModels) { + const positionAccount = await getByNameAndCurrency(limitPositionObj.name, limitPositionObj.currency, settlementModel.ledgerAccountTypeId) + const settlementAccount = await getByNameAndCurrency(limitPositionObj.name, limitPositionObj.currency, settlementModel.settlementAccountTypeId) - const settlementPosition = { - participantCurrencyId: settlementAccount.participantCurrencyId, - value: 0, - reservedValue: 0 - } - await knex('participantPosition').transacting(trx).insert(settlementPosition) - if (setCurrencyActive) { // if the flag is true then set the isActive flag for corresponding participantCurrency record to true - await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', positionAccount.participantCurrencyId) - await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', settlementAccount.participantCurrencyId) - await ParticipantCurrencyModelCached.invalidateParticipantCurrencyCache() - await ParticipantLimitCached.invalidateParticipantLimitCache() - } - } - } else { const participantPosition = { - participantCurrencyId, - value: limitPositionObj.initialPosition, + participantCurrencyId: positionAccount.participantCurrencyId, + value: (settlementModel.ledgerAccountTypeId === Enum.Accounts.LedgerAccountType.POSITION ? limitPositionObj.initialPosition : 0), reservedValue: 0 } - const participantPositionResult = await knex('participantPosition').transacting(trx).insert(participantPosition) - participantPosition.participantPositionId = participantPositionResult[0] + await knex('participantPosition').transacting(trx).insert(participantPosition) + const settlementPosition = { - participantCurrencyId: settlementAccountId, + participantCurrencyId: settlementAccount.participantCurrencyId, value: 0, reservedValue: 0 } await knex('participantPosition').transacting(trx).insert(settlementPosition) if (setCurrencyActive) { // if the flag is true then set the isActive flag for corresponding participantCurrency record to true - await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', participantCurrencyId) - await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', settlementAccountId) + await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', positionAccount.participantCurrencyId) + await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', settlementAccount.participantCurrencyId) await ParticipantCurrencyModelCached.invalidateParticipantCurrencyCache() await ParticipantLimitCached.invalidateParticipantLimitCache() } } - - await trx.commit - return true - } catch (err) { - await trx.rollback - throw err + } else { + const participantPosition = { + participantCurrencyId, + value: limitPositionObj.initialPosition, + reservedValue: 0 + } + const participantPositionResult = await knex('participantPosition').transacting(trx).insert(participantPosition) + participantPosition.participantPositionId = participantPositionResult[0] + const settlementPosition = { + participantCurrencyId: settlementAccountId, + value: 0, + reservedValue: 0 + } + await knex('participantPosition').transacting(trx).insert(settlementPosition) + if (setCurrencyActive) { // if the flag is true then set the isActive flag for corresponding participantCurrency record to true + await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', participantCurrencyId) + await knex('participantCurrency').transacting(trx).update({ isActive: 1 }).where('participantCurrencyId', settlementAccountId) + await ParticipantCurrencyModelCached.invalidateParticipantCurrencyCache() + await ParticipantLimitCached.invalidateParticipantLimitCache() + } } + + return true }) } catch (err) { throw ErrorHandler.Factory.reformatFSPIOPError(err) @@ -510,7 +572,7 @@ const addLimitAndInitialPosition = async (participantCurrencyId, settlementAccou const adjustLimits = async (participantCurrencyId, limit, trx) => { try { - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { const limitType = await knex('participantLimitType').where({ name: limit.type, isActive: 1 }).select('participantLimitTypeId').first() // const limitType = await trx.first('participantLimitTypeId').from('participantLimitType').where({ 'name': limit.type, 'isActive': 1 }) @@ -535,23 +597,17 @@ const adjustLimits = async (participantCurrencyId, limit, trx) => { } const result = await knex('participantLimit').transacting(trx).insert(newLimit) newLimit.participantLimitId = result[0] - if (doCommit) { - await trx.commit - } return { participantLimit: newLimit } } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } const knex = Db.getKnex() if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -640,34 +696,28 @@ const addHubAccountAndInitPosition = async (participantId, currencyId, ledgerAcc try { const knex = Db.getKnex() return knex.transaction(async trx => { - try { - let result - const participantCurrency = { - participantId, - currencyId, - ledgerAccountTypeId, - createdBy: 'unknown', - isActive: 1, - createdDate: Time.getUTCString(new Date()) - } - result = await knex('participantCurrency').transacting(trx).insert(participantCurrency) - await ParticipantCurrencyModelCached.invalidateParticipantCurrencyCache() - participantCurrency.participantCurrencyId = result[0] - const participantPosition = { - participantCurrencyId: participantCurrency.participantCurrencyId, - value: 0, - reservedValue: 0 - } - result = await knex('participantPosition').transacting(trx).insert(participantPosition) - participantPosition.participantPositionId = result[0] - await trx.commit - return { - participantCurrency, - participantPosition - } - } catch (err) { - await trx.rollback - throw err + let result + const participantCurrency = { + participantId, + currencyId, + ledgerAccountTypeId, + createdBy: 'unknown', + isActive: 1, + createdDate: Time.getUTCString(new Date()) + } + result = await knex('participantCurrency').transacting(trx).insert(participantCurrency) + await ParticipantCurrencyModelCached.invalidateParticipantCurrencyCache() + participantCurrency.participantCurrencyId = result[0] + const participantPosition = { + participantCurrencyId: participantCurrency.participantCurrencyId, + value: 0, + reservedValue: 0 + } + result = await knex('participantPosition').transacting(trx).insert(participantPosition) + participantPosition.participantPositionId = result[0] + return { + participantCurrency, + participantPosition } }) } catch (err) { @@ -706,7 +756,7 @@ const getAllNonHubParticipantsWithCurrencies = async (trx) => { try { const HUB_ACCOUNT_NAME = Config.HUB_NAME const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { const res = await knex.distinct('participant.participantId', 'pc.participantId', 'pc.currencyId') .from('participant') @@ -714,19 +764,13 @@ const getAllNonHubParticipantsWithCurrencies = async (trx) => { .whereNot('participant.name', HUB_ACCOUNT_NAME) .transacting(trx) - if (doCommit) { - await trx.commit - } return res } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -735,9 +779,35 @@ const getAllNonHubParticipantsWithCurrencies = async (trx) => { } } +const getExternalParticipantIdByNameOrCreate = async ({ name, proxyId }) => { + try { + let externalFsp = await externalParticipantModelCached.getByName(name) + if (!externalFsp) { + const proxy = await ParticipantModelCached.getByName(proxyId) + if (!proxy) { + throw new Error(`Proxy participant not found: ${proxyId}`) + } + const externalParticipantId = await externalParticipantModelCached.create({ + name, + proxyId: proxy.participantId + }) + externalFsp = externalParticipantId + ? { externalParticipantId } + : await externalParticipantModelCached.getByName(name) + } + const id = externalFsp?.externalParticipantId + logger.verbose('getExternalParticipantIdByNameOrCreate result:', { id, name }) + return id + } catch (err) { + logger.child({ name, proxyId }).warn('error in getExternalParticipantIdByNameOrCreate:', err) + return null + } +} + module.exports = { addHubAccountAndInitPosition, getByNameAndCurrency, + getByIDAndCurrency, getParticipantLimitByParticipantIdAndCurrencyId, getEndpoint, getAllEndpoints, @@ -750,5 +820,6 @@ module.exports = { getParticipantLimitsByParticipantId, getAllAccountsByNameAndCurrency, getLimitsForAllParticipants, - getAllNonHubParticipantsWithCurrencies + getAllNonHubParticipantsWithCurrencies, + getExternalParticipantIdByNameOrCreate } diff --git a/src/models/participant/participant.js b/src/models/participant/participant.js index 8c379e06b..c02de3cdf 100644 --- a/src/models/participant/participant.js +++ b/src/models/participant/participant.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -43,7 +46,8 @@ exports.create = async (participant) => { try { const result = await Db.from('participant').insert({ name: participant.name, - createdBy: 'unknown' + createdBy: 'unknown', + isProxy: !!participant.isProxy }) return result } catch (err) { diff --git a/src/models/participant/participantCached.js b/src/models/participant/participantCached.js index 0660e552d..831df8860 100644 --- a/src/models/participant/participantCached.js +++ b/src/models/participant/participantCached.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/participant/participantCurrency.js b/src/models/participant/participantCurrency.js index 36f07e3e9..dee5bc46c 100644 --- a/src/models/participant/participantCurrency.js +++ b/src/models/participant/participantCurrency.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -43,7 +46,7 @@ exports.create = async (participantId, currencyId, ledgerAccountTypeId, isActive exports.getAll = async () => { try { - return Db.from('participantCurrency').find({}, { order: 'participantCurrencyId asc' }) + return await Db.from('participantCurrency').find({}, { order: 'participantCurrencyId asc' }) } catch (err) { throw ErrorHandler.Factory.reformatFSPIOPError(err) } diff --git a/src/models/participant/participantCurrencyCached.js b/src/models/participant/participantCurrencyCached.js index 9ae8a4933..be76e7122 100644 --- a/src/models/participant/participantCurrencyCached.js +++ b/src/models/participant/participantCurrencyCached.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/participant/participantLimit.js b/src/models/participant/participantLimit.js index e228918ea..b142ce969 100644 --- a/src/models/participant/participantLimit.js +++ b/src/models/participant/participantLimit.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/participant/participantLimitCached.js b/src/models/participant/participantLimitCached.js index dd6c64d1d..aa59cedbd 100644 --- a/src/models/participant/participantLimitCached.js +++ b/src/models/participant/participantLimitCached.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/participant/participantPosition.js b/src/models/participant/participantPosition.js index 1a3fa0770..1d779c1cf 100644 --- a/src/models/participant/participantPosition.js +++ b/src/models/participant/participantPosition.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -107,23 +110,17 @@ const destroyByParticipantId = async (participantId) => { const createParticipantPositionRecords = async (participantPositions, trx) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { await knex .batchInsert('participantPosition', participantPositions) .transacting(trx) - if (doCommit) { - await trx.commit - } } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } diff --git a/src/models/participant/participantPositionChange.js b/src/models/participant/participantPositionChange.js index a6902c1ba..c0d7153c2 100644 --- a/src/models/participant/participantPositionChange.js +++ b/src/models/participant/participantPositionChange.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/position/batch.js b/src/models/position/batch.js index 934f42696..986a9fe04 100644 --- a/src/models/position/batch.js +++ b/src/models/position/batch.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -63,6 +63,28 @@ const getLatestTransferStateChangesByTransferIdList = async (trx, transfersIdLis } } +const getLatestFxTransferStateChangesByCommitRequestIdList = async (trx, commitRequestIdList) => { + const knex = await Db.getKnex() + try { + const latestFxTransferStateChanges = {} + const results = await knex('fxTransferStateChange') + .transacting(trx) + .whereIn('fxTransferStateChange.commitRequestId', commitRequestIdList) + .orderBy('fxTransferStateChangeId', 'desc') + .select('*') + + for (const result of results) { + if (!latestFxTransferStateChanges[result.commitRequestId]) { + latestFxTransferStateChanges[result.commitRequestId] = result + } + } + return latestFxTransferStateChanges + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + const getAllParticipantCurrency = async (trx) => { const knex = await Db.getKnex() if (trx) { @@ -138,6 +160,11 @@ const bulkInsertTransferStateChanges = async (trx, transferStateChangeList) => { return await knex.batchInsert('transferStateChange', transferStateChangeList).transacting(trx) } +const bulkInsertFxTransferStateChanges = async (trx, fxTransferStateChangeList) => { + const knex = await Db.getKnex() + return await knex.batchInsert('fxTransferStateChange', fxTransferStateChangeList).transacting(trx) +} + const bulkInsertParticipantPositionChanges = async (trx, participantPositionChangeList) => { const knex = await Db.getKnex() return await knex.batchInsert('participantPositionChange', participantPositionChangeList).transacting(trx) @@ -184,14 +211,76 @@ const getTransferByIdsForReserve = async (trx, transferIds) => { return {} } +const getFxTransferInfoList = async (trx, commitRequestId, transferParticipantRoleTypeId, ledgerEntryTypeId) => { + try { + const knex = await Db.getKnex() + const transferInfos = await knex('fxTransferParticipant') + .transacting(trx) + .where({ + 'fxTransferParticipant.transferParticipantRoleTypeId': transferParticipantRoleTypeId, + 'fxTransferParticipant.ledgerEntryTypeId': ledgerEntryTypeId + }) + .whereIn('fxTransferParticipant.commitRequestId', commitRequestId) + .select( + 'fxTransferParticipant.*' + ) + const info = {} + // This should key the transfer info with the latest transferStateChangeId + for (const transferInfo of transferInfos) { + if (!(transferInfo.commitRequestId in info)) { + info[transferInfo.commitRequestId] = transferInfo + } + } + return info + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + +// This model assumes that there is only one RESERVED participantPositionChange per commitRequestId and participantPositionId. +// If an fxTransfer use case changes in the future where more than one reservation happens to a participant's account +// for the same commitRequestId, this model will need to be updated. +const getReservedPositionChangesByCommitRequestIds = async (trx, commitRequestIdList) => { + try { + const knex = await Db.getKnex() + const participantPositionChanges = await knex('fxTransferStateChange') + .transacting(trx) + .whereIn('fxTransferStateChange.commitRequestId', commitRequestIdList) + .where('fxTransferStateChange.transferStateId', Enum.Transfers.TransferInternalState.RESERVED) + .leftJoin('participantPositionChange AS ppc', 'ppc.fxTransferStateChangeId', 'fxTransferStateChange.fxTransferStateChangeId') + .select( + 'ppc.*', + 'fxTransferStateChange.commitRequestId AS commitRequestId' + ) + const info = {} + for (const participantPositionChange of participantPositionChanges) { + if (!(participantPositionChange.commitRequestId in info)) { + info[participantPositionChange.commitRequestId] = {} + } + if (participantPositionChange.participantCurrencyId) { + info[participantPositionChange.commitRequestId][participantPositionChange.participantCurrencyId] = participantPositionChange + } + } + return info + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + module.exports = { startDbTransaction, getLatestTransferStateChangesByTransferIdList, + getLatestFxTransferStateChangesByCommitRequestIdList, getPositionsByAccountIdsForUpdate, updateParticipantPosition, bulkInsertTransferStateChanges, + bulkInsertFxTransferStateChanges, bulkInsertParticipantPositionChanges, getAllParticipantCurrency, getTransferInfoList, - getTransferByIdsForReserve + getTransferByIdsForReserve, + getFxTransferInfoList, + getReservedPositionChangesByCommitRequestIds } diff --git a/src/models/position/batchCached.js b/src/models/position/batchCached.js index 4adcd5a54..6e5634989 100644 --- a/src/models/position/batchCached.js +++ b/src/models/position/batchCached.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/models/position/facade.js b/src/models/position/facade.js index a2fa69d28..b808a91f5 100644 --- a/src/models/position/facade.js +++ b/src/models/position/facade.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -229,11 +232,13 @@ const prepareChangeParticipantPositionTransaction = async (transferList) => { const processedTransfersKeysList = Object.keys(processedTransfers) const batchParticipantPositionChange = [] for (const keyIndex in processedTransfersKeysList) { - const { runningPosition, runningReservedValue } = processedTransfers[processedTransfersKeysList[keyIndex]] + const { transferAmount, runningPosition, runningReservedValue } = processedTransfers[processedTransfersKeysList[keyIndex]] const participantPositionChange = { participantPositionId: initialParticipantPosition.participantPositionId, + participantCurrencyId: participantCurrency.participantCurrencyId, transferStateChangeId: processedTransferStateChangeIdList[keyIndex], value: runningPosition, + change: transferAmount.toNumber(), // processBatch: - a single value uuid for this entire batch to make sure the set of transfers in this batch can be clearly grouped reservedValue: runningReservedValue } @@ -241,11 +246,9 @@ const prepareChangeParticipantPositionTransaction = async (transferList) => { } batchParticipantPositionChange.length && await knex.batchInsert('participantPositionChange', batchParticipantPositionChange).transacting(trx) histTimerPersistTransferStateChangeEnd({ success: true, queryName: 'facade_prepareChangeParticipantPositionTransaction_transaction_PersistTransferState' }) - await trx.commit() histTimerChangeParticipantPositionTransEnd({ success: true, queryName: 'facade_prepareChangeParticipantPositionTransaction_transaction' }) } catch (err) { Logger.isErrorEnabled && Logger.error(err) - await trx.rollback() histTimerChangeParticipantPositionTransEnd({ success: false, queryName: 'facade_prepareChangeParticipantPositionTransaction_transaction' }) throw ErrorHandler.Factory.reformatFSPIOPError(err) } @@ -292,16 +295,16 @@ const changeParticipantPositionTransaction = async (participantCurrencyId, isRev const insertedTransferStateChange = await knex('transferStateChange').transacting(trx).where({ transferId: transferStateChange.transferId }).forUpdate().first().orderBy('transferStateChangeId', 'desc') const participantPositionChange = { participantPositionId: participantPosition.participantPositionId, + participantCurrencyId, transferStateChangeId: insertedTransferStateChange.transferStateChangeId, value: latestPosition, + change: isReversal ? -amount : amount, reservedValue: participantPosition.reservedValue, createdDate: transactionTimestamp } await knex('participantPositionChange').transacting(trx).insert(participantPositionChange) - await trx.commit() histTimerChangeParticipantPositionTransactionEnd({ success: true, queryName: 'facade_changeParticipantPositionTransaction' }) } catch (err) { - await trx.rollback() throw ErrorHandler.Factory.reformatFSPIOPError(err) } }).catch((err) => { diff --git a/src/models/position/participantPosition.js b/src/models/position/participantPosition.js index d01d262d2..cbd0be695 100644 --- a/src/models/position/participantPosition.js +++ b/src/models/position/participantPosition.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/position/participantPositionChanges.js b/src/models/position/participantPositionChanges.js new file mode 100644 index 000000000..99e6cf37a --- /dev/null +++ b/src/models/position/participantPositionChanges.js @@ -0,0 +1,71 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +const Db = require('../../lib/db') +const Logger = require('@mojaloop/central-services-logger') +const Enum = require('@mojaloop/central-services-shared').Enum + +const getReservedPositionChangesByCommitRequestId = async (commitRequestId) => { + try { + const knex = await Db.getKnex() + const participantPositionChanges = await knex('fxTransferStateChange') + .where('fxTransferStateChange.commitRequestId', commitRequestId) + .where('fxTransferStateChange.transferStateId', Enum.Transfers.TransferInternalState.RESERVED) + .innerJoin('participantPositionChange AS ppc', 'ppc.fxTransferStateChangeId', 'fxTransferStateChange.fxTransferStateChangeId') + .select( + 'ppc.*' + ) + return participantPositionChanges + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + +const getReservedPositionChangesByTransferId = async (transferId) => { + try { + const knex = await Db.getKnex() + const participantPositionChanges = await knex('transferStateChange') + .where('transferStateChange.transferId', transferId) + .where('transferStateChange.transferStateId', Enum.Transfers.TransferInternalState.RESERVED) + .innerJoin('participantPositionChange AS ppc', 'ppc.transferStateChangeId', 'transferStateChange.transferStateChangeId') + .select( + 'ppc.*' + ) + return participantPositionChanges + } catch (err) { + Logger.isErrorEnabled && Logger.error(err) + throw err + } +} + +module.exports = { + getReservedPositionChangesByCommitRequestId, + getReservedPositionChangesByTransferId +} diff --git a/src/models/settlement/settlementModel.js b/src/models/settlement/settlementModel.js index b0c36cd32..6170a7136 100644 --- a/src/models/settlement/settlementModel.js +++ b/src/models/settlement/settlementModel.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -32,7 +35,7 @@ const ErrorHandler = require('@mojaloop/central-services-error-handling') exports.create = async (name, isActive, settlementGranularityId, settlementInterchangeId, settlementDelayId, currencyId, requireLiquidityCheck, ledgerAccountTypeId, settlementAccountTypeId, autoPositionReset, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { await knex('settlementModel') .insert({ @@ -48,18 +51,12 @@ exports.create = async (name, isActive, settlementGranularityId, settlementInter autoPositionReset }) .transacting(trx) - if (doCommit) { - await trx.commit - } } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -77,19 +74,13 @@ exports.getByName = async (name, trx = null) => { .select() .where('name', name) .transacting(trx) - if (doCommit) { - await trx.commit - } return result.length > 0 ? result[0] : null } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } @@ -116,25 +107,19 @@ exports.update = async (settlementModel, isActive) => { exports.getSettlementModelsByName = async (names, trx = null) => { try { const knex = Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { try { const settlementModelNames = knex('settlementModel') .select('name') .whereIn('name', names) .transacting(trx) - if (doCommit) { - await trx.commit - } return settlementModelNames } catch (err) { - if (doCommit) { - await trx.rollback - } throw ErrorHandler.Factory.reformatFSPIOPError(err) } } if (trx) { - return trxFunction(trx, false) + return trxFunction(trx) } else { return knex.transaction(trxFunction) } diff --git a/src/models/settlement/settlementModelCached.js b/src/models/settlement/settlementModelCached.js index 7a1f36769..11d4ee1b6 100644 --- a/src/models/settlement/settlementModelCached.js +++ b/src/models/settlement/settlementModelCached.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/facade.js b/src/models/transfer/facade.js index ada363bd7..4073df1c2 100644 --- a/src/models/transfer/facade.js +++ b/src/models/transfer/facade.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -23,6 +26,7 @@ * Rajiv Mothilal * Miguel de Barros * Shashikant Hirugade + * Vijay Kumar Guthi -------------- ******/ @@ -32,19 +36,21 @@ * @module src/models/transfer/facade/ */ -const Db = require('../../lib/db') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Metrics = require('@mojaloop/central-services-metrics') +const MLNumber = require('@mojaloop/ml-number') const Enum = require('@mojaloop/central-services-shared').Enum -const TransferEventAction = Enum.Events.Event.Action -const TransferInternalState = Enum.Transfers.TransferInternalState -const TransferExtensionModel = require('./transferExtension') -const ParticipantFacade = require('../participant/facade') const Time = require('@mojaloop/central-services-shared').Util.Time -const MLNumber = require('@mojaloop/ml-number') + +const { logger } = require('../../shared/logger') +const Db = require('../../lib/db') const Config = require('../../lib/config') -const _ = require('lodash') -const ErrorHandler = require('@mojaloop/central-services-error-handling') -const Logger = require('@mojaloop/central-services-logger') -const Metrics = require('@mojaloop/central-services-metrics') +const ParticipantFacade = require('../participant/facade') +const ParticipantCachedModel = require('../participant/participantCached') +const TransferExtensionModel = require('./transferExtension') + +const TransferEventAction = Enum.Events.Event.Action +const TransferInternalState = Enum.Transfers.TransferInternalState // Alphabetically ordered list of error texts used below const UnsupportedActionText = 'Unsupported action' @@ -53,24 +59,25 @@ const getById = async (id) => { try { /** @namespace Db.transfer **/ return await Db.from('transfer').query(async (builder) => { + /* istanbul ignore next */ const transferResult = await builder .where({ 'transfer.transferId': id, 'tprt1.name': 'PAYER_DFSP', // TODO: refactor to use transferParticipantRoleTypeId 'tprt2.name': 'PAYEE_DFSP' }) - .whereRaw('pc1.currencyId = transfer.currencyId') - .whereRaw('pc2.currencyId = transfer.currencyId') // PAYER .innerJoin('transferParticipant AS tp1', 'tp1.transferId', 'transfer.transferId') + .leftJoin('externalParticipant AS ep1', 'ep1.externalParticipantId', 'tp1.externalParticipantId') .innerJoin('transferParticipantRoleType AS tprt1', 'tprt1.transferParticipantRoleTypeId', 'tp1.transferParticipantRoleTypeId') - .innerJoin('participantCurrency AS pc1', 'pc1.participantCurrencyId', 'tp1.participantCurrencyId') - .innerJoin('participant AS da', 'da.participantId', 'pc1.participantId') + .innerJoin('participant AS da', 'da.participantId', 'tp1.participantId') + .leftJoin('participantCurrency AS pc1', 'pc1.participantCurrencyId', 'tp1.participantCurrencyId') // PAYEE .innerJoin('transferParticipant AS tp2', 'tp2.transferId', 'transfer.transferId') + .leftJoin('externalParticipant AS ep2', 'ep2.externalParticipantId', 'tp2.externalParticipantId') .innerJoin('transferParticipantRoleType AS tprt2', 'tprt2.transferParticipantRoleTypeId', 'tp2.transferParticipantRoleTypeId') - .innerJoin('participantCurrency AS pc2', 'pc2.participantCurrencyId', 'tp2.participantCurrencyId') - .innerJoin('participant AS ca', 'ca.participantId', 'pc2.participantId') + .innerJoin('participant AS ca', 'ca.participantId', 'tp2.participantId') + .leftJoin('participantCurrency AS pc2', 'pc2.participantCurrencyId', 'tp2.participantCurrencyId') // OTHER JOINS .innerJoin('ilpPacket AS ilpp', 'ilpp.transferId', 'transfer.transferId') .leftJoin('transferStateChange AS tsc', 'tsc.transferId', 'transfer.transferId') @@ -84,10 +91,12 @@ const getById = async (id) => { 'tp1.amount AS payerAmount', 'da.participantId AS payerParticipantId', 'da.name AS payerFsp', + 'da.isProxy AS payerIsProxy', 'pc2.participantCurrencyId AS payeeParticipantCurrencyId', 'tp2.amount AS payeeAmount', 'ca.participantId AS payeeParticipantId', 'ca.name AS payeeFsp', + 'ca.isProxy AS payeeIsProxy', 'tsc.transferStateChangeId', 'tsc.transferStateId AS transferState', 'tsc.reason AS reason', @@ -98,10 +107,13 @@ const getById = async (id) => { 'transfer.ilpCondition AS condition', 'tf.ilpFulfilment AS fulfilment', 'te.errorCode', - 'te.errorDescription' + 'te.errorDescription', + 'ep1.name AS externalPayerName', + 'ep2.name AS externalPayeeName' ) .orderBy('tsc.transferStateChangeId', 'desc') .first() + if (transferResult) { transferResult.extensionList = await TransferExtensionModel.getByTransferId(id) // TODO: check if this is needed if (transferResult.errorCode && transferResult.transferStateEnumeration === Enum.Transfers.TransferState.ABORTED) { @@ -116,6 +128,7 @@ const getById = async (id) => { return transferResult }) } catch (err) { + logger.warn('error in transfer.getById', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -168,6 +181,7 @@ const getByIdLight = async (id) => { return transferResult }) } catch (err) { + logger.warn('error in transfer.getByIdLight', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -222,6 +236,7 @@ const getAll = async () => { return transferResultList }) } catch (err) { + logger.warn('error in transfer.getAll', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -237,8 +252,10 @@ const getTransferInfoToChangePosition = async (id, transferParticipantRoleTypeId 'transferParticipant.ledgerEntryTypeId': ledgerEntryTypeId }) .innerJoin('transferStateChange AS tsc', 'tsc.transferId', 'transferParticipant.transferId') + .innerJoin('transfer AS t', 't.transferId', 'transferParticipant.transferId') .select( 'transferParticipant.*', + 't.currencyId', 'tsc.transferStateId', 'tsc.reason' ) @@ -246,6 +263,7 @@ const getTransferInfoToChangePosition = async (id, transferParticipantRoleTypeId .first() }) } catch (err) { + logger.warn('error in getTransferInfoToChangePosition', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } @@ -353,12 +371,12 @@ const savePayeeTransferResponse = async (transferId, payload, action, fspiopErro .orderBy('changedDate', 'desc') }) transferFulfilmentRecord.settlementWindowId = res[0].settlementWindowId - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::settlementWindowId') + logger.debug('savePayeeTransferResponse::settlementWindowId') } if (isFulfilment) { await knex('transferFulfilment').transacting(trx).insert(transferFulfilmentRecord) result.transferFulfilmentRecord = transferFulfilmentRecord - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::transferFulfilment') + logger.debug('savePayeeTransferResponse::transferFulfilment') } if (transferExtensionRecordsList.length > 0) { // ###! CAN BE DONE THROUGH A BATCH @@ -367,11 +385,11 @@ const savePayeeTransferResponse = async (transferId, payload, action, fspiopErro } // ###! result.transferExtensionRecordsList = transferExtensionRecordsList - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::transferExtensionRecordsList') + logger.debug('savePayeeTransferResponse::transferExtensionRecordsList') } await knex('transferStateChange').transacting(trx).insert(transferStateChangeRecord) result.transferStateChangeRecord = transferStateChangeRecord - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::transferStateChange') + logger.debug('savePayeeTransferResponse::transferStateChange') if (fspiopError) { const insertedTransferStateChange = await knex('transferStateChange').transacting(trx) .where({ transferId }) @@ -380,45 +398,81 @@ const savePayeeTransferResponse = async (transferId, payload, action, fspiopErro transferErrorRecord.transferStateChangeId = insertedTransferStateChange.transferStateChangeId await knex('transferError').transacting(trx).insert(transferErrorRecord) result.transferErrorRecord = transferErrorRecord - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::transferError') + logger.debug('savePayeeTransferResponse::transferError') } histTPayeeResponseValidationPassedEnd({ success: true, queryName: 'facade_saveTransferPrepared_transaction' }) result.savePayeeTransferResponseExecuted = true - Logger.isDebugEnabled && Logger.debug('savePayeeTransferResponse::success') + logger.debug('savePayeeTransferResponse::success') } catch (err) { - await trx.rollback() + logger.error('savePayeeTransferResponse::failure', err) histTPayeeResponseValidationPassedEnd({ success: false, queryName: 'facade_saveTransferPrepared_transaction' }) - Logger.isErrorEnabled && Logger.error('savePayeeTransferResponse::failure') throw err } }) histTimerSavePayeeTranferResponsedEnd({ success: true, queryName: 'facade_savePayeeTransferResponse' }) return result } catch (err) { + logger.warn('error in savePayeeTransferResponse', err) histTimerSavePayeeTranferResponsedEnd({ success: false, queryName: 'facade_savePayeeTransferResponse' }) throw ErrorHandler.Factory.reformatFSPIOPError(err) } } -const saveTransferPrepared = async (payload, stateReason = null, hasPassedValidation = true) => { +/** + * Saves prepare transfer details to DB. + * + * @param {Object} payload - Message payload. + * @param {string | null} stateReason - Validation failure reasons. + * @param {Boolean} hasPassedValidation - Is transfer prepare validation passed. + * @param {DeterminingTransferCheckResult} determiningTransferCheckResult - Determining transfer check result. + * @param {ProxyObligation} proxyObligation - The proxy obligation + * @returns {Promise} + */ +const saveTransferPrepared = async (payload, stateReason = null, hasPassedValidation = true, determiningTransferCheckResult, proxyObligation) => { const histTimerSaveTransferPreparedEnd = Metrics.getHistogram( 'model_transfer', 'facade_saveTransferPrepared - Metrics for transfer model', ['success', 'queryName'] ).startTimer() try { - const participants = [] - const names = [payload.payeeFsp, payload.payerFsp] + const participants = { + [payload.payeeFsp]: {}, + [payload.payerFsp]: {} + } - for (const name of names) { - const participant = await ParticipantFacade.getByNameAndCurrency(name, payload.amount.currency, Enum.Accounts.LedgerAccountType.POSITION) + // Iterate over the participants and get the details + for (const name of Object.keys(participants)) { + const participant = await ParticipantCachedModel.getByName(name) if (participant) { - participants.push(participant) + participants[name].id = participant.participantId + } + // If determiningTransferCheckResult.participantCurrencyValidationList contains the participant name, then get the participantCurrencyId + const participantCurrency = determiningTransferCheckResult && determiningTransferCheckResult.participantCurrencyValidationList.find(participantCurrencyItem => participantCurrencyItem.participantName === name) + if (participantCurrency) { + const participantCurrencyRecord = await ParticipantFacade.getByNameAndCurrency(participantCurrency.participantName, participantCurrency.currencyId, Enum.Accounts.LedgerAccountType.POSITION) + participants[name].participantCurrencyId = participantCurrencyRecord?.participantCurrencyId } } - const participantCurrencyIds = await _.reduce(participants, (m, acct) => - _.set(m, acct.name, acct.participantCurrencyId), {}) + if (proxyObligation?.isInitiatingFspProxy) { + const proxyId = proxyObligation.initiatingFspProxyOrParticipantId.proxyId + const proxyParticipant = await ParticipantCachedModel.getByName(proxyId) + participants[proxyId] = {} + participants[proxyId].id = proxyParticipant.participantId + const participantCurrencyRecord = await ParticipantFacade.getByNameAndCurrency( + proxyId, payload.amount.currency, Enum.Accounts.LedgerAccountType.POSITION + ) + // In a regional scheme, the stand-in initiating FSP proxy may not have a participantCurrencyId + // of the target currency of the transfer, so set to null if not found + participants[proxyId].participantCurrencyId = participantCurrencyRecord?.participantCurrencyId + } + + if (proxyObligation?.isCounterPartyFspProxy) { + const proxyId = proxyObligation.counterPartyFspProxyOrParticipantId.proxyId + const proxyParticipant = await ParticipantCachedModel.getByName(proxyId) + participants[proxyId] = {} + participants[proxyId].id = proxyParticipant.participantId + } const transferRecord = { transferId: payload.transferId, @@ -433,29 +487,58 @@ const saveTransferPrepared = async (payload, stateReason = null, hasPassedValida value: payload.ilpPacket } - const state = ((hasPassedValidation) ? Enum.Transfers.TransferInternalState.RECEIVED_PREPARE : Enum.Transfers.TransferInternalState.INVALID) - const transferStateChangeRecord = { transferId: payload.transferId, - transferStateId: state, + transferStateId: hasPassedValidation ? TransferInternalState.RECEIVED_PREPARE : TransferInternalState.INVALID, reason: stateReason, createdDate: Time.getUTCString(new Date()) } - const payerTransferParticipantRecord = { - transferId: payload.transferId, - participantCurrencyId: participantCurrencyIds[payload.payerFsp], - transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, - ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, - amount: payload.amount.amount + let payerTransferParticipantRecord + if (proxyObligation?.isInitiatingFspProxy) { + const externalParticipantId = await ParticipantFacade.getExternalParticipantIdByNameOrCreate(proxyObligation.initiatingFspProxyOrParticipantId) + payerTransferParticipantRecord = { + transferId: payload.transferId, + participantId: participants[proxyObligation.initiatingFspProxyOrParticipantId.proxyId].id, + participantCurrencyId: participants[proxyObligation.initiatingFspProxyOrParticipantId.proxyId].participantCurrencyId, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, + amount: -payload.amount.amount, + externalParticipantId + } + } else { + payerTransferParticipantRecord = { + transferId: payload.transferId, + participantId: participants[payload.payerFsp].id, + participantCurrencyId: participants[payload.payerFsp].participantCurrencyId, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, + amount: payload.amount.amount + } } - const payeeTransferParticipantRecord = { - transferId: payload.transferId, - participantCurrencyId: participantCurrencyIds[payload.payeeFsp], - transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP, - ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, - amount: -payload.amount.amount + logger.debug('saveTransferPrepared participants:', { participants }) + let payeeTransferParticipantRecord + if (proxyObligation?.isCounterPartyFspProxy) { + const externalParticipantId = await ParticipantFacade.getExternalParticipantIdByNameOrCreate(proxyObligation.counterPartyFspProxyOrParticipantId) + payeeTransferParticipantRecord = { + transferId: payload.transferId, + participantId: participants[proxyObligation.counterPartyFspProxyOrParticipantId.proxyId].id, + participantCurrencyId: null, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, + amount: -payload.amount.amount, + externalParticipantId + } + } else { + payeeTransferParticipantRecord = { + transferId: payload.transferId, + participantId: participants[payload.payeeFsp].id, + participantCurrencyId: participants[payload.payeeFsp].participantCurrencyId, + transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP, + ledgerEntryTypeId: Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE, + amount: -payload.amount.amount + } } const knex = await Db.getKnex() @@ -485,10 +568,8 @@ const saveTransferPrepared = async (payload, stateReason = null, hasPassedValida } await knex('ilpPacket').transacting(trx).insert(ilpPacketRecord) await knex('transferStateChange').transacting(trx).insert(transferStateChangeRecord) - await trx.commit() histTimerSaveTranferTransactionValidationPassedEnd({ success: true, queryName: 'facade_saveTransferPrepared_transaction' }) } catch (err) { - await trx.rollback() histTimerSaveTranferTransactionValidationPassedEnd({ success: false, queryName: 'facade_saveTransferPrepared_transaction' }) throw err } @@ -503,14 +584,14 @@ const saveTransferPrepared = async (payload, stateReason = null, hasPassedValida try { await knex('transferParticipant').insert(payerTransferParticipantRecord) } catch (err) { - Logger.isWarnEnabled && Logger.warn(`Payer transferParticipant insert error: ${err.message}`) + logger.warn('Payer transferParticipant insert error', err) histTimerSaveTranferNoValidationEnd({ success: false, queryName: 'facade_saveTransferPrepared_no_validation' }) } try { await knex('transferParticipant').insert(payeeTransferParticipantRecord) } catch (err) { + logger.warn('Payee transferParticipant insert error:', err) histTimerSaveTranferNoValidationEnd({ success: false, queryName: 'facade_saveTransferPrepared_no_validation' }) - Logger.isWarnEnabled && Logger.warn(`Payee transferParticipant insert error: ${err.message}`) } payerTransferParticipantRecord.name = payload.payerFsp payeeTransferParticipantRecord.name = payload.payeeFsp @@ -526,26 +607,27 @@ const saveTransferPrepared = async (payload, stateReason = null, hasPassedValida try { await knex.batchInsert('transferExtension', transferExtensionsRecordList) } catch (err) { - Logger.isWarnEnabled && Logger.warn(`batchInsert transferExtension error: ${err.message}`) + logger.warn('batchInsert transferExtension error:', err) histTimerSaveTranferNoValidationEnd({ success: false, queryName: 'facade_saveTransferPrepared_no_validation' }) } } try { await knex('ilpPacket').insert(ilpPacketRecord) } catch (err) { - Logger.isWarnEnabled && Logger.warn(`ilpPacket insert error: ${err.message}`) + logger.warn('ilpPacket insert error:', err) histTimerSaveTranferNoValidationEnd({ success: false, queryName: 'facade_saveTransferPrepared_no_validation' }) } try { await knex('transferStateChange').insert(transferStateChangeRecord) histTimerSaveTranferNoValidationEnd({ success: true, queryName: 'facade_saveTransferPrepared_no_validation' }) } catch (err) { - Logger.isWarnEnabled && Logger.warn(`transferStateChange insert error: ${err.message}`) + logger.warn('transferStateChange insert error:', err) histTimerSaveTranferNoValidationEnd({ success: false, queryName: 'facade_saveTransferPrepared_no_validation' }) } } histTimerSaveTransferPreparedEnd({ success: true, queryName: 'transfer_model_facade_saveTransferPrepared' }) } catch (err) { + logger.warn('error in saveTransferPrepared', err) histTimerSaveTransferPreparedEnd({ success: false, queryName: 'transfer_model_facade_saveTransferPrepared' }) throw ErrorHandler.Factory.reformatFSPIOPError(err) } @@ -592,7 +674,265 @@ const getTransferStateByTransferId = async (id) => { } } -const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax) => { +const _processTimeoutEntries = async (knex, trx, transactionTimestamp) => { + // Insert `transferStateChange` records for RECEIVED_PREPARE + await knex.from(knex.raw('transferStateChange (transferId, transferStateId, reason)')).transacting(trx) + .insert(function () { + this.from('transferTimeout AS tt') + .innerJoin(knex('transferStateChange AS tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') + .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + ) + .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') + .where('tt.expirationDate', '<', transactionTimestamp) + .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`) + .select('tt.transferId', knex.raw('?', Enum.Transfers.TransferInternalState.EXPIRED_PREPARED), knex.raw('?', 'Aborted by Timeout Handler')) + }) + + // Insert `transferStateChange` records for RESERVED + await knex.from(knex.raw('transferStateChange (transferId, transferStateId, reason)')).transacting(trx) + .insert(function () { + this.from('transferTimeout AS tt') + .innerJoin(knex('transferStateChange AS tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') + .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + ) + .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') + .where('tt.expirationDate', '<', transactionTimestamp) + .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferState.RESERVED}`) + .select('tt.transferId', knex.raw('?', Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT), knex.raw('?', 'Marked for expiration by Timeout Handler')) + }) +} + +const _insertTransferErrorEntries = async (knex, trx, transactionTimestamp) => { + // Insert `transferError` records + await knex.from(knex.raw('transferError (transferId, transferStateChangeId, errorCode, errorDescription)')).transacting(trx) + .insert(function () { + this.from('transferTimeout AS tt') + .innerJoin(knex('transferStateChange AS tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') + .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + ) + .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') + .where('tt.expirationDate', '<', transactionTimestamp) + .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT}`) + .select('tt.transferId', 'tsc.transferStateChangeId', knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code), knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message)) + }) +} + +const _processFxTimeoutEntries = async (knex, trx, transactionTimestamp) => { + // Insert `fxTransferStateChange` records for RECEIVED_PREPARE + /* istanbul ignore next */ + await knex.from(knex.raw('fxTransferStateChange (commitRequestId, transferStateId, reason)')).transacting(trx) + .insert(function () { + this.from('fxTransferTimeout AS ftt') + .innerJoin(knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId').as('fts'), 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .where('ftt.expirationDate', '<', transactionTimestamp) + .andWhere('ftsc.transferStateId', `${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`) + .select('ftt.commitRequestId', knex.raw('?', Enum.Transfers.TransferInternalState.EXPIRED_PREPARED), knex.raw('?', 'Aborted by Timeout Handler')) + }) + + // Insert `fxTransferStateChange` records for RESERVED + await knex.from(knex.raw('fxTransferStateChange (commitRequestId, transferStateId, reason)')).transacting(trx) + .insert(function () { + this.from('fxTransferTimeout AS ftt') + .innerJoin(knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId').as('fts'), 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .where('ftt.expirationDate', '<', transactionTimestamp) + .andWhere('ftsc.transferStateId', `${Enum.Transfers.TransferState.RESERVED}`) + .select('ftt.commitRequestId', knex.raw('?', Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT), knex.raw('?', 'Marked for expiration by Timeout Handler')) + }) + + // Insert `fxTransferStateChange` records for RECEIVED_FULFIL_DEPENDENT + await knex.from(knex.raw('fxTransferStateChange (commitRequestId, transferStateId, reason)')).transacting(trx) + .insert(function () { + this.from('fxTransferTimeout AS ftt') + .innerJoin(knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId').as('fts'), 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .where('ftt.expirationDate', '<', transactionTimestamp) + .andWhere('ftsc.transferStateId', `${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT}`) + .select('ftt.commitRequestId', knex.raw('?', Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT), knex.raw('?', 'Marked for expiration by Timeout Handler')) + }) +} + +const _insertFxTransferErrorEntries = async (knex, trx, transactionTimestamp) => { + // Insert `fxTransferError` records + await knex.from(knex.raw('fxTransferError (commitRequestId, fxTransferStateChangeId, errorCode, errorDescription)')).transacting(trx) + .insert(function () { + this.from('fxTransferTimeout AS ftt') + .innerJoin(knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId').as('fts'), 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .where('ftt.expirationDate', '<', transactionTimestamp) + .andWhere('ftsc.transferStateId', `${Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT}`) + .select('ftt.commitRequestId', 'ftsc.fxTransferStateChangeId', knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code), knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message)) + }) +} + +const _getTransferTimeoutList = async (knex, transactionTimestamp) => { + /* istanbul ignore next */ + return knex('transferTimeout AS tt') + .innerJoin(knex('transferStateChange AS tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') + .groupBy('tsc1.transferId') + .as('ts'), 'ts.transferId', 'tt.transferId' + ) + .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') + .innerJoin('transferParticipant AS tp1', function () { + this.on('tp1.transferId', 'tt.transferId') + .andOn('tp1.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP) + .andOn('tp1.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + }) + .leftJoin('externalParticipant AS ep1', 'ep1.externalParticipantId', 'tp1.externalParticipantId') + .innerJoin('transferParticipant AS tp2', function () { + this.on('tp2.transferId', 'tt.transferId') + .andOn('tp2.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP) + .andOn('tp2.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + }) + .leftJoin('externalParticipant AS ep2', 'ep2.externalParticipantId', 'tp2.externalParticipantId') + .innerJoin('participant AS p1', 'p1.participantId', 'tp1.participantId') + .innerJoin('participant AS p2', 'p2.participantId', 'tp2.participantId') + .innerJoin(knex('transferStateChange AS tsc2') + .select('tsc2.transferId', 'tsc2.transferStateChangeId', 'ppc1.participantCurrencyId') + .innerJoin('transferTimeout AS tt2', 'tt2.transferId', 'tsc2.transferId') + .innerJoin('participantPositionChange AS ppc1', 'ppc1.transferStateChangeId', 'tsc2.transferStateChangeId') + .as('tpc'), 'tpc.transferId', 'tt.transferId' + ) + .leftJoin('bulkTransferAssociation AS bta', 'bta.transferId', 'tt.transferId') + + .where('tt.expirationDate', '<', transactionTimestamp) + .select( + 'tt.*', + 'tsc.transferStateId', + 'tp1.participantCurrencyId AS payerParticipantCurrencyId', + 'p1.name AS payerFsp', + 'p2.name AS payeeFsp', + 'tp2.participantCurrencyId AS payeeParticipantCurrencyId', + 'bta.bulkTransferId', + 'tpc.participantCurrencyId AS effectedParticipantCurrencyId', + 'ep1.name AS externalPayerName', + 'ep2.name AS externalPayeeName' + ) +} + +const _getFxTransferTimeoutList = async (knex, transactionTimestamp) => { + /* istanbul ignore next */ + return knex('fxTransferTimeout AS ftt') + .innerJoin(knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId') + .as('fts'), 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .innerJoin('fxTransferParticipant AS ftp1', function () { + this.on('ftp1.commitRequestId', 'ftt.commitRequestId') + .andOn('ftp1.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.INITIATING_FSP) + .andOn('ftp1.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + }) + .leftJoin('externalParticipant AS ep1', 'ep1.externalParticipantId', 'ftp1.externalParticipantId') + .innerJoin('fxTransferParticipant AS ftp2', function () { + this.on('ftp2.commitRequestId', 'ftt.commitRequestId') + .andOn('ftp2.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.COUNTER_PARTY_FSP) + .andOn('ftp2.fxParticipantCurrencyTypeId', Enum.Fx.FxParticipantCurrencyType.TARGET) + .andOn('ftp2.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) + }) + .leftJoin('externalParticipant AS ep2', 'ep2.externalParticipantId', 'ftp2.externalParticipantId') + .innerJoin('participant AS p1', 'p1.participantId', 'ftp1.participantId') + .innerJoin('participant AS p2', 'p2.participantId', 'ftp2.participantId') + .innerJoin(knex('fxTransferStateChange AS ftsc2') + .select('ftsc2.commitRequestId', 'ftsc2.fxTransferStateChangeId', 'ppc1.participantCurrencyId') + .innerJoin('fxTransferTimeout AS ftt2', 'ftt2.commitRequestId', 'ftsc2.commitRequestId') + .innerJoin('participantPositionChange AS ppc1', 'ppc1.fxTransferStateChangeId', 'ftsc2.fxTransferStateChangeId') + .as('ftpc'), 'ftpc.commitRequestId', 'ftt.commitRequestId' + ) + .where('ftt.expirationDate', '<', transactionTimestamp) + .select( + 'ftt.*', + 'ftsc.transferStateId', + 'ftp1.participantCurrencyId AS initiatingParticipantCurrencyId', + 'p1.name AS initiatingFsp', + 'p2.name AS counterPartyFsp', + 'ftp2.participantCurrencyId AS counterPartyParticipantCurrencyId', + 'ftpc.participantCurrencyId AS effectedParticipantCurrencyId', + 'ep1.name AS externalInitiatingFspName', + 'ep2.name AS externalCounterPartyFspName' + ) +} + +/** + * @typedef {Object} TimedOutTransfer + * + * @property {Integer} transferTimeoutId + * @property {String} transferId + * @property {Date} expirationDate + * @property {Date} createdDate + * @property {String} transferStateId + * @property {String} payerFsp + * @property {String} payeeFsp + * @property {Integer} payerParticipantCurrencyId + * @property {Integer} payeeParticipantCurrencyId + * @property {Integer} bulkTransferId + * @property {Integer} effectedParticipantCurrencyId + * @property {String} externalPayerName + * @property {String} externalPayeeName + */ + +/** + * @typedef {Object} TimedOutFxTransfer + * + * @property {Integer} fxTransferTimeoutId + * @property {String} commitRequestId + * @property {Date} expirationDate + * @property {Date} createdDate + * @property {String} transferStateId + * @property {String} initiatingFsp + * @property {String} counterPartyFsp + * @property {Integer} initiatingParticipantCurrencyId + * @property {Integer} counterPartyParticipantCurrencyId + * @property {Integer} effectedParticipantCurrencyId + * @property {String} externalInitiatingFspName + * @property {String} externalCounterPartyFspName + */ + +/** + * Returns the list of transfers/fxTransfers that have timed out + * + * @returns {Promise<{ + * transferTimeoutList: TimedOutTransfer, + * fxTransferTimeoutList: TimedOutFxTransfer + * }>} + */ +const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax, fxSegmentId, fxIntervalMin, fxIntervalMax) => { try { const transactionTimestamp = Time.getUTCString(new Date()) const knex = await Db.getKnex() @@ -607,66 +947,129 @@ const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax) => { .max('transferStateChangeId AS maxTransferStateChangeId') .where('transferStateChangeId', '>', intervalMin) .andWhere('transferStateChangeId', '<=', intervalMax) - .groupBy('transferId').as('ts'), 'ts.transferId', 't.transferId' + .groupBy('transferId') + .as('ts'), 'ts.transferId', 't.transferId' ) .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') .leftJoin('transferTimeout AS tt', 'tt.transferId', 't.transferId') .whereNull('tt.transferId') .whereIn('tsc.transferStateId', [`${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`, `${Enum.Transfers.TransferState.RESERVED}`]) .select('t.transferId', 't.expirationDate') - }) // .toSQL().sql - // console.log('SQL: ' + q1) + }) - // Insert `transferStateChange` records for RECEIVED_PREPARE - await knex.from(knex.raw('transferStateChange (transferId, transferStateId, reason)')).transacting(trx) + // Insert `fxTransferTimeout` records for fxTransfers found between the interval intervalMin <= intervalMax and related fxTransfers + await knex.from(knex.raw('fxTransferTimeout (commitRequestId, expirationDate)')).transacting(trx) .insert(function () { - this.from('transferTimeout AS tt') - .innerJoin(knex('transferStateChange AS tsc1') - .select('tsc1.transferId') - .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') - .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') - .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + this.from('fxTransfer AS ft') + .innerJoin(knex('fxTransferStateChange') + .select('commitRequestId') + .max('fxTransferStateChangeId AS maxFxTransferStateChangeId') + .where('fxTransferStateChangeId', '>', fxIntervalMin) + .andWhere('fxTransferStateChangeId', '<=', fxIntervalMax) + .groupBy('commitRequestId').as('fts'), 'fts.commitRequestId', 'ft.commitRequestId' ) - .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') - .where('tt.expirationDate', '<', transactionTimestamp) - .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`) - .select('tt.transferId', knex.raw('?', Enum.Transfers.TransferInternalState.EXPIRED_PREPARED), knex.raw('?', 'Aborted by Timeout Handler')) - }) // .toSQL().sql - // console.log('SQL: ' + q2) - - // Insert `transferStateChange` records for RESERVED - await knex.from(knex.raw('transferStateChange (transferId, transferStateId, reason)')).transacting(trx) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .leftJoin('fxTransferTimeout AS ftt', 'ftt.commitRequestId', 'ft.commitRequestId') + .leftJoin('fxTransfer AS ft1', 'ft1.determiningTransferId', 'ft.determiningTransferId') + .whereNull('ftt.commitRequestId') + .whereIn('ftsc.transferStateId', [ + `${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`, + `${Enum.Transfers.TransferState.RESERVED}`, + `${Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT}` + ]) + .select('ft1.commitRequestId', 'ft.expirationDate') // Passing expiration date of the timed out fxTransfer for all related fxTransfers + }) + + await _processTimeoutEntries(knex, trx, transactionTimestamp) + await _processFxTimeoutEntries(knex, trx, transactionTimestamp) + + // Insert `fxTransferTimeout` records for the related fxTransfers, or update if exists. The expiration date will be of the transfer and not from fxTransfer + await knex.from(knex.raw('fxTransferTimeout (commitRequestId, expirationDate)')).transacting(trx) .insert(function () { - this.from('transferTimeout AS tt') - .innerJoin(knex('transferStateChange AS tsc1') - .select('tsc1.transferId') - .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') - .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') - .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + this.from('fxTransfer AS ft') + .innerJoin( + knex('transferTimeout AS tt') + .select('tt.transferId', 'tt.expirationDate') + .innerJoin( + knex('transferStateChange as tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') + .groupBy('tsc1.transferId') + .as('ts'), + 'ts.transferId', 'tt.transferId' + ) + .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') + .where('tt.expirationDate', '<', transactionTimestamp) + .whereIn('tsc.transferStateId', [ + `${Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT}`, + `${Enum.Transfers.TransferInternalState.EXPIRED_PREPARED}` + ]) + .as('tt1'), + 'ft.determiningTransferId', 'tt1.transferId' ) - .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') - .where('tt.expirationDate', '<', transactionTimestamp) - .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferState.RESERVED}`) - .select('tt.transferId', knex.raw('?', Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT), knex.raw('?', 'Marked for expiration by Timeout Handler')) - }) // .toSQL().sql - // console.log('SQL: ' + q3) - - // Insert `transferError` records - await knex.from(knex.raw('transferError (transferId, transferStateChangeId, errorCode, errorDescription)')).transacting(trx) + .select('ft.commitRequestId', 'tt1.expirationDate') + }) + .onConflict('commitRequestId') + .merge({ + expirationDate: knex.raw('VALUES(expirationDate)') + }) + + // Insert `transferTimeout` records for the related transfers, or update if exists. The expiration date will be of the fxTransfer and not from transfer + await knex.from(knex.raw('transferTimeout (transferId, expirationDate)')).transacting(trx) .insert(function () { - this.from('transferTimeout AS tt') - .innerJoin(knex('transferStateChange AS tsc1') - .select('tsc1.transferId') - .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') - .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') - .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' + this.from('fxTransfer AS ft') + .innerJoin( + knex('fxTransferTimeout AS ftt') + .select('ftt.commitRequestId', 'ftt.expirationDate') + .innerJoin( + knex('fxTransferStateChange AS ftsc1') + .select('ftsc1.commitRequestId') + .max('ftsc1.fxTransferStateChangeId AS maxFxTransferStateChangeId') + .innerJoin('fxTransferTimeout AS ftt1', 'ftt1.commitRequestId', 'ftsc1.commitRequestId') + .groupBy('ftsc1.commitRequestId') + .as('fts'), + 'fts.commitRequestId', 'ftt.commitRequestId' + ) + .innerJoin('fxTransferStateChange AS ftsc', 'ftsc.fxTransferStateChangeId', 'fts.maxFxTransferStateChangeId') + .where('ftt.expirationDate', '<', transactionTimestamp) + .whereIn('ftsc.transferStateId', [ + `${Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT}`, + `${Enum.Transfers.TransferInternalState.EXPIRED_PREPARED}` + ]) + .as('ftt1'), + 'ft.commitRequestId', 'ftt1.commitRequestId' ) - .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') - .where('tt.expirationDate', '<', transactionTimestamp) - .andWhere('tsc.transferStateId', `${Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT}`) - .select('tt.transferId', 'tsc.transferStateChangeId', knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code), knex.raw('?', ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message)) - }) // .toSQL().sql - // console.log('SQL: ' + q4) + .innerJoin( + knex('transferStateChange AS tsc') + .select('tsc.transferId') + .innerJoin( + knex('transferStateChange AS tsc1') + .select('tsc1.transferId') + .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') + .groupBy('tsc1.transferId') + .as('ts'), + 'ts.transferId', 'tsc.transferId' + ) + .whereRaw('tsc.transferStateChangeId = ts.maxTransferStateChangeId') + .whereIn('tsc.transferStateId', [ + `${Enum.Transfers.TransferInternalState.RECEIVED_PREPARE}`, + `${Enum.Transfers.TransferState.RESERVED}` + ]) + .as('tt1'), + 'ft.determiningTransferId', 'tt1.transferId' + ) + .select('tt1.transferId', 'ftt1.expirationDate') + }) + .onConflict('transferId') + .merge({ + expirationDate: knex.raw('VALUES(expirationDate)') + }) + + await _processTimeoutEntries(knex, trx, transactionTimestamp) + await _processFxTimeoutEntries(knex, trx, transactionTimestamp) + await _insertTransferErrorEntries(knex, trx, transactionTimestamp) + await _insertFxTransferErrorEntries(knex, trx, transactionTimestamp) if (segmentId === 0) { const segment = { @@ -679,45 +1082,31 @@ const timeoutExpireReserved = async (segmentId, intervalMin, intervalMax) => { } else { await knex('segment').transacting(trx).where({ segmentId }).update({ value: intervalMax }) } - await trx.commit + if (fxSegmentId === 0) { + const fxSegment = { + segmentType: 'timeout', + enumeration: 0, + tableName: 'fxTransferStateChange', + value: fxIntervalMax + } + await knex('segment').transacting(trx).insert(fxSegment) + } else { + await knex('segment').transacting(trx).where({ segmentId: fxSegmentId }).update({ value: fxIntervalMax }) + } } catch (err) { - await trx.rollback throw ErrorHandler.Factory.reformatFSPIOPError(err) } }).catch((err) => { throw ErrorHandler.Factory.reformatFSPIOPError(err) }) - return knex('transferTimeout AS tt') - .innerJoin(knex('transferStateChange AS tsc1') - .select('tsc1.transferId') - .max('tsc1.transferStateChangeId AS maxTransferStateChangeId') - .innerJoin('transferTimeout AS tt1', 'tt1.transferId', 'tsc1.transferId') - .groupBy('tsc1.transferId').as('ts'), 'ts.transferId', 'tt.transferId' - ) - .innerJoin('transferStateChange AS tsc', 'tsc.transferStateChangeId', 'ts.maxTransferStateChangeId') - .innerJoin('transferParticipant AS tp1', function () { - this.on('tp1.transferId', 'tt.transferId') - .andOn('tp1.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP) - .andOn('tp1.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) - }) - .innerJoin('transferParticipant AS tp2', function () { - this.on('tp2.transferId', 'tt.transferId') - .andOn('tp2.transferParticipantRoleTypeId', Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP) - .andOn('tp2.ledgerEntryTypeId', Enum.Accounts.LedgerEntryType.PRINCIPLE_VALUE) - }) - .innerJoin('participantCurrency AS pc1', 'pc1.participantCurrencyId', 'tp1.participantCurrencyId') - .innerJoin('participant AS p1', 'p1.participantId', 'pc1.participantId') - - .innerJoin('participantCurrency AS pc2', 'pc2.participantCurrencyId', 'tp2.participantCurrencyId') - .innerJoin('participant AS p2', 'p2.participantId', 'pc2.participantId') - - .leftJoin('bulkTransferAssociation AS bta', 'bta.transferId', 'tt.transferId') + const transferTimeoutList = await _getTransferTimeoutList(knex, transactionTimestamp) + const fxTransferTimeoutList = await _getFxTransferTimeoutList(knex, transactionTimestamp) - .where('tt.expirationDate', '<', transactionTimestamp) - .select('tt.*', 'tsc.transferStateId', 'tp1.participantCurrencyId AS payerParticipantCurrencyId', - 'p1.name AS payerFsp', 'p2.name AS payeeFsp', 'tp2.participantCurrencyId AS payeeParticipantCurrencyId', - 'bta.bulkTransferId') + return { + transferTimeoutList, + fxTransferTimeoutList + } } catch (err) { throw ErrorHandler.Factory.reformatFSPIOPError(err) } @@ -727,119 +1116,113 @@ const transferStateAndPositionUpdate = async function (param1, enums, trx = null try { const knex = await Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { + const trxFunction = async (trx) => { const transactionTimestamp = Time.getUTCString(new Date()) - let info, transferStateChangeId - try { - info = await knex('transfer AS t') - .join('transferParticipant AS dr', function () { - this.on('dr.transferId', 't.transferId') - .andOn('dr.amount', '>', 0) - }) - .join('participantCurrency AS drpc', 'drpc.participantCurrencyId', 'dr.participantCurrencyId') - .join('participantPosition AS drp', 'drp.participantCurrencyId', 'dr.participantCurrencyId') - .join('transferParticipant AS cr', function () { - this.on('cr.transferId', 't.transferId') - .andOn('cr.amount', '<', 0) + const info = await knex('transfer AS t') + .join('transferParticipant AS dr', function () { + this.on('dr.transferId', 't.transferId') + .andOn('dr.amount', '>', 0) + }) + .join('participantCurrency AS drpc', 'drpc.participantCurrencyId', 'dr.participantCurrencyId') + .join('participantPosition AS drp', 'drp.participantCurrencyId', 'dr.participantCurrencyId') + .join('transferParticipant AS cr', function () { + this.on('cr.transferId', 't.transferId') + .andOn('cr.amount', '<', 0) + }) + .join('participantCurrency AS crpc', 'crpc.participantCurrencyId', 'dr.participantCurrencyId') + .join('participantPosition AS crp', 'crp.participantCurrencyId', 'cr.participantCurrencyId') + .join('transferStateChange AS tsc', 'tsc.transferId', 't.transferId') + .where('t.transferId', param1.transferId) + .whereIn('drpc.ledgerAccountTypeId', [enums.ledgerAccountType.POSITION, enums.ledgerAccountType.SETTLEMENT, + enums.ledgerAccountType.HUB_RECONCILIATION, enums.ledgerAccountType.HUB_MULTILATERAL_SETTLEMENT]) + .whereIn('crpc.ledgerAccountTypeId', [enums.ledgerAccountType.POSITION, enums.ledgerAccountType.SETTLEMENT, + enums.ledgerAccountType.HUB_RECONCILIATION, enums.ledgerAccountType.HUB_MULTILATERAL_SETTLEMENT]) + .select('dr.participantCurrencyId AS drAccountId', 'dr.amount AS drAmount', 'drp.participantPositionId AS drPositionId', + 'drp.value AS drPositionValue', 'drp.reservedValue AS drReservedValue', 'cr.participantCurrencyId AS crAccountId', + 'cr.amount AS crAmount', 'crp.participantPositionId AS crPositionId', 'crp.value AS crPositionValue', + 'crp.reservedValue AS crReservedValue', 'tsc.transferStateId', 'drpc.ledgerAccountTypeId', 'crpc.ledgerAccountTypeId') + .orderBy('tsc.transferStateChangeId', 'desc') + .first() + .transacting(trx) + + if (param1.transferStateId === enums.transferState.COMMITTED || + param1.transferStateId === TransferInternalState.RESERVED_FORWARDED + ) { + await knex('transferStateChange') + .insert({ + transferId: param1.transferId, + transferStateId: enums.transferState.RECEIVED_FULFIL, + reason: param1.reason, + createdDate: param1.createdDate }) - .join('participantCurrency AS crpc', 'crpc.participantCurrencyId', 'dr.participantCurrencyId') - .join('participantPosition AS crp', 'crp.participantCurrencyId', 'cr.participantCurrencyId') - .join('transferStateChange AS tsc', 'tsc.transferId', 't.transferId') - .where('t.transferId', param1.transferId) - .whereIn('drpc.ledgerAccountTypeId', [enums.ledgerAccountType.POSITION, enums.ledgerAccountType.SETTLEMENT, - enums.ledgerAccountType.HUB_RECONCILIATION, enums.ledgerAccountType.HUB_MULTILATERAL_SETTLEMENT]) - .whereIn('crpc.ledgerAccountTypeId', [enums.ledgerAccountType.POSITION, enums.ledgerAccountType.SETTLEMENT, - enums.ledgerAccountType.HUB_RECONCILIATION, enums.ledgerAccountType.HUB_MULTILATERAL_SETTLEMENT]) - .select('dr.participantCurrencyId AS drAccountId', 'dr.amount AS drAmount', 'drp.participantPositionId AS drPositionId', - 'drp.value AS drPositionValue', 'drp.reservedValue AS drReservedValue', 'cr.participantCurrencyId AS crAccountId', - 'cr.amount AS crAmount', 'crp.participantPositionId AS crPositionId', 'crp.value AS crPositionValue', - 'crp.reservedValue AS crReservedValue', 'tsc.transferStateId', 'drpc.ledgerAccountTypeId', 'crpc.ledgerAccountTypeId') - .orderBy('tsc.transferStateChangeId', 'desc') - .first() .transacting(trx) - - if (param1.transferStateId === enums.transferState.COMMITTED) { - await knex('transferStateChange') - .insert({ - transferId: param1.transferId, - transferStateId: enums.transferState.RECEIVED_FULFIL, - reason: param1.reason, - createdDate: param1.createdDate - }) - .transacting(trx) - } else if (param1.transferStateId === enums.transferState.ABORTED_REJECTED) { - await knex('transferStateChange') - .insert({ - transferId: param1.transferId, - transferStateId: enums.transferState.RECEIVED_REJECT, - reason: param1.reason, - createdDate: param1.createdDate - }) - .transacting(trx) - } - transferStateChangeId = await knex('transferStateChange') + } else if (param1.transferStateId === enums.transferState.ABORTED_REJECTED) { + await knex('transferStateChange') .insert({ transferId: param1.transferId, - transferStateId: param1.transferStateId, + transferStateId: enums.transferState.RECEIVED_REJECT, reason: param1.reason, createdDate: param1.createdDate }) .transacting(trx) + } + const transferStateChangeId = await knex('transferStateChange') + .insert({ + transferId: param1.transferId, + transferStateId: param1.transferStateId, + reason: param1.reason, + createdDate: param1.createdDate + }) + .transacting(trx) - if (param1.drUpdated === true) { - if (param1.transferStateId === 'ABORTED_REJECTED') { - info.drAmount = -info.drAmount - } - await knex('participantPosition') - .update({ - value: new MLNumber(info.drPositionValue).add(info.drAmount).toFixed(Config.AMOUNT.SCALE), - changedDate: transactionTimestamp - }) - .where('participantPositionId', info.drPositionId) - .transacting(trx) - - await knex('participantPositionChange') - .insert({ - participantPositionId: info.drPositionId, - transferStateChangeId, - value: new MLNumber(info.drPositionValue).add(info.drAmount).toFixed(Config.AMOUNT.SCALE), - reservedValue: info.drReservedValue, - createdDate: param1.createdDate - }) - .transacting(trx) + if (param1.drUpdated === true) { + if (param1.transferStateId === 'ABORTED_REJECTED') { + info.drAmount = -info.drAmount } + await knex('participantPosition') + .update({ + value: new MLNumber(info.drPositionValue).add(info.drAmount).toFixed(Config.AMOUNT.SCALE), + changedDate: transactionTimestamp + }) + .where('participantPositionId', info.drPositionId) + .transacting(trx) - if (param1.crUpdated === true) { - if (param1.transferStateId === 'ABORTED_REJECTED') { - info.crAmount = -info.crAmount - } - await knex('participantPosition') - .update({ - value: new MLNumber(info.crPositionValue).add(info.crAmount).toFixed(Config.AMOUNT.SCALE), - changedDate: transactionTimestamp - }) - .where('participantPositionId', info.crPositionId) - .transacting(trx) - - await knex('participantPositionChange') - .insert({ - participantPositionId: info.crPositionId, - transferStateChangeId, - value: new MLNumber(info.crPositionValue).add(info.crAmount).toFixed(Config.AMOUNT.SCALE), - reservedValue: info.crReservedValue, - createdDate: param1.createdDate - }) - .transacting(trx) - } + await knex('participantPositionChange') + .insert({ + participantPositionId: info.drPositionId, + participantCurrencyId: info.drAccountId, + transferStateChangeId, + value: new MLNumber(info.drPositionValue).add(info.drAmount).toFixed(Config.AMOUNT.SCALE), + change: info.drAmount, + reservedValue: info.drReservedValue, + createdDate: param1.createdDate + }) + .transacting(trx) + } - if (doCommit) { - await trx.commit - } - } catch (err) { - if (doCommit) { - await trx.rollback + if (param1.crUpdated === true) { + if (param1.transferStateId === 'ABORTED_REJECTED') { + info.crAmount = -info.crAmount } - throw err + await knex('participantPosition') + .update({ + value: new MLNumber(info.crPositionValue).add(info.crAmount).toFixed(Config.AMOUNT.SCALE), + changedDate: transactionTimestamp + }) + .where('participantPositionId', info.crPositionId) + .transacting(trx) + + await knex('participantPositionChange') + .insert({ + participantPositionId: info.crPositionId, + participantCurrencyId: info.crAccountId, + transferStateChangeId, + value: new MLNumber(info.crPositionValue).add(info.crAmount).toFixed(Config.AMOUNT.SCALE), + change: info.crAmount, + reservedValue: info.crReservedValue, + createdDate: param1.createdDate + }) + .transacting(trx) } return { transferStateChangeId, @@ -849,7 +1232,7 @@ const transferStateAndPositionUpdate = async function (param1, enums, trx = null } if (trx) { - return await trxFunction(trx, false) + return await trxFunction(trx) } else { return await knex.transaction(trxFunction) } @@ -858,115 +1241,128 @@ const transferStateAndPositionUpdate = async function (param1, enums, trx = null } } -const reconciliationTransferPrepare = async function (payload, transactionTimestamp, enums, trx = null) { +const updatePrepareReservedForwarded = async function (transferId) { try { const knex = await Db.getKnex() + return await knex('transferStateChange') + .insert({ + transferId, + transferStateId: TransferInternalState.RESERVED_FORWARDED, + reason: null, + createdDate: Time.getUTCString(new Date()) + }) + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} - const trxFunction = async (trx, doCommit = true) => { - try { - // transferDuplicateCheck check and insert is done prior to calling the prepare - // see admin/handler.js :: transfer -> Comparators.duplicateCheckComparator - - // Insert transfer - await knex('transfer') - .insert({ - transferId: payload.transferId, - amount: payload.amount.amount, - currencyId: payload.amount.currency, - ilpCondition: 0, - expirationDate: Time.getUTCString(new Date(+new Date() + - 1000 * Number(Config.INTERNAL_TRANSFER_VALIDITY_SECONDS))), - createdDate: transactionTimestamp - }) - .transacting(trx) +const reconciliationTransferPrepare = async function (payload, transactionTimestamp, enums, trx = null) { + try { + const knex = await Db.getKnex() - // Retrieve hub reconciliation account for the specified currency - const { reconciliationAccountId } = await knex('participantCurrency') - .select('participantCurrencyId AS reconciliationAccountId') - .where('participantId', Config.HUB_ID) - .andWhere('currencyId', payload.amount.currency) - .first() - .transacting(trx) + const trxFunction = async (trx) => { + // transferDuplicateCheck check and insert is done prior to calling the prepare + // see admin/handler.js :: transfer -> Comparators.duplicateCheckComparator - let ledgerEntryTypeId, amount - if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_IN) { - ledgerEntryTypeId = enums.ledgerEntryType.RECORD_FUNDS_IN - amount = payload.amount.amount - } else if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_PREPARE_RESERVE) { - ledgerEntryTypeId = enums.ledgerEntryType.RECORD_FUNDS_OUT - amount = -payload.amount.amount - } else { - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, 'Action not allowed for reconciliationTransferPrepare') - } + // Insert transfer + await knex('transfer') + .insert({ + transferId: payload.transferId, + amount: payload.amount.amount, + currencyId: payload.amount.currency, + ilpCondition: 0, + expirationDate: Time.getUTCString(new Date(+new Date() + + 1000 * Number(Config.INTERNAL_TRANSFER_VALIDITY_SECONDS))), + createdDate: transactionTimestamp + }) + .transacting(trx) - // Insert transferParticipant records - await knex('transferParticipant') - .insert({ - transferId: payload.transferId, - participantCurrencyId: reconciliationAccountId, - transferParticipantRoleTypeId: enums.transferParticipantRoleType.HUB, - ledgerEntryTypeId, - amount, - createdDate: transactionTimestamp - }) - .transacting(trx) - await knex('transferParticipant') - .insert({ - transferId: payload.transferId, - participantCurrencyId: payload.participantCurrencyId, - transferParticipantRoleTypeId: enums.transferParticipantRoleType.DFSP_SETTLEMENT, - ledgerEntryTypeId, - amount: -amount, - createdDate: transactionTimestamp - }) - .transacting(trx) + // Retrieve hub reconciliation account for the specified currency + const { reconciliationAccountId } = await knex('participantCurrency') + .select('participantCurrencyId AS reconciliationAccountId') + .where('participantId', Config.HUB_ID) + .andWhere('currencyId', payload.amount.currency) + .first() + .transacting(trx) - await knex('transferStateChange') - .insert({ - transferId: payload.transferId, - transferStateId: enums.transferState.RECEIVED_PREPARE, - reason: payload.reason, - createdDate: transactionTimestamp - }) - .transacting(trx) + // Get participantId based on participantCurrencyId + const { participantId } = await knex('participantCurrency') + .select('participantId') + .where('participantCurrencyId', payload.participantCurrencyId) + .first() + .transacting(trx) + + let ledgerEntryTypeId, amount + if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_IN) { + ledgerEntryTypeId = enums.ledgerEntryType.RECORD_FUNDS_IN + amount = payload.amount.amount + } else if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_PREPARE_RESERVE) { + ledgerEntryTypeId = enums.ledgerEntryType.RECORD_FUNDS_OUT + amount = -payload.amount.amount + } else { + throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, 'Action not allowed for reconciliationTransferPrepare') + } - // Save transaction reference and transfer extensions - let transferExtensions = [] - transferExtensions.push({ + // Insert transferParticipant records + await knex('transferParticipant') + .insert({ transferId: payload.transferId, - key: 'externalReference', - value: payload.externalReference, + participantId: Config.HUB_ID, + participantCurrencyId: reconciliationAccountId, + transferParticipantRoleTypeId: enums.transferParticipantRoleType.HUB, + ledgerEntryTypeId, + amount, createdDate: transactionTimestamp }) - if (payload.extensionList && payload.extensionList.extension) { - transferExtensions = transferExtensions.concat( - payload.extensionList.extension.map(ext => { - return { - transferId: payload.transferId, - key: ext.key, - value: ext.value, - createdDate: transactionTimestamp - } - }) - ) - } - for (const transferExtension of transferExtensions) { - await knex('transferExtension').insert(transferExtension).transacting(trx) - } + .transacting(trx) + await knex('transferParticipant') + .insert({ + transferId: payload.transferId, + participantId, + participantCurrencyId: payload.participantCurrencyId, + transferParticipantRoleTypeId: enums.transferParticipantRoleType.DFSP_SETTLEMENT, + ledgerEntryTypeId, + amount: -amount, + createdDate: transactionTimestamp + }) + .transacting(trx) - if (doCommit) { - await trx.commit - } - } catch (err) { - if (doCommit) { - await trx.rollback - } - throw err + await knex('transferStateChange') + .insert({ + transferId: payload.transferId, + transferStateId: enums.transferState.RECEIVED_PREPARE, + reason: payload.reason, + createdDate: transactionTimestamp + }) + .transacting(trx) + + // Save transaction reference and transfer extensions + let transferExtensions = [] + transferExtensions.push({ + transferId: payload.transferId, + key: 'externalReference', + value: payload.externalReference, + createdDate: transactionTimestamp + }) + if (payload.extensionList && payload.extensionList.extension) { + transferExtensions = transferExtensions.concat( + payload.extensionList.extension.map(ext => { + return { + transferId: payload.transferId, + key: ext.key, + value: ext.value, + createdDate: transactionTimestamp + } + }) + ) + } + for (const transferExtension of transferExtensions) { + await knex('transferExtension').insert(transferExtension).transacting(trx) } } if (trx) { - await trxFunction(trx, false) + await trxFunction(trx) } else { await knex.transaction(trxFunction) } @@ -980,38 +1376,27 @@ const reconciliationTransferReserve = async function (payload, transactionTimest try { const knex = await Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { - try { - const param1 = { - transferId: payload.transferId, - transferStateId: enums.transferState.RESERVED, - reason: payload.reason, - createdDate: transactionTimestamp, - drUpdated: true, - crUpdated: false - } - const positionResult = await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) - - if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_PREPARE_RESERVE && - positionResult.drPositionValue > 0) { - payload.reason = 'Aborted due to insufficient funds' - payload.action = Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_ABORT - await TransferFacade.reconciliationTransferAbort(payload, transactionTimestamp, enums, trx) - } + const trxFunction = async (trx) => { + const param1 = { + transferId: payload.transferId, + transferStateId: enums.transferState.RESERVED, + reason: payload.reason, + createdDate: transactionTimestamp, + drUpdated: true, + crUpdated: false + } + const positionResult = await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) - if (doCommit) { - await trx.commit - } - } catch (err) { - if (doCommit) { - await trx.rollback - } - throw err + if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_PREPARE_RESERVE && + positionResult.drPositionValue > 0) { + payload.reason = 'Aborted due to insufficient funds' + payload.action = Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_ABORT + await TransferFacade.reconciliationTransferAbort(payload, transactionTimestamp, enums, trx) } } if (trx) { - await trxFunction(trx, false) + await trxFunction(trx) } else { await knex.transaction(trxFunction) } @@ -1025,55 +1410,44 @@ const reconciliationTransferCommit = async function (payload, transactionTimesta try { const knex = await Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { - try { - // Persist transfer state and participant position change - const transferId = payload.transferId - await knex('transferFulfilmentDuplicateCheck') - .insert({ - transferId - }) - .transacting(trx) - - await knex('transferFulfilment') - .insert({ - transferId, - ilpFulfilment: 0, - completedDate: transactionTimestamp, - isValid: 1, - settlementWindowId: null, - createdDate: transactionTimestamp - }) - .transacting(trx) - - if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_IN || - payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_COMMIT) { - const param1 = { - transferId: payload.transferId, - transferStateId: enums.transferState.COMMITTED, - reason: payload.reason, - createdDate: transactionTimestamp, - drUpdated: false, - crUpdated: true - } - await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) - } else { - throw new Error('Action not allowed for reconciliationTransferCommit') - } + const trxFunction = async (trx) => { + // Persist transfer state and participant position change + const transferId = payload.transferId + await knex('transferFulfilmentDuplicateCheck') + .insert({ + transferId + }) + .transacting(trx) + + await knex('transferFulfilment') + .insert({ + transferId, + ilpFulfilment: 0, + completedDate: transactionTimestamp, + isValid: 1, + settlementWindowId: null, + createdDate: transactionTimestamp + }) + .transacting(trx) - if (doCommit) { - await trx.commit - } - } catch (err) { - if (doCommit) { - await trx.rollback + if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_IN || + payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_COMMIT) { + const param1 = { + transferId: payload.transferId, + transferStateId: enums.transferState.COMMITTED, + reason: payload.reason, + createdDate: transactionTimestamp, + drUpdated: false, + crUpdated: true } - throw err + await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) + } else { + throw new Error('Action not allowed for reconciliationTransferCommit') } } if (trx) { - await trxFunction(trx, false) + await trxFunction(trx) } else { await knex.transaction(trxFunction) } @@ -1087,54 +1461,43 @@ const reconciliationTransferAbort = async function (payload, transactionTimestam try { const knex = await Db.getKnex() - const trxFunction = async (trx, doCommit = true) => { - try { - // Persist transfer state and participant position change - const transferId = payload.transferId - await knex('transferFulfilmentDuplicateCheck') - .insert({ - transferId - }) - .transacting(trx) - - await knex('transferFulfilment') - .insert({ - transferId, - ilpFulfilment: 0, - completedDate: transactionTimestamp, - isValid: 1, - settlementWindowId: null, - createdDate: transactionTimestamp - }) - .transacting(trx) - - if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_ABORT) { - const param1 = { - transferId: payload.transferId, - transferStateId: enums.transferState.ABORTED_REJECTED, - reason: payload.reason, - createdDate: transactionTimestamp, - drUpdated: true, - crUpdated: false - } - await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) - } else { - throw new Error('Action not allowed for reconciliationTransferAbort') - } + const trxFunction = async (trx) => { + // Persist transfer state and participant position change + const transferId = payload.transferId + await knex('transferFulfilmentDuplicateCheck') + .insert({ + transferId + }) + .transacting(trx) + + await knex('transferFulfilment') + .insert({ + transferId, + ilpFulfilment: 0, + completedDate: transactionTimestamp, + isValid: 1, + settlementWindowId: null, + createdDate: transactionTimestamp + }) + .transacting(trx) - if (doCommit) { - await trx.commit - } - } catch (err) { - if (doCommit) { - await trx.rollback + if (payload.action === Enum.Transfers.AdminTransferAction.RECORD_FUNDS_OUT_ABORT) { + const param1 = { + transferId: payload.transferId, + transferStateId: enums.transferState.ABORTED_REJECTED, + reason: payload.reason, + createdDate: transactionTimestamp, + drUpdated: true, + crUpdated: false } - throw err + await TransferFacade.transferStateAndPositionUpdate(param1, enums, trx) + } else { + throw new Error('Action not allowed for reconciliationTransferAbort') } } if (trx) { - await trxFunction(trx, false) + await trxFunction(trx) } else { await knex.transaction(trxFunction) } @@ -1151,11 +1514,9 @@ const getTransferParticipant = async (participantName, transferId) => { .where({ 'participant.name': participantName, 'tp.transferId': transferId, - 'participant.isActive': 1, - 'pc.isActive': 1 + 'participant.isActive': 1 }) - .innerJoin('participantCurrency AS pc', 'pc.participantId', 'participant.participantId') - .innerJoin('transferParticipant AS tp', 'tp.participantCurrencyId', 'pc.participantCurrencyId') + .innerJoin('transferParticipant AS tp', 'tp.participantId', 'participant.participantId') .select( 'tp.*' ) @@ -1173,10 +1534,8 @@ const recordFundsIn = async (payload, transactionTimestamp, enums) => { await TransferFacade.reconciliationTransferPrepare(payload, transactionTimestamp, enums, trx) await TransferFacade.reconciliationTransferReserve(payload, transactionTimestamp, enums, trx) await TransferFacade.reconciliationTransferCommit(payload, transactionTimestamp, enums, trx) - await trx.commit } catch (err) { - Logger.isErrorEnabled && Logger.error(err) - await trx.rollback + logger.error('error in recordFundsIn:', err) throw ErrorHandler.Factory.reformatFSPIOPError(err) } }) @@ -1197,7 +1556,8 @@ const TransferFacade = { reconciliationTransferCommit, reconciliationTransferAbort, getTransferParticipant, - recordFundsIn + recordFundsIn, + updatePrepareReservedForwarded } module.exports = TransferFacade diff --git a/src/models/transfer/ilpPacket.js b/src/models/transfer/ilpPacket.js index 88f7c7a8a..fe40c7214 100644 --- a/src/models/transfer/ilpPacket.js +++ b/src/models/transfer/ilpPacket.js @@ -1,29 +1,30 @@ /* -- License -- -------------- -- Copyright © 2017 Bill & Melinda Gates Foundation -- The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at -- http://www.apache.org/licenses/LICENSE-2.0 -- Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -- Contributors -- -------------- -- This is the official list of the Mojaloop project contributors for this file. -- Names of the original copyright holders (individuals or organizations) -- should be listed with a '*' in the first column. People who have -- contributed from an organization can be listed under the organization -- that actually holds the copyright for their contributions (see the -- Gates Foundation organization for an example). Those individuals should have -- their names indented and be marked with a '-'. Email address can be added -- optionally within square brackets . -- * Gates Foundation -- - Name Surname + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at -- * Georgi Georgiev -- * Valentin Genev -- * Rajiv Mothilal -- * Miguel de Barros -- -------------- -- ******/ + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Georgi Georgiev + * Valentin Genev + * Rajiv Mothilal + * Miguel de Barros + -------------- +******/ 'use strict' diff --git a/src/models/transfer/transfer.js b/src/models/transfer/transfer.js index ff4d24571..f943b05d9 100644 --- a/src/models/transfer/transfer.js +++ b/src/models/transfer/transfer.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferDuplicateCheck.js b/src/models/transfer/transferDuplicateCheck.js index 35e121241..53912d43e 100644 --- a/src/models/transfer/transferDuplicateCheck.js +++ b/src/models/transfer/transferDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferError.js b/src/models/transfer/transferError.js index 43741e52b..b699eea70 100644 --- a/src/models/transfer/transferError.js +++ b/src/models/transfer/transferError.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferErrorDuplicateCheck.js b/src/models/transfer/transferErrorDuplicateCheck.js index 06ab156b9..5bba69841 100644 --- a/src/models/transfer/transferErrorDuplicateCheck.js +++ b/src/models/transfer/transferErrorDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferExtension.js b/src/models/transfer/transferExtension.js index edc48f440..58f907b0a 100644 --- a/src/models/transfer/transferExtension.js +++ b/src/models/transfer/transferExtension.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferFulfilment.js b/src/models/transfer/transferFulfilment.js index 522db90b0..f22762271 100644 --- a/src/models/transfer/transferFulfilment.js +++ b/src/models/transfer/transferFulfilment.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferFulfilmentDuplicateCheck.js b/src/models/transfer/transferFulfilmentDuplicateCheck.js index 7ece58146..41dc9bc17 100644 --- a/src/models/transfer/transferFulfilmentDuplicateCheck.js +++ b/src/models/transfer/transferFulfilmentDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferParticipant.js b/src/models/transfer/transferParticipant.js index bafbfa96b..488ed1337 100644 --- a/src/models/transfer/transferParticipant.js +++ b/src/models/transfer/transferParticipant.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferStateChange.js b/src/models/transfer/transferStateChange.js index ed7234bd2..aec23d74c 100644 --- a/src/models/transfer/transferStateChange.js +++ b/src/models/transfer/transferStateChange.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/models/transfer/transferTimeout.js b/src/models/transfer/transferTimeout.js index ac33b20cc..c59b085c6 100644 --- a/src/models/transfer/transferTimeout.js +++ b/src/models/transfer/transferTimeout.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/src/schema/bulkTransfer.js b/src/schema/bulkTransfer.js index 34a8b586e..5afaa09d5 100644 --- a/src/schema/bulkTransfer.js +++ b/src/schema/bulkTransfer.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . diff --git a/src/shared/constants.js b/src/shared/constants.js new file mode 100644 index 000000000..92f4d65ae --- /dev/null +++ b/src/shared/constants.js @@ -0,0 +1,52 @@ +const { Enum } = require('@mojaloop/central-services-shared') + +const TABLE_NAMES = Object.freeze({ + externalParticipant: 'externalParticipant', + fxTransfer: 'fxTransfer', + fxTransferDuplicateCheck: 'fxTransferDuplicateCheck', + fxTransferErrorDuplicateCheck: 'fxTransferErrorDuplicateCheck', + fxTransferFulfilmentDuplicateCheck: 'fxTransferFulfilmentDuplicateCheck', + fxTransferParticipant: 'fxTransferParticipant', + fxTransferStateChange: 'fxTransferStateChange', + fxTransferExtension: 'fxTransferExtension', + fxWatchList: 'fxWatchList', + transferDuplicateCheck: 'transferDuplicateCheck', + participantPositionChange: 'participantPositionChange' +}) + +const FX_METRIC_PREFIX = 'fx_' +const FORWARDED_METRIC_PREFIX = 'fwd_' + +const PROM_METRICS = Object.freeze({ + transferGet: (isFx) => `${isFx ? FX_METRIC_PREFIX : ''}transfer_get`, + transferPrepare: (isFx, isForwarded) => `${isFx ? FX_METRIC_PREFIX : ''}${isForwarded ? FORWARDED_METRIC_PREFIX : ''}transfer_prepare`, + transferFulfil: (isFx) => `${isFx ? FX_METRIC_PREFIX : ''}transfer_fulfil`, + transferFulfilError: (isFx) => `${isFx ? FX_METRIC_PREFIX : ''}transfer_fulfil_error` +}) + +const ERROR_MESSAGES = Object.freeze({ + fxTransferNotFound: 'fxTransfer not found', + fxTransferHeaderSourceValidationError: `${Enum.Http.Headers.FSPIOP.SOURCE} header does not match counterPartyFsp on the fxFulfil callback response`, + fxTransferHeaderDestinationValidationError: `${Enum.Http.Headers.FSPIOP.DESTINATION} header does not match initiatingFsp on the fxFulfil callback response`, + fxInvalidFulfilment: 'Invalid FX fulfilment', + fxTransferNonReservedState: 'Non-RESERVED fxTransfer state', + fxTransferExpired: 'fxTransfer expired', + invalidApiErrorCode: 'API specification undefined errorCode', + invalidEventType: type => `Invalid event type:(${type})`, + invalidAction: action => `Invalid action:(${action})`, + invalidFxTransferState: ({ transferStateEnum, action, type }) => `Invalid fxTransferStateEnumeration:(${transferStateEnum}) for event action:(${action}) and type:(${type})`, + fxActionIsNotAllowed: action => `action ${action} is not allowed into fxFulfil handler`, + noFxDuplicateHash: 'No fxDuplicateHash found', + transferNotFound: 'transfer not found' +}) + +const DB_ERROR_CODES = Object.freeze({ + duplicateEntry: 'ER_DUP_ENTRY' +}) + +module.exports = { + DB_ERROR_CODES, + ERROR_MESSAGES, + TABLE_NAMES, + PROM_METRICS +} diff --git a/src/shared/fspiopErrorFactory.js b/src/shared/fspiopErrorFactory.js new file mode 100644 index 000000000..2eef9c223 --- /dev/null +++ b/src/shared/fspiopErrorFactory.js @@ -0,0 +1,130 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +/* eslint-disable no-return-assign */ +const { Factory, Enums } = require('@mojaloop/central-services-error-handling') +const { logger } = require('../shared/logger') +const { ERROR_MESSAGES } = require('./constants') + +const fspiopErrorFactory = { + fxTransferNotFound: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.TRANSFER_ID_NOT_FOUND, + ERROR_MESSAGES.fxTransferNotFound, + cause, replyTo + ) + }, + + fxHeaderSourceValidationError: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.VALIDATION_ERROR, + ERROR_MESSAGES.fxTransferHeaderSourceValidationError, + cause, replyTo + ) + }, + + fxHeaderDestinationValidationError: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.VALIDATION_ERROR, + ERROR_MESSAGES.fxTransferHeaderDestinationValidationError, + cause, replyTo + ) + }, + + fxInvalidFulfilment: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.VALIDATION_ERROR, + ERROR_MESSAGES.fxInvalidFulfilment, + cause, replyTo + ) + }, + + fxTransferNonReservedState: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.VALIDATION_ERROR, + ERROR_MESSAGES.fxTransferNonReservedState, + cause, replyTo + ) + }, + + fxTransferExpired: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED, + ERROR_MESSAGES.fxTransferExpired, + cause, replyTo + ) + }, + + invalidEventType: (type, cause = null, replyTo = '') => { + return Factory.createInternalServerFSPIOPError( + ERROR_MESSAGES.invalidEventType(type), + cause, replyTo + ) + }, + + fxActionIsNotAllowed: (action, cause = null, replyTo = '') => { + return Factory.createInternalServerFSPIOPError( + ERROR_MESSAGES.fxActionIsNotAllowed(action), + cause, replyTo + ) + }, + + invalidFxTransferState: ({ transferStateEnum, action, type }, cause = null, replyTo = '') => { + return Factory.createInternalServerFSPIOPError( + ERROR_MESSAGES.invalidFxTransferState({ transferStateEnum, action, type }), + cause, replyTo + ) + }, + + noFxDuplicateHash: (cause = null, replyTo = '') => { + return Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.MODIFIED_REQUEST, + ERROR_MESSAGES.noFxDuplicateHash, + cause, replyTo + ) + }, + + fromErrorInformation: (errInfo, cause = null, replyTo = '') => { + let fspiopError + + try { // handle only valid errorCodes provided by the payee + fspiopError = Factory.createFSPIOPErrorFromErrorInformation(errInfo) + } catch (err) { + logger.error(`apiErrorCode error: ${err?.message}`) + fspiopError = Factory.createFSPIOPError( + Enums.FSPIOPErrorCodes.VALIDATION_ERROR, + ERROR_MESSAGES.invalidApiErrorCode, + cause, replyTo + ) + } + return fspiopError + } + +} + +module.exports = fspiopErrorFactory diff --git a/src/shared/logger/index.js b/src/shared/logger/index.js new file mode 100644 index 000000000..96b77abeb --- /dev/null +++ b/src/shared/logger/index.js @@ -0,0 +1,8 @@ +const { loggerFactory } = require('@mojaloop/central-services-logger/src/contextLogger') + +const logger = loggerFactory('CL') // global logger + +module.exports = { + logger, + loggerFactory +} diff --git a/src/shared/loggingPlugin.js b/src/shared/loggingPlugin.js new file mode 100644 index 000000000..e0f01a991 --- /dev/null +++ b/src/shared/loggingPlugin.js @@ -0,0 +1,43 @@ +const { asyncStorage } = require('@mojaloop/central-services-logger/src/contextLogger') +const { logger } = require('./logger') // pass though options + +const loggingPlugin = { + name: 'loggingPlugin', + version: '1.0.0', + once: true, + register: async (server, options) => { + // const { logger } = options; + server.ext({ + type: 'onPreHandler', + method: (request, h) => { + const { path, method, headers, payload, query } = request + const { remoteAddress } = request.info + const requestId = request.info.id = `${request.info.id}__${headers.traceid}` + asyncStorage.enterWith({ requestId }) + + logger.isInfoEnabled && logger.info(`[==> req] ${method.toUpperCase()} ${path}`, { headers, payload, query, remoteAddress }) + return h.continue + } + }) + + server.ext({ + type: 'onPreResponse', + method: (request, h) => { + if (logger.isInfoEnabled) { + const { path, method, headers, payload, query, response } = request + const { received } = request.info + + const statusCode = response instanceof Error + ? response.output?.statusCode + : response.statusCode + const respTimeSec = ((Date.now() - received) / 1000).toFixed(3) + + logger.info(`[<== ${statusCode}][${respTimeSec} s] ${method.toUpperCase()} ${path}`, { headers, payload, query }) + } + return h.continue + } + }) + } +} + +module.exports = loggingPlugin diff --git a/src/shared/plugins.js b/src/shared/plugins.js index 9717dec5e..f1afa820a 100644 --- a/src/shared/plugins.js +++ b/src/shared/plugins.js @@ -7,6 +7,7 @@ const Blipp = require('blipp') const ErrorHandling = require('@mojaloop/central-services-error-handling') const APIDocumentation = require('@mojaloop/central-services-shared').Util.Hapi.APIDocumentation const Config = require('../lib/config') +const LoggingPlugin = require('./loggingPlugin') const registerPlugins = async (server) => { if (Config.API_DOC_ENDPOINTS_ENABLED) { @@ -39,6 +40,11 @@ const registerPlugins = async (server) => { plugin: require('hapi-auth-bearer-token') }) + await server.register({ + plugin: LoggingPlugin, + options: {} + }) + await server.register([Inert, Vision, Blipp, ErrorHandling]) } diff --git a/src/shared/setup.js b/src/shared/setup.js index 19fd3b2e7..44d157f37 100644 --- a/src/shared/setup.js +++ b/src/shared/setup.js @@ -2,8 +2,8 @@ * @file This registers all handlers for the central-ledger API License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -16,7 +16,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -36,6 +36,7 @@ const Hapi = require('@hapi/hapi') const Migrator = require('../lib/migrator') const Db = require('../lib/db') +const ProxyCache = require('../lib/proxyCache') const ObjStoreDb = require('@mojaloop/object-store-lib').Db const Plugins = require('./plugins') const Config = require('../lib/config') @@ -51,6 +52,7 @@ const EnumCached = require('../lib/enumCached') const ParticipantCached = require('../models/participant/participantCached') const ParticipantCurrencyCached = require('../models/participant/participantCurrencyCached') const ParticipantLimitCached = require('../models/participant/participantLimitCached') +const externalParticipantCached = require('../models/participant/externalParticipantCached') const BatchPositionModelCached = require('../models/position/batchCached') const MongoUriBuilder = require('mongo-uri-builder') @@ -236,6 +238,8 @@ const initializeCache = async () => { await ParticipantCurrencyCached.initialize() await ParticipantLimitCached.initialize() await BatchPositionModelCached.initialize() + // all cached models initialize-methods are SYNC!! + externalParticipantCached.initialize() await Cache.initCache() } @@ -265,6 +269,9 @@ const initialize = async function ({ service, port, modules = [], runMigrations await connectDatabase() await connectMongoose() await initializeCache() + if (Config.PROXY_CACHE_CONFIG?.enabled) { + await ProxyCache.connect() + } let server switch (service) { @@ -303,6 +310,9 @@ const initialize = async function ({ service, port, modules = [], runMigrations Logger.isErrorEnabled && Logger.error(`Error while initializing ${err}`) await Db.disconnect() + if (Config.PROXY_CACHE_CONFIG?.enabled) { + await ProxyCache.disconnect() + } process.exit(1) } } diff --git a/test-integration.Dockerfile b/test-integration.Dockerfile index cca862220..4772cae9e 100644 --- a/test-integration.Dockerfile +++ b/test-integration.Dockerfile @@ -2,7 +2,7 @@ ARG NODE_VERSION=lts-alpine # Build Image -FROM node:${NODE_VERSION} as builder +FROM node:${NODE_VERSION} AS builder USER root diff --git a/test.Dockerfile b/test.Dockerfile index 6d8b708cb..e2174a439 100644 --- a/test.Dockerfile +++ b/test.Dockerfile @@ -2,7 +2,7 @@ ARG NODE_VERSION=lts-alpine # Build Image -FROM node:${NODE_VERSION} as builder +FROM node:${NODE_VERSION} AS builder USER root diff --git a/test/fixtures.js b/test/fixtures.js new file mode 100644 index 000000000..5b4b8b564 --- /dev/null +++ b/test/fixtures.js @@ -0,0 +1,366 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const { randomUUID } = require('node:crypto') +const { Enum } = require('@mojaloop/central-services-shared') +const Config = require('../src/lib/config') + +const ILP_PACKET = 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA' +const CONDITION = '8x04dj-RKEtfjStajaKXKJ5eL1mWm9iG2ltEKvEDOHc' +const FULFILMENT = 'uz0FAeutW6o8Mz7OmJh8ALX6mmsZCcIDOqtE01eo4uI' + +const DFSP1_ID = 'dfsp1' +const DFSP2_ID = 'dfsp2' +const FXP_ID = 'fxp' +const SWITCH_ID = Config.HUB_NAME + +const TOPICS = Object.freeze({ + notificationEvent: 'topic-notification-event', + transferPosition: 'topic-transfer-position', + transferFulfil: 'topic-transfer-fulfil', + transferPositionBatch: 'topic-transfer-position-batch' +}) +// think, how to define TOPICS dynamically (based on TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE) + +const extensionListDto = ({ + key = 'key1', + value = 'value1' +} = {}) => ({ + extension: [ + { key, value } + ] +}) + +const fulfilPayloadDto = ({ + fulfilment = FULFILMENT, + transferState = 'RECEIVED', + completedTimestamp = new Date().toISOString(), + extensionList = extensionListDto() +} = {}) => ({ + fulfilment, + transferState, + completedTimestamp, + extensionList +}) + +const fxFulfilPayloadDto = ({ + fulfilment = FULFILMENT, + conversionState = 'RECEIVED', + completedTimestamp = new Date().toISOString(), + extensionList = extensionListDto() +} = {}) => ({ + fulfilment, + conversionState, + completedTimestamp, + extensionList +}) + +const fulfilContentDto = ({ + payload = fulfilPayloadDto(), + transferId = randomUUID(), + from = DFSP1_ID, + to = DFSP2_ID +} = {}) => ({ + payload, + uriParams: { + id: transferId + }, + headers: { + 'fspiop-source': from, + 'fspiop-destination': to, + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' + } +}) + +const fxFulfilContentDto = ({ + payload = fxFulfilPayloadDto(), + commitRequestId = randomUUID(), + from = FXP_ID, + to = DFSP1_ID +} = {}) => ({ + payload, + uriParams: { + id: commitRequestId + }, + headers: { + 'fspiop-source': from, + 'fspiop-destination': to, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } +}) + +const fulfilMetadataDto = ({ + id = randomUUID(), // think, how it relates to other ids + type = 'fulfil', + action = 'commit' +} = {}) => ({ + event: { + id, + type, + action, + createdAt: new Date() + } +}) + +const metadataEventStateDto = ({ + status = 'success', + code = 0, + description = 'action successful' +} = {}) => ({ + status, + code, + description +}) + +const createKafkaMessage = ({ + id = randomUUID(), + from = DFSP1_ID, + to = DFSP2_ID, + content = fulfilContentDto({ from, to }), + metadata = fulfilMetadataDto(), + topic = 'topic-transfer-fulfil' +}) => ({ + topic, + value: { + id, + from, + to, + content, + metadata, + type: 'application/json', + pp: '' + } +}) + +const fulfilKafkaMessageDto = ({ + id = randomUUID(), + from = DFSP1_ID, + to = DFSP2_ID, + content = fulfilContentDto({ from, to }), + metadata = fulfilMetadataDto(), + topic +} = {}) => createKafkaMessage({ + id, + from, + to, + content, + metadata, + topic +}) + +const fxFulfilKafkaMessageDto = ({ + id = randomUUID(), + from = FXP_ID, + to = DFSP1_ID, + content = fxFulfilContentDto({ from, to }), + metadata = fulfilMetadataDto(), + topic +} = {}) => createKafkaMessage({ + id, + from, + to, + content, + metadata, + topic +}) + +const amountDto = ({ + currency = 'BWP', + amount = '300.33' +} = {}) => ({ currency, amount }) + +const errorInfoDto = ({ + errorCode = 5104, + errorDescription = 'Transfer rejection error' +} = {}) => ({ + errorInformation: { + errorCode, + errorDescription + } +}) + +const transferDto = ({ + transferId = randomUUID(), + payerFsp = DFSP1_ID, + payeeFsp = DFSP2_ID, + amount = amountDto(), + ilpPacket = ILP_PACKET, + condition = CONDITION, + expiration = new Date().toISOString(), + extensionList = extensionListDto() +} = {}) => ({ + transferId, + payerFsp, + payeeFsp, + amount, + ilpPacket, + condition, + expiration, + extensionList +}) + +const fxTransferDto = ({ + commitRequestId = randomUUID(), + determiningTransferId = randomUUID(), + initiatingFsp = DFSP1_ID, + counterPartyFsp = FXP_ID, + amountType = 'SEND', + sourceAmount = amountDto({ currency: 'BWP', amount: '300.33' }), + targetAmount = amountDto({ currency: 'TZS', amount: '48000' }), + condition = CONDITION, + expiration = new Date(Date.now() + (24 * 60 * 60 * 1000)) +} = {}) => ({ + commitRequestId, + determiningTransferId, + initiatingFsp, + counterPartyFsp, + amountType, + sourceAmount, + targetAmount, + condition, + expiration +}) + +const fxtGetAllDetailsByCommitRequestIdDto = ({ + commitRequestId, + determiningTransferId, + sourceAmount, + targetAmount, + condition, + initiatingFsp, + counterPartyFsp +} = fxTransferDto()) => ({ + commitRequestId, + determiningTransferId, + sourceAmount: sourceAmount.amount, + sourceCurrency: sourceAmount.currency, + targetAmount: targetAmount.amount, + targetCurrency: targetAmount.currency, + ilpCondition: condition, + initiatingFspName: initiatingFsp, + initiatingFspParticipantId: 1, + counterPartyFspName: counterPartyFsp, + counterPartyFspParticipantId: 2, + counterPartyFspTargetParticipantCurrencyId: 22, + counterPartyFspSourceParticipantCurrencyId: 33, + transferState: Enum.Transfers.TransferState.RESERVED, + transferStateEnumeration: 'RECEIVED', // or RECEIVED_FULFIL? + fulfilment: FULFILMENT, + expirationDate: new Date(), + createdDate: new Date() +}) + +const fxFulfilResponseDto = ({ + savePayeeTransferResponseExecuted = true, + fxTransferFulfilmentRecord = {}, + fxTransferStateChangeRecord = {} +} = {}) => ({ + savePayeeTransferResponseExecuted, + fxTransferFulfilmentRecord, + fxTransferStateChangeRecord +}) + +const watchListItemDto = ({ + fxWatchList = 100, + commitRequestId = 'commitRequestId', + determiningTransferId = 'determiningTransferId', + fxTransferTypeId = 'fxTransferTypeId', + createdDate = new Date() +} = {}) => ({ + fxWatchList, + commitRequestId, + determiningTransferId, + fxTransferTypeId, + createdDate +}) + +const mockExternalParticipantDto = ({ + name = `extFsp-${Date.now()}`, + proxyId = new Date().getMilliseconds(), + id = Date.now(), + createdDate = new Date() +} = {}) => ({ + name, + proxyId, + ...(id && { externalParticipantId: id }), + ...(createdDate && { createdDate }) +}) + +/** + * @returns {ProxyObligation} proxyObligation + */ +const mockProxyObligationDto = ({ + isFx = false, + payloadClone = transferDto(), // or fxTransferDto() + proxy1 = null, + proxy2 = null +} = {}) => ({ + isFx, + payloadClone, + isInitiatingFspProxy: !!proxy1, + isCounterPartyFspProxy: !!proxy2, + initiatingFspProxyOrParticipantId: { + inScheme: !proxy1, + proxyId: proxy1, + name: payloadClone.payerFsp || payloadClone.initiatingFsp + }, + counterPartyFspProxyOrParticipantId: { + inScheme: !proxy2, + proxyId: proxy2, + name: payloadClone.payeeFsp || payloadClone.counterPartyFsp + } +}) + +module.exports = { + ILP_PACKET, + CONDITION, + FULFILMENT, + DFSP1_ID, + DFSP2_ID, + FXP_ID, + SWITCH_ID, + TOPICS, + + fulfilKafkaMessageDto, + fulfilMetadataDto, + fulfilContentDto, + fulfilPayloadDto, + metadataEventStateDto, + errorInfoDto, + extensionListDto, + amountDto, + transferDto, + fxFulfilKafkaMessageDto, + fxFulfilPayloadDto, + fxFulfilContentDto, + fxTransferDto, + fxFulfilResponseDto, + fxtGetAllDetailsByCommitRequestIdDto, + watchListItemDto, + mockExternalParticipantDto, + mockProxyObligationDto +} diff --git a/test/integration-override/handlers/positions/handlerBatch.test.js b/test/integration-override/handlers/positions/handlerBatch.test.js index beed5c9d9..942ce5061 100644 --- a/test/integration-override/handlers/positions/handlerBatch.test.js +++ b/test/integration-override/handlers/positions/handlerBatch.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,9 +31,10 @@ const Test = require('tape') const { randomUUID } = require('crypto') const Logger = require('@mojaloop/central-services-logger') const Config = require('#src/lib/config') +const ProxyCache = require('#src/lib/proxyCache') const Db = require('@mojaloop/database-lib').Db const Cache = require('#src/lib/cache') -const Producer = require('@mojaloop/central-services-stream').Util.Producer +const { Producer, Consumer } = require('@mojaloop/central-services-stream').Util const Utility = require('@mojaloop/central-services-shared').Util.Kafka const Enum = require('@mojaloop/central-services-shared').Enum const ParticipantHelper = require('#test/integration/helpers/participant') @@ -40,6 +44,7 @@ const ParticipantEndpointHelper = require('#test/integration/helpers/participant const SettlementHelper = require('#test/integration/helpers/settlementModels') const HubAccountsHelper = require('#test/integration/helpers/hubAccounts') const TransferService = require('#src/domain/transfer/index') +const FxTransferModels = require('#src/models/fxTransfer/index') const ParticipantService = require('#src/domain/participant/index') const Util = require('@mojaloop/central-services-shared').Util const ErrorHandler = require('@mojaloop/central-services-error-handling') @@ -56,6 +61,7 @@ const SettlementModelCached = require('#src/models/settlement/settlementModelCac const Handlers = { index: require('#src/handlers/register'), positions: require('#src/handlers/positions/handler'), + positionsBatch: require('#src/handlers/positions/handlerBatch'), transfers: require('#src/handlers/transfers/handler'), timeouts: require('#src/handlers/timeouts/handler') } @@ -65,10 +71,10 @@ const TransferInternalState = Enum.Transfers.TransferInternalState const TransferEventType = Enum.Events.Event.Type const TransferEventAction = Enum.Events.Event.Action -const debug = process?.env?.TEST_INT_DEBUG || false -// const rebalanceDelay = process?.env?.TEST_INT_REBALANCE_DELAY || 10000 -const retryDelay = process?.env?.TEST_INT_RETRY_DELAY || 2 -const retryCount = process?.env?.TEST_INT_RETRY_COUNT || 40 +const debug = process?.env?.skip_INT_DEBUG || false +// const rebalanceDelay = process?.env?.skip_INT_REBALANCE_DELAY || 10000 +const retryDelay = process?.env?.skip_INT_RETRY_DELAY || 2 +const retryCount = process?.env?.skip_INT_RETRY_COUNT || 40 const retryOpts = { retries: retryCount, minTimeout: retryDelay, @@ -158,6 +164,154 @@ const testData = { expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)) // tomorrow } +const testFxData = { + currencies: ['USD', 'XXX'], + transfers: [ + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, + { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + } + ], + payer: { + name: 'payerFsp', + limit: 1000, + number: 1, + fundsIn: 10000 + }, + payee: { + name: 'payeeFsp', + number: 1, + limit: 1000 + }, + fxp: { + name: 'testFxp', + number: 1, + limit: 1000 + }, + endpoint: { + base: 'http://localhost:1080', + email: 'test@example.com' + }, + now: new Date(), + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)) // tomorrow +} + const testDataLimitExceeded = { currencies: ['USD', 'XXX'], transfers: [ @@ -450,12 +604,17 @@ const _endpointSetup = async (participantName, baseURL) => { await ParticipantEndpointHelper.prepareData(participantName, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_PUT', `${baseURL}/bulkTransfers/{{id}}`) await ParticipantEndpointHelper.prepareData(participantName, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_ERROR', `${baseURL}/bulkTransfers/{{id}}/error`) await ParticipantEndpointHelper.prepareData(participantName, 'FSPIOP_CALLBACK_URL_QUOTES', `${baseURL}`) + await ParticipantEndpointHelper.prepareData(participantName, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${baseURL}`) + await ParticipantEndpointHelper.prepareData(participantName, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${baseURL}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(participantName, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${baseURL}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(participantName, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${baseURL}/fxTransfers/{{commitRequestId}}/error`) } const prepareTestData = async (dataObj) => { try { const payerList = [] const payeeList = [] + const fxpList = [] // Create Payers for (let i = 0; i < dataObj.payer.number; i++) { @@ -502,14 +661,42 @@ const prepareTestData = async (dataObj) => { payeeList.push(payee) } - const kafkacat = 'GROUP=abc; T=topic; TR=transfer; kafkacat -b localhost -G $GROUP $T-$TR-prepare $T-$TR-position $T-$TR-position-batch $T-$TR-fulfil $T-$TR-get $T-admin-$TR $T-notification-event $T-bulk-prepare' - if (debug) console.error(kafkacat) + // Create FXPs + + if (dataObj.fxp) { + for (let i = 0; i < dataObj.fxp.number; i++) { + // Create payer + const fxp = await ParticipantHelper.prepareData(dataObj.fxp.name, dataObj.currencies[0], dataObj.currencies[1]) + // limit,initial position and funds in + fxp.payerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.currencies[0], + limit: { value: dataObj.fxp.limit } + }) + fxp.payerLimitAndInitialPositionSecondaryCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.currencies[1], + limit: { value: dataObj.fxp.limit } + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyId2, { + currency: dataObj.currencies[0], + amount: dataObj.fxp.fundsIn + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyIdSecondary2, { + currency: dataObj.currencies[1], + amount: dataObj.fxp.fundsIn + }) + // endpoint setup + await _endpointSetup(fxp.participant.name, dataObj.endpoint.base) + + fxpList.push(fxp) + } + } // Create payloads for number of transfers const transfersArray = [] for (let i = 0; i < dataObj.transfers.length; i++) { const payer = payerList[i % payerList.length] const payee = payeeList[i % payeeList.length] + const fxp = fxpList.length > 0 ? fxpList[i % fxpList.length] : payee const transferPayload = { transferId: randomUUID(), @@ -536,11 +723,47 @@ const prepareTestData = async (dataObj) => { } } + const fxTransferPayload = { + commitRequestId: randomUUID(), + determiningTransferId: randomUUID(), + initiatingFsp: payer.participant.name, + counterPartyFsp: fxp.participant.name, + sourceAmount: { + currency: dataObj.transfers[i].amount.currency, + amount: dataObj.transfers[i].amount.amount.toString() + }, + targetAmount: { + currency: dataObj.transfers[i].fx?.targetAmount.currency || dataObj.transfers[i].amount.currency, + amount: dataObj.transfers[i].fx?.targetAmount.amount.toString() || dataObj.transfers[i].amount.amount.toString() + }, + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration + } + + const fxFulfilPayload = { + fulfilment: 'UNlJ98hZTY_dsw0cAqw4i_UN3v4utt7CZFB4yfLbVFA', + completedTimestamp: dataObj.now, + conversionState: 'RESERVED', + extensionList: { + extension: [] + } + } + const prepareHeaders = { 'fspiop-source': payer.participant.name, - 'fspiop-destination': payee.participant.name, + 'fspiop-destination': fxp.participant.name, 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' } + const fxPrepareHeaders = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': fxp.participant.name, + 'content-type': 'application/vnd.interoperability.fxtransfers+json;version=2.0' + } + const fxFulfilHeaders = { + 'fspiop-source': fxp.participant.name, + 'fspiop-destination': payer.participant.name, + 'content-type': 'application/vnd.interoperability.fxtransfers+json;version=2.0' + } const fulfilAbortRejectHeaders = { 'fspiop-source': payee.participant.name, 'fspiop-destination': payer.participant.name, @@ -593,6 +816,28 @@ const prepareTestData = async (dataObj) => { } } + const messageProtocolFxPrepare = Util.clone(messageProtocolPrepare) + messageProtocolFxPrepare.id = randomUUID() + messageProtocolFxPrepare.from = fxTransferPayload.initiatingFsp + messageProtocolFxPrepare.to = fxTransferPayload.counterPartyFsp + messageProtocolFxPrepare.content.headers = fxPrepareHeaders + messageProtocolFxPrepare.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxPrepare.content.payload = fxTransferPayload + messageProtocolFxPrepare.metadata.event.id = randomUUID() + messageProtocolFxPrepare.metadata.event.type = TransferEventType.PREPARE + messageProtocolFxPrepare.metadata.event.action = TransferEventAction.FX_PREPARE + + const messageProtocolFxFulfil = Util.clone(messageProtocolPrepare) + messageProtocolFxFulfil.id = randomUUID() + messageProtocolFxFulfil.from = fxTransferPayload.counterPartyFsp + messageProtocolFxFulfil.to = fxTransferPayload.initiatingFsp + messageProtocolFxFulfil.content.headers = fxFulfilHeaders + messageProtocolFxFulfil.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxFulfil.content.payload = fxFulfilPayload + messageProtocolFxFulfil.metadata.event.id = randomUUID() + messageProtocolFxFulfil.metadata.event.type = TransferEventType.FULFIL + messageProtocolFxFulfil.metadata.event.action = TransferEventAction.FX_RESERVE + const messageProtocolFulfil = Util.clone(messageProtocolPrepare) messageProtocolFulfil.id = randomUUID() messageProtocolFulfil.from = transferPayload.payeeFsp @@ -628,6 +873,7 @@ const prepareTestData = async (dataObj) => { messageProtocolError.metadata.event.action = TransferEventAction.ABORT transfersArray.push({ transferPayload, + fxTransferPayload, fulfilPayload, rejectPayload, errorPayload, @@ -636,8 +882,11 @@ const prepareTestData = async (dataObj) => { messageProtocolReject, messageProtocolError, messageProtocolFulfilReserved, + messageProtocolFxPrepare, + messageProtocolFxFulfil, payer, - payee + payee, + fxp }) } const topicConfTransferPrepare = Utility.createGeneralTopicConf(Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, TransferEventType.TRANSFER, TransferEventType.PREPARE) @@ -645,6 +894,7 @@ const prepareTestData = async (dataObj) => { return { payerList, payeeList, + fxpList, topicConfTransferPrepare, topicConfTransferFulfil, transfersArray @@ -718,6 +968,8 @@ Test('Handlers test', async handlersTest => { await setupTests.test('start testConsumer', async (test) => { // Set up the testConsumer here await testConsumer.startListening() + await new Promise(resolve => setTimeout(resolve, 5_000)) + testConsumer.clearEvents() test.pass('done') test.end() @@ -736,10 +988,16 @@ Test('Handlers test', async handlersTest => { Enum.Kafka.Config.PRODUCER, TransferEventType.TRANSFER.toUpperCase(), TransferEventType.FULFIL.toUpperCase()) + const positionConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.POSITION.toUpperCase()) prepareConfig.logger = Logger fulfilConfig.logger = Logger + positionConfig.logger = Logger - await transferPositionPrepare.test('process batch of messages with mixed keys (accountIds) and update transfer state to RESERVED', async (test) => { + await transferPositionPrepare.skip('process batch of messages with mixed keys (accountIds) and update transfer state to RESERVED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testData) @@ -800,7 +1058,7 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of messages with payer limit reached and update transfer state to ABORTED_REJECTED', async (test) => { + await transferPositionPrepare.skip('process batch of messages with payer limit reached and update transfer state to ABORTED_REJECTED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testDataLimitExceeded) @@ -841,7 +1099,7 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of messages with not enough liquidity and update transfer state to ABORTED_REJECTED', async (test) => { + await transferPositionPrepare.skip('process batch of messages with not enough liquidity and update transfer state to ABORTED_REJECTED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testDataLimitNoLiquidity) @@ -883,7 +1141,7 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of messages with some transfers having amount that exceeds NDC. Those transfers should be ABORTED', async (test) => { + await transferPositionPrepare.skip('process batch of messages with some transfers having amount that exceeds NDC. Those transfers should be ABORTED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testDataMixedWithLimitExceeded) @@ -939,7 +1197,7 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of transfers with mixed currencies', async (test) => { + await transferPositionPrepare.skip('process batch of transfers with mixed currencies', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testDataWithMixedCurrencies) @@ -982,7 +1240,136 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of prepare/commit messages with mixed keys (accountIds) and update transfer state to COMMITTED', async (test) => { + await transferPositionPrepare.skip('process batch of fxtransfers', async (test) => { + // Construct test data for 10 fxTransfers. + const td = await prepareTestData(testFxData) + + // Produce fx prepare messages for transfersArray + for (const transfer of td.transfersArray) { + await Producer.produceMessage(transfer.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + } + await new Promise(resolve => setTimeout(resolve, 5000)) + // Consume messages from notification topic + const positionFxPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-prepare' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // filter positionFxPrepare messages where destination is not Hub + const positionFxPrepareFiltered = positionFxPrepare.filter((notification) => notification.to !== 'Hub') + test.equal(positionFxPrepareFiltered.length, 10, 'Notification Messages received for all 10 fxTransfers') + + // Check that initiating FSP position is only updated by sum of transfers relevant to the source currency + const initiatingFspCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyId) || {} + const initiatingFspExpectedPositionForSourceCurrency = td.transfersArray.reduce((acc, tdTest) => acc + Number(tdTest.fxTransferPayload.sourceAmount.amount), 0) + test.equal(initiatingFspCurrentPositionForSourceCurrency.value, initiatingFspExpectedPositionForSourceCurrency, 'Initiating FSP position increases for Source Currency') + + // Check that initiating FSP position is not updated for target currency + const initiatingFspCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyIdSecondary) || {} + const initiatingFspExpectedPositionForTargetCurrency = 0 + test.equal(initiatingFspCurrentPositionForTargetCurrency.value, initiatingFspExpectedPositionForTargetCurrency, 'Initiating FSP position not changed for Target Currency') + + // Check that CounterParty FSP position is only updated by sum of transfers relevant to the source currency + const counterPartyFspCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyId) || {} + const counterPartyFspExpectedPositionForSourceCurrency = 0 + test.equal(counterPartyFspCurrentPositionForSourceCurrency.value, counterPartyFspExpectedPositionForSourceCurrency, 'CounterParty FSP position not changed for Source Currency') + + // Check that CounterParty FSP position is not updated for target currency + const counterPartyFspCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyIdSecondary) || {} + const counterPartyFspExpectedPositionForTargetCurrency = 0 + test.equal(counterPartyFspCurrentPositionForTargetCurrency.value, counterPartyFspExpectedPositionForTargetCurrency, 'CounterParty FSP position not changed for Target Currency') + + // Check that the fx transfer state for fxTransfers is RESERVED + try { + for (const tdTest of td.transfersArray) { + const fxTransfer = await FxTransferModels.fxTransfer.getByIdLight(tdTest.fxTransferPayload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED, 'FX Transfer state updated to RESERVED') + } + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferPositionPrepare.skip('process batch of transfers and fxtransfers', async (test) => { + // Construct test data for 10 transfers / fxTransfers. + const td = await prepareTestData(testFxData) + + // Produce prepare and fx prepare messages + for (const transfer of td.transfersArray) { + await Producer.produceMessage(transfer.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + await Producer.produceMessage(transfer.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + } + + await new Promise(resolve => setTimeout(resolve, 5000)) + // Consume messages from notification topic + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'prepare' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const positionFxPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-prepare' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // filter positionPrepare messages where destination is not Hub + const positionPrepareFiltered = positionPrepare.filter((notification) => notification.to !== 'Hub') + test.equal(positionPrepareFiltered.length, 10, 'Notification Messages received for all 10 transfers') + + // filter positionFxPrepare messages where destination is not Hub + const positionFxPrepareFiltered = positionFxPrepare.filter((notification) => notification.to !== 'Hub') + test.equal(positionFxPrepareFiltered.length, 10, 'Notification Messages received for all 10 fxTransfers') + + // Check that payer / initiating FSP position is only updated by sum of transfers relevant to the source currency + const payerCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyId) || {} + const payerExpectedPositionForSourceCurrency = td.transfersArray.reduce((acc, tdTest) => acc + Number(tdTest.transferPayload.amount.amount), 0) + td.transfersArray.reduce((acc, tdTest) => acc + Number(tdTest.fxTransferPayload.sourceAmount.amount), 0) + test.equal(payerCurrentPositionForSourceCurrency.value, payerExpectedPositionForSourceCurrency, 'Payer / Initiating FSP position increases for Source Currency') + + // Check that payer / initiating FSP position is not updated for target currency + const payerCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyIdSecondary) || {} + const payerExpectedPositionForTargetCurrency = 0 + test.equal(payerCurrentPositionForTargetCurrency.value, payerExpectedPositionForTargetCurrency, 'Payer / Initiating FSP position not changed for Target Currency') + + // Check that FXP position is only updated by sum of transfers relevant to the source currency + const fxpCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyId) || {} + const fxpExpectedPositionForSourceCurrency = 0 + test.equal(fxpCurrentPositionForSourceCurrency.value, fxpExpectedPositionForSourceCurrency, 'FXP position not changed for Source Currency') + + // Check that payee / CounterParty FSP position is not updated for target currency + const fxpCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyIdSecondary) || {} + const fxpExpectedPositionForTargetCurrency = 0 + test.equal(fxpCurrentPositionForTargetCurrency.value, fxpExpectedPositionForTargetCurrency, 'FXP position not changed for Target Currency') + + // Check that the transfer state for transfers is RESERVED + try { + for (const tdTest of td.transfersArray) { + const transfer = await TransferService.getById(tdTest.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED, 'Transfer state updated to RESERVED') + } + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Check that the fx transfer state for fxTransfers is RESERVED + try { + for (const tdTest of td.transfersArray) { + const fxTransfer = await FxTransferModels.fxTransfer.getByIdLight(tdTest.fxTransferPayload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED, 'FX Transfer state updated to RESERVED') + } + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferPositionPrepare.skip('process batch of prepare/commit messages with mixed keys (accountIds) and update transfer state to COMMITTED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testData) @@ -1099,7 +1486,7 @@ Test('Handlers test', async handlersTest => { test.end() }) - await transferPositionPrepare.test('process batch of prepare/reserve messages with mixed keys (accountIds) and update transfer state to COMMITTED', async (test) => { + await transferPositionPrepare.skip('process batch of prepare/reserve messages with mixed keys (accountIds) and update transfer state to COMMITTED', async (test) => { // Construct test data for 10 transfers. Default object contains 10 transfers. const td = await prepareTestData(testData) @@ -1215,6 +1602,241 @@ Test('Handlers test', async handlersTest => { testConsumer.clearEvents() test.end() }) + + await transferPositionPrepare.skip('process batch of fx prepare/ fx reserve messages with mixed keys (accountIds) and update transfer state to COMMITTED', async (test) => { + // Construct test data for 10 transfers. Default object contains 10 transfers. + const td = await prepareTestData(testFxData) + + // Produce prepare messages for transfersArray + for (const transfer of td.transfersArray) { + await Producer.produceMessage(transfer.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + } + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const positionFxPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-prepare' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // filter positionPrepare messages where destination is not Hub + const positionFxPrepareFiltered = positionFxPrepare.filter((notification) => notification.to !== 'Hub') + test.equal(positionFxPrepareFiltered.length, 10, 'Notification Messages received for all 10 fx transfers') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + // Check that payer / initiating FSP position is only updated by sum of transfers relevant to the source currency + const payerCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyId) || {} + const payerExpectedPositionForSourceCurrency = td.transfersArray.reduce((acc, tdTest) => acc + Number(tdTest.fxTransferPayload.sourceAmount.amount), 0) + test.equal(payerCurrentPositionForSourceCurrency.value, payerExpectedPositionForSourceCurrency, 'Payer / Initiating FSP position increases for Source Currency') + + // Check that payer / initiating FSP position is not updated for target currency + const payerCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyIdSecondary) || {} + const payerExpectedPositionForTargetCurrency = 0 + test.equal(payerCurrentPositionForTargetCurrency.value, payerExpectedPositionForTargetCurrency, 'Payer / Initiating FSP position not changed for Target Currency') + + // Check that FXP position is only updated by sum of transfers relevant to the source currency + const fxpCurrentPositionForSourceCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyId) || {} + const fxpExpectedPositionForSourceCurrency = 0 + test.equal(fxpCurrentPositionForSourceCurrency.value, fxpExpectedPositionForSourceCurrency, 'FXP position not changed for Source Currency') + + // Check that FXP position is not updated for target currency + const fxpCurrentPositionForTargetCurrency = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyIdSecondary) || {} + const fxpExpectedPositionForTargetCurrency = 0 + test.equal(fxpCurrentPositionForTargetCurrency.value, fxpExpectedPositionForTargetCurrency, 'FXP position not changed for Target Currency') + + // Check that the fx transfer state for fxTransfers is RESERVED + try { + for (const tdTest of td.transfersArray) { + const fxTransfer = await FxTransferModels.fxTransfer.getByIdLight(tdTest.fxTransferPayload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED, 'FX Transfer state updated to RESERVED') + } + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + + // Produce fx fulfil messages for transfersArray + for (const transfer of td.transfersArray) { + await Producer.produceMessage(transfer.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + } + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // filter positionFxFulfil messages where destination is not Hub + const positionFxFulfilFiltered = positionFxFulfil.filter((notification) => notification.to !== 'Hub') + test.equal(positionFxFulfilFiltered.length, 10, 'Notification Messages received for all 10 transfers') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Check that payer / initiating FSP position is not updated for source currency + const payerCurrentPositionForSourceCurrencyAfterFxFulfil = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyId) || {} + test.equal(payerCurrentPositionForSourceCurrencyAfterFxFulfil.value, payerExpectedPositionForSourceCurrency, 'Payer / Initiating FSP position not changed for Source Currency') + + // Check that payer / initiating FSP position is not updated for target currency + const payerCurrentPositionForTargetCurrencyAfterFxFulfil = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].payer.participantCurrencyIdSecondary) || {} + test.equal(payerCurrentPositionForTargetCurrencyAfterFxFulfil.value, payerExpectedPositionForTargetCurrency, 'Payer / Initiating FSP position not changed for Target Currency') + + // Check that FXP position is only updated by sum of transfers relevant to the source currency + const fxpCurrentPositionForSourceCurrencyAfterFxFulfil = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyId) || {} + test.equal(fxpCurrentPositionForSourceCurrencyAfterFxFulfil.value, fxpExpectedPositionForSourceCurrency, 'FXP position not changed for Source Currency') + + // Check that FXP position is not updated for target currency + const fxpCurrentPositionForTargetCurrencyAfterFxFulfil = await ParticipantService.getPositionByParticipantCurrencyId(td.transfersArray[0].fxp.participantCurrencyIdSecondary) || {} + test.equal(fxpCurrentPositionForTargetCurrencyAfterFxFulfil.value, fxpExpectedPositionForTargetCurrency, 'FXP position not changed for Target Currency') + + testConsumer.clearEvents() + test.end() + }) + + await transferPositionPrepare.skip('timeout should', async timeoutTest => { + const td = await prepareTestData(testData) + + await timeoutTest.skip('update transfer state to RESERVED by PREPARE request', async (test) => { + // Produce prepare messages for transfersArray + for (const transfer of td.transfersArray) { + transfer.messageProtocolPrepare.content.payload.expiration = new Date((new Date()).getTime() + (5 * 1000)) // 4 seconds + await Producer.produceMessage(transfer.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + } + await new Promise(resolve => setTimeout(resolve, 2500)) + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'prepare' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // filter positionPrepare messages where destination is not Hub + const positionPrepareFiltered = positionPrepare.filter((notification) => notification.to !== 'Hub') + test.equal(positionPrepareFiltered.length, 10, 'Notification Messages received for all 10 transfers') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + const tests = async (totalTransferAmounts) => { + for (const value of Object.values(totalTransferAmounts)) { + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(value.payer.participantCurrencyId) || {} + const payerInitialPosition = value.payer.payerLimitAndInitialPosition.participantPosition.value + const payerExpectedPosition = payerInitialPosition + value.totalTransferAmount + const payerPositionChange = await ParticipantService.getPositionChangeByParticipantPositionId(payerCurrentPosition.participantPositionId) || {} + test.equal(payerCurrentPosition.value, payerExpectedPosition, 'Payer position incremented by transfer amount and updated in participantPosition') + test.equal(payerPositionChange.value, payerCurrentPosition.value, 'Payer position change value inserted and matches the updated participantPosition value') + } + } + + try { + const totalTransferAmounts = {} + for (const tdTest of td.transfersArray) { + const transfer = await TransferService.getById(tdTest.messageProtocolPrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + throw ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, + `#1 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail. TRANSFER STATE: ${transfer?.transferState}` + ) + } + totalTransferAmounts[tdTest.payer.participantCurrencyId] = { + payer: tdTest.payer, + totalTransferAmount: ( + (totalTransferAmounts[tdTest.payer.participantCurrencyId] && + totalTransferAmounts[tdTest.payer.participantCurrencyId].totalTransferAmount) || 0 + ) + tdTest.transferPayload.amount.amount + } + } + await tests(totalTransferAmounts) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + testConsumer.clearEvents() + test.end() + }) + + await timeoutTest.skip('update transfer after timeout with timeout status & error', async (test) => { + for (const tf of td.transfersArray) { + // Re-try function with conditions + const inspectTransferState = async () => { + try { + // Fetch Transfer record + const transfer = await TransferService.getById(tf.messageProtocolPrepare.content.payload.transferId) || {} + + // Check Transfer for correct state + if (transfer?.transferState === Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) { + // We have a Transfer with the correct state, lets check if we can get the TransferError record + try { + // Fetch the TransferError record + const transferError = await TransferService.getTransferErrorByTransferId(tf.messageProtocolPrepare.content.payload.transferId) + // TransferError record found, so lets return it + return { + transfer, + transferError + } + } catch (err) { + // NO TransferError record found, so lets return the transfer and the error + return { + transfer, + err + } + } + } else { + // NO Transfer with the correct state was found, so we return false + return false + } + } catch (err) { + // NO Transfer with the correct state was found, so we return false + Logger.error(err) + return false + } + } + const result = await wrapWithRetries( + inspectTransferState, + wrapWithRetriesConf.remainingRetries, + wrapWithRetriesConf.timeout + ) + + // Assert + if (result === false) { + test.fail(`Transfer['${tf.messageProtocolPrepare.content.payload.transferId}'].TransferState failed to transition to ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + } else { + test.equal(result.transfer && result.transfer?.transferState, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED, `Transfer['${tf.messageProtocolPrepare.content.payload.transferId}'].TransferState = ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + test.equal(result.transferError && result.transferError.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code, `Transfer['${tf.messageProtocolPrepare.content.payload.transferId}'].transferError.errorCode = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code}`) + test.equal(result.transferError && result.transferError.errorDescription, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message, `Transfer['${tf.messageProtocolPrepare.content.payload.transferId}'].transferError.errorDescription = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message}`) + test.pass() + } + } + test.end() + }) + + await timeoutTest.skip('position resets after a timeout', async (test) => { + // Arrange + for (const payer of td.payerList) { + const payerInitialPosition = payer.payerLimitAndInitialPosition.participantPosition.value + // Act + const payerPositionDidReset = async () => { + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(payer.participantCurrencyId) + console.log(payerCurrentPosition) + return payerCurrentPosition.value === payerInitialPosition + } + // wait until we know the position reset, or throw after 5 tries + await wrapWithRetries(payerPositionDidReset, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(payer.participantCurrencyId) || {} + + // Assert + test.equal(payerCurrentPosition.value, payerInitialPosition, 'Position resets after a timeout') + } + + test.end() + }) + + timeoutTest.end() + }) transferPositionPrepare.end() }) @@ -1225,12 +1847,17 @@ Test('Handlers test', async handlersTest => { await Db.disconnect() assert.pass('database connection closed') await testConsumer.destroy() // this disconnects the consumers - + await ProxyCache.disconnect() await Producer.disconnect() + // Disconnect all consumers + await Promise.all(Consumer.getListOfTopics().map(async (topic) => { + Logger.info(`Disconnecting consumer for topic: ${topic}`) + return Consumer.getConsumer(topic).disconnect() + })) if (debug) { const elapsedTime = Math.round(((new Date()) - startTime) / 100) / 10 - console.log(`handlers.test.js finished in (${elapsedTime}s)`) + console.log(`handlers.skip.js finished in (${elapsedTime}s)`) } assert.end() diff --git a/test/integration-override/handlers/transfers/fxAbort.test.js b/test/integration-override/handlers/transfers/fxAbort.test.js new file mode 100644 index 000000000..96a879c33 --- /dev/null +++ b/test/integration-override/handlers/transfers/fxAbort.test.js @@ -0,0 +1,854 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + **********/ + +'use strict' + +const Test = require('tape') +const { randomUUID } = require('crypto') +const Logger = require('@mojaloop/central-services-logger') +const Config = require('#src/lib/config') +const Db = require('@mojaloop/database-lib').Db +const Cache = require('#src/lib/cache') +const ProxyCache = require('#src/lib/proxyCache') +const Producer = require('@mojaloop/central-services-stream').Util.Producer +const Utility = require('@mojaloop/central-services-shared').Util.Kafka +const Util = require('@mojaloop/central-services-shared').Util +const Enum = require('@mojaloop/central-services-shared').Enum +const ParticipantHelper = require('#test/integration/helpers/participant') +const ParticipantLimitHelper = require('#test/integration/helpers/participantLimit') +const ParticipantFundsInOutHelper = require('#test/integration/helpers/participantFundsInOut') +const ParticipantEndpointHelper = require('#test/integration/helpers/participantEndpoint') +const SettlementHelper = require('#test/integration/helpers/settlementModels') +const HubAccountsHelper = require('#test/integration/helpers/hubAccounts') +const TransferService = require('#src/domain/transfer/index') +const FxTransferModels = require('#src/models/fxTransfer/index') +const ParticipantService = require('#src/domain/participant/index') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const { + wrapWithRetries +} = require('#test/util/helpers') +const TestConsumer = require('#test/integration/helpers/testConsumer') + +const ParticipantCached = require('#src/models/participant/participantCached') +const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') +const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') +const SettlementModelCached = require('#src/models/settlement/settlementModelCached') + +const Handlers = { + index: require('#src/handlers/register'), + positions: require('#src/handlers/positions/handler'), + transfers: require('#src/handlers/transfers/handler'), + timeouts: require('#src/handlers/timeouts/handler') +} + +const TransferState = Enum.Transfers.TransferState +const TransferInternalState = Enum.Transfers.TransferInternalState +const TransferEventType = Enum.Events.Event.Type +const TransferEventAction = Enum.Events.Event.Action + +const debug = process?.env?.TEST_INT_DEBUG || false +const rebalanceDelay = process?.env?.TEST_INT_REBALANCE_DELAY || 20000 +const retryDelay = process?.env?.TEST_INT_RETRY_DELAY || 2 +const retryCount = process?.env?.TEST_INT_RETRY_COUNT || 40 +const retryOpts = { + retries: retryCount, + minTimeout: retryDelay, + maxTimeout: retryDelay +} +const TOPIC_POSITION = 'topic-transfer-position' +const TOPIC_POSITION_BATCH = 'topic-transfer-position-batch' + +const testFxData = { + sourceAmount: { + currency: 'USD', + amount: 433.88 + }, + targetAmount: { + currency: 'XXX', + amount: 200.00 + }, + payer: { + name: 'payerFsp', + limit: 5000 + }, + payee: { + name: 'payeeFsp', + limit: 5000 + }, + fxp: { + name: 'fxp', + limit: 3000 + }, + endpoint: { + base: 'http://localhost:1080', + email: 'test@example.com' + }, + now: new Date(), + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)) // tomorrow +} + +const prepareFxTestData = async (dataObj) => { + try { + const payer = await ParticipantHelper.prepareData(dataObj.payer.name, dataObj.sourceAmount.currency) + const fxp = await ParticipantHelper.prepareData(dataObj.fxp.name, dataObj.sourceAmount.currency, dataObj.targetAmount.currency) + const payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.targetAmount.currency) + + const payerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payer.participant.name, { + currency: dataObj.sourceAmount.currency, + limit: { value: dataObj.payer.limit } + }) + const fxpLimitAndInitialPositionSourceCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.sourceAmount.currency, + limit: { value: dataObj.fxp.limit } + }) + const fxpLimitAndInitialPositionTargetCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.targetAmount.currency, + limit: { value: dataObj.fxp.limit } + }) + const payeeLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + currency: dataObj.targetAmount.currency, + limit: { value: dataObj.payee.limit } + }) + await ParticipantFundsInOutHelper.recordFundsIn(payer.participant.name, payer.participantCurrencyId2, { + currency: dataObj.sourceAmount.currency, + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyId2, { + currency: dataObj.sourceAmount.currency, + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyIdSecondary2, { + currency: dataObj.targetAmount.currency, + amount: 10000 + }) + + for (const name of [payer.participant.name, fxp.participant.name]) { + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_POST', `${dataObj.endpoint.base}/transfers`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_PUT', `${dataObj.endpoint.base}/transfers/{{transferId}}`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_ERROR', `${dataObj.endpoint.base}/transfers/{{transferId}}/error`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_POST', `${dataObj.endpoint.base}/bulkTransfers`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_PUT', `${dataObj.endpoint.base}/bulkTransfers/{{id}}`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_ERROR', `${dataObj.endpoint.base}/bulkTransfers/{{id}}/error`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_QUOTES', `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${dataObj.endpoint.base}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}/error`) + } + + const transferId = randomUUID() + + const fxTransferPayload = { + commitRequestId: randomUUID(), + determiningTransferId: transferId, + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration, + initiatingFsp: payer.participant.name, + counterPartyFsp: fxp.participant.name, + sourceAmount: { + currency: dataObj.sourceAmount.currency, + amount: dataObj.sourceAmount.amount + }, + targetAmount: { + currency: dataObj.targetAmount.currency, + amount: dataObj.targetAmount.amount + } + } + + const fxPrepareHeaders = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': fxp.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } + + const transferPayload = { + transferId, + payerFsp: payer.participant.name, + payeeFsp: payee.participant.name, + amount: { + currency: dataObj.targetAmount.currency, + amount: dataObj.targetAmount.amount + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration, + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } + } + + const sourceTransferPayload = { + transferId, + payerFsp: payer.participant.name, + payeeFsp: fxp.participant.name, + amount: { + currency: dataObj.sourceAmount.currency, + amount: dataObj.sourceAmount.amount + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration + } + + const fulfilPayload = { + fulfilment: 'UNlJ98hZTY_dsw0cAqw4i_UN3v4utt7CZFB4yfLbVFA', + completedTimestamp: dataObj.now, + transferState: 'COMMITTED' + } + + const rejectPayload = Object.assign({}, fulfilPayload, { transferState: TransferInternalState.ABORTED_REJECTED }) + + const prepareHeaders = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': payee.participant.name, + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' + } + + const fulfilHeaders = { + 'fspiop-source': payee.participant.name, + 'fspiop-destination': payer.participant.name, + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' + } + + const fxFulfilHeaders = { + 'fspiop-source': fxp.participant.name, + 'fspiop-destination': payer.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } + + const errorPayload = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.PAYEE_FSP_REJECTED_TXN + ).toApiErrorObject() + errorPayload.errorInformation.extensionList = { + extension: [{ + key: 'errorDetail', + value: 'This is an abort extension' + }] + } + + const messageProtocolPayerInitiatedConversionFxPrepare = { + id: randomUUID(), + from: fxTransferPayload.initiatingFsp, + to: fxTransferPayload.counterPartyFsp, + type: 'application/json', + content: { + headers: fxPrepareHeaders, + payload: fxTransferPayload + }, + metadata: { + event: { + id: randomUUID(), + type: TransferEventType.TRANSFER, + action: TransferEventAction.FX_PREPARE, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const messageProtocolPrepare = { + id: randomUUID(), + from: transferPayload.payerFsp, + to: transferPayload.payeeFsp, + type: 'application/json', + content: { + headers: prepareHeaders, + payload: transferPayload + }, + metadata: { + event: { + id: randomUUID(), + type: TransferEventAction.PREPARE, + action: TransferEventType.PREPARE, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const messageProtocolSourcePrepare = Util.clone(messageProtocolPrepare) + messageProtocolSourcePrepare.to = sourceTransferPayload.payeeFsp + messageProtocolSourcePrepare.content.payload = sourceTransferPayload + messageProtocolSourcePrepare.content.headers = { + ...prepareHeaders, + 'fspiop-destination': fxp.participant.name + } + + const messageProtocolFulfil = Util.clone(messageProtocolPrepare) + messageProtocolFulfil.id = randomUUID() + messageProtocolFulfil.from = transferPayload.payeeFsp + messageProtocolFulfil.to = transferPayload.payerFsp + messageProtocolFulfil.content.headers = fulfilHeaders + messageProtocolFulfil.content.uriParams = { id: transferPayload.transferId } + messageProtocolFulfil.content.payload = fulfilPayload + messageProtocolFulfil.metadata.event.id = randomUUID() + messageProtocolFulfil.metadata.event.type = TransferEventType.FULFIL + messageProtocolFulfil.metadata.event.action = TransferEventAction.COMMIT + + const messageProtocolPayerInitiatedConversionFxFulfil = Util.clone(messageProtocolPayerInitiatedConversionFxPrepare) + messageProtocolPayerInitiatedConversionFxFulfil.id = randomUUID() + messageProtocolPayerInitiatedConversionFxFulfil.from = fxTransferPayload.counterPartyFsp + messageProtocolPayerInitiatedConversionFxFulfil.to = fxTransferPayload.initiatingFsp + messageProtocolPayerInitiatedConversionFxFulfil.content.headers = fxFulfilHeaders + messageProtocolPayerInitiatedConversionFxFulfil.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolPayerInitiatedConversionFxFulfil.content.payload = fulfilPayload + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.id = randomUUID() + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.type = TransferEventType.FULFIL + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.action = TransferEventAction.FX_RESERVE + + const messageProtocolReject = Util.clone(messageProtocolFulfil) + messageProtocolReject.id = randomUUID() + messageProtocolReject.content.uriParams = { id: transferPayload.transferId } + messageProtocolReject.content.payload = rejectPayload + messageProtocolReject.metadata.event.action = TransferEventAction.REJECT + + const messageProtocolError = Util.clone(messageProtocolFulfil) + messageProtocolError.id = randomUUID() + messageProtocolError.content.uriParams = { id: transferPayload.transferId } + messageProtocolError.content.payload = errorPayload + messageProtocolError.metadata.event.action = TransferEventAction.ABORT + + const messageProtocolFxAbort = Util.clone(messageProtocolPayerInitiatedConversionFxFulfil) + messageProtocolFxAbort.id = randomUUID() + messageProtocolFxAbort.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxAbort.content.payload = errorPayload + messageProtocolFxAbort.metadata.event.action = TransferEventAction.FX_ABORT + + const topicConfFxTransferPrepare = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventAction.PREPARE + ) + + const topicConfTransferPrepare = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventType.PREPARE + ) + + const topicConfFxTransferFulfil = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventType.FULFIL + ) + + const topicConfTransferFulfil = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventType.FULFIL + ) + + return { + fxTransferPayload, + transferPayload, + fulfilPayload, + rejectPayload, + errorPayload, + messageProtocolPayerInitiatedConversionFxPrepare, + messageProtocolPayerInitiatedConversionFxFulfil, + messageProtocolFxAbort, + messageProtocolPrepare, + messageProtocolFulfil, + messageProtocolReject, + messageProtocolError, + messageProtocolSourcePrepare, + topicConfTransferPrepare, + topicConfTransferFulfil, + topicConfFxTransferPrepare, + topicConfFxTransferFulfil, + payer, + payerLimitAndInitialPosition, + fxp, + fxpLimitAndInitialPositionSourceCurrency, + fxpLimitAndInitialPositionTargetCurrency, + payee, + payeeLimitAndInitialPosition + } + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +Test('Handlers test', async handlersTest => { + const startTime = new Date() + await Db.connect(Config.DATABASE) + await ParticipantCached.initialize() + await ParticipantCurrencyCached.initialize() + await ParticipantLimitCached.initialize() + await SettlementModelCached.initialize() + await Cache.initCache() + await SettlementHelper.prepareData() + await HubAccountsHelper.prepareData() + + const wrapWithRetriesConf = { + remainingRetries: retryOpts?.retries || 10, // default 10 + timeout: retryOpts?.maxTimeout || 2 // default 2 + } + + // Start a testConsumer to monitor events that our handlers emit + const testConsumer = new TestConsumer([ + { + topicName: Utility.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Enum.Events.Event.Type.TRANSFER, + Enum.Events.Event.Action.FULFIL + ), + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.FULFIL.toUpperCase() + ) + }, + { + topicName: Utility.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Enum.Events.Event.Type.NOTIFICATION, + Enum.Events.Event.Action.EVENT + ), + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.NOTIFICATION.toUpperCase(), + Enum.Events.Event.Action.EVENT.toUpperCase() + ) + }, + { + topicName: TOPIC_POSITION, + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.POSITION.toUpperCase() + ) + }, + { + topicName: TOPIC_POSITION_BATCH, + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.POSITION.toUpperCase() + ) + } + ]) + + await handlersTest.test('Setup kafka consumer should', async registerAllHandlers => { + await registerAllHandlers.test('start consumer', async (test) => { + // Set up the testConsumer here + await testConsumer.startListening() + + // Disabling these handlers to test running the CL as a separate service independently. + await new Promise(resolve => setTimeout(resolve, rebalanceDelay)) + testConsumer.clearEvents() + + test.pass('done') + test.end() + registerAllHandlers.end() + }) + }) + + await handlersTest.test('When only transfer is sent and followed by transfer abort', async abortTest => { + const td = await prepareFxTestData(testFxData) + + await abortTest.test('update transfer state to RESERVED by PREPARE request', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + config.logger = Logger + + const producerResponse = await Producer.produceMessage(td.messageProtocolSourcePrepare, td.topicConfTransferPrepare, config) + Logger.info(producerResponse) + + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolSourcePrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + test.end() + }) + + await abortTest.test('update transfer state to ABORTED by FULFIL-ABORT callback', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + config.logger = Logger + + await Producer.produceMessage(td.messageProtocolError, td.topicConfTransferFulfil, config) + + // Check for the transfer state to be ABORTED + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolSourcePrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferInternalState.ABORTED_ERROR) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + test.end() + }) + + abortTest.end() + }) + + await handlersTest.test('When fxTransfer followed by a transfer and transferFulfilAbort are sent', async abortTest => { + const td = await prepareFxTestData(testFxData) + + await abortTest.test('update fxTransfer state to RESERVED by PREPARE request', async (test) => { + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.PREPARE.toUpperCase() + ) + prepareConfig.logger = Logger + + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxPrepare, + td.topicConfFxTransferPrepare, + prepareConfig + ) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_PREPARE, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Check the position of the payer is updated + const payerPositionAfterReserve = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) + test.equal(payerPositionAfterReserve.value, testFxData.sourceAmount.amount) + + testConsumer.clearEvents() + test.end() + }) + + await abortTest.test('update fxTransfer state to RECEIVED_FULFIL_DEPENDENT by FULFIL request', async (test) => { + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.FULFIL.toUpperCase() + ) + fulfilConfig.logger = Logger + + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxFulfil, + td.topicConfFxTransferFulfil, + fulfilConfig + ) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_RESERVE + // NOTE: The key is the fxp participantCurrencyId of the source currency (USD) + // Is that correct...? + // keyFilter: td.fxp.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fx-fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId( + td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + + if (fxTransfer?.transferState !== TransferInternalState.RECEIVED_FULFIL_DEPENDENT) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await abortTest.test('update transfer state to RESERVED by PREPARE request', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + config.logger = Logger + + const producerResponse = await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, config) + Logger.info(producerResponse) + + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Check the position of the fxp is updated + const fxpTargetPositionAfterReserve = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyIdSecondary) + test.equal(fxpTargetPositionAfterReserve.value, testFxData.targetAmount.amount) + + testConsumer.clearEvents() + test.end() + }) + + await abortTest.test('update transfer state to ABORTED by FULFIL-ABORT callback', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + config.logger = Logger + + await Producer.produceMessage(td.messageProtocolError, td.topicConfTransferFulfil, config) + + // Check for the transfer state to be ABORTED + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferInternalState.ABORTED_ERROR) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Check for the fxTransfer state to be ABORTED + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId( + td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.ABORTED_ERROR) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Check the position of the payer is reverted + const payerPositionAfterAbort = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) + test.equal(payerPositionAfterAbort.value, 0) + + // Check the position of the fxp is reverted + const fxpTargetPositionAfterAbort = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyIdSecondary) + test.equal(fxpTargetPositionAfterAbort.value, 0) + + // Check the position of the payee is not changed + const payeePositionAfterAbort = await ParticipantService.getPositionByParticipantCurrencyId(td.payee.participantCurrencyId) + test.equal(payeePositionAfterAbort.value, 0) + + // Check the position of the fxp source currency is not changed + const fxpSourcePositionAfterAbort = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyId) + test.equal(fxpSourcePositionAfterAbort.value, 0) + + testConsumer.clearEvents() + test.end() + }) + + abortTest.end() + }) + + await handlersTest.test('When there is an abort from FXP for fxTransfer', async abortTest => { + const td = await prepareFxTestData(testFxData) + + await abortTest.test('update fxTransfer state to RESERVED by PREPARE request', async (test) => { + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.PREPARE.toUpperCase() + ) + prepareConfig.logger = Logger + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxPrepare, + td.topicConfFxTransferPrepare, + prepareConfig + ) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_PREPARE, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await abortTest.test('update fxTransfer state to ABORTED by FULFIL-ABORT callback', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + config.logger = Logger + + await Producer.produceMessage(td.messageProtocolFxAbort, td.topicConfTransferFulfil, config) + + // Check for the fxTransfer state to be ABORTED + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId( + td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.ABORTED_ERROR) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + testConsumer.clearEvents() + test.end() + }) + + abortTest.end() + }) + + await handlersTest.test('teardown', async (assert) => { + try { + await Handlers.timeouts.stop() + await Cache.destroyCache() + await Db.disconnect() + assert.pass('database connection closed') + await testConsumer.destroy() // this disconnects the consumers + + await Producer.disconnect() + await ProxyCache.disconnect() + + if (debug) { + const elapsedTime = Math.round(((new Date()) - startTime) / 100) / 10 + console.log(`handlers.test.js finished in (${elapsedTime}s)`) + } + + assert.end() + } catch (err) { + Logger.error(`teardown failed with error - ${err}`) + assert.fail() + assert.end() + } finally { + handlersTest.end() + } + }) +}) diff --git a/test/integration-override/handlers/transfers/fxFulfil.test.js b/test/integration-override/handlers/transfers/fxFulfil.test.js new file mode 100644 index 000000000..f1d62a525 --- /dev/null +++ b/test/integration-override/handlers/transfers/fxFulfil.test.js @@ -0,0 +1,312 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const Test = require('tape') +const { Db } = require('@mojaloop/database-lib') +const { Enum, Util } = require('@mojaloop/central-services-shared') +const { Producer } = require('@mojaloop/central-services-stream').Kafka + +const Config = require('#src/lib/config') +const Cache = require('#src/lib/cache') +const ProxyCache = require('#src/lib/proxyCache') +const fspiopErrorFactory = require('#src/shared/fspiopErrorFactory') +const ParticipantCached = require('#src/models/participant/participantCached') +const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') +const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') +const fxTransferModel = require('#src/models/fxTransfer/index') +const prepare = require('#src/handlers/transfers/prepare') +const cyril = require('#src/domain/fx/cyril') +const { logger } = require('#src/shared/logger/index') +const { TABLE_NAMES } = require('#src/shared/constants') + +const { checkErrorPayload, wrapWithRetries } = require('#test/util/helpers') +const createTestConsumer = require('#test/integration/helpers/createTestConsumer') +const ParticipantHelper = require('#test/integration/helpers/participant') +const HubAccountsHelper = require('#test/integration/helpers/hubAccounts') +const fixtures = require('#test/fixtures') + +const kafkaUtil = Util.Kafka +const { Action, Type } = Enum.Events.Event +const { TOPICS } = fixtures + +const storeFxTransferPreparePayload = async (fxTransfer, transferStateId = '', addToWatchList = true) => { + const { commitRequestId } = fxTransfer + const isFx = true + const proxyObligation = { + isInitiatingFspProxy: false, + isCounterPartyFspProxy: false, + initiatingFspProxyOrParticipantId: null, + counterPartyFspProxyOrParticipantId: null + } + const dupResult = await prepare.checkDuplication({ + payload: fxTransfer, + isFx, + ID: commitRequestId, + location: {} + }) + if (dupResult.hasDuplicateId) throw new Error('fxTransfer prepare Duplication Error') + + await prepare.savePreparedRequest({ + payload: fxTransfer, + isFx, + functionality: Type.NOTIFICATION, + params: {}, + validationPassed: true, + reasons: [], + location: {}, + proxyObligation + }) + + if (transferStateId) { + const knex = Db.getKnex() + await knex(TABLE_NAMES.fxTransferStateChange) + .update({ + transferStateId, + reason: 'fxFulfil int-test' + }) + .where({ commitRequestId }) + // https://github.com/mojaloop/central-ledger/blob/ad4dd53d6914628813aa30a1dcd3af2a55f12b0d/src/domain/position/fx-prepare.js#L187 + logger.info('fxTransfer state is updated', { transferStateId }) + if (transferStateId === Enum.Transfers.TransferState.RESERVED) { + const fxTransferStateChangeId = await knex(TABLE_NAMES.fxTransferStateChange).where({ commitRequestId }).select('fxTransferStateChangeId') + await knex(TABLE_NAMES.participantPositionChange).insert({ + participantPositionId: 1, + fxTransferStateChangeId: fxTransferStateChangeId[0].fxTransferStateChangeId, + participantCurrencyId: 1, + value: 0, + change: 0, + reservedValue: 0 + }) + } + } + + if (addToWatchList) { + const determiningTransferCheckResult = await cyril.checkIfDeterminingTransferExistsForFxTransferMessage( + fxTransfer, + proxyObligation + ) + await cyril.getParticipantAndCurrencyForFxTransferMessage(fxTransfer, determiningTransferCheckResult) + logger.info('fxTransfer is added to watchList', { fxTransfer }) + } +} + +Test('FxFulfil flow Integration Tests -->', async fxFulfilTest => { + await Db.connect(Config.DATABASE) + await Promise.all([ + Cache.initCache(), + ParticipantCached.initialize(), + ParticipantCurrencyCached.initialize(), + ParticipantLimitCached.initialize(), + HubAccountsHelper.prepareData() + ]) + + const dfspNamePrefix = 'dfsp_' + const fxpNamePrefix = 'fxp_' + const sourceAmount = fixtures.amountDto({ currency: 'USD', amount: 433.88 }) + const targetAmount = fixtures.amountDto({ currency: 'XXX', amount: 200.22 }) + + const [payer, fxp] = await Promise.all([ + ParticipantHelper.prepareData(dfspNamePrefix, sourceAmount.currency), + ParticipantHelper.prepareData(fxpNamePrefix, sourceAmount.currency, targetAmount.currency) + ]) + const DFSP_1 = payer.participant.name + const FXP = fxp.participant.name + + const createFxFulfilKafkaMessage = ({ commitRequestId, fulfilment, action = Action.FX_RESERVE } = {}) => { + const content = fixtures.fxFulfilContentDto({ + commitRequestId, + payload: fixtures.fxFulfilPayloadDto({ fulfilment }), + from: FXP, + to: DFSP_1 + }) + const fxFulfilMessage = fixtures.fxFulfilKafkaMessageDto({ + content, + from: FXP, + to: DFSP_1, + metadata: fixtures.fulfilMetadataDto({ action }) + }) + return fxFulfilMessage.value + } + + const topicFxFulfilConfig = kafkaUtil.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Type.TRANSFER, + Action.FULFIL + ) + const fxFulfilProducerConfig = kafkaUtil.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + Type.TRANSFER.toUpperCase(), + Action.FULFIL.toUpperCase() + ) + const producer = new Producer(fxFulfilProducerConfig) + await producer.connect() + const produceMessageToFxFulfilTopic = async (message) => producer.sendMessage(message, topicFxFulfilConfig) + + const testConsumer = createTestConsumer([ + { type: Type.NOTIFICATION, action: Action.EVENT }, + { type: Type.TRANSFER, action: Action.POSITION }, + { type: Type.TRANSFER, action: Action.FULFIL } + ]) + const batchTopicConfig = { + topicName: TOPICS.transferPositionBatch, + config: Util.Kafka.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.POSITION.toUpperCase() + ) + } + testConsumer.handlers.push(batchTopicConfig) + await testConsumer.startListening() + await new Promise(resolve => setTimeout(resolve, 5_000)) + testConsumer.clearEvents() + fxFulfilTest.pass('setup is done') + + fxFulfilTest.test('should publish a message to send error callback if fxTransfer does not exist', async (t) => { + const noFxTransferMessage = createFxFulfilKafkaMessage() + const isTriggered = await produceMessageToFxFulfilTopic(noFxTransferMessage) + t.ok(isTriggered, 'test is triggered') + + const messages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPICS.notificationEvent, + action: Action.FX_RESERVE, + valueToFilter: FXP + })) + t.ok(messages[0], 'Notification event message is sent') + t.equal(messages[0].value.id, noFxTransferMessage.id) + checkErrorPayload(t)(messages[0].value.content.payload, fspiopErrorFactory.fxTransferNotFound()) + t.end() + }) + + fxFulfilTest.test('should process fxFulfil message (happy path)', async (t) => { + const fxTransfer = fixtures.fxTransferDto({ + initiatingFsp: DFSP_1, + counterPartyFsp: FXP, + sourceAmount, + targetAmount + }) + const { commitRequestId } = fxTransfer + + await storeFxTransferPreparePayload(fxTransfer, Enum.Transfers.TransferState.RESERVED) + t.pass(`fxTransfer prepare is saved in DB: ${commitRequestId}`) + + const fxFulfilMessage = createFxFulfilKafkaMessage({ commitRequestId }) + const isTriggered = await produceMessageToFxFulfilTopic(fxFulfilMessage) + t.ok(isTriggered, 'test is triggered') + + const messages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPICS.transferPositionBatch, + action: Action.FX_RESERVE + })) + t.ok(messages[0], `Message is sent to ${TOPICS.transferPositionBatch}`) + const knex = Db.getKnex() + const extension = await knex(TABLE_NAMES.fxTransferExtension).where({ commitRequestId }).select('key', 'value') + const { from, to, content } = messages[0].value + t.equal(extension.length, fxFulfilMessage.content.payload.extensionList.extension.length, 'Saved extension') + t.equal(extension[0].key, fxFulfilMessage.content.payload.extensionList.extension[0].key, 'Saved extension key') + t.equal(extension[0].value, fxFulfilMessage.content.payload.extensionList.extension[0].value, 'Saved extension value') + t.equal(from, FXP) + t.equal(to, DFSP_1) + t.equal(content.payload.fulfilment, fxFulfilMessage.content.payload.fulfilment, 'fulfilment is correct') + t.end() + }) + + fxFulfilTest.test('should check duplicates, and detect modified request (hash is not the same)', async (t) => { + const fxTransfer = fixtures.fxTransferDto({ + initiatingFsp: DFSP_1, + counterPartyFsp: FXP, + sourceAmount, + targetAmount + }) + const { commitRequestId } = fxTransfer + + await storeFxTransferPreparePayload(fxTransfer, '', false) + await fxTransferModel.duplicateCheck.saveFxTransferFulfilmentDuplicateCheck(commitRequestId, 'wrongHash') + t.pass(`fxTransfer prepare and duplicateCheck are saved in DB: ${commitRequestId}`) + + const fxFulfilMessage = createFxFulfilKafkaMessage({ commitRequestId }) + const isTriggered = await produceMessageToFxFulfilTopic(fxFulfilMessage) + t.ok(isTriggered, 'test is triggered') + + const messages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPICS.notificationEvent, + action: Action.FX_FULFIL_DUPLICATE + })) + t.ok(messages[0], `Message is sent to ${TOPICS.notificationEvent}`) + const { from, to, content, metadata } = messages[0].value + t.equal(from, fixtures.SWITCH_ID) + t.equal(to, FXP) + t.equal(metadata.event.type, Type.NOTIFICATION) + checkErrorPayload(t)(content.payload, fspiopErrorFactory.noFxDuplicateHash()) + t.end() + }) + + fxFulfilTest.test('should detect invalid fulfilment', async (t) => { + const fxTransfer = fixtures.fxTransferDto({ + initiatingFsp: DFSP_1, + counterPartyFsp: FXP, + sourceAmount, + targetAmount + }) + const { commitRequestId } = fxTransfer + + await storeFxTransferPreparePayload(fxTransfer, Enum.Transfers.TransferState.RESERVED) + t.pass(`fxTransfer prepare is saved in DB: ${commitRequestId}`) + + const fulfilment = 'wrongFulfilment' + const fxFulfilMessage = createFxFulfilKafkaMessage({ commitRequestId, fulfilment }) + const isTriggered = await produceMessageToFxFulfilTopic(fxFulfilMessage) + t.ok(isTriggered, 'test is triggered') + + const messages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPICS.transferPositionBatch, + action: Action.FX_ABORT_VALIDATION + })) + t.ok(messages[0], `Message is sent to ${TOPICS.transferPosition}`) + const { from, to, content } = messages[0].value + t.equal(from, fixtures.SWITCH_ID) + t.equal(to, DFSP_1) + checkErrorPayload(t)(content.payload, fspiopErrorFactory.fxInvalidFulfilment()) + t.end() + }) + + fxFulfilTest.test('teardown', async (t) => { + await Promise.all([ + Db.disconnect(), + Cache.destroyCache(), + producer.disconnect(), + testConsumer.destroy() + ]) + await ProxyCache.disconnect() + await new Promise(resolve => setTimeout(resolve, 5_000)) + t.pass('teardown is finished') + t.end() + }) + + fxFulfilTest.end() +}) diff --git a/test/integration-override/handlers/transfers/fxTimeout.test.js b/test/integration-override/handlers/transfers/fxTimeout.test.js new file mode 100644 index 000000000..17c2fd721 --- /dev/null +++ b/test/integration-override/handlers/transfers/fxTimeout.test.js @@ -0,0 +1,875 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + **********/ + +'use strict' + +const Test = require('tape') +const { randomUUID } = require('crypto') +const Logger = require('@mojaloop/central-services-logger') +const Config = require('#src/lib/config') +const Db = require('@mojaloop/database-lib').Db +const Cache = require('#src/lib/cache') +const ProxyCache = require('#src/lib/proxyCache') +const Producer = require('@mojaloop/central-services-stream').Util.Producer +const Utility = require('@mojaloop/central-services-shared').Util.Kafka +const Util = require('@mojaloop/central-services-shared').Util +const Enum = require('@mojaloop/central-services-shared').Enum +const ParticipantHelper = require('#test/integration/helpers/participant') +const ParticipantLimitHelper = require('#test/integration/helpers/participantLimit') +const ParticipantFundsInOutHelper = require('#test/integration/helpers/participantFundsInOut') +const ParticipantEndpointHelper = require('#test/integration/helpers/participantEndpoint') +const SettlementHelper = require('#test/integration/helpers/settlementModels') +const HubAccountsHelper = require('#test/integration/helpers/hubAccounts') +const TransferService = require('#src/domain/transfer/index') +const FxTransferModels = require('#src/models/fxTransfer/index') +const ParticipantService = require('#src/domain/participant/index') +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const { + wrapWithRetries +} = require('#test/util/helpers') +const TestConsumer = require('#test/integration/helpers/testConsumer') + +const ParticipantCached = require('#src/models/participant/participantCached') +const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') +const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') +const SettlementModelCached = require('#src/models/settlement/settlementModelCached') + +const Handlers = { + index: require('#src/handlers/register'), + positions: require('#src/handlers/positions/handler'), + transfers: require('#src/handlers/transfers/handler'), + timeouts: require('#src/handlers/timeouts/handler') +} + +const TransferState = Enum.Transfers.TransferState +const TransferInternalState = Enum.Transfers.TransferInternalState +const TransferEventType = Enum.Events.Event.Type +const TransferEventAction = Enum.Events.Event.Action + +const debug = process?.env?.TEST_INT_DEBUG || false +const rebalanceDelay = process?.env?.TEST_INT_REBALANCE_DELAY || 20000 +const retryDelay = process?.env?.TEST_INT_RETRY_DELAY || 2 +const retryCount = process?.env?.TEST_INT_RETRY_COUNT || 40 +const retryOpts = { + retries: retryCount, + minTimeout: retryDelay, + maxTimeout: retryDelay +} +const TOPIC_POSITION = 'topic-transfer-position' +const TOPIC_POSITION_BATCH = 'topic-transfer-position-batch' + +const testFxData = { + sourceAmount: { + currency: 'USD', + amount: 433.88 + }, + targetAmount: { + currency: 'XXX', + amount: 200.00 + }, + payer: { + name: 'payerFsp', + limit: 5000 + }, + payee: { + name: 'payeeFsp', + limit: 5000 + }, + fxp: { + name: 'fxp', + limit: 3000 + }, + endpoint: { + base: 'http://localhost:1080', + email: 'test@example.com' + }, + now: new Date(), + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)) // tomorrow +} + +const prepareFxTestData = async (dataObj) => { + try { + const payer = await ParticipantHelper.prepareData(dataObj.payer.name, dataObj.sourceAmount.currency) + const fxp = await ParticipantHelper.prepareData(dataObj.fxp.name, dataObj.sourceAmount.currency, dataObj.targetAmount.currency) + const payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.targetAmount.currency) + + const payerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payer.participant.name, { + currency: dataObj.sourceAmount.currency, + limit: { value: dataObj.payer.limit } + }) + const fxpLimitAndInitialPositionSourceCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.sourceAmount.currency, + limit: { value: dataObj.fxp.limit } + }) + const fxpLimitAndInitialPositionTargetCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.targetAmount.currency, + limit: { value: dataObj.fxp.limit } + }) + const payeeLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + currency: dataObj.targetAmount.currency, + limit: { value: dataObj.payee.limit } + }) + await ParticipantFundsInOutHelper.recordFundsIn(payer.participant.name, payer.participantCurrencyId2, { + currency: dataObj.sourceAmount.currency, + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyId2, { + currency: dataObj.sourceAmount.currency, + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyIdSecondary2, { + currency: dataObj.targetAmount.currency, + amount: 10000 + }) + + for (const name of [payer.participant.name, fxp.participant.name]) { + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_POST', `${dataObj.endpoint.base}/transfers`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_PUT', `${dataObj.endpoint.base}/transfers/{{transferId}}`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_ERROR', `${dataObj.endpoint.base}/transfers/{{transferId}}/error`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_POST', `${dataObj.endpoint.base}/bulkTransfers`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_PUT', `${dataObj.endpoint.base}/bulkTransfers/{{id}}`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_ERROR', `${dataObj.endpoint.base}/bulkTransfers/{{id}}/error`) + await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_QUOTES', `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${dataObj.endpoint.base}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}/error`) + } + + const transferId = randomUUID() + + const fxTransferPayload = { + commitRequestId: randomUUID(), + determiningTransferId: transferId, + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration, + initiatingFsp: payer.participant.name, + counterPartyFsp: fxp.participant.name, + sourceAmount: { + currency: dataObj.sourceAmount.currency, + amount: dataObj.sourceAmount.amount + }, + targetAmount: { + currency: dataObj.targetAmount.currency, + amount: dataObj.targetAmount.amount + } + } + + const fxPrepareHeaders = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': fxp.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=1.1' + } + + const transfer1Payload = { + transferId, + payerFsp: payer.participant.name, + payeeFsp: payee.participant.name, + amount: { + currency: dataObj.targetAmount.currency, + amount: dataObj.targetAmount.amount + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration, + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } + } + + const prepare1Headers = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': payee.participant.name, + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' + } + + const errorPayload = ErrorHandler.Factory.createFSPIOPError( + ErrorHandler.Enums.FSPIOPErrorCodes.PAYEE_FSP_REJECTED_TXN + ).toApiErrorObject() + errorPayload.errorInformation.extensionList = { + extension: [{ + key: 'errorDetail', + value: 'This is an abort extension' + }] + } + + const messageProtocolPayerInitiatedConversionFxPrepare = { + id: randomUUID(), + from: fxTransferPayload.initiatingFsp, + to: fxTransferPayload.counterPartyFsp, + type: 'application/json', + content: { + headers: fxPrepareHeaders, + payload: fxTransferPayload + }, + metadata: { + event: { + id: randomUUID(), + type: TransferEventType.TRANSFER, + action: TransferEventAction.FX_PREPARE, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const messageProtocolPrepare1 = { + id: randomUUID(), + from: transfer1Payload.payerFsp, + to: transfer1Payload.payeeFsp, + type: 'application/json', + content: { + headers: prepare1Headers, + payload: transfer1Payload + }, + metadata: { + event: { + id: randomUUID(), + type: TransferEventAction.PREPARE, + action: TransferEventType.PREPARE, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const topicConfFxTransferPrepare = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventAction.PREPARE + ) + + const topicConfTransferPrepare = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventType.PREPARE + ) + + const topicConfFxTransferFulfil = Utility.createGeneralTopicConf( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + TransferEventType.TRANSFER, + TransferEventType.FULFIL + ) + + const fxFulfilHeaders = { + 'fspiop-source': fxp.participant.name, + 'fspiop-destination': payer.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } + + const fulfilPayload = { + fulfilment: 'UNlJ98hZTY_dsw0cAqw4i_UN3v4utt7CZFB4yfLbVFA', + completedTimestamp: dataObj.now, + transferState: 'COMMITTED' + } + + const messageProtocolPayerInitiatedConversionFxFulfil = Util.clone(messageProtocolPayerInitiatedConversionFxPrepare) + messageProtocolPayerInitiatedConversionFxFulfil.id = randomUUID() + messageProtocolPayerInitiatedConversionFxFulfil.from = fxTransferPayload.counterPartyFsp + messageProtocolPayerInitiatedConversionFxFulfil.to = fxTransferPayload.initiatingFsp + messageProtocolPayerInitiatedConversionFxFulfil.content.headers = fxFulfilHeaders + messageProtocolPayerInitiatedConversionFxFulfil.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolPayerInitiatedConversionFxFulfil.content.payload = fulfilPayload + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.id = randomUUID() + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.type = TransferEventType.FULFIL + messageProtocolPayerInitiatedConversionFxFulfil.metadata.event.action = TransferEventAction.FX_RESERVE + + return { + fxTransferPayload, + transfer1Payload, + errorPayload, + messageProtocolPayerInitiatedConversionFxPrepare, + messageProtocolPayerInitiatedConversionFxFulfil, + messageProtocolPrepare1, + topicConfTransferPrepare, + topicConfFxTransferPrepare, + topicConfFxTransferFulfil, + payer, + payerLimitAndInitialPosition, + fxp, + fxpLimitAndInitialPositionSourceCurrency, + fxpLimitAndInitialPositionTargetCurrency, + payee, + payeeLimitAndInitialPosition + } + } catch (err) { + throw ErrorHandler.Factory.reformatFSPIOPError(err) + } +} + +Test('fxTimeout Handler Tests -->', async fxTimeoutTest => { + const startTime = new Date() + await Db.connect(Config.DATABASE) + await ParticipantCached.initialize() + await ParticipantCurrencyCached.initialize() + await ParticipantLimitCached.initialize() + await SettlementModelCached.initialize() + await Cache.initCache() + await SettlementHelper.prepareData() + await HubAccountsHelper.prepareData() + + const wrapWithRetriesConf = { + remainingRetries: retryOpts?.retries || 10, // default 10 + timeout: retryOpts?.maxTimeout || 2 // default 2 + } + + // Start a testConsumer to monitor events that our handlers emit + const testConsumer = new TestConsumer([ + { + topicName: Utility.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Enum.Events.Event.Type.TRANSFER, + Enum.Events.Event.Action.FULFIL + ), + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.FULFIL.toUpperCase() + ) + }, + { + topicName: Utility.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Enum.Events.Event.Type.NOTIFICATION, + Enum.Events.Event.Action.EVENT + ), + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.NOTIFICATION.toUpperCase(), + Enum.Events.Event.Action.EVENT.toUpperCase() + ) + }, + { + topicName: TOPIC_POSITION, + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.POSITION.toUpperCase() + ) + }, + { + topicName: TOPIC_POSITION_BATCH, + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.TRANSFER.toUpperCase(), + Enum.Events.Event.Action.POSITION.toUpperCase() + ) + } + ]) + + await fxTimeoutTest.test('Setup kafka consumer should', async registerAllHandlers => { + await registerAllHandlers.test('start consumer', async (test) => { + // Set up the testConsumer here + await testConsumer.startListening() + + await new Promise(resolve => setTimeout(resolve, rebalanceDelay)) + testConsumer.clearEvents() + + test.pass('done') + test.end() + registerAllHandlers.end() + }) + }) + + await fxTimeoutTest.test('fxTransferPrepare should', async fxTransferPrepare => { + await fxTransferPrepare.test('should handle payer initiated conversion fxTransfer', async (test) => { + const td = await prepareFxTestData(testFxData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.PREPARE.toUpperCase() + ) + prepareConfig.logger = Logger + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxPrepare, + td.topicConfFxTransferPrepare, + prepareConfig + ) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_PREPARE, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + test.end() + }) + + fxTransferPrepare.end() + }) + + await fxTimeoutTest.test('When only fxTransfer is sent, fxTimeout should', async timeoutTest => { + const expiration = new Date((new Date()).getTime() + (10 * 1000)) // 10 seconds + const newTestFxData = { + ...testFxData, + expiration: expiration.toISOString() + } + const td = await prepareFxTestData(newTestFxData) + + await timeoutTest.test('update fxTransfer state to RESERVED by PREPARE request', async (test) => { + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.PREPARE.toUpperCase() + ) + prepareConfig.logger = Logger + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxPrepare, + td.topicConfFxTransferPrepare, + prepareConfig + ) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_PREPARE, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + test.end() + }) + + await timeoutTest.test('update fxTransfer after timeout with timeout status & error', async (test) => { + // Arrange + // Nothing to do here... + + // Act + + // Re-try function with conditions + const inspectTransferState = async () => { + try { + // Fetch FxTransfer record + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + + // Check Transfer for correct state + if (fxTransfer?.transferState === Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) { + // We have a Transfer with the correct state, lets check if we can get the TransferError record + try { + // Fetch the TransferError record + const fxTransferError = await FxTransferModels.fxTransferError.getByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) + // FxTransferError record found, so lets return it + return { + fxTransfer, + fxTransferError + } + } catch (err) { + // NO FxTransferError record found, so lets return the fxTransfer and the error + return { + fxTransfer, + err + } + } + } else { + // NO FxTransfer with the correct state was found, so we return false + return false + } + } catch (err) { + // NO FxTransfer with the correct state was found, so we return false + Logger.error(err) + return false + } + } + + // wait until we inspect a fxTransfer with the correct status, or return false if all re-try attempts have failed + const result = await wrapWithRetries(inspectTransferState, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // Assert + if (result === false) { + test.fail(`FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].TransferState failed to transition to ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + test.end() + } else { + test.equal(result.fxTransfer && result.fxTransfer?.transferState, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].TransferState = ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + test.equal(result.fxTransferError && result.fxTransferError.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].transferError.errorCode = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code}`) + test.equal(result.fxTransferError && result.fxTransferError.errorDescription, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].transferError.errorDescription = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message}`) + test.pass() + test.end() + } + }) + + await timeoutTest.test('fxTransfer position timeout should be keyed with proper account id', async (test) => { + try { + const positionTimeout = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_TIMEOUT_RESERVED, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionTimeout[0], 'Position timeout message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + test.end() + }) + + await timeoutTest.test('position resets after a timeout', async (test) => { + // Arrange + const payerInitialPosition = td.fxpLimitAndInitialPositionTargetCurrency.participantPosition.value + + // Act + const payerPositionDidReset = async () => { + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyId) + console.log(td.payerLimitAndInitialPosition) + console.log(payerInitialPosition) + console.log(payerCurrentPosition) + return payerCurrentPosition.value === payerInitialPosition + } + // wait until we know the position reset, or throw after 5 tries + await wrapWithRetries(payerPositionDidReset, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) || {} + + // Assert + test.equal(payerCurrentPosition.value, payerInitialPosition, 'Position resets after a timeout') + test.end() + }) + + timeoutTest.end() + }) + + await fxTimeoutTest.test('When fxTransfer followed by a transfer are sent, fxTimeout should', async timeoutTest => { + const td = await prepareFxTestData(testFxData) + // Modify expiration of only fxTransfer + const expiration = new Date((new Date()).getTime() + (10 * 1000)) // 10 seconds + td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.expiration = expiration.toISOString() + + await timeoutTest.test('update fxTransfer state to RESERVED by PREPARE request', async (test) => { + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.PREPARE.toUpperCase() + ) + prepareConfig.logger = Logger + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxPrepare, + td.topicConfFxTransferPrepare, + prepareConfig + ) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_PREPARE, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.transferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + test.end() + }) + + await timeoutTest.test('update fxTransfer state to RECEIVED_FULFIL_DEPENDENT by FULFIL request', async (test) => { + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventAction.FULFIL.toUpperCase() + ) + fulfilConfig.logger = Logger + + await Producer.produceMessage( + td.messageProtocolPayerInitiatedConversionFxFulfil, + td.topicConfFxTransferFulfil, + fulfilConfig + ) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_RESERVE + // NOTE: The key is the fxp participantCurrencyId of the source currency (USD) + // Is that correct...? + // keyFilter: td.fxp.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fx-fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId( + td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + + if (fxTransfer?.transferState !== TransferInternalState.RECEIVED_FULFIL_DEPENDENT) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await timeoutTest.test('update transfer state to RESERVED by PREPARE request', async (test) => { + const config = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + config.logger = Logger + + const producerResponse = await Producer.produceMessage(td.messageProtocolPrepare1, td.topicConfTransferPrepare, config) + Logger.info(producerResponse) + + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolPrepare1.content.payload.transferId) || {} + if (transfer?.transferState !== TransferState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + test.end() + }) + + await timeoutTest.test('update fxTransfer after timeout with timeout status & error', async (test) => { + // Arrange + // Nothing to do here... + + // Act + + // Re-try function with conditions + const inspectTransferState = async () => { + try { + // Fetch FxTransfer record + const fxTransfer = await FxTransferModels.fxTransfer.getAllDetailsByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) || {} + // Check Transfer for correct state + if (fxTransfer?.transferState === Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) { + // We have a Transfer with the correct state, lets check if we can get the TransferError record + try { + // Fetch the TransferError record + const fxTransferError = await FxTransferModels.fxTransferError.getByCommitRequestId(td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId) + // FxTransferError record found, so lets return it + return { + fxTransfer, + fxTransferError + } + } catch (err) { + // NO FxTransferError record found, so lets return the fxTransfer and the error + return { + fxTransfer, + err + } + } + } else { + // NO FxTransfer with the correct state was found, so we return false + return false + } + } catch (err) { + // NO FxTransfer with the correct state was found, so we return false + Logger.error(err) + return false + } + } + + // wait until we inspect a fxTransfer with the correct status, or return false if all re-try attempts have failed + const result = await wrapWithRetries(inspectTransferState, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + + // Assert + if (result === false) { + test.fail(`FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].TransferState failed to transition to ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + test.end() + } else { + test.equal(result.fxTransfer && result.fxTransfer?.transferState, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].TransferState = ${Enum.Transfers.TransferInternalState.EXPIRED_RESERVED}`) + test.equal(result.fxTransferError && result.fxTransferError.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].transferError.errorCode = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.code}`) + test.equal(result.fxTransferError && result.fxTransferError.errorDescription, ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message, `FxTransfer['${td.messageProtocolPayerInitiatedConversionFxPrepare.content.payload.commitRequestId}'].transferError.errorDescription = ${ErrorHandler.Enums.FSPIOPErrorCodes.TRANSFER_EXPIRED.message}`) + test.pass() + test.end() + } + }) + + await timeoutTest.test('fxTransfer position timeout should be keyed with proper account id', async (test) => { + try { + const positionTimeout = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.FX_TIMEOUT_RESERVED, + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionTimeout[0], 'Position timeout message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + test.end() + }) + + await timeoutTest.test('transfer position timeout should be keyed with proper account id', async (test) => { + try { + const positionTimeout = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: TOPIC_POSITION_BATCH, + action: Enum.Events.Event.Action.TIMEOUT_RESERVED, + keyFilter: td.fxp.participantCurrencyIdSecondary.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionTimeout[0], 'Position timeout message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + test.end() + }) + + await timeoutTest.test('payer position resets after a timeout', async (test) => { + // Arrange + const payerInitialPosition = td.payerLimitAndInitialPosition.participantPosition.value + + // Act + const payerPositionDidReset = async () => { + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) + return payerCurrentPosition.value === payerInitialPosition + } + // wait until we know the position reset, or throw after 5 tries + await wrapWithRetries(payerPositionDidReset, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) || {} + + // Assert + test.equal(payerCurrentPosition.value, payerInitialPosition, 'Position resets after a timeout') + test.end() + }) + + await timeoutTest.test('fxp target currency position resets after a timeout', async (test) => { + // td.fxp.participantCurrencyIdSecondary is the fxp's target currency + // Arrange + const fxpInitialPosition = td.fxpLimitAndInitialPositionTargetCurrency.participantPosition.value + + // Act + const fxpPositionDidReset = async () => { + const fxpCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyIdSecondary) + return fxpCurrentPosition.value === fxpInitialPosition + } + // wait until we know the position reset, or throw after 5 tries + await wrapWithRetries(fxpPositionDidReset, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const fxpCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.fxp.participantCurrencyIdSecondary) || {} + + // Assert + test.equal(fxpCurrentPosition.value, fxpInitialPosition, 'Position resets after a timeout') + test.end() + }) + + timeoutTest.end() + }) + + await fxTimeoutTest.test('teardown', async (assert) => { + try { + await Handlers.timeouts.stop() + await Cache.destroyCache() + await Db.disconnect() + assert.pass('database connection closed') + await testConsumer.destroy() // this disconnects the consumers + + await Producer.disconnect() + await ProxyCache.disconnect() + + if (debug) { + const elapsedTime = Math.round(((new Date()) - startTime) / 100) / 10 + console.log(`handlers.test.js finished in (${elapsedTime}s)`) + } + + assert.end() + } catch (err) { + Logger.error(`teardown failed with error - ${err}`) + assert.fail() + assert.end() + } finally { + fxTimeoutTest.end() + } + }) +}) diff --git a/test/integration-override/handlers/transfers/handlers.test.js b/test/integration-override/handlers/transfers/handlers.test.js index cfc801ab3..e9e178bd0 100644 --- a/test/integration-override/handlers/transfers/handlers.test.js +++ b/test/integration-override/handlers/transfers/handlers.test.js @@ -1,20 +1,24 @@ /***** License --------------- -Copyright © 2017 Bill & Melinda Gates Foundation -The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at -http://www.apache.org/licenses/LICENSE-2.0 -Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. -Contributors --------------- -This is the official list of the Mojaloop project contributors for this file. -Names of the original copyright holders (individuals or organizations) -should be listed with a '*' in the first column. People who have -contributed from an organization can be listed under the organization -that actually holds the copyright for their contributions (see the -Gates Foundation organization for an example). Those individuals should have -their names indented and be marked with a '-'. Email address can be added -optionally within square brackets . + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation - Name Surname @@ -30,6 +34,7 @@ const Logger = require('@mojaloop/central-services-logger') const Config = require('#src/lib/config') const Db = require('@mojaloop/database-lib').Db const Cache = require('#src/lib/cache') +const ProxyCache = require('#src/lib/proxyCache') const Producer = require('@mojaloop/central-services-stream').Util.Producer const Utility = require('@mojaloop/central-services-shared').Util.Kafka const Enum = require('@mojaloop/central-services-shared').Enum @@ -45,12 +50,14 @@ const { wrapWithRetries } = require('#test/util/helpers') const TestConsumer = require('#test/integration/helpers/testConsumer') -const KafkaHelper = require('#test/integration/helpers/kafkaHelper') const ParticipantCached = require('#src/models/participant/participantCached') const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') const SettlementModelCached = require('#src/models/settlement/settlementModelCached') +const TransferService = require('#src/domain/transfer/index') +const FxTransferService = require('#src/domain/fx/index') +const ParticipantService = require('#src/domain/participant/index') const Handlers = { index: require('#src/handlers/register'), @@ -58,15 +65,15 @@ const Handlers = { transfers: require('#src/handlers/transfers/handler'), timeouts: require('#src/handlers/timeouts/handler') } - +const TransferStateEnum = Enum.Transfers.TransferState const TransferInternalState = Enum.Transfers.TransferInternalState const TransferEventType = Enum.Events.Event.Type const TransferEventAction = Enum.Events.Event.Action -const debug = process?.env?.TEST_INT_DEBUG || false -const rebalanceDelay = process?.env?.TEST_INT_REBALANCE_DELAY || 10000 -const retryDelay = process?.env?.TEST_INT_RETRY_DELAY || 2 -const retryCount = process?.env?.TEST_INT_RETRY_COUNT || 40 +const debug = process?.env?.test_INT_DEBUG || false +const rebalanceDelay = process?.env?.test_INT_REBALANCE_DELAY || 10000 +const retryDelay = process?.env?.test_INT_RETRY_DELAY || 2 +const retryCount = process?.env?.test_INT_RETRY_COUNT || 40 const retryOpts = { retries: retryCount, minTimeout: retryDelay, @@ -74,6 +81,7 @@ const retryOpts = { } const testData = { + currencies: ['USD', 'XXX'], amount: { currency: 'USD', amount: 110 @@ -86,6 +94,31 @@ const testData = { name: 'payeeFsp', limit: 300 }, + proxyAR: { + name: 'proxyAR', + limit: 99999 + }, + proxyRB: { + name: 'proxyRB', + limit: 99999 + }, + fxp: { + name: 'testFxp', + number: 1, + limit: 1000 + }, + fxTransfer: { + amount: { + currency: 'USD', + amount: 5 + }, + fx: { + targetAmount: { + currency: 'XXX', + amount: 50 + } + } + }, endpoint: { base: 'http://localhost:1080', email: 'test@example.com' @@ -129,25 +162,75 @@ const prepareTestData = async (dataObj) => { // } const payer = await ParticipantHelper.prepareData(dataObj.payer.name, dataObj.amount.currency) - const payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.amount.currency) - - const kafkacat = 'GROUP=abc; T=topic; TR=transfer; kafkacat -b localhost -G $GROUP $T-$TR-prepare $T-$TR-position $T-$TR-fulfil $T-$TR-get $T-admin-$TR $T-notification-event $T-bulk-prepare' - if (debug) console.error(kafkacat) + const fxp = await ParticipantHelper.prepareData(dataObj.fxp.name, dataObj.currencies[0], dataObj.currencies[1]) + const proxyAR = await ParticipantHelper.prepareData(dataObj.proxyAR.name, dataObj.amount.currency, undefined, undefined, true) + const proxyRB = await ParticipantHelper.prepareData(dataObj.proxyRB.name, dataObj.currencies[1], undefined, undefined, true) const payerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payer.participant.name, { currency: dataObj.amount.currency, limit: { value: dataObj.payer.limit } }) - const payeeLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + const fxpPayerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.currencies[0], + limit: { value: dataObj.fxp.limit } + }) + const fxpPayerLimitAndInitialPositionSecondaryCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(fxp.participant.name, { + currency: dataObj.currencies[1], + limit: { value: dataObj.fxp.limit } + }) + const proxyARLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(proxyAR.participant.name, { currency: dataObj.amount.currency, - limit: { value: dataObj.payee.limit } + limit: { value: dataObj.proxyAR.limit } }) + const proxyRBLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(proxyRB.participant.name, { + currency: dataObj.currencies[1], + limit: { value: dataObj.proxyRB.limit } + }) + await ParticipantFundsInOutHelper.recordFundsIn(payer.participant.name, payer.participantCurrencyId2, { currency: dataObj.amount.currency, amount: 10000 }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyId2, { + currency: dataObj.currencies[0], + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(fxp.participant.name, fxp.participantCurrencyIdSecondary2, { + currency: dataObj.currencies[1], + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(proxyAR.participant.name, proxyAR.participantCurrencyId2, { + currency: dataObj.amount.currency, + amount: 10000 + }) + await ParticipantFundsInOutHelper.recordFundsIn(proxyRB.participant.name, proxyRB.participantCurrencyId2, { + currency: dataObj.currencies[1], + amount: 10000 + }) + + let payee + let payeeLimitAndInitialPosition + let payeeLimitAndInitialPositionSecondaryCurrency + if (dataObj.crossSchemeSetup) { + payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.currencies[1], undefined) + payeeLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + currency: dataObj.currencies[1], + limit: { value: dataObj.payee.limit } + }) + payeeLimitAndInitialPositionSecondaryCurrency = null + } else { + payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.amount.currency, dataObj.currencies[1]) + payeeLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + currency: dataObj.amount.currency, + limit: { value: dataObj.payee.limit } + }) + payeeLimitAndInitialPositionSecondaryCurrency = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payee.participant.name, { + currency: dataObj.currencies[1], + limit: { value: dataObj.payee.limit } + }) + } - for (const name of [payer.participant.name, payee.participant.name]) { + for (const name of [payer.participant.name, payee.participant.name, proxyAR.participant.name, proxyRB.participant.name, fxp.participant.name]) { await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_POST', `${dataObj.endpoint.base}/transfers`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_PUT', `${dataObj.endpoint.base}/transfers/{{transferId}}`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_TRANSFER_ERROR', `${dataObj.endpoint.base}/transfers/{{transferId}}/error`) @@ -155,10 +238,14 @@ const prepareTestData = async (dataObj) => { await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_PUT', `${dataObj.endpoint.base}/bulkTransfers/{{id}}`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_ERROR', `${dataObj.endpoint.base}/bulkTransfers/{{id}}/error`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_QUOTES', `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${dataObj.endpoint.base}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}/error`) } - + const transferId = randomUUID() const transferPayload = { - transferId: randomUUID(), + transferId, payerFsp: payer.participant.name, payeeFsp: payee.participant.name, amount: { @@ -187,6 +274,16 @@ const prepareTestData = async (dataObj) => { 'fspiop-destination': payee.participant.name, 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' } + const fxPrepareHeaders = { + 'fspiop-source': payer.participant.name, + 'fspiop-destination': fxp.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } + const fxFulfilAbortRejectHeaders = { + 'fspiop-source': fxp.participant.name, + 'fspiop-destination': payer.participant.name, + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0' + } const fulfilAbortRejectHeaders = { 'fspiop-source': payee.participant.name, 'fspiop-destination': payer.participant.name, @@ -211,6 +308,29 @@ const prepareTestData = async (dataObj) => { } } + const fxTransferPayload = { + commitRequestId: randomUUID(), + determiningTransferId: transferId, + initiatingFsp: payer.participant.name, + counterPartyFsp: fxp.participant.name, + sourceAmount: { + currency: dataObj.fxTransfer.amount.currency, + amount: dataObj.fxTransfer.amount.amount.toString() + }, + targetAmount: { + currency: dataObj.fxTransfer.fx?.targetAmount.currency || dataObj.fxTransfer.amount.currency, + amount: dataObj.fxTransfer.fx?.targetAmount.amount.toString() || dataObj.fxTransfer.amount.amount.toString() + }, + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: dataObj.expiration + } + + const fxFulfilPayload = { + fulfilment: 'UNlJ98hZTY_dsw0cAqw4i_UN3v4utt7CZFB4yfLbVFA', + completedTimestamp: dataObj.now, + conversionState: 'RESERVED' + } + const rejectPayload = Object.assign({}, fulfilPayload, { transferState: TransferInternalState.ABORTED_REJECTED }) const errorPayload = ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.PAYEE_FSP_REJECTED_TXN).toApiErrorObject() @@ -239,6 +359,67 @@ const prepareTestData = async (dataObj) => { } } + const messageProtocolPrepareForwarded = { + id: transferPayload.transferId, + from: 'payerFsp', + to: 'proxyFsp', + type: 'application/json', + content: { + payload: { + proxyId: 'test', + transferId: transferPayload.transferId + } + }, + metadata: { + event: { + id: transferPayload.transferId, + type: TransferEventType.PREPARE, + action: TransferEventAction.FORWARDED, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const messageProtocolPrepareFxForwarded = { + id: fxTransferPayload.commitRequestId, + from: 'payerFsp', + to: 'proxyFsp', + type: 'application/json', + content: { + payload: { + proxyId: 'test', + commitRequestId: fxTransferPayload.commitRequestId + } + }, + metadata: { + event: { + id: transferPayload.transferId, + type: TransferEventType.PREPARE, + action: TransferEventAction.FX_FORWARDED, + createdAt: dataObj.now, + state: { + status: 'success', + code: 0 + } + } + } + } + + const messageProtocolFxPrepare = Util.clone(messageProtocolPrepare) + messageProtocolFxPrepare.id = randomUUID() + messageProtocolFxPrepare.from = fxTransferPayload.initiatingFsp + messageProtocolFxPrepare.to = fxTransferPayload.counterPartyFsp + messageProtocolFxPrepare.content.headers = fxPrepareHeaders + messageProtocolFxPrepare.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxPrepare.content.payload = fxTransferPayload + messageProtocolFxPrepare.metadata.event.id = randomUUID() + messageProtocolFxPrepare.metadata.event.type = TransferEventType.PREPARE + messageProtocolFxPrepare.metadata.event.action = TransferEventAction.FX_PREPARE + const messageProtocolFulfil = Util.clone(messageProtocolPrepare) messageProtocolFulfil.id = randomUUID() messageProtocolFulfil.from = transferPayload.payeeFsp @@ -250,6 +431,17 @@ const prepareTestData = async (dataObj) => { messageProtocolFulfil.metadata.event.type = TransferEventType.FULFIL messageProtocolFulfil.metadata.event.action = TransferEventAction.COMMIT + const messageProtocolFxFulfil = Util.clone(messageProtocolFxPrepare) + messageProtocolFxFulfil.id = randomUUID() + messageProtocolFxFulfil.from = fxTransferPayload.counterPartyFsp + messageProtocolFxFulfil.to = fxTransferPayload.initiatingFsp + messageProtocolFxFulfil.content.headers = fxFulfilAbortRejectHeaders + messageProtocolFxFulfil.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxFulfil.content.payload = fxFulfilPayload + messageProtocolFxFulfil.metadata.event.id = randomUUID() + messageProtocolFxFulfil.metadata.event.type = TransferEventType.FULFIL + messageProtocolFxFulfil.metadata.event.action = TransferEventAction.FX_RESERVE + const messageProtocolReject = Util.clone(messageProtocolFulfil) messageProtocolReject.id = randomUUID() messageProtocolFulfil.content.uriParams = { id: transferPayload.transferId } @@ -258,20 +450,33 @@ const prepareTestData = async (dataObj) => { const messageProtocolError = Util.clone(messageProtocolFulfil) messageProtocolError.id = randomUUID() - messageProtocolFulfil.content.uriParams = { id: transferPayload.transferId } + messageProtocolError.content.uriParams = { id: transferPayload.transferId } messageProtocolError.content.payload = errorPayload messageProtocolError.metadata.event.action = TransferEventAction.ABORT + const messageProtocolFxError = Util.clone(messageProtocolFxFulfil) + messageProtocolFxError.id = randomUUID() + messageProtocolFxError.content.uriParams = { id: fxTransferPayload.commitRequestId } + messageProtocolFxError.content.payload = errorPayload + messageProtocolFxError.metadata.event.action = TransferEventAction.FX_ABORT + const topicConfTransferPrepare = Utility.createGeneralTopicConf(Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, TransferEventType.TRANSFER, TransferEventType.PREPARE) const topicConfTransferFulfil = Utility.createGeneralTopicConf(Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, TransferEventType.TRANSFER, TransferEventType.FULFIL) return { transferPayload, + fxTransferPayload, fulfilPayload, + fxFulfilPayload, rejectPayload, errorPayload, messageProtocolPrepare, + messageProtocolPrepareForwarded, + messageProtocolPrepareFxForwarded, + messageProtocolFxPrepare, + messageProtocolFxError, messageProtocolFulfil, + messageProtocolFxFulfil, messageProtocolReject, messageProtocolError, topicConfTransferPrepare, @@ -279,7 +484,15 @@ const prepareTestData = async (dataObj) => { payer, payerLimitAndInitialPosition, payee, - payeeLimitAndInitialPosition + payeeLimitAndInitialPosition, + payeeLimitAndInitialPositionSecondaryCurrency, + proxyAR, + proxyARLimitAndInitialPosition, + proxyRB, + proxyRBLimitAndInitialPosition, + fxp, + fxpPayerLimitAndInitialPosition, + fxpPayerLimitAndInitialPositionSecondaryCurrency } } catch (err) { throw ErrorHandler.Factory.reformatFSPIOPError(err) @@ -312,6 +525,19 @@ Test('Handlers test', async handlersTest => { Enum.Events.Event.Type.TRANSFER.toUpperCase(), Enum.Events.Event.Action.POSITION.toUpperCase() ) + }, + { + topicName: Utility.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + Enum.Events.Event.Type.NOTIFICATION, + Enum.Events.Event.Action.EVENT + ), + config: Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + Enum.Events.Event.Type.NOTIFICATION.toUpperCase(), + Enum.Events.Event.Action.EVENT.toUpperCase() + ) } ]) @@ -327,10 +553,10 @@ Test('Handlers test', async handlersTest => { // Set up the testConsumer here await testConsumer.startListening() - await KafkaHelper.producers.connect() // TODO: MIG - Disabling these handlers to test running the CL as a separate service independently. await new Promise(resolve => setTimeout(resolve, rebalanceDelay)) - + await ProxyCache.connect() + testConsumer.clearEvents() test.pass('done') test.end() registerAllHandlers.end() @@ -366,8 +592,49 @@ Test('Handlers test', async handlersTest => { transferPrepare.end() }) - await handlersTest.test('transferFulfil should', async transferFulfil => { - await transferFulfil.test('should create position fulfil message to override topic name in config', async (test) => { + await handlersTest.test('fxTransferPrepare should', async transferPrepare => { + await transferPrepare.test('ignore non COMMITTED/ABORTED fxTransfer on duplicate request', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: TransferEventAction.FX_PREPARE, + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + await new Promise(resolve => setTimeout(resolve, 5000)) + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + try { + await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: TransferEventAction.FX_PREPARE, + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.notOk('Secondary position prepare message with key should not be found') + } catch (err) { + test.ok('Duplicate prepare message ignored') + console.error(err) + } + test.end() + }) + + await transferPrepare.test('send fxTransfer information callback when fxTransfer is (RECEIVED_FULFIL_DEPENDENT) RESERVED on duplicate request', async (test) => { const td = await prepareTestData(testData) const prepareConfig = Utility.getKafkaConfig( Config.KAFKA_CONFIG, @@ -381,13 +648,90 @@ Test('Handlers test', async handlersTest => { TransferEventType.TRANSFER.toUpperCase(), TransferEventType.FULFIL.toUpperCase()) fulfilConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) - await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: TransferEventAction.FX_PREPARE, + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_RESERVE, + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RECEIVED_FULFIL_DEPENDENT, 'FxTransfer state updated to RECEIVED_FULFIL_DEPENDENT') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Resend fx-prepare after state is RECEIVED_FULFIL_DEPENDENT + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + // Should send fxTransfer state in callback + // Internal state RECEIVED_FULFIL_DEPENDENT maps to TransferStateEnum.RESERVED enumeration. + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_PREPARE_DUPLICATE + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare duplicate message with key found') + // Check if the error message is correct + test.equal(positionPrepare[0].value.content.payload.conversionState, TransferStateEnum.RESERVED) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + test.end() + }) + + await transferPrepare.test('send fxTransfer information callback when fxTransfer is COMMITTED on duplicate request', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + // Set up the fxTransfer + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) try { const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ topicFilter: 'topic-transfer-position-batch', - action: 'prepare', + action: TransferEventAction.FX_PREPARE, + // To be keyed with the Payer DFSP participantCurrencyId keyFilter: td.payer.participantCurrencyId.toString() }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) test.ok(positionPrepare[0], 'Position prepare message with key found') @@ -396,24 +740,1665 @@ Test('Handlers test', async handlersTest => { console.error(err) } testConsumer.clearEvents() + + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_RESERVE, + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil notification message found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RECEIVED_FULFIL_DEPENDENT, 'FxTransfer state updated to RECEIVED_FULFIL_DEPENDENT') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Complete dependent transfer + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, fulfilConfig) + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.PREPARE + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Prepare notification message found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.COMMIT + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Fulfil notification message found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + // Assert FXP notification message is produced try { - const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ - topicFilter: 'topic-transfer-position-batch', - action: 'commit', - keyFilter: td.payee.participantCurrencyId.toString() + const notifyFxp = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_NOTIFY }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) - test.ok(positionFulfil[0], 'Position fulfil message with key found') + test.ok(notifyFxp[0], 'FXP notify notification message found') + test.equal(notifyFxp[0].value.content.payload.conversionState, TransferStateEnum.COMMITTED) + test.equal(notifyFxp[0].value.content.uriParams.id, td.messageProtocolFxPrepare.content.payload.commitRequestId) + test.ok(notifyFxp[0].value.content.payload.completedTimestamp) + test.equal(notifyFxp[0].value.to, td.fxp.participant.name) } catch (err) { test.notOk('Error should not be thrown') console.error(err) } testConsumer.clearEvents() + + // Resend fx-prepare after fxTransfer state is COMMITTED + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + // Should send fxTransfer state in callback + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_PREPARE_DUPLICATE + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare duplicate notification found') + // Check if the error message is correct + test.equal(positionPrepare[0].value.content.payload.conversionState, TransferStateEnum.COMMITTED) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + test.end() }) - transferFulfil.end() + await transferPrepare.test('send fxTransfer information callback when fxTransfer is ABORTED on duplicate request', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: TransferEventAction.FX_PREPARE, + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxError, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_ABORT, + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.ABORTED_ERROR, 'FxTransfer state updated to ABORTED_ERROR') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Resend fx-prepare after state is ABORTED_ERROR + await new Promise(resolve => setTimeout(resolve, 2000)) + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + // Should send fxTransfer state in callback + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: TransferEventAction.FX_PREPARE_DUPLICATE + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare duplicate message with key found') + // Check if the error message is correct + test.equal(positionPrepare[0].value.content.payload.conversionState, TransferStateEnum.ABORTED) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + test.end() + }) + transferPrepare.end() + }) + + await handlersTest.test('transferForwarded should', async transferForwarded => { + await transferForwarded.test('should update transfer internal state on prepare event forwarded action', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED_FORWARDED, 'Transfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + testConsumer.clearEvents() + test.end() + }) + + await transferForwarded.test('not timeout transfer in RESERVED_FORWARDED internal transfer state', async (test) => { + const expiringTestData = Util.clone(testData) + expiringTestData.expiration = new Date((new Date()).getTime() + 5000) + + const td = await prepareTestData(expiringTestData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED_FORWARDED, 'Transfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED_FORWARDED, 'Transfer state is still RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferForwarded.test('should be able to transition from RESERVED_FORWARDED to RECEIVED_FULFIL and COMMITED on fulfil', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED_FORWARDED, 'Transfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.payee.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.COMMITTED, 'Transfer state updated to COMMITTED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferForwarded.test('should be able to transition from RESERVED_FORWARDED to RECEIVED_ERROR and ABORTED_ERROR on fulfil error', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.RESERVED_FORWARDED, 'Transfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + await Producer.produceMessage(td.messageProtocolError, td.topicConfTransferFulfil, fulfilConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + test.equal(transfer?.transferState, TransferInternalState.ABORTED_ERROR, 'Transfer state updated to ABORTED_ERROR') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferForwarded.test('should create notification message if transfer is not found', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + try { + const notificationMessages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'forwarded' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(notificationMessages[0], 'notification message found') + test.equal(notificationMessages[0].value.to, 'proxyFsp') + test.equal(notificationMessages[0].value.from, 'payerFsp') + test.equal( + notificationMessages[0].value.content.payload.errorInformation.errorDescription, + 'Generic ID not found - Forwarded transfer could not be found.' + ) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferForwarded.test('should create notification message if transfer is found in incorrect state', async (test) => { + const expiredTestData = Util.clone(testData) + expiredTestData.expiration = new Date((new Date()).getTime() + 3000) + + const td = await prepareTestData(expiredTestData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + await new Promise(resolve => setTimeout(resolve, 3000)) + + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferInternalState.EXPIRED_RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Send the prepare forwarded message after the prepare message has timed out + await Producer.produceMessage(td.messageProtocolPrepareForwarded, td.topicConfTransferPrepare, prepareConfig) + + try { + const notificationMessages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'forwarded' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(notificationMessages[0], 'notification message found') + test.equal(notificationMessages[0].value.to, 'proxyFsp') + test.equal(notificationMessages[0].value.from, 'payerFsp') + test.equal( + notificationMessages[0].value.content.payload.errorInformation.errorDescription, + 'Internal server error - Invalid State: EXPIRED_RESERVED - expected: RESERVED' + ) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + transferForwarded.end() + }) + + await handlersTest.test('transferFxForwarded should', async transferFxForwarded => { + await transferFxForwarded.test('should update fxTransfer internal state on prepare event fx-forwarded action', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + + await new Promise(resolve => setTimeout(resolve, 5000)) + + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED_FORWARDED, 'FxTransfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + testConsumer.clearEvents() + test.end() + }) + + await transferFxForwarded.test('not timeout fxTransfer in RESERVED_FORWARDED internal transfer state', async (test) => { + const expiringTestData = Util.clone(testData) + expiringTestData.expiration = new Date((new Date()).getTime() + 5000) + + const td = await prepareTestData(expiringTestData) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED_FORWARDED, 'FxTransfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED_FORWARDED, 'FxTransfer still in RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + testConsumer.clearEvents() + test.end() + }) + + await transferFxForwarded.test('should be able to transition from RESERVED_FORWARDED to RECEIVED_FULFIL_DEPENDENT on fx-fulfil', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED_FORWARDED, 'FxTransfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Fulfil the fxTransfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RECEIVED_FULFIL_DEPENDENT, 'FxTransfer state updated to RECEIVED_FULFIL_DEPENDENT') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferFxForwarded.test('should be able to transition from RESERVED_FORWARDED to RECEIVED_ERROR and ABORTED_ERROR on fx-fulfil error', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position fx-prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.RESERVED_FORWARDED, 'FxTransfer state updated to RESERVED_FORWARDED') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Fulfil the fxTransfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + console.log('messageProtocolFxError', td.messageProtocolFxError) + await Producer.produceMessage(td.messageProtocolFxError, td.topicConfTransferFulfil, fulfilConfig) + await new Promise(resolve => setTimeout(resolve, 5000)) + try { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + test.equal(fxTransfer?.fxTransferState, TransferInternalState.ABORTED_ERROR, 'FxTransfer state updated to ABORTED_ERROR') + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferFxForwarded.test('should create notification message if fxTransfer is not found', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + + try { + const notificationMessages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-forwarded' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(notificationMessages[0], 'notification message found') + test.equal(notificationMessages[0].value.to, 'proxyFsp') + test.equal(notificationMessages[0].value.from, 'payerFsp') + test.equal( + notificationMessages[0].value.content.payload.errorInformation.errorDescription, + 'Generic ID not found - Forwarded fxTransfer could not be found.' + ) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferFxForwarded.test('should create notification message if transfer is found in incorrect state', async (test) => { + const expiredTestData = Util.clone(testData) + expiredTestData.expiration = new Date((new Date()).getTime() + 3000) + + const td = await prepareTestData(expiredTestData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + await new Promise(resolve => setTimeout(resolve, 3000)) + + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + if (fxTransfer?.fxTransferState !== TransferInternalState.EXPIRED_RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Send the prepare forwarded message after the prepare message has timed out + await Producer.produceMessage(td.messageProtocolPrepareFxForwarded, td.topicConfTransferPrepare, prepareConfig) + + try { + const notificationMessages = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-forwarded' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(notificationMessages[0], 'notification message found') + test.equal(notificationMessages[0].value.to, 'proxyFsp') + test.equal(notificationMessages[0].value.from, 'payerFsp') + test.equal( + notificationMessages[0].value.content.payload.errorInformation.errorDescription, + 'Internal server error - Invalid State: EXPIRED_RESERVED - expected: RESERVED' + ) + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + transferFxForwarded.end() + }) + + await handlersTest.test('transferFulfil should', async transferFulfil => { + await transferFulfil.test('should create position fulfil message to override topic name in config', async (test) => { + const td = await prepareTestData(testData) + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.payee.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + test.end() + }) + + transferFulfil.end() + }) + + await handlersTest.test('transferProxyPrepare should', async transferProxyPrepare => { + await transferProxyPrepare.test(` + Scheme A: POST /fxTransfer call I.e. Debtor: Payer DFSP → Creditor: Proxy AR + Payer DFSP position account must be updated (reserved)`, async (test) => { + const creditor = 'regionalSchemeFXP' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(creditor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolFxPrepare.to = creditor + td.messageProtocolFxPrepare.content.headers['fspiop-destination'] = creditor + td.messageProtocolFxPrepare.content.payload.counterPartyFsp = creditor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme A: POST /Transfer call I.e. Debtor: Proxy AR → Creditor: Proxy AR + Do nothing (produce message with key 0)`, async (test) => { + // Create dependent fxTransfer + let creditor = 'regionalSchemeFXP' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(creditor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFxPrepare.to = creditor + td.messageProtocolFxPrepare.content.headers['fspiop-destination'] = creditor + td.messageProtocolFxPrepare.content.payload.counterPartyFsp = creditor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Payer DFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Payer DFSP position account must be updated (reserved) + let payerPositionAfterFxPrepare + const tests = async () => { + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) || {} + const payerInitialPosition = td.payerLimitAndInitialPosition.participantPosition.value + const payerExpectedPosition = Number(payerInitialPosition) + Number(td.fxTransferPayload.sourceAmount.amount) + const payerPositionChange = await ParticipantService.getPositionChangeByParticipantPositionId(payerCurrentPosition.participantPositionId) || {} + test.equal(payerCurrentPosition.value, payerExpectedPosition, 'Payer position incremented by transfer amount and updated in participantPosition') + test.equal(payerPositionChange.value, payerCurrentPosition.value, 'Payer position change value inserted and matches the updated participantPosition value') + payerPositionAfterFxPrepare = payerExpectedPosition + } + try { + await wrapWithRetries(async () => { + const fxTransfer = await FxTransferService.getByIdLight(td.messageProtocolFxPrepare.content.payload.commitRequestId) || {} + Logger.warn(`fxTransfer: ${JSON.stringify(fxTransfer)}`) + if (fxTransfer?.fxTransferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return fxTransfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + td.messageProtocolFxFulfil.to = td.payer.participant.name + td.messageProtocolFxFulfil.from = 'regionalSchemeFXP' + td.messageProtocolFxFulfil.content.headers['fspiop-destination'] = td.payer.participant.name + td.messageProtocolFxFulfil.content.headers['fspiop-source'] = 'regionalSchemeFXP' + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Fulfil notification found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Create subsequent transfer + creditor = 'regionalSchemePayeeFsp' + await ProxyCache.getCache().addDfspIdToProxyMapping(creditor, td.proxyAR.participant.name) + + td.messageProtocolPrepare.to = creditor + td.messageProtocolPrepare.content.headers['fspiop-destination'] = creditor + td.messageProtocolPrepare.content.payload.payeeFsp = creditor + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // To be keyed with 0 + keyFilter: '0' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key 0 found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Hard to test that the position messageKey=0 equates to doing nothing + // so we'll just check that the positions are unchanged for the participants + const payerCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.payer.participantCurrencyId) || {} + test.equal(payerCurrentPosition.value, payerPositionAfterFxPrepare, 'Payer position unchanged') + const proxyARCurrentPosition = await ParticipantService.getPositionByParticipantCurrencyId(td.proxyAR.participantCurrencyId) || {} + test.equal(proxyARCurrentPosition.value, td.proxyARLimitAndInitialPosition.participantPosition.value, 'FXP position unchanged') + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: POST /fxTransfer call I.e. Debtor: Proxy AR → Creditor: FXP + Proxy AR position account in source currency must be updated (reserved)`, async (test) => { + const debtor = 'jurisdictionalFspPayerFsp' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(debtor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolFxPrepare.from = debtor + td.messageProtocolFxPrepare.content.headers['fspiop-source'] = debtor + td.messageProtocolFxPrepare.content.payload.initiatingFsp = debtor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Proxy AR participantCurrencyId + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: POST /transfer call I.e. Debtor: FXP → Creditor: Proxy RB + FXP position account in targeted currency must be updated (reserved)`, async (test) => { + const debtor = 'jurisdictionalFspPayerFsp' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(debtor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolFxPrepare.from = debtor + td.messageProtocolFxPrepare.content.headers['fspiop-source'] = debtor + td.messageProtocolFxPrepare.content.payload.initiatingFsp = debtor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Proxy AR participantCurrencyId + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the fxTransfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFxFulfil.to = debtor + td.messageProtocolFxFulfil.content.headers['fspiop-destination'] = debtor + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Create subsequent transfer + const creditor = 'regionalSchemePayeeFsp' + await ProxyCache.getCache().addDfspIdToProxyMapping(creditor, td.proxyRB.participant.name) + + td.messageProtocolPrepare.to = creditor + td.messageProtocolPrepare.content.headers['fspiop-destination'] = creditor + td.messageProtocolPrepare.content.payload.payeeFsp = creditor + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // A position prepare message reserving the FXP's targeted currency account should be created + // Specifically for this test the targetCurrency is XXX + keyFilter: td.fxp.participantCurrencyIdSecondary.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of fxp target currency account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme B: POST /transfer call I.e. Debtor: Proxy RB → Creditor: Payee DFSP + Proxy RB position account must be updated (reserved)`, async (test) => { + const debtor = 'jurisdictionalFspPayerFsp' + + // Proxy RB and Payee are only set up to deal in XXX currency + const td = await prepareTestData({ + ...testData, + amount: { + currency: 'XXX', + amount: '100' + }, + crossSchemeSetup: true + }) + await ProxyCache.getCache().addDfspIdToProxyMapping(debtor, td.proxyRB.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolPrepare.from = debtor + td.messageProtocolPrepare.content.headers['fspiop-source'] = debtor + td.messageProtocolPrepare.content.payload.payerFsp = debtor + td.messageProtocolPrepare.content.payload.amount.currency = 'XXX' + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // A position prepare message reserving the proxy of ProxyRB on it's XXX participant currency account + keyFilter: td.proxyRB.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of proxyRB target currency account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + transferProxyPrepare.end() + }) + + await handlersTest.test('transferProxyFulfil should', async transferProxyPrepare => { + await transferProxyPrepare.test(` + Scheme B: PUT /transfers call I.e. From: Payee DFSP → To: Proxy RB + Payee DFSP position account must be updated`, async (test) => { + const transferPrepareFrom = 'schemeAPayerFsp' + + // Proxy RB and Payee are only set up to deal in XXX currency + const td = await prepareTestData({ + ...testData, + crossSchemeSetup: true, + amount: { + currency: 'XXX', + amount: '100' + } + }) + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareFrom, td.proxyRB.participant.name) + + // Prepare the transfer + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolPrepare.from = transferPrepareFrom + td.messageProtocolPrepare.content.headers['fspiop-source'] = transferPrepareFrom + td.messageProtocolPrepare.content.payload.payerFsp = transferPrepareFrom + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // A position prepare message reserving the proxy of ProxyRB on it's XXX participant currency account + keyFilter: td.proxyRB.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of fxp target currency account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the transfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFulfil.to = transferPrepareFrom + td.messageProtocolFulfil.content.headers['fspiop-destination'] = transferPrepareFrom + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.payee.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: PUT /transfers call I.e. From: Proxy RB → To: Proxy AR + If it is a normal transfer without currency conversion + ProxyRB account must be updated`, async (test) => { + const transferPrepareFrom = 'schemeAPayerFsp' + const transferPrepareTo = 'schemeBPayeeFsp' + + // In this particular test, without currency conversion proxyRB and proxyAR + // should have accounts in the same currency. proxyRB default currency is already XXX. + // So configure proxy AR to operate in XXX currency. + const td = await prepareTestData({ + ...testData, + amount: { + currency: 'XXX', + amount: '100' + }, + crossSchemeSetup: true + }) + + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareFrom, td.proxyAR.participant.name) + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareTo, td.proxyRB.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolPrepare.from = transferPrepareFrom + td.messageProtocolPrepare.to = transferPrepareTo + td.messageProtocolPrepare.content.headers['fspiop-source'] = transferPrepareFrom + td.messageProtocolPrepare.content.headers['fspiop-destination'] = transferPrepareTo + td.messageProtocolPrepare.content.payload.payerFsp = transferPrepareFrom + td.messageProtocolPrepare.content.payload.payeeFsp = transferPrepareTo + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of proxyAR account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the transfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFulfil.from = transferPrepareTo + td.messageProtocolFulfil.to = transferPrepareFrom + td.messageProtocolFulfil.content.headers['fspiop-source'] = transferPrepareTo + td.messageProtocolFulfil.content.headers['fspiop-destination'] = transferPrepareFrom + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.proxyRB.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: PUT /fxTransfer call I.e. From: FXP → To: Proxy AR + No position changes should happen`, async (test) => { + const debtor = 'jurisdictionalFspPayerFsp' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(debtor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolFxPrepare.from = debtor + td.messageProtocolFxPrepare.content.headers['fspiop-source'] = debtor + td.messageProtocolFxPrepare.content.payload.initiatingFsp = debtor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Proxy AR participantCurrencyId + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the fxTransfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFxFulfil.to = debtor + td.messageProtocolFxFulfil.content.headers['fspiop-destination'] = debtor + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: PUT /fxTransfer call I.e. From: FXP → To: Proxy AR + with wrong headers - ABORT VALIDATION`, async (test) => { + const debtor = 'jurisdictionalFspPayerFsp' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(debtor, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + + td.messageProtocolFxPrepare.from = debtor + td.messageProtocolFxPrepare.content.headers['fspiop-source'] = debtor + td.messageProtocolFxPrepare.content.payload.initiatingFsp = debtor + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Proxy AR participantCurrencyId + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with debtor key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the fxTransfer + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + td.messageProtocolFxFulfil.to = debtor + td.messageProtocolFxFulfil.content.headers['fspiop-destination'] = debtor + + // If initiatingFsp is proxy, fx fulfil handler doesn't validate fspiop-destination header. + // But it should validate fspiop-source header, because counterPartyFsp is not a proxy. + td.messageProtocolFxFulfil.content.headers['fspiop-source'] = 'wrongfsp' + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-abort-validation', + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme R: PUT /transfers call I.e. From: Proxy RB → To: Proxy AR + If it is a FX transfer with currency conversion + FXP and ProxyRB account must be updated`, async (test) => { + const transferPrepareFrom = 'schemeAPayerFsp' + const transferPrepareTo = 'schemeBPayeeFsp' + + // In this particular test, with currency conversion, we're assuming that proxyAR and proxyRB + // operate in different currencies. ProxyRB's default currency is XXX, and ProxyAR's default currency is USD. + const td = await prepareTestData({ + ...testData, + crossSchemeSetup: true + }) + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareFrom, td.proxyAR.participant.name) + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareTo, td.proxyRB.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + // FX Transfer from proxyAR to FXP + td.messageProtocolFxPrepare.from = transferPrepareFrom + td.messageProtocolFxPrepare.content.headers['fspiop-source'] = transferPrepareFrom + td.messageProtocolFxPrepare.content.payload.initiatingFsp = transferPrepareFrom + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the Proxy AR participantCurrencyId + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with proxyAR key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the fxTransfer + td.messageProtocolFxFulfil.to = transferPrepareFrom + td.messageProtocolFxFulfil.content.headers['fspiop-destination'] = transferPrepareFrom + td.messageProtocolFxFulfil.from = td.fxp.participant.name + td.messageProtocolFxFulfil.content.headers['fspiop-source'] = td.fxp.participant.name + + testConsumer.clearEvents() + Logger.warn(`td.messageProtocolFxFulfil: ${JSON.stringify(td.messageProtocolFxFulfil)}`) + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: transferPrepareFrom + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fxFulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Create subsequent transfer + td.messageProtocolPrepare.from = transferPrepareFrom + td.messageProtocolPrepare.to = transferPrepareTo + td.messageProtocolPrepare.content.headers['fspiop-source'] = transferPrepareFrom + td.messageProtocolPrepare.content.headers['fspiop-destination'] = transferPrepareTo + td.messageProtocolPrepare.content.payload.payerFsp = transferPrepareFrom + td.messageProtocolPrepare.content.payload.payeeFsp = transferPrepareTo + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // A position prepare message reserving the FXP's targeted currency account should be created + keyFilter: td.fxp.participantCurrencyIdSecondary.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of fxp target currency account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the transfer + td.messageProtocolFulfil.from = transferPrepareTo + td.messageProtocolFulfil.to = transferPrepareFrom + td.messageProtocolFulfil.content.headers['fspiop-source'] = transferPrepareTo + td.messageProtocolFulfil.content.headers['fspiop-destination'] = transferPrepareFrom + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFulfil1 = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.fxp.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + const positionFulfil2 = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.proxyRB.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil1[0], 'Position fulfil message with key found') + test.ok(positionFulfil2[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + await transferProxyPrepare.test(` + Scheme A: PUT /transfers call I.e. From: Proxy AR → To: Payer FSP + If it is a FX transfer with currency conversion + PayerFSP and ProxyAR account must be updated`, async (test) => { + const transferPrepareTo = 'schemeBPayeeFsp' + const fxTransferPrepareTo = 'schemeRFxp' + + const td = await prepareTestData({ ...testData, crossSchemeSetup: true }) + await ProxyCache.getCache().addDfspIdToProxyMapping(fxTransferPrepareTo, td.proxyAR.participant.name) + await ProxyCache.getCache().addDfspIdToProxyMapping(transferPrepareTo, td.proxyAR.participant.name) + + const prepareConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.PREPARE.toUpperCase()) + prepareConfig.logger = Logger + const fulfilConfig = Utility.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.PRODUCER, + TransferEventType.TRANSFER.toUpperCase(), + TransferEventType.FULFIL.toUpperCase()) + fulfilConfig.logger = Logger + + // FX Transfer from payer to proxyAR + td.messageProtocolFxPrepare.to = fxTransferPrepareTo + td.messageProtocolFxPrepare.content.headers['fspiop-destination'] = fxTransferPrepareTo + td.messageProtocolFxPrepare.content.payload.counterPartyFsp = fxTransferPrepareTo + await Producer.produceMessage(td.messageProtocolFxPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'fx-prepare', + // To be keyed with the PayerFSP participantCurrencyId + keyFilter: td.payer.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with proxyAR key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Fulfil the fxTransfer + td.messageProtocolFulfil.from = fxTransferPrepareTo + td.messageProtocolFulfil.content.headers['fspiop-source'] = fxTransferPrepareTo + + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFxFulfil, td.topicConfTransferFulfil, fulfilConfig) + + try { + const positionFxFulfil = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-notification-event', + action: 'fx-reserve', + valueToFilter: td.payer.name + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFxFulfil[0], 'Position fxFulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + // Create subsequent transfer + td.messageProtocolPrepare.to = transferPrepareTo + td.messageProtocolPrepare.content.headers['fspiop-destination'] = transferPrepareTo + td.messageProtocolPrepare.content.payload.payeeFsp = transferPrepareTo + + await Producer.produceMessage(td.messageProtocolPrepare, td.topicConfTransferPrepare, prepareConfig) + + try { + const positionPrepare = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'prepare', + // A position prepare message without need for any position changes should be created (key 0) + keyFilter: '0' + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionPrepare[0], 'Position prepare message with key of fxp target currency account found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + try { + await wrapWithRetries(async () => { + const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} + if (transfer?.transferState !== TransferInternalState.RESERVED) { + if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) + return null + } + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + } catch (err) { + Logger.error(err) + test.fail(err.message) + } + + // Fulfil the transfer + td.messageProtocolFulfil.from = transferPrepareTo + td.messageProtocolFulfil.content.headers['fspiop-source'] = transferPrepareTo + testConsumer.clearEvents() + await Producer.produceMessage(td.messageProtocolFulfil, td.topicConfTransferFulfil, fulfilConfig) + try { + const positionFulfil1 = await wrapWithRetries(() => testConsumer.getEventsForFilter({ + topicFilter: 'topic-transfer-position-batch', + action: 'commit', + keyFilter: td.proxyAR.participantCurrencyId.toString() + }), wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + test.ok(positionFulfil1[0], 'Position fulfil message with key found') + } catch (err) { + test.notOk('Error should not be thrown') + console.error(err) + } + + testConsumer.clearEvents() + test.end() + }) + + transferProxyPrepare.end() }) await handlersTest.test('teardown', async (assert) => { @@ -425,6 +2410,7 @@ Test('Handlers test', async handlersTest => { await testConsumer.destroy() // this disconnects the consumers await Producer.disconnect() + await ProxyCache.disconnect() if (debug) { const elapsedTime = Math.round(((new Date()) - startTime) / 100) / 10 diff --git a/test/integration-override/handlers/transfers/prepare/prepare-internals.test.js b/test/integration-override/handlers/transfers/prepare/prepare-internals.test.js new file mode 100644 index 000000000..d745faf29 --- /dev/null +++ b/test/integration-override/handlers/transfers/prepare/prepare-internals.test.js @@ -0,0 +1,179 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const { randomUUID } = require('node:crypto') +const Test = require('tape') + +const prepareHandler = require('#src/handlers/transfers/prepare') +const config = require('#src/lib/config') +const Db = require('#src/lib/db') +const proxyCache = require('#src/lib/proxyCache') +const Cache = require('#src/lib/cache') +const externalParticipantCached = require('#src/models/participant/externalParticipantCached') +const ParticipantCached = require('#src/models/participant/participantCached') +const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') +const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') +const transferFacade = require('#src/models/transfer/facade') + +const participantHelper = require('#test/integration/helpers/participant') +const fixtures = require('#test/fixtures') +const { tryCatchEndTest } = require('#test/util/helpers') + +Test('Prepare Handler internals Tests -->', (prepareHandlerTest) => { + const initiatingFsp = `externalPayer-${Date.now()}` + const counterPartyFsp = `externalPayee-${Date.now()}` + const proxyId1 = `proxy1-${Date.now()}` + const proxyId2 = `proxy2-${Date.now()}` + + const curr1 = 'BWP' + // const curr2 = 'TZS'; + + const transferId = randomUUID() + + prepareHandlerTest.test('setup', tryCatchEndTest(async (t) => { + await Db.connect(config.DATABASE) + await proxyCache.connect() + await ParticipantCached.initialize() + await ParticipantCurrencyCached.initialize() + await ParticipantLimitCached.initialize() + externalParticipantCached.initialize() + await Cache.initCache() + + const [proxy1, proxy2] = await Promise.all([ + participantHelper.prepareData(proxyId1, curr1, null, false, true), + participantHelper.prepareData(proxyId2, curr1, null, false, true) + ]) + t.ok(proxy1, 'proxy1 is created') + t.ok(proxy2, 'proxy2 is created') + + await Promise.all([ + ParticipantCurrencyCached.update(proxy1.participantCurrencyId, true), + ParticipantCurrencyCached.update(proxy1.participantCurrencyId2, true) + ]) + t.pass('proxy1 currencies are activated') + + const [isPayerAdded, isPayeeAdded] = await Promise.all([ + proxyCache.getCache().addDfspIdToProxyMapping(initiatingFsp, proxyId1), + proxyCache.getCache().addDfspIdToProxyMapping(counterPartyFsp, proxyId2) + ]) + t.ok(isPayerAdded, 'payer is added to proxyCache') + t.ok(isPayeeAdded, 'payee is added to proxyCache') + + t.pass('setup is done') + })) + + prepareHandlerTest.test('should create proxyObligation for inter-scheme fxTransfer', tryCatchEndTest(async (t) => { + const payload = fixtures.fxTransferDto({ initiatingFsp, counterPartyFsp }) + const isFx = true + + const obligation = await prepareHandler.calculateProxyObligation({ + payload, + isFx, + params: {}, + functionality: 'functionality', + action: 'action' + }) + t.equals(obligation.isFx, isFx) + t.equals(obligation.initiatingFspProxyOrParticipantId.inScheme, false) + t.equals(obligation.initiatingFspProxyOrParticipantId.proxyId, proxyId1) + t.equals(obligation.initiatingFspProxyOrParticipantId.name, initiatingFsp) + t.equals(obligation.counterPartyFspProxyOrParticipantId.inScheme, false) + t.equals(obligation.counterPartyFspProxyOrParticipantId.proxyId, proxyId2) + t.equals(obligation.counterPartyFspProxyOrParticipantId.name, counterPartyFsp) + })) + + prepareHandlerTest.test('should save preparedRequest for inter-scheme transfer, and create external participants', tryCatchEndTest(async (t) => { + let [extPayer, extPayee] = await Promise.all([ + externalParticipantCached.getByName(initiatingFsp), + externalParticipantCached.getByName(counterPartyFsp) + ]) + t.equals(extPayer, undefined) + t.equals(extPayee, undefined) + + const isFx = false + const payload = fixtures.transferDto({ + transferId, + payerFsp: initiatingFsp, + payeeFsp: counterPartyFsp + }) + const proxyObligation = fixtures.mockProxyObligationDto({ + isFx, + payloadClone: payload, + proxy1: proxyId1, + proxy2: proxyId2 + }) + const determiningTransferCheckResult = { + determiningTransferExistsInTransferList: null, + watchListRecords: [], + participantCurrencyValidationList: [] + } + + await prepareHandler.checkDuplication({ + isFx, + payload, + ID: transferId, + location: {} + }) + await prepareHandler.savePreparedRequest({ + isFx, + payload, + validationPassed: true, + reasons: [], + functionality: 'functionality', + params: {}, + location: {}, + determiningTransferCheckResult, + proxyObligation + }) + + const dbTransfer = await transferFacade.getByIdLight(payload.transferId) + t.ok(dbTransfer, 'transfer is saved') + t.equals(dbTransfer.transferId, transferId, 'dbTransfer.transferId') + + ;[extPayer, extPayee] = await Promise.all([ + externalParticipantCached.getByName(initiatingFsp), + externalParticipantCached.getByName(counterPartyFsp) + ]) + t.ok(extPayer) + t.ok(extPayee) + + const [participant1] = await transferFacade.getTransferParticipant(proxyId1, transferId) + t.equals(participant1.externalParticipantId, extPayer.externalParticipantId) + t.equals(participant1.participantId, extPayer.proxyId) + })) + + prepareHandlerTest.test('teardown', tryCatchEndTest(async (t) => { + await Promise.all([ + Db.disconnect(), + proxyCache.disconnect(), + Cache.destroyCache() + ]) + t.pass('connections are closed') + })) + + prepareHandlerTest.end() +}) diff --git a/test/integration-override/lib/proxyCache.js b/test/integration-override/lib/proxyCache.js new file mode 100644 index 000000000..a0b14d684 --- /dev/null +++ b/test/integration-override/lib/proxyCache.js @@ -0,0 +1,220 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +'use strict' + +const Test = require('tape') +const Sinon = require('sinon') +const Db = require('#src/lib/db') +const Cache = require('#src/lib/cache') +const Logger = require('@mojaloop/central-services-logger') +const Config = require('#src/lib/config') +const ProxyCache = require('#src/lib/proxyCache') +const ParticipantService = require('#src/domain/participant') +const ParticipantCached = require('#src/models/participant/participantCached') +const ParticipantCurrencyCached = require('#src/models/participant/participantCurrencyCached') +const ParticipantLimitCached = require('#src/models/participant/participantLimitCached') +const ParticipantHelper = require('../../integration/helpers/participant') + +const debug = false + +Test('Participant service', async (participantTest) => { + let sandbox + const participantFixtures = [] + const participantMap = new Map() + + const testData = { + currency: 'USD', + fsp1Name: 'dfsp1', + fsp2Name: 'dfsp2', + endpointBase: 'http://localhost:1080', + fsp3Name: 'payerfsp', + fsp4Name: 'payeefsp', + simulatorBase: 'http://localhost:8444', + notificationEmail: 'test@example.com', + proxyParticipant: 'xnProxy' + } + + await participantTest.test('setup', async (test) => { + try { + sandbox = Sinon.createSandbox() + await Db.connect(Config.DATABASE) + await ParticipantCached.initialize() + await ParticipantCurrencyCached.initialize() + await ParticipantLimitCached.initialize() + await Cache.initCache() + await ProxyCache.connect() + test.pass() + test.end() + } catch (err) { + Logger.error(`Setup for test failed with error - ${err}`) + test.fail() + test.end() + } + }) + + await participantTest.test('create participants', async (assert) => { + try { + let getByNameResult, result + getByNameResult = await ParticipantService.getByName(testData.fsp1Name) + result = await ParticipantHelper.prepareData(testData.fsp1Name, testData.currency, undefined, !!getByNameResult) + participantFixtures.push(result.participant) + getByNameResult = await ParticipantService.getByName(testData.fsp2Name) + result = await ParticipantHelper.prepareData(testData.fsp2Name, testData.currency, undefined, !!getByNameResult) + participantFixtures.push(result.participant) + getByNameResult = await ParticipantService.getByName(testData.fsp3Name) + result = await ParticipantHelper.prepareData(testData.fsp3Name, testData.currency, undefined, !!getByNameResult) + participantFixtures.push(result.participant) + getByNameResult = await ParticipantService.getByName(testData.fsp4Name) + result = await ParticipantHelper.prepareData(testData.fsp4Name, testData.currency, undefined, !!getByNameResult) + participantFixtures.push(result.participant) + for (const participant of participantFixtures) { + const read = await ParticipantService.getById(participant.participantId) + participantMap.set(participant.participantId, read) + if (debug) assert.comment(`Testing with participant \n ${JSON.stringify(participant, null, 2)}`) + assert.equal(read.name, participant.name, 'names are equal') + assert.deepEqual(read.currencyList, participant.currencyList, 'currency match') + assert.equal(read.isActive, participant.isActive, 'isActive flag matches') + assert.equal(read.createdDate.toString(), participant.createdDate.toString(), 'created date matches') + } + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.test('getFSPProxy should return proxyId if fsp not in scheme', async (assert) => { + try { + const proxyCache = ProxyCache.getCache() + proxyCache.addDfspIdToProxyMapping('notInSchemeFsp', 'proxyId') + const result = await ProxyCache.getFSPProxy('notInSchemeFsp') + assert.equal(result.inScheme, false, 'not in scheme') + assert.equal(result.proxyId, 'proxyId', 'proxy id matches') + proxyCache.removeDfspIdFromProxyMapping('notInSchemeFsp') + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.test('getFSPProxy should not return proxyId if fsp is in scheme', async (assert) => { + try { + const proxyCache = ProxyCache.getCache() + proxyCache.addDfspIdToProxyMapping('dfsp1', 'proxyId') + const result = await ProxyCache.getFSPProxy('dfsp1') + assert.equal(result.inScheme, true, 'is in scheme') + assert.equal(result.proxyId, null, 'proxy id is null') + proxyCache.removeDfspIdFromProxyMapping('dfsp1') + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.test('checkSameCreditorDebtorProxy should return true if debtor and creditor proxy are the same', async (assert) => { + try { + const proxyCache = ProxyCache.getCache() + proxyCache.addDfspIdToProxyMapping('dfsp1', 'proxyId') + proxyCache.addDfspIdToProxyMapping('dfsp2', 'proxyId') + const result = await ProxyCache.checkSameCreditorDebtorProxy('dfsp1', 'dfsp2') + assert.equal(result, true, 'returned true') + proxyCache.removeDfspIdFromProxyMapping('dfsp1') + proxyCache.removeDfspIdFromProxyMapping('dfsp2') + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.test('checkSameCreditorDebtorProxy should return false if debtor and creditor proxy are not the same', async (assert) => { + try { + const proxyCache = ProxyCache.getCache() + proxyCache.addDfspIdToProxyMapping('dfsp1', 'proxyId') + proxyCache.addDfspIdToProxyMapping('dfsp2', 'proxyId2') + const result = await ProxyCache.checkSameCreditorDebtorProxy('dfsp1', 'dfsp2') + assert.equal(result, false, 'returned false') + proxyCache.removeDfspIdFromProxyMapping('dfsp1') + proxyCache.removeDfspIdFromProxyMapping('dfsp2') + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.test('teardown', async (assert) => { + try { + for (const participant of participantFixtures) { + if (participant.name === testData.fsp1Name || + participant.name === testData.fsp2Name || + participant.name === testData.fsp3Name || + participant.name === testData.fsp4Name) { + assert.pass(`participant ${participant.name} preserved`) + } else { + const result = await ParticipantHelper.deletePreparedData(participant.name) + assert.ok(result, `destroy ${participant.name} success`) + } + } + await Cache.destroyCache() + await Db.disconnect() + await ProxyCache.disconnect() + + assert.pass('database connection closed') + // @ggrg: Having the following 3 lines commented prevents the current test from exiting properly when run individually, + // BUT it is required in order to have successful run of all integration test scripts as a sequence, where + // the last script will actually disconnect topic-notification-event producer. + // const Producer = require('../../../../src/handlers/lib/kafka/producer') + // await Producer.getProducer('topic-notification-event').disconnect() + // assert.pass('producer to topic-notification-event disconnected') + sandbox.restore() + assert.end() + } catch (err) { + Logger.error(`teardown failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantTest.end() +}) diff --git a/test/integration/domain/participant/index.test.js b/test/integration/domain/participant/index.test.js index 4dbdf976c..0c9a1160c 100644 --- a/test/integration/domain/participant/index.test.js +++ b/test/integration/domain/participant/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -32,6 +35,7 @@ const Test = require('tape') const Sinon = require('sinon') const Db = require('../../../../src/lib/db') const Cache = require('../../../../src/lib/cache') +const ProxyCache = require('../../../../src/lib/proxyCache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') const ParticipantService = require('../../../../src/domain/participant') @@ -49,6 +53,7 @@ Test('Participant service', async (participantTest) => { let sandbox const participantFixtures = [] const endpointsFixtures = [] + const participantProxyFixtures = [] const participantMap = new Map() const testData = { @@ -59,13 +64,15 @@ Test('Participant service', async (participantTest) => { fsp3Name: 'payerfsp', fsp4Name: 'payeefsp', simulatorBase: 'http://localhost:8444', - notificationEmail: 'test@example.com' + notificationEmail: 'test@example.com', + proxyParticipant: 'xnProxy' } await participantTest.test('setup', async (test) => { try { sandbox = Sinon.createSandbox() await Db.connect(Config.DATABASE) + await ProxyCache.connect() await ParticipantCached.initialize() await ParticipantCurrencyCached.initialize() await ParticipantLimitCached.initialize() @@ -172,6 +179,7 @@ Test('Participant service', async (participantTest) => { for (const participantId of participantMap.keys()) { const participant = await ParticipantService.getById(participantId) assert.equal(JSON.stringify(participant), JSON.stringify(participantMap.get(participantId))) + assert.equal(participant.isProxy, 0, 'isProxy flag set to false') } assert.end() } catch (err) { @@ -220,6 +228,10 @@ Test('Participant service', async (participantTest) => { await ParticipantEndpointHelper.prepareData(participant.name, 'SETTLEMENT_TRANSFER_POSITION_CHANGE_EMAIL', testData.notificationEmail) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_AUTHORIZATIONS', testData.endpointBase) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRX_REQ_SERVICE', testData.endpointBase) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${testData.endpointBase}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${testData.endpointBase}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}/error`) participant = participantFixtures[2] await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRANSFER_POST', `${testData.simulatorBase}/${participant.name}/transfers`) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRANSFER_PUT', `${testData.simulatorBase}/${participant.name}/transfers/{{transferId}}`) @@ -233,6 +245,10 @@ Test('Participant service', async (participantTest) => { await ParticipantEndpointHelper.prepareData(participant.name, 'SETTLEMENT_TRANSFER_POSITION_CHANGE_EMAIL', testData.notificationEmail) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_AUTHORIZATIONS', testData.endpointBase) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRX_REQ_SERVICE', testData.endpointBase) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${testData.endpointBase}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${testData.endpointBase}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}/error`) participant = participantFixtures[3] await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRANSFER_POST', `${testData.simulatorBase}/${participant.name}/transfers`) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRANSFER_PUT', `${testData.simulatorBase}/${participant.name}/transfers/{{transferId}}`) @@ -246,6 +262,10 @@ Test('Participant service', async (participantTest) => { await ParticipantEndpointHelper.prepareData(participant.name, 'SETTLEMENT_TRANSFER_POSITION_CHANGE_EMAIL', testData.notificationEmail) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_AUTHORIZATIONS', testData.endpointBase) await ParticipantEndpointHelper.prepareData(participant.name, 'FSPIOP_CALLBACK_URL_TRX_REQ_SERVICE', testData.endpointBase) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${testData.endpointBase}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${testData.endpointBase}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(participant.name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${testData.endpointBase}/fxTransfers/{{commitRequestId}}/error`) assert.end() } catch (err) { console.log(err) @@ -411,6 +431,30 @@ Test('Participant service', async (participantTest) => { } }) + await participantTest.test('create participant with proxy', async (assert) => { + try { + const getByNameResult = await ParticipantService.getByName(testData.proxyParticipant) + const result = await ParticipantHelper.prepareData(testData.proxyParticipant, testData.currency, undefined, !!getByNameResult, true) + participantProxyFixtures.push(result.participant) + + for (const participant of participantProxyFixtures) { + const read = await ParticipantService.getById(participant.participantId) + participantMap.set(participant.participantId, read) + if (debug) assert.comment(`Testing with participant \n ${JSON.stringify(participant, null, 2)}`) + assert.equal(read.name, participant.name, 'names are equal') + assert.deepEqual(read.currencyList, participant.currencyList, 'currency match') + assert.equal(read.isActive, participant.isActive, 'isActive flag matches') + assert.equal(read.createdDate.toString(), participant.createdDate.toString(), 'created date matches') + assert.equal(read.isProxy, 1, 'isProxy flag set to true') + } + assert.end() + } catch (err) { + Logger.error(`create participant failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + await participantTest.test('teardown', async (assert) => { try { for (const participant of participantFixtures) { @@ -426,6 +470,8 @@ Test('Participant service', async (participantTest) => { } await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() + assert.pass('database connection closed') // @ggrg: Having the following 3 lines commented prevents the current test from exiting properly when run individually, // BUT it is required in order to have successful run of all integration test scripts as a sequence, where diff --git a/test/integration/handlers/root.test.js b/test/integration/handlers/root.test.js index 175459c4b..f5ab0f88a 100644 --- a/test/integration/handlers/root.test.js +++ b/test/integration/handlers/root.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -30,6 +33,7 @@ const Logger = require('@mojaloop/central-services-logger') const Db = require('@mojaloop/database-lib').Db const Config = require('../../../src/lib/config') +const ProxyCache = require('../../../src/lib/proxyCache') const Consumer = require('@mojaloop/central-services-stream').Util.Consumer // const Producer = require('@mojaloop/central-services-stream').Util.Producer const rootApiHandler = require('../../../src/api/root/handler') @@ -52,6 +56,7 @@ Test('Root handler test', async handlersTest => { await handlersTest.test('registerAllHandlers should', async registerAllHandlers => { await registerAllHandlers.test('setup handlers', async (test) => { await Db.connect(Config.DATABASE) + await ProxyCache.connect() await Handlers.transfers.registerPrepareHandler() await Handlers.positions.registerPositionHandler() await Handlers.transfers.registerFulfilHandler() @@ -88,7 +93,8 @@ Test('Root handler test', async handlersTest => { const expectedStatus = 200 const expectedServices = [ { name: 'datastore', status: 'OK' }, - { name: 'broker', status: 'OK' } + { name: 'broker', status: 'OK' }, + { name: 'proxyCache', status: 'OK' } ] // Act @@ -112,7 +118,7 @@ Test('Root handler test', async handlersTest => { try { await Db.disconnect() assert.pass('database connection closed') - + await ProxyCache.disconnect() // TODO: Replace this with KafkaHelper.topics const topics = [ 'topic-transfer-prepare', diff --git a/test/integration/handlers/transfers/handlers.test.js b/test/integration/handlers/transfers/handlers.test.js index 0700d4f72..b5c2054fc 100644 --- a/test/integration/handlers/transfers/handlers.test.js +++ b/test/integration/handlers/transfers/handlers.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -27,9 +30,9 @@ const Test = require('tape') const { randomUUID } = require('crypto') -const retry = require('async-retry') const Logger = require('@mojaloop/central-services-logger') const Config = require('#src/lib/config') +const ProxyCache = require('#src/lib/proxyCache') const Time = require('@mojaloop/central-services-shared').Util.Time const Db = require('@mojaloop/database-lib').Db const Cache = require('#src/lib/cache') @@ -160,9 +163,6 @@ const prepareTestData = async (dataObj) => { const payer = await ParticipantHelper.prepareData(dataObj.payer.name, dataObj.amount.currency) const payee = await ParticipantHelper.prepareData(dataObj.payee.name, dataObj.amount.currency) - const kafkacat = 'GROUP=abc; T=topic; TR=transfer; kafkacat -b localhost -G $GROUP $T-$TR-prepare $T-$TR-position $T-$TR-fulfil $T-$TR-get $T-admin-$TR $T-notification-event $T-bulk-prepare' - if (debug) console.error(kafkacat) - const payerLimitAndInitialPosition = await ParticipantLimitHelper.prepareLimitAndInitialPosition(payer.participant.name, { currency: dataObj.amount.currency, limit: { value: dataObj.payer.limit } @@ -184,6 +184,10 @@ const prepareTestData = async (dataObj) => { await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_PUT', `${dataObj.endpoint.base}/bulkTransfers/{{id}}`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_BULK_TRANSFER_ERROR', `${dataObj.endpoint.base}/bulkTransfers/{{id}}/error`) await ParticipantEndpointHelper.prepareData(name, 'FSPIOP_CALLBACK_URL_QUOTES', `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_QUOTES, `${dataObj.endpoint.base}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_POST, `${dataObj.endpoint.base}/fxTransfers`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_PUT, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}`) + await ParticipantEndpointHelper.prepareData(name, Enum.EndPoints.FspEndpointTypes.FSPIOP_CALLBACK_URL_FX_TRANSFER_ERROR, `${dataObj.endpoint.base}/fxTransfers/{{commitRequestId}}/error`) } const transferPayload = { @@ -318,6 +322,7 @@ const prepareTestData = async (dataObj) => { Test('Handlers test', async handlersTest => { const startTime = new Date() await Db.connect(Config.DATABASE) + await ProxyCache.connect() await ParticipantCached.initialize() await ParticipantCurrencyCached.initialize() await ParticipantLimitCached.initialize() @@ -389,6 +394,7 @@ Test('Handlers test', async handlersTest => { // TODO: MIG - Disabling these handlers to test running the CL as a separate service independently. await new Promise(resolve => setTimeout(resolve, rebalanceDelay)) + testConsumer.clearEvents() test.pass('done') test.end() @@ -860,14 +866,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.RESERVED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#1 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -900,14 +907,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.COMMITTED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#2 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -959,14 +967,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.RESERVED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#1 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -997,14 +1006,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.COMMITTED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#2 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1035,14 +1045,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.RESERVED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#3 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1074,14 +1085,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferInternalState.ABORTED_REJECTED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#4 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1113,14 +1125,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.RESERVED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#5 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1160,14 +1173,15 @@ Test('Handlers test', async handlersTest => { } try { - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferInternalState.ABORTED_ERROR) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw ErrorHandler.Factory.createFSPIOPError(ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR, `#6 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1194,7 +1208,7 @@ Test('Handlers test', async handlersTest => { }) await handlersTest.test('timeout should', async timeoutTest => { - testData.expiration = new Date((new Date()).getTime() + (2 * 1000)) // 2 seconds + testData.expiration = new Date((new Date()).getTime() + (10 * 1000)) // 10 seconds const td = await prepareTestData(testData) await timeoutTest.test('update transfer state to RESERVED by PREPARE request', async (test) => { @@ -1222,20 +1236,15 @@ Test('Handlers test', async handlersTest => { } try { - const retryTimeoutOpts = { - retries: Number(retryOpts.retries) * 2, - minTimeout: retryOpts.minTimeout, - maxTimeout: retryOpts.maxTimeout - } - - await retry(async () => { // use bail(new Error('to break before max retries')) + await wrapWithRetries(async () => { const transfer = await TransferService.getById(td.messageProtocolPrepare.content.payload.transferId) || {} if (transfer?.transferState !== TransferState.RESERVED) { if (debug) console.log(`retrying in ${retryDelay / 1000}s..`) - throw new Error(`#7 Max retry count ${retryCount} reached after ${retryCount * retryDelay / 1000}s. Tests fail`) + return null } - return tests() - }, retryTimeoutOpts) + return transfer + }, wrapWithRetriesConf.remainingRetries, wrapWithRetriesConf.timeout) + await tests() } catch (err) { Logger.error(err) test.fail(err.message) @@ -1342,6 +1351,7 @@ Test('Handlers test', async handlersTest => { await Handlers.timeouts.stop() await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') await testConsumer.destroy() // this disconnects the consumers diff --git a/test/integration/helpers/createTestConsumer.js b/test/integration/helpers/createTestConsumer.js new file mode 100644 index 000000000..01c187c84 --- /dev/null +++ b/test/integration/helpers/createTestConsumer.js @@ -0,0 +1,59 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const { Enum, Util } = require('@mojaloop/central-services-shared') +const Config = require('#src/lib/config') +const TestConsumer = require('./testConsumer') + +/** + * Creates a TestConsumer with handlers based on the specified types/actions configurations. + * + * @param {Array} typeActionList - An array of objects with 'type' and 'action' properties + * - `type` {string} - Represents the type parameter for the topic and configuration. + * - `action` {string} - Represents the action parameter for the topic and configuration. + * + * @returns {TestConsumer} An instance of TestConsumer configured with handlers derived from + */ +const createTestConsumer = (typeActionList) => { + const handlers = typeActionList.map(({ type, action }) => ({ + topicName: Util.Kafka.transformGeneralTopicName( + Config.KAFKA_CONFIG.TOPIC_TEMPLATES.GENERAL_TOPIC_TEMPLATE.TEMPLATE, + type, + action + ), + config: Util.Kafka.getKafkaConfig( + Config.KAFKA_CONFIG, + Enum.Kafka.Config.CONSUMER, + type.toUpperCase(), + action.toUpperCase() + ) + })) + + return new TestConsumer(handlers) +} + +module.exports = createTestConsumer diff --git a/test/integration/helpers/hubAccounts.js b/test/integration/helpers/hubAccounts.js index 9d14ea325..06e97083a 100644 --- a/test/integration/helpers/hubAccounts.js +++ b/test/integration/helpers/hubAccounts.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/ilpPacket.js b/test/integration/helpers/ilpPacket.js index 43bb07269..e37671927 100644 --- a/test/integration/helpers/ilpPacket.js +++ b/test/integration/helpers/ilpPacket.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/index.js b/test/integration/helpers/index.js index a9ff5ee5b..90b504c98 100644 --- a/test/integration/helpers/index.js +++ b/test/integration/helpers/index.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/kafkaHelper.js b/test/integration/helpers/kafkaHelper.js deleted file mode 100644 index efdc78d15..000000000 --- a/test/integration/helpers/kafkaHelper.js +++ /dev/null @@ -1,127 +0,0 @@ -/***** - License - -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at - http://www.apache.org/licenses/LICENSE-2.0 - Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. - Contributors - -------------- - This is the official list of the Mojaloop project contributors for this file. - Names of the original copyright holders (individuals or organizations) - should be listed with a '*' in the first column. People who have - contributed from an organization can be listed under the organization - that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have - their names indented and be marked with a '-'. Email address can be added - optionally within square brackets . - * Gates Foundation - - Name Surname - - * Miguel de Barros - -------------- - **********/ - -const Producer = require('@mojaloop/central-services-stream').Util.Producer -const Consumer = require('@mojaloop/central-services-stream').Util.Consumer - -const topics = [ - 'topic-transfer-prepare', - 'topic-transfer-position', - 'topic-transfer-fulfil', - 'topic-notification-event' -] - -exports.topics = topics - -exports.producers = { - connect: async (assert) => { - // lets make sure all our Producers are already connected if they have already been defined. - for (const topic of topics) { - try { - // lets make sure check if any of our Producers are already connected if they have already been defined. - console.log(`Producer[${topic}] checking connectivity!`) - const isConnected = await Producer.isConnected(topic) - if (!isConnected) { - try { - console.log(`Producer[${topic}] is connecting`) - await Producer.getProducer(topic).connect() - console.log(`Producer[${topic}] is connected`) - if (assert) assert.pass(`Producer[${topic}] is connected`) - } catch (err) { - console.log(`Producer[${topic}] connection failed!`) - if (assert) assert.fail(err) - console.error(err) - } - } else { - console.log(`Producer[${topic}] is ALREADY connected`) - } - } catch (err) { - console.log(`Producer[${topic}] has not been initialized`) - if (assert) assert.fail(err) - console.error(err) - } - } - }, - - disconnect: async (assert) => { - for (const topic of topics) { - try { - console.log(`Producer[${topic}] disconnecting`) - await Producer.getProducer(topic).disconnect() - if (assert) assert.pass(`Producer[${topic}] is disconnected`) - console.log(`Producer[${topic}] disconnected`) - } catch (err) { - if (assert) assert.fail(err.message) - console.log(`Producer[${topic}] disconnection failed`) - console.error(err) - } - } - } -} - -exports.consumers = { - connect: async (assert) => { - // lets make sure all our Consumers are already connected if they have already been defined. - for (const topic of topics) { - try { - // lets make sure check if any of our Consumers are already connected if they have already been defined. - console.log(`Consumer[${topic}] checking connectivity!`) - const isConnected = await Consumer.isConnected(topic) - if (!isConnected) { - try { - console.log(`Consumer[${topic}] is connecting`) - await Consumer.getConsumer(topic).connect() - console.log(`Consumer[${topic}] is connected`) - if (assert) assert.pass(`Consumer[${topic}] is connected`) - } catch (err) { - console.log(`Consumer[${topic}] connection failed!`) - if (assert) assert.fail(`Consumer[${topic}] connection failed!`) - console.error(err) - } - } else { - console.log(`Consumer[${topic}] is ALREADY connected`) - } - } catch (err) { - console.log(`Consumer[${topic}] has not been initialized`) - if (assert) assert.fail(`Consumer[${topic}] has not been initialized`) - console.error(err) - } - } - }, - - disconnect: async (assert) => { - for (const topic of topics) { - try { - console.log(`Consumer[${topic}] disconnecting`) - await Consumer.getConsumer(topic).disconnect() - if (assert) assert.pass(`Consumer[${topic}] is disconnected`) - console.log(`Consumer[${topic}] disconnected`) - } catch (err) { - if (assert) assert.fail(err.message) - console.log(`Consumer[${topic}] disconnection failed`) - console.error(err) - } - } - } -} diff --git a/test/integration/helpers/participant.js b/test/integration/helpers/participant.js index 004985684..51444f1e3 100644 --- a/test/integration/helpers/participant.js +++ b/test/integration/helpers/participant.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -42,19 +45,24 @@ const testParticipant = { createdDate: new Date() } -exports.prepareData = async (name, currencyId = 'USD', secondaryCurrencyId = 'XXX', isUnique = true) => { +exports.prepareData = async (name, currencyId = 'USD', secondaryCurrencyId = null, isUnique = true, isProxy = false) => { try { const participantId = await Model.create(Object.assign( {}, testParticipant, { - name: (name || testParticipant.name) + (isUnique ? time.msToday().toString() : '') + name: (name || testParticipant.name) + (isUnique ? time.msToday().toString() : ''), + isProxy } )) const participantCurrencyId = await ParticipantCurrencyModel.create(participantId, currencyId, Enum.Accounts.LedgerAccountType.POSITION, false) const participantCurrencyId2 = await ParticipantCurrencyModel.create(participantId, currencyId, Enum.Accounts.LedgerAccountType.SETTLEMENT, false) - const participantCurrencyIdSecondary = await ParticipantCurrencyModel.create(participantId, secondaryCurrencyId, Enum.Accounts.LedgerAccountType.POSITION, false) - const participantCurrencyIdSecondary2 = await ParticipantCurrencyModel.create(participantId, secondaryCurrencyId, Enum.Accounts.LedgerAccountType.SETTLEMENT, false) + let participantCurrencyIdSecondary + let participantCurrencyIdSecondary2 + if (secondaryCurrencyId) { + participantCurrencyIdSecondary = await ParticipantCurrencyModel.create(participantId, secondaryCurrencyId, Enum.Accounts.LedgerAccountType.POSITION, false) + participantCurrencyIdSecondary2 = await ParticipantCurrencyModel.create(participantId, secondaryCurrencyId, Enum.Accounts.LedgerAccountType.SETTLEMENT, false) + } const participant = await Model.getById(participantId) return { participant, diff --git a/test/integration/helpers/participantEndpoint.js b/test/integration/helpers/participantEndpoint.js index 6dc49a080..ececdee9b 100644 --- a/test/integration/helpers/participantEndpoint.js +++ b/test/integration/helpers/participantEndpoint.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/participantFundsInOut.js b/test/integration/helpers/participantFundsInOut.js index 0a2b1d5c4..32a00532e 100644 --- a/test/integration/helpers/participantFundsInOut.js +++ b/test/integration/helpers/participantFundsInOut.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/participantLimit.js b/test/integration/helpers/participantLimit.js index 91f41d5b2..40e93387d 100644 --- a/test/integration/helpers/participantLimit.js +++ b/test/integration/helpers/participantLimit.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/settlementModels.js b/test/integration/helpers/settlementModels.js index 975070586..5688eac80 100644 --- a/test/integration/helpers/settlementModels.js +++ b/test/integration/helpers/settlementModels.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -34,6 +37,7 @@ const Enums = require('../../../src/lib/enumCached') const ErrorHandler = require('@mojaloop/central-services-error-handling') const Db = require('@mojaloop/database-lib').Db const Cache = require('../../../src/lib/cache') +const ProxyCache = require('../../../src/lib/proxyCache') const ParticipantCached = require('../../../src/models/participant/participantCached') const ParticipantCurrencyCached = require('../../../src/models/participant/participantCurrencyCached') const ParticipantLimitCached = require('../../../src/models/participant/participantLimitCached') @@ -66,6 +70,7 @@ const settlementModels = [ exports.prepareData = async () => { await Db.connect(Config.DATABASE) + await ProxyCache.connect() await Enums.initialize() await ParticipantCached.initialize() await ParticipantCurrencyCached.initialize() diff --git a/test/integration/helpers/testConsumer.js b/test/integration/helpers/testConsumer.js index d154159d4..8a40fab9d 100644 --- a/test/integration/helpers/testConsumer.js +++ b/test/integration/helpers/testConsumer.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -27,8 +27,8 @@ ******/ 'use strict' -const Logger = require('@mojaloop/central-services-logger') const { uniqueId } = require('lodash') +const Logger = require('@mojaloop/central-services-logger') const Consumer = require('@mojaloop/central-services-stream').Kafka.Consumer /** @@ -55,12 +55,13 @@ class TestConsumer { config: handlerConfig.config } // Override the client and group ids: - handler.config.rdkafkaConf['client.id'] = 'testConsumer' + const id = uniqueId() + handler.config.rdkafkaConf['client.id'] = 'testConsumer' + id // Fix issue of consumers with different partition.assignment.strategy being assigned to the same group - handler.config.rdkafkaConf['group.id'] = 'testConsumerGroup' + uniqueId() + handler.config.rdkafkaConf['group.id'] = 'testConsumerGroup' + id delete handler.config.rdkafkaConf['partition.assignment.strategy'] - Logger.warn(`TestConsumer.startListening(): registering consumer with topicName: ${handler.topicName}`) + Logger.warn(`TestConsumer.startListening(): registering consumer with uniqueId ${id} - topicName: ${handler.topicName}`) const topics = [handler.topicName] const consumer = new Consumer(topics, handler.config) await consumer.connect() diff --git a/test/integration/helpers/testProducer.js b/test/integration/helpers/testProducer.js index f2b270726..537025f97 100644 --- a/test/integration/helpers/testProducer.js +++ b/test/integration/helpers/testProducer.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transfer.js b/test/integration/helpers/transfer.js index 387f33f93..068a62ff9 100644 --- a/test/integration/helpers/transfer.js +++ b/test/integration/helpers/transfer.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferDuplicateCheck.js b/test/integration/helpers/transferDuplicateCheck.js index 220cc6b32..0dd28d5a7 100644 --- a/test/integration/helpers/transferDuplicateCheck.js +++ b/test/integration/helpers/transferDuplicateCheck.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferError.js b/test/integration/helpers/transferError.js index e6ac446c3..e407685ce 100644 --- a/test/integration/helpers/transferError.js +++ b/test/integration/helpers/transferError.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferExtension.js b/test/integration/helpers/transferExtension.js index 6265c8322..37a99c79d 100644 --- a/test/integration/helpers/transferExtension.js +++ b/test/integration/helpers/transferExtension.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferState.js b/test/integration/helpers/transferState.js index 7273c5708..8cbccb780 100644 --- a/test/integration/helpers/transferState.js +++ b/test/integration/helpers/transferState.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferStateChange.js b/test/integration/helpers/transferStateChange.js index 077bcbb08..e155c5cb1 100644 --- a/test/integration/helpers/transferStateChange.js +++ b/test/integration/helpers/transferStateChange.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/integration/helpers/transferTestHelper.js b/test/integration/helpers/transferTestHelper.js index 054dc6386..ad4abe189 100644 --- a/test/integration/helpers/transferTestHelper.js +++ b/test/integration/helpers/transferTestHelper.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -87,6 +90,7 @@ exports.prepareData = async () => { await TransferParticipantModel.saveTransferParticipant({ transferId: transferResult.transfer.transferId, + participantId: transferDuplicateCheckResult.participantPayerResult.participant.participantId, participantCurrencyId: transferDuplicateCheckResult.participantPayerResult.participantCurrencyId, transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYER_DFSP, ledgerEntryTypeId: Enum.Accounts.LedgerAccountType.POSITION, @@ -95,6 +99,7 @@ exports.prepareData = async () => { await TransferParticipantModel.saveTransferParticipant({ transferId: transferResult.transfer.transferId, + participantId: transferDuplicateCheckResult.participantPayerResult.participant.participantId, participantCurrencyId: transferDuplicateCheckResult.participantPayeeResult.participantCurrencyId, transferParticipantRoleTypeId: Enum.Accounts.TransferParticipantRoleType.PAYEE_DFSP, ledgerEntryTypeId: Enum.Accounts.LedgerAccountType.POSITION, diff --git a/test/integration/models/participant/externalParticipant.test.js b/test/integration/models/participant/externalParticipant.test.js new file mode 100644 index 000000000..a39568bb9 --- /dev/null +++ b/test/integration/models/participant/externalParticipant.test.js @@ -0,0 +1,71 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const Test = require('tape') +const externalParticipant = require('#src/models/participant/externalParticipant') +const config = require('#src/lib/config') +const db = require('#src/lib/db') + +const fixtures = require('#test/fixtures') +const { tryCatchEndTest } = require('#test/util/helpers') + +Test('externalParticipant Model Tests -->', (epModelTest) => { + epModelTest.test('setup', tryCatchEndTest(async (t) => { + await db.connect(config.DATABASE) + t.ok(db.getKnex()) + t.pass('setup is done') + })) + + epModelTest.test('should throw error on inserting a record without related proxyId in participant table', tryCatchEndTest(async (t) => { + const err = await externalParticipant.create({ proxyId: 0, name: 'name' }) + .catch(e => e) + t.ok(err.cause.includes('ER_NO_REFERENCED_ROW_2')) + })) + + epModelTest.test('should not throw error on inserting a record, if the name already exists', tryCatchEndTest(async (t) => { + const { participantId } = await db.from('participant').findOne({}) + const name = `epName-${Date.now()}` + const data = fixtures.mockExternalParticipantDto({ + name, + proxyId: participantId, + id: null, + createdDate: null + }) + const created = await externalParticipant.create(data) + t.ok(created) + + const result = await externalParticipant.create(data) + t.equals(result, null) + })) + + epModelTest.test('teardown', tryCatchEndTest(async (t) => { + await db.disconnect() + t.pass('connections are closed') + })) + + epModelTest.end() +}) diff --git a/test/integration/models/transfer/facade.test.js b/test/integration/models/transfer/facade.test.js index 29b625f46..4a6432dd8 100644 --- a/test/integration/models/transfer/facade.test.js +++ b/test/integration/models/transfer/facade.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -32,6 +35,7 @@ const Test = require('tape') const Db = require('../../../../src/lib/db') const Cache = require('../../../../src/lib/cache') +const ProxyCache = require('../../../../src/lib/proxyCache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') const TransferFacade = require('../../../../src/models/transfer/facade') @@ -44,6 +48,7 @@ Test('Transfer read model test', async (transferReadModelTest) => { try { await Db.connect(Config.DATABASE).then(async () => { await Cache.initCache() + await ProxyCache.connect() transferPrepareResult = await HelperModule.prepareNeededData('transferModel') assert.pass('setup OK') assert.end() @@ -88,6 +93,7 @@ Test('Transfer read model test', async (transferReadModelTest) => { try { await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') assert.end() } catch (err) { diff --git a/test/integration/models/transfer/ilpPacket.test.js b/test/integration/models/transfer/ilpPacket.test.js index 41eaa0461..03cb98192 100644 --- a/test/integration/models/transfer/ilpPacket.test.js +++ b/test/integration/models/transfer/ilpPacket.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -30,6 +33,7 @@ const Test = require('tape') const Db = require('../../../../src/lib/db') +const ProxyCache = require('../../../../src/lib/proxyCache') const Cache = require('../../../../src/lib/cache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') @@ -48,6 +52,7 @@ Test('Ilp service tests', async (ilpTest) => { await ilpTest.test('setup', async (assert) => { try { + await ProxyCache.connect() await Db.connect(Config.DATABASE).then(() => { assert.pass('setup OK') assert.end() @@ -178,6 +183,7 @@ Test('Ilp service tests', async (ilpTest) => { try { await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') assert.end() } catch (err) { diff --git a/test/integration/models/transfer/transferError.test.js b/test/integration/models/transfer/transferError.test.js index 2c851ed55..c100b500d 100644 --- a/test/integration/models/transfer/transferError.test.js +++ b/test/integration/models/transfer/transferError.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -27,6 +30,7 @@ const Test = require('tape') const Db = require('../../../../src/lib/db') const Cache = require('../../../../src/lib/cache') +const ProxyCache = require('../../../../src/lib/proxyCache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') const Model = require('../../../../src/models/transfer/transferError') @@ -38,6 +42,7 @@ Test('Transfer Error model test', async (transferErrorTest) => { try { await Db.connect(Config.DATABASE).then(async () => { await Cache.initCache() + await ProxyCache.connect() assert.pass('setup OK') assert.end() }).catch(err => { @@ -90,6 +95,7 @@ Test('Transfer Error model test', async (transferErrorTest) => { try { await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') assert.end() } catch (err) { diff --git a/test/integration/models/transfer/transferExtension.test.js b/test/integration/models/transfer/transferExtension.test.js index cf943240b..0e5bbe6a0 100644 --- a/test/integration/models/transfer/transferExtension.test.js +++ b/test/integration/models/transfer/transferExtension.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -31,6 +34,7 @@ const Test = require('tape') const Db = require('../../../../src/lib/db') const Cache = require('../../../../src/lib/cache') +const ProxyCache = require('../../../../src/lib/proxyCache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') const Model = require('../../../../src/models/transfer/transferExtension') @@ -52,6 +56,7 @@ Test('Extension model test', async (extensionTest) => { await extensionTest.test('setup', async (assert) => { try { + await ProxyCache.connect() await Db.connect(Config.DATABASE).then(() => { assert.pass('setup OK') assert.end() @@ -196,6 +201,7 @@ Test('Extension model test', async (extensionTest) => { try { await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') assert.end() } catch (err) { diff --git a/test/integration/models/transfer/transferStateChange.test.js b/test/integration/models/transfer/transferStateChange.test.js index a1b33048c..84304fa0e 100644 --- a/test/integration/models/transfer/transferStateChange.test.js +++ b/test/integration/models/transfer/transferStateChange.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -31,6 +34,7 @@ const Test = require('tape') const Db = require('../../../../src/lib/db') const Cache = require('../../../../src/lib/cache') +const ProxyCache = require('../../../../src/lib/proxyCache') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../../src/lib/config') const Model = require('../../../../src/models/transfer/transferStateChange') @@ -45,6 +49,7 @@ Test('Transfer State Change model test', async (stateChangeTest) => { await stateChangeTest.test('setup', async (assert) => { try { await Db.connect(Config.DATABASE).then(async () => { + await ProxyCache.connect() await ParticipantCached.initialize() await ParticipantCurrencyCached.initialize() await ParticipantLimitCached.initialize() @@ -127,6 +132,7 @@ Test('Transfer State Change model test', async (stateChangeTest) => { try { await Cache.destroyCache() await Db.disconnect() + await ProxyCache.disconnect() assert.pass('database connection closed') assert.end() } catch (err) { diff --git a/test/scripts/test-functional.sh b/test/scripts/test-functional.sh old mode 100644 new mode 100755 index 5dea98e0f..255c24447 --- a/test/scripts/test-functional.sh +++ b/test/scripts/test-functional.sh @@ -4,10 +4,10 @@ echo "--=== Running Functional Test Runner ===--" echo CENTRAL_LEDGER_VERSION=${CENTRAL_LEDGER_VERSION:-"local"} -ML_CORE_TEST_HARNESS_VERSION=${ML_CORE_TEST_HARNESS_VERSION:-"v1.1.1"} +ML_CORE_TEST_HARNESS_VERSION=${ML_CORE_TEST_HARNESS_VERSION:-"v1.2.4-fx-snapshot.12"} ML_CORE_TEST_HARNESS_GIT=${ML_CORE_TEST_HARNESS_GIT:-"https://github.com/mojaloop/ml-core-test-harness.git"} -ML_CORE_TEST_HARNESS_TEST_PROV_CONT_NAME=${ML_CORE_TEST_HARNESS_TEST_PROV_CONT_NAME:-"ttk-func-ttk-provisioning-1"} -ML_CORE_TEST_HARNESS_TEST_FUNC_CONT_NAME=${ML_CORE_TEST_HARNESS_TEST_FUNC_CONT_NAME:-"ttk-func-ttk-tests-1"} +ML_CORE_TEST_HARNESS_TEST_PROV_CONT_NAME=${ML_CORE_TEST_HARNESS_TEST_PROV_CONT_NAME:-"ttk-func-ttk-provisioning-fx-1"} +ML_CORE_TEST_HARNESS_TEST_FUNC_CONT_NAME=${ML_CORE_TEST_HARNESS_TEST_FUNC_CONT_NAME:-"ttk-func-ttk-fx-tests-1"} ML_CORE_TEST_HARNESS_DIR=${ML_CORE_TEST_HARNESS_DIR:-"/tmp/ml-api-adapter-core-test-harness"} ML_CORE_TEST_SKIP_SHUTDOWN=${ML_CORE_TEST_SKIP_SHUTDOWN:-false} @@ -24,7 +24,7 @@ echo "==> Cloning $ML_CORE_TEST_HARNESS_GIT:$ML_CORE_TEST_HARNESS_VERSION into d git clone --depth 1 --branch $ML_CORE_TEST_HARNESS_VERSION $ML_CORE_TEST_HARNESS_GIT $ML_CORE_TEST_HARNESS_DIR echo "==> Copying configs from ./docker/config-modifier/*.* to $ML_CORE_TEST_HARNESS_DIR/docker/config-modifier/configs/" -cp -f ./docker/config-modifier/*.* $ML_CORE_TEST_HARNESS_DIR/docker/config-modifier/configs/ +cp -rf ./docker/config-modifier/configs/* $ML_CORE_TEST_HARNESS_DIR/docker/config-modifier/configs/ ## Set initial exit code value to 1 (i.e. assume error!) TTK_FUNC_TEST_EXIT_CODE=1 @@ -37,7 +37,7 @@ pushd $ML_CORE_TEST_HARNESS_DIR ## Start the test harness echo "==> Starting Docker compose" - docker compose --project-name ttk-func --ansi never --profile all-services --profile ttk-provisioning --profile ttk-tests up -d + docker compose --project-name ttk-func --ansi never --profile testing-toolkit --profile fx --profile ttk-provisioning-fx --profile ttk-fx-tests up -d echo "==> Running wait-for-container.sh $ML_CORE_TEST_HARNESS_TEST_FUNC_CONT_NAME" ## Wait for the test harness to complete, and capture the exit code @@ -59,7 +59,7 @@ pushd $ML_CORE_TEST_HARNESS_DIR echo "==> Skipping test harness shutdown" else echo "==> Shutting down test harness" - docker compose --project-name ttk-func --ansi never --profile all-services --profile ttk-provisioning --profile ttk-tests down -v + docker compose --project-name ttk-func --ansi never --profile testing-toolkit --profile fx --profile ttk-provisioning-fx --profile ttk-fx-tests down -v fi ## Dump log to console diff --git a/test/scripts/test-integration.sh b/test/scripts/test-integration.sh old mode 100644 new mode 100755 index faffe3988..ef93080aa --- a/test/scripts/test-integration.sh +++ b/test/scripts/test-integration.sh @@ -18,10 +18,13 @@ TTK_FUNC_TEST_EXIT_CODE=1 ## Make reports directory mkdir ./test/results +## Set environment variables +source ./docker/env.sh + ## Start backend services echo "==> Starting Docker backend services" -docker compose pull mysql kafka init-kafka -docker compose up -d mysql kafka init-kafka +docker compose pull mysql kafka init-kafka redis-node-0 +docker compose up -d mysql kafka init-kafka redis-node-0 redis-node-1 redis-node-2 redis-node-3 redis-node-4 redis-node-5 docker compose ps npm run wait-4-docker @@ -49,8 +52,8 @@ echo "==> integration tests exited with code: $INTEGRATION_TEST_EXIT_CODE" ## Kill service echo "Stopping Service with Process ID=$PID" -kill $(cat /tmp/int-test-service.pid) -kill $(lsof -t -i:3001) +kill -9 $(cat /tmp/int-test-service.pid) +kill -9 $(lsof -t -i:3001) ## Give some time before restarting service for override tests sleep $WAIT_FOR_REBALANCE @@ -60,6 +63,11 @@ echo "Starting Service in the background" export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__PREPARE='topic-transfer-position-batch' export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__COMMIT='topic-transfer-position-batch' export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__RESERVE='topic-transfer-position-batch' +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__TIMEOUT_RESERVED='topic-transfer-position-batch' +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_TIMEOUT_RESERVED='topic-transfer-position-batch' +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__ABORT='topic-transfer-position-batch' +export CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_ABORT='topic-transfer-position-batch' + npm start > ./test/results/cl-service-override.log & ## Store PID for cleanup echo $! > /tmp/int-test-service.pid @@ -69,6 +77,10 @@ echo $! > /tmp/int-test-handler.pid unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__PREPARE unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__COMMIT unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__RESERVE +unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__TIMEOUT_RESERVED +unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_TIMEOUT_RESERVED +unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__ABORT +unset CLEDG_KAFKA__EVENT_TYPE_ACTION_TOPIC_MAP__POSITION__FX_ABORT PID1=$(cat /tmp/int-test-service.pid) echo "Service started with Process ID=$PID1" @@ -91,10 +103,10 @@ echo "==> override integration tests exited with code: $OVERRIDE_INTEGRATION_TES ## Kill service echo "Stopping Service with Process ID=$PID1" -kill $(cat /tmp/int-test-service.pid) -kill $(lsof -t -i:3001) +kill -9 $(cat /tmp/int-test-service.pid) +kill -9 $(lsof -t -i:3001) echo "Stopping Service with Process ID=$PID2" -kill $(cat /tmp/int-test-handler.pid) +kill -9 $(cat /tmp/int-test-handler.pid) ## Shutdown the backend services if [ $INT_TEST_SKIP_SHUTDOWN == true ]; then diff --git a/test/unit/api/index.test.js b/test/unit/api/index.test.js index fbfa37bd9..5f277aba1 100644 --- a/test/unit/api/index.test.js +++ b/test/unit/api/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,6 +32,7 @@ const Sinon = require('sinon') const Logger = require('@mojaloop/central-services-logger') const Config = require('../../../src/lib/config') +const ProxyCache = require('#src/lib/proxyCache') const Routes = require('../../../src/api/routes') const Setup = require('../../../src/shared/setup') @@ -39,6 +43,10 @@ Test('Api index', indexTest => { sandbox = Sinon.createSandbox() sandbox.stub(Setup) sandbox.stub(Logger) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) test.end() }) @@ -66,6 +74,7 @@ Test('Api index', indexTest => { runMigrations: true, runHandlers: !Config.HANDLERS_DISABLED })) + test.end() }) exportTest.end() diff --git a/test/unit/api/ledgerAccountTypes/handler.test.js b/test/unit/api/ledgerAccountTypes/handler.test.js index 7a8e82530..fbc3359a3 100644 --- a/test/unit/api/ledgerAccountTypes/handler.test.js +++ b/test/unit/api/ledgerAccountTypes/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,6 +32,7 @@ const Sinon = require('sinon') const Logger = require('@mojaloop/central-services-logger') const Handler = require('../../../../src/api/ledgerAccountTypes/handler') const LedgerAccountTypeService = require('../../../../src/domain/ledgerAccountTypes') +const ProxyCache = require('#src/lib/proxyCache') Test('LedgerAccountTypes', ledgerAccountTypesHandlerTest => { let sandbox @@ -37,6 +41,11 @@ Test('LedgerAccountTypes', ledgerAccountTypesHandlerTest => { sandbox = Sinon.createSandbox() sandbox.stub(Logger) sandbox.stub(LedgerAccountTypeService) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) test.end() }) diff --git a/test/unit/api/ledgerAccountTypes/routes.test.js b/test/unit/api/ledgerAccountTypes/routes.test.js index 693c7d3e1..c8e6c4996 100644 --- a/test/unit/api/ledgerAccountTypes/routes.test.js +++ b/test/unit/api/ledgerAccountTypes/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/api/metrics/handler.test.js b/test/unit/api/metrics/handler.test.js index 1163c94e4..9f68e6680 100644 --- a/test/unit/api/metrics/handler.test.js +++ b/test/unit/api/metrics/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,6 +31,7 @@ const Test = require('tapes')(require('tape')) const Sinon = require('sinon') const Handler = require('../../../../src/api/metrics/handler') const Metrics = require('@mojaloop/central-services-metrics') +const ProxyCache = require('#src/lib/proxyCache') function createRequest (routes) { const value = routes || [] @@ -45,6 +49,11 @@ Test('metrics handler', (handlerTest) => { handlerTest.beforeEach(t => { sandbox = Sinon.createSandbox() sandbox.stub(Metrics) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) t.end() }) diff --git a/test/unit/api/metrics/plugin.test.js b/test/unit/api/metrics/plugin.test.js index ac5b78670..3857741a9 100644 --- a/test/unit/api/metrics/plugin.test.js +++ b/test/unit/api/metrics/plugin.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/api/participants/handler.test.js b/test/unit/api/participants/handler.test.js index 0fa165d07..97a3694c2 100644 --- a/test/unit/api/participants/handler.test.js +++ b/test/unit/api/participants/handler.test.js @@ -9,6 +9,8 @@ const Participant = require('../../../../src/domain/participant') const EnumCached = require('../../../../src/lib/enumCached') const FSPIOPError = require('@mojaloop/central-services-error-handling').Factory.FSPIOPError const SettlementModel = require('../../../../src/domain/settlement') +const ProxyCache = require('#src/lib/proxyCache') +const Config = require('#src/lib/config') const createRequest = ({ payload, params, query }) => { const sandbox = Sinon.createSandbox() @@ -43,7 +45,8 @@ Test('Participant', participantHandlerTest => { currencyList: [ { participantCurrencyId: 1, currencyId: 'USD', ledgerAccountTypeId: 1, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' }, { participantCurrencyId: 2, currencyId: 'USD', ledgerAccountTypeId: 2, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' } - ] + ], + isProxy: 0 }, { participantId: 2, @@ -54,7 +57,8 @@ Test('Participant', participantHandlerTest => { currencyList: [ { participantCurrencyId: 3, currencyId: 'EUR', ledgerAccountTypeId: 1, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' }, { participantCurrencyId: 4, currencyId: 'EUR', ledgerAccountTypeId: 2, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' } - ] + ], + isProxy: 0 }, { participantId: 3, @@ -64,48 +68,78 @@ Test('Participant', participantHandlerTest => { createdDate: '2018-07-17T16:04:24.185Z', currencyList: [ { participantCurrencyId: 5, currencyId: 'USD', ledgerAccountTypeId: 5, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' } - ] + ], + isProxy: 0 + }, + { + participantId: 4, + name: 'xnProxy', + currency: 'EUR', + isActive: 1, + createdDate: '2018-07-17T16:04:24.185Z', + currencyList: [ + { participantCurrencyId: 6, currencyId: 'EUR', ledgerAccountTypeId: 1, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' }, + { participantCurrencyId: 7, currencyId: 'EUR', ledgerAccountTypeId: 2, isActive: 1, createdBy: 'unknown', createdDate: '2018-07-17T16:04:24.185Z' } + ], + isProxy: 1 } ] const participantResults = [ { name: 'fsp1', - id: 'http://central-ledger/participants/fsp1', + id: 'https://central-ledger/participants/fsp1', created: '2018-07-17T16:04:24.185Z', isActive: 1, links: { - self: 'http://central-ledger/participants/fsp1' + self: 'https://central-ledger/participants/fsp1' }, accounts: [ { id: 1, currency: 'USD', ledgerAccountType: 'POSITION', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') }, { id: 2, currency: 'USD', ledgerAccountType: 'SETTLEMENT', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') } - ] + ], + isProxy: 0 }, { name: 'fsp2', - id: 'http://central-ledger/participants/fsp2', + id: 'https://central-ledger/participants/fsp2', created: '2018-07-17T16:04:24.185Z', isActive: 1, links: { - self: 'http://central-ledger/participants/fsp2' + self: 'https://central-ledger/participants/fsp2' }, accounts: [ { id: 3, currency: 'EUR', ledgerAccountType: 'POSITION', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') }, { id: 4, currency: 'EUR', ledgerAccountType: 'SETTLEMENT', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') } - ] + ], + isProxy: 0 }, { name: 'Hub', - id: 'http://central-ledger/participants/Hub', + id: 'https://central-ledger/participants/Hub', created: '2018-07-17T16:04:24.185Z', isActive: 1, links: { - self: 'http://central-ledger/participants/Hub' + self: 'https://central-ledger/participants/Hub' }, accounts: [ { id: 5, currency: 'USD', ledgerAccountType: 'HUB_FEE', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') } - ] + ], + isProxy: 0 + }, + { + name: 'xnProxy', + id: 'https://central-ledger/participants/xnProxy', + created: '2018-07-17T16:04:24.185Z', + isActive: 1, + links: { + self: 'https://central-ledger/participants/xnProxy' + }, + accounts: [ + { id: 6, currency: 'EUR', ledgerAccountType: 'POSITION', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') }, + { id: 7, currency: 'EUR', ledgerAccountType: 'SETTLEMENT', isActive: 1, createdBy: 'unknown', createdDate: new Date('2018-07-17T16:04:24.185Z') } + ], + isProxy: 1 } ] const settlementModelFixtures = [ @@ -131,6 +165,12 @@ Test('Participant', participantHandlerTest => { sandbox.stub(Participant) sandbox.stub(EnumCached) sandbox.stub(SettlementModel, 'getAll') + sandbox.stub(Config, 'HOSTNAME').value('https://central-ledger') + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) EnumCached.getEnums.returns(Promise.resolve({ POSITION: 1, SETTLEMENT: 2, HUB_RECONCILIATION: 3, HUB_MULTILATERAL_SETTLEMENT: 4, HUB_FEE: 5 })) Logger.isDebugEnabled = true test.end() @@ -149,6 +189,13 @@ Test('Participant', participantHandlerTest => { test.end() }) + handlerTest.test('getAll should return all proxies when isProxy query is true', async function (test) { + Participant.getAll.returns(Promise.resolve(participantFixtures)) + const result = await Handler.getAll(createRequest({ query: { isProxy: true } })) + test.deepEqual(result, participantResults.filter(record => record.isProxy), 'The results match') + test.end() + }) + handlerTest.test('getByName should return the participant', async function (test) { Participant.getByName.withArgs(participantFixtures[0].name).returns(Promise.resolve(participantFixtures[0])) const result = await Handler.getByName(createRequest({ params: { name: participantFixtures[0].name } })) @@ -236,7 +283,8 @@ Test('Participant', participantHandlerTest => { name: 'fsp1', currency: 'USD', isActive: 1, - createdDate: '2018-07-17T16:04:24.185Z' + createdDate: '2018-07-17T16:04:24.185Z', + isProxy: 0 } const participantCurrencyId1 = 1 @@ -327,7 +375,8 @@ Test('Participant', participantHandlerTest => { currency: 'USD', isActive: 1, createdDate: '2018-07-17T16:04:24.185Z', - currencyList: [] + currencyList: [], + isProxy: 0 } const participantCurrencyId1 = 1 @@ -1231,7 +1280,8 @@ Test('Participant', participantHandlerTest => { isActive: 1, createdDate: '2018-07-17T16:04:24.185Z', createdBy: 'unknown', - currencyList: [] + currencyList: [], + isProxy: 0 } const ledgerAccountType = { ledgerAccountTypeId: 5, diff --git a/test/unit/api/participants/routes.test.js b/test/unit/api/participants/routes.test.js index b39f42cc8..45839553c 100644 --- a/test/unit/api/participants/routes.test.js +++ b/test/unit/api/participants/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/api/root/handler.test.js b/test/unit/api/root/handler.test.js index e84d7e8f4..1680e3c5b 100644 --- a/test/unit/api/root/handler.test.js +++ b/test/unit/api/root/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,19 +31,29 @@ const Test = require('tapes')(require('tape')) const Joi = require('joi') const Sinon = require('sinon') -const Handler = require('../../../../src/api/root/handler') const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const MigrationLockModel = require('../../../../src/models/misc/migrationLock') +const ProxyCache = require('#src/lib/proxyCache') +const Config = require('#src/lib/config') const { createRequest, unwrapResponse } = require('../../../util/helpers') +const requireUncached = module => { + delete require.cache[require.resolve(module)] + return require(module) +} + Test('Root', rootHandlerTest => { let sandbox - rootHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().returns(Promise.resolve(true)) + }) test.end() }) @@ -54,6 +67,43 @@ Test('Root', rootHandlerTest => { rootHandlerTest.test('Handler Test', async handlerTest => { handlerTest.test('getHealth returns the detailed health check', async function (test) { // Arrange + const Handler = requireUncached('../../../../src/api/root/handler') + sandbox.stub(MigrationLockModel, 'getIsMigrationLocked').returns(false) + sandbox.stub(Consumer, 'getListOfTopics').returns(['admin']) + sandbox.stub(Consumer, 'isConnected').returns(Promise.resolve()) + const schema = Joi.compile({ + status: Joi.string().valid('OK').required(), + uptime: Joi.number().required(), + startTime: Joi.date().iso().required(), + versionNumber: Joi.string().required(), + services: Joi.array().required() + }) + const expectedStatus = 200 + const expectedServices = [ + { name: 'datastore', status: 'OK' }, + { name: 'broker', status: 'OK' }, + { name: 'proxyCache', status: 'OK' } + ] + + // Act + const { + responseBody, + responseCode + } = await unwrapResponse((reply) => Handler.getHealth(createRequest({}), reply)) + + // Assert + const validationResult = Joi.attempt(responseBody, schema) // We use Joi to validate the results as they rely on timestamps that are variable + test.equal(validationResult.error, undefined, 'The response matches the validation schema') + test.deepEqual(responseCode, expectedStatus, 'The response code matches') + test.deepEqual(responseBody.services, expectedServices, 'The sub-services are correct') + test.end() + }) + + handlerTest.test('getHealth returns the detailed health check without proxyCache if disabled', async function (test) { + // Arrange + Config.PROXY_CACHE_CONFIG.enabled = false + const Handler = requireUncached('../../../../src/api/root/handler') + sandbox.stub(MigrationLockModel, 'getIsMigrationLocked').returns(false) sandbox.stub(Consumer, 'getListOfTopics').returns(['admin']) sandbox.stub(Consumer, 'isConnected').returns(Promise.resolve()) diff --git a/test/unit/api/root/routes.test.js b/test/unit/api/root/routes.test.js index ad6378067..877ac412a 100644 --- a/test/unit/api/root/routes.test.js +++ b/test/unit/api/root/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,18 +32,31 @@ const Base = require('../../base') const AdminRoutes = require('../../../../src/api/routes') const Sinon = require('sinon') const Enums = require('../../../../src/lib/enumCached') +const ProxyCache = require('#src/lib/proxyCache') Test('test root routes - health', async function (assert) { + const sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) const req = Base.buildRequest({ url: '/health', method: 'GET' }) const server = await Base.setup(AdminRoutes) const res = await server.inject(req) assert.ok(res) await server.stop() + sandbox.restore() assert.end() }) Test('test root routes - enums', async function (assert) { const sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) sandbox.stub(Enums, 'getEnums').returns(Promise.resolve({})) const req = Base.buildRequest({ url: '/enums', method: 'GET' }) const server = await Base.setup(AdminRoutes) @@ -52,10 +68,17 @@ Test('test root routes - enums', async function (assert) { }) Test('test root routes - /', async function (assert) { + const sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) const req = Base.buildRequest({ url: '/', method: 'GET' }) const server = await Base.setup(AdminRoutes) const res = await server.inject(req) assert.ok(res) await server.stop() + sandbox.restore() assert.end() }) diff --git a/test/unit/api/routes.test.js b/test/unit/api/routes.test.js index 8a12ba533..6a5e1187d 100644 --- a/test/unit/api/routes.test.js +++ b/test/unit/api/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -27,12 +30,21 @@ const Test = require('tape') const Base = require('../base') const ApiRoutes = require('../../../src/api/routes') +const ProxyCache = require('#src/lib/proxyCache') +const Sinon = require('sinon') Test('test health', async function (assert) { + const sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) const req = Base.buildRequest({ url: '/health', method: 'GET' }) const server = await Base.setup(ApiRoutes) const res = await server.inject(req) assert.ok(res) await server.stop() + sandbox.restore() assert.end() }) diff --git a/test/unit/api/settlementModels/handler.test.js b/test/unit/api/settlementModels/handler.test.js index 98b826f31..b2682e541 100644 --- a/test/unit/api/settlementModels/handler.test.js +++ b/test/unit/api/settlementModels/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -32,6 +35,7 @@ const Handler = require('../../../../src/api/settlementModels/handler') const SettlementService = require('../../../../src/domain/settlement') const EnumCached = require('../../../../src/lib/enumCached') const FSPIOPError = require('@mojaloop/central-services-error-handling').Factory.FSPIOPError +const ProxyCache = require('#src/lib/proxyCache') const createRequest = ({ payload, params, query }) => { const sandbox = Sinon.createSandbox() @@ -97,6 +101,11 @@ Test('SettlementModel', settlementModelHandlerTest => { sandbox.stub(Logger) sandbox.stub(SettlementService) sandbox.stub(EnumCached) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) EnumCached.getEnums.returns(Promise.resolve({ POSITION: 1, SETTLEMENT: 2, HUB_RECONCILIATION: 3, HUB_MULTILATERAL_SETTLEMENT: 4, HUB_FEE: 5 })) test.end() }) diff --git a/test/unit/api/settlementModels/routes.test.js b/test/unit/api/settlementModels/routes.test.js index 900d0facc..ead96f5e4 100644 --- a/test/unit/api/settlementModels/routes.test.js +++ b/test/unit/api/settlementModels/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/api/transactions/handler.test.js b/test/unit/api/transactions/handler.test.js index 73502dba7..bf6fab3e4 100644 --- a/test/unit/api/transactions/handler.test.js +++ b/test/unit/api/transactions/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,6 +31,7 @@ const Test = require('tapes')(require('tape')) const Sinon = require('sinon') const Handler = require('../../../../src/api/transactions/handler') const TransactionsService = require('../../../../src/domain/transactions') +const ProxyCache = require('#src/lib/proxyCache') Test('IlpPackets', IlpPacketsHandlerTest => { let sandbox @@ -74,6 +78,11 @@ Test('IlpPackets', IlpPacketsHandlerTest => { IlpPacketsHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() sandbox.stub(TransactionsService) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().resolves() + }) test.end() }) diff --git a/test/unit/api/transactions/routes.test.js b/test/unit/api/transactions/routes.test.js index ae9ca8305..f6981ab3e 100644 --- a/test/unit/api/transactions/routes.test.js +++ b/test/unit/api/transactions/routes.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/base.js b/test/unit/base.js index f9adcd291..de53f15a1 100644 --- a/test/unit/base.js +++ b/test/unit/base.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/domain/fx/cyril.test.js b/test/unit/domain/fx/cyril.test.js new file mode 100644 index 000000000..041538142 --- /dev/null +++ b/test/unit/domain/fx/cyril.test.js @@ -0,0 +1,1228 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') +const Cyril = require('../../../../src/domain/fx/cyril') +const Logger = require('@mojaloop/central-services-logger') +const { Enum } = require('@mojaloop/central-services-shared') +const TransferModel = require('../../../../src/models/transfer/transfer') +const TransferFacade = require('../../../../src/models/transfer/facade') +const ParticipantFacade = require('../../../../src/models/participant/facade') +const ParticipantPositionChangesModel = require('../../../../src/models/position/participantPositionChanges') +const { fxTransfer, watchList } = require('../../../../src/models/fxTransfer') +const ProxyCache = require('../../../../src/lib/proxyCache') +const config = require('#src/lib/config') + +const defaultGetProxyParticipantAccountDetailsResponse = { inScheme: true, participantCurrencyId: 1 } + +Test('Cyril', cyrilTest => { + let sandbox + let fxPayload + let payload + cyrilTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + sandbox.stub(Logger, 'isDebugEnabled').value(true) + sandbox.stub(watchList) + sandbox.stub(fxTransfer) + sandbox.stub(TransferModel) + sandbox.stub(ParticipantFacade) + sandbox.stub(ProxyCache) + sandbox.stub(ParticipantPositionChangesModel) + sandbox.stub(TransferFacade) + payload = { + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + payerFsp: 'dfsp1', + payeeFsp: 'dfsp2', + amount: { + currency: 'USD', + amount: '433.88' + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } + } + + fxPayload = { + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + determiningTransferId: 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + initiatingFsp: 'fx_dfsp1', + counterPartyFsp: 'fx_dfsp2', + sourceAmount: { + currency: 'USD', + amount: '433.88' + }, + targetAmount: { + currency: 'EUR', + amount: '200.00' + } + } + + t.end() + }) + + cyrilTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + cyrilTest.test('getParticipantAndCurrencyForTransferMessage should', getParticipantAndCurrencyForTransferMessageTest => { + getParticipantAndCurrencyForTransferMessageTest.test('return details about regular transfer', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([])) + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: false, + isInitiatingFspProxy: false + } + ) + const result = await Cyril.getParticipantAndCurrencyForTransferMessage(payload, determiningTransferCheckResult) + + test.deepEqual(result, { + participantName: 'dfsp1', + currencyId: 'USD', + amount: '433.88' + }) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('return details about fxtransfer', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([ + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + } + ])) + fxTransfer.getAllDetailsByCommitRequestId.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: false, + isInitiatingFspProxy: false + } + ) + const result = await Cyril.getParticipantAndCurrencyForTransferMessage( + payload, + determiningTransferCheckResult, + { isCounterPartyFspProxy: false } + ) + + test.deepEqual(result, { + participantName: 'fx_dfsp2', + currencyId: 'EUR', + amount: '200.00' + }) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestId.calledWith(fxPayload.commitRequestId)) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('return details about proxied fxtransfer', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([ + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + } + ])) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: true, + isInitiatingFspProxy: false + } + ) + const result = await Cyril.getParticipantAndCurrencyForTransferMessage( + payload, + determiningTransferCheckResult, + { isCounterPartyFspProxy: true } + ) + + test.deepEqual(result, { + participantName: 'fx_dfsp2', + currencyId: 'EUR', + amount: '200.00' + }) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('skips adding payee participantCurrency for validation when payee has proxy representation', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([ + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + } + ])) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: true, + isInitiatingFspProxy: false + } + ) + test.deepEqual(determiningTransferCheckResult.participantCurrencyValidationList, []) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('skips adding payer participantCurrency for validation when payer has proxy representation', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([])) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: false, + isInitiatingFspProxy: true + } + ) + test.deepEqual(determiningTransferCheckResult.participantCurrencyValidationList, []) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('skips adding payee participantCurrency for validation when payee has proxy representation, PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED=true', async (test) => { + try { + config.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED = true + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([])) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: true, + isInitiatingFspProxy: true + } + ) + test.deepEqual(determiningTransferCheckResult.participantCurrencyValidationList, []) + test.pass('Error not thrown') + config.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED = false + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.test('adds payee participantCurrency for validation for payee, PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED=true', async (test) => { + try { + config.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED = true + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([])) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.withArgs( + fxPayload.commitRequestId + ).returns(Promise.resolve( + { + targetAmount: fxPayload.targetAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: 'fx_dfsp2' + } + )) + + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForTransferMessage(payload, + { + isCounterPartyFspProxy: false, + isInitiatingFspProxy: true + } + ) + test.deepEqual(determiningTransferCheckResult.participantCurrencyValidationList, [{ participantName: 'dfsp2', currencyId: 'USD' }]) + test.pass('Error not thrown') + config.PAYEE_PARTICIPANT_CURRENCY_VALIDATION_ENABLED = false + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForTransferMessageTest.end() + }) + + cyrilTest.test('getParticipantAndCurrencyForFxTransferMessage should', getParticipantAndCurrencyForFxTransferMessageTest => { + getParticipantAndCurrencyForFxTransferMessageTest.test('return details about fxtransfer debtor party initited msg', async (test) => { + try { + TransferModel.getById.returns(Promise.resolve(null)) + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForFxTransferMessage(fxPayload, { + isCounterPartyFspProxy: false + }) + const result = await Cyril.getParticipantAndCurrencyForFxTransferMessage(fxPayload, determiningTransferCheckResult) + + test.ok(watchList.addToWatchList.calledWith({ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION + })) + test.deepEqual(result, { + participantName: fxPayload.initiatingFsp, + currencyId: fxPayload.sourceAmount.currency, + amount: fxPayload.sourceAmount.amount + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e.stack) + test.fail('Error Thrown') + test.end() + } + }) + + getParticipantAndCurrencyForFxTransferMessageTest.test('return details about fxtransfer creditor party initited msg', async (test) => { + try { + TransferModel.getById.returns(Promise.resolve({})) + const determiningTransferCheckResult = await Cyril.checkIfDeterminingTransferExistsForFxTransferMessage(fxPayload, { + isCounterPartyFspProxy: false + }) + const result = await Cyril.getParticipantAndCurrencyForFxTransferMessage(fxPayload, determiningTransferCheckResult) + + test.ok(watchList.addToWatchList.calledWith({ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION + })) + test.deepEqual(result, { + participantName: fxPayload.counterPartyFsp, + currencyId: fxPayload.targetAmount.currency, + amount: fxPayload.targetAmount.amount + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + getParticipantAndCurrencyForFxTransferMessageTest.end() + }) + + cyrilTest.test('processFxFulfilMessage should', processFxFulfilMessageTest => { + processFxFulfilMessageTest.test('throws error when commitRequestId not in watchlist', async (test) => { + try { + watchList.getItemInWatchListByCommitRequestId.returns(Promise.resolve(null)) + await Cyril.processFxFulfilMessage(fxPayload.commitRequestId) + test.ok(watchList.getItemInWatchListByCommitRequestId.calledWith(fxPayload.commitRequestId)) + test.fail('Error not thrown') + test.end() + } catch (e) { + test.pass('Error Thrown') + test.end() + } + }) + + processFxFulfilMessageTest.test('should return true when commitRequestId is in watchlist', async (test) => { + try { + watchList.getItemInWatchListByCommitRequestId.returns(Promise.resolve({ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + })) + const result = await Cyril.processFxFulfilMessage(fxPayload.commitRequestId) + test.ok(watchList.getItemInWatchListByCommitRequestId.calledWith(fxPayload.commitRequestId)) + test.ok(result) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + processFxFulfilMessageTest.end() + }) + + cyrilTest.test('processFulfilMessage should', processFulfilMessageTest => { + processFulfilMessageTest.test('return false if transferId is not in watchlist', async (test) => { + try { + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.deepEqual(result, { + isFx: false, + positionChanges: [], + patchNotifications: [] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payer conversion found', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 2, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'fx_dfsp1', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve(defaultGetProxyParticipantAccountDetailsResponse)) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.ok(ProxyCache.getProxyParticipantAccountDetails.calledWith( + 'dfsp2', + fxPayload.targetAmount.currency + )) + + test.deepEqual(result, { + isFx: true, + positionChanges: [{ + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 1, + amount: -433.88 + }, + { + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 1, + amount: -200 + } + ], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payee conversion found', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve(defaultGetProxyParticipantAccountDetailsResponse)) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [{ + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 1, + amount: -200 + }, + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 1, + amount: -433.88 + } + ], + patchNotifications: [] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with both payer and payee conversion found', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [ + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }, + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + } + ] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve(defaultGetProxyParticipantAccountDetailsResponse)) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 1, + amount: -200 + }, + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 1, + amount: -433.88 + }, + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 1, + amount: -433.88 + } + ], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payer conversion found, but payee is a proxy and have no account in the currency', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 2, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'fx_dfsp1', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve({ inScheme: false, participantCurrencyId: null })) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.ok(ProxyCache.getProxyParticipantAccountDetails.calledWith( + 'dfsp2', + fxPayload.targetAmount.currency + )) + + test.deepEqual(result, { + isFx: true, + positionChanges: [], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payer conversion found, but payee is a proxy and have account in the currency somehow', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 2, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'fx_dfsp1', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.onCall(0).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 234 })) // FXP Source Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(1).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 456 })) // Payee Target Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(2).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 345 })) // FXP Target Currency + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.ok(ProxyCache.getProxyParticipantAccountDetails.calledWith( + 'dfsp2', + fxPayload.targetAmount.currency + )) + + test.deepEqual(result, { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 234, + amount: -433.88 + }, + { + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 456, + amount: -200 + } + ], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payer conversion found, but payee is a proxy and have account in the currency somehow and it is same as fxp target account', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 2, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'fx_dfsp1', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.onCall(0).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 234 })) // FXP Source Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(1).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 456 })) // Payee Target Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(2).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 456 })) // FXP Target Currency + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.ok(ProxyCache.getProxyParticipantAccountDetails.calledWith( + 'dfsp2', + fxPayload.targetAmount.currency + )) + + test.deepEqual(result, { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 234, + amount: -433.88 + } + ], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payee conversion found but fxp is proxy and have no account', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve({ inScheme: false, participantCurrencyId: null })) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [], + patchNotifications: [] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payee conversion found but fxp is proxy and have account in source currency somehow', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.onCall(0).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 456 })) // Payee Target Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(1).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 234 })) // FXP Source Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(2).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 123 })) // Payer Source Currency + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 456, + amount: -200 + }, + { + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + participantCurrencyId: 234, + amount: -433.88 + } + ], + patchNotifications: [] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with only payee conversion found but fxp is proxy and have account in source currency somehow and it is same as payer account', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [{ + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.onCall(0).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 456 })) // Payee Target Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(1).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 234 })) // FXP Source Currency + ProxyCache.getProxyParticipantAccountDetails.onCall(2).returns(Promise.resolve({ inScheme: false, participantCurrencyId: 234 })) // Payer Source Currency + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: false, + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + participantCurrencyId: 456, + amount: -200 + } + ], + patchNotifications: [] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + + processFulfilMessageTest.test('process watchlist with both payer and payee conversion found, but derived currencyId is null', async (test) => { + try { + const completedTimestamp = new Date().toISOString() + watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve( + [ + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYEE_CONVERSION, + createdDate: new Date() + }, + { + commitRequestId: fxPayload.commitRequestId, + determiningTransferId: fxPayload.determiningTransferId, + fxTransferTypeId: Enum.Fx.FxTransferType.PAYER_CONVERSION, + createdDate: new Date() + } + ] + )) + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve( + { + initiatingFspParticipantId: 1, + targetAmount: fxPayload.targetAmount.amount, + commitRequestId: fxPayload.commitRequestId, + counterPartyFspSourceParticipantCurrencyId: 1, + counterPartyFspTargetParticipantCurrencyId: 2, + sourceAmount: fxPayload.sourceAmount.amount, + targetCurrency: fxPayload.targetAmount.currency, + counterPartyFspName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + } + )) + ParticipantFacade.getByNameAndCurrency.returns(Promise.resolve({ + participantId: 1, + participantCurrencyId: 1, + participantName: 'payeeFsp', + isActive: 1 + })) + ProxyCache.getProxyParticipantAccountDetails.returns(Promise.resolve({ inScheme: true, participantCurrencyId: null })) + const result = await Cyril.processFulfilMessage(payload.transferId, payload, payload) + test.ok(watchList.getItemsInWatchListByDeterminingTransferId.calledWith(payload.transferId)) + test.ok(fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.calledWith(fxPayload.commitRequestId)) + test.deepEqual(result, { + isFx: true, + positionChanges: [], + patchNotifications: [{ + commitRequestId: fxPayload.commitRequestId, + fxpName: fxPayload.counterPartyFsp, + fulfilment: 'fulfilment', + completedTimestamp + }] + } + ) + test.pass('Error not thrown') + test.end() + } catch (e) { + console.log(e) + test.fail('Error Thrown') + test.end() + } + }) + processFulfilMessageTest.end() + }) + + cyrilTest.test('processAbortMessage should', processAbortMessageTest => { + processAbortMessageTest.test('return false if transferId is not in watchlist', async (test) => { + try { + fxTransfer.getByDeterminingTransferId.returns(Promise.resolve([ + { commitRequestId: fxPayload.commitRequestId } + ])) + // Mocks for _getPositionChnages + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve({ + initiatingFspName: fxPayload.initiatingFsp + })) + ParticipantPositionChangesModel.getReservedPositionChangesByCommitRequestId.returns(Promise.resolve([ + { + participantCurrencyId: 1, + change: payload.amount.amount + } + ])) + TransferFacade.getById.returns(Promise.resolve({ + payerFsp: payload.payerFsp + })) + ParticipantPositionChangesModel.getReservedPositionChangesByTransferId.returns(Promise.resolve([ + { + participantCurrencyId: 1, + change: payload.amount.amount + } + ])) + + const result = await Cyril.processAbortMessage(payload.transferId) + + test.deepEqual(result, { positionChanges: [{ isFxTransferStateChange: true, commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', notifyTo: 'fx_dfsp1', participantCurrencyId: 1, amount: -433.88 }, { isFxTransferStateChange: false, transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', notifyTo: 'dfsp1', participantCurrencyId: 1, amount: -433.88 }] }) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + processAbortMessageTest.end() + }) + + cyrilTest.test('processFxAbortMessage should', processFxAbortMessageTest => { + processFxAbortMessageTest.test('return false if transferId is not in watchlist', async (test) => { + try { + fxTransfer.getByCommitRequestId.returns(Promise.resolve({ + determiningTransferId: fxPayload.determiningTransferId + })) + fxTransfer.getByDeterminingTransferId.returns(Promise.resolve([ + { commitRequestId: fxPayload.commitRequestId } + ])) + // Mocks for _getPositionChnages + fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.returns(Promise.resolve({ + initiatingFspName: fxPayload.initiatingFsp + })) + ParticipantPositionChangesModel.getReservedPositionChangesByCommitRequestId.returns(Promise.resolve([ + { + participantCurrencyId: 1, + change: payload.amount.amount + } + ])) + TransferFacade.getById.returns(Promise.resolve({ + payerFsp: payload.payerFsp + })) + ParticipantPositionChangesModel.getReservedPositionChangesByTransferId.returns(Promise.resolve([ + { + participantCurrencyId: 1, + change: payload.amount.amount + } + ])) + + const result = await Cyril.processFxAbortMessage(payload.transferId) + + test.deepEqual(result, { + positionChanges: [{ + isFxTransferStateChange: true, + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + notifyTo: 'fx_dfsp1', + participantCurrencyId: 1, + amount: -433.88 + }, { + isFxTransferStateChange: false, + transferId: 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c', + notifyTo: 'dfsp1', + participantCurrencyId: 1, + amount: -433.88 + }] + }) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + processFxAbortMessageTest.end() + }) + + cyrilTest.end() +}) diff --git a/test/unit/domain/fx/index.test.js b/test/unit/domain/fx/index.test.js new file mode 100644 index 000000000..7c31abf1a --- /dev/null +++ b/test/unit/domain/fx/index.test.js @@ -0,0 +1,167 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') +const Fx = require('../../../../src/domain/fx') +const Logger = require('@mojaloop/central-services-logger') +const { fxTransfer } = require('../../../../src/models/fxTransfer') +const { Enum } = require('@mojaloop/central-services-shared') + +const TransferEventAction = Enum.Events.Event.Action + +Test('Fx', fxIndexTest => { + let sandbox + let payload + let fxPayload + fxIndexTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + sandbox.stub(Logger, 'isDebugEnabled').value(true) + sandbox.stub(fxTransfer) + payload = { + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + payerFsp: 'dfsp1', + payeeFsp: 'dfsp2', + amount: { + currency: 'USD', + amount: '433.88' + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } + } + fxPayload = { + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + determiningTransferId: 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + initiatingFsp: 'dfsp1', + counterPartyFsp: 'fx_dfsp', + sourceAmount: { + currency: 'USD', + amount: '433.88' + }, + targetAmount: { + currency: 'EUR', + amount: '200.00' + } + } + t.end() + }) + + fxIndexTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + fxIndexTest.test('handleFulfilResponse should', handleFulfilResponseTest => { + handleFulfilResponseTest.test('return details about regular transfer', async (test) => { + try { + fxTransfer.saveFxFulfilResponse.returns(Promise.resolve()) + const result = await Fx.handleFulfilResponse(payload.transferId, payload, TransferEventAction.FX_RESERVE, null) + test.deepEqual(result, {}) + test.ok(fxTransfer.saveFxFulfilResponse.calledWith(payload.transferId, payload, TransferEventAction.FX_RESERVE, null)) + test.pass('Error not thrown') + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + handleFulfilResponseTest.test('throw errors', async (test) => { + try { + fxTransfer.saveFxFulfilResponse.throws(new Error('Error')) + const result = await Fx.handleFulfilResponse(payload.transferId, payload, TransferEventAction.FX_RESERVE, null) + test.deepEqual(result, {}) + test.ok(fxTransfer.saveFxFulfilResponse.calledWith(payload.transferId, payload, TransferEventAction.FX_RESERVE, null)) + test.fail('Error not thrown') + test.end() + } catch (e) { + test.pass('Error Thrown') + test.end() + } + }) + + handleFulfilResponseTest.end() + }) + + fxIndexTest.test('forwardedPrepare should', forwardedPrepareTest => { + forwardedPrepareTest.test('commit transfer', async (test) => { + try { + fxTransfer.updateFxPrepareReservedForwarded.returns(Promise.resolve()) + await Fx.forwardedFxPrepare(fxPayload.commitRequestId) + test.ok(fxTransfer.updateFxPrepareReservedForwarded.calledWith(fxPayload.commitRequestId)) + test.pass() + test.end() + } catch (err) { + Logger.error(`handlePayeeResponse failed with error - ${err}`) + test.fail() + test.end() + } + }) + + forwardedPrepareTest.test('throw error', async (test) => { + try { + fxTransfer.updateFxPrepareReservedForwarded.throws(new Error()) + await Fx.forwardedFxPrepare(fxPayload.commitRequestId) + test.fail('Error not thrown') + test.end() + } catch (err) { + Logger.error(`handlePayeeResponse failed with error - ${err}`) + test.pass('Error thrown') + test.end() + } + }) + + forwardedPrepareTest.end() + }) + + fxIndexTest.end() +}) diff --git a/test/unit/domain/ledgerAccountTypes/index.test.js b/test/unit/domain/ledgerAccountTypes/index.test.js index 1c9b684c6..da3538a79 100644 --- a/test/unit/domain/ledgerAccountTypes/index.test.js +++ b/test/unit/domain/ledgerAccountTypes/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/domain/participant/index.test.js b/test/unit/domain/participant/index.test.js index 5f8ceca27..41b1c231b 100644 --- a/test/unit/domain/participant/index.test.js +++ b/test/unit/domain/participant/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -52,14 +55,16 @@ Test('Participant service', async (participantTest) => { name: 'fsp1', currency: 'USD', isActive: 1, - createdDate: new Date() + createdDate: new Date(), + isProxy: 0 }, { participantId: 1, name: 'fsp2', currency: 'EUR', isActive: 1, - createdDate: new Date() + createdDate: new Date(), + isProxy: 0 } ] @@ -70,7 +75,8 @@ Test('Participant service', async (participantTest) => { currency: 'USD', isActive: 1, createdDate: new Date(), - currencyList: ['USD'] + currencyList: ['USD'], + isProxy: 0 }, { participantId: 1, @@ -78,7 +84,8 @@ Test('Participant service', async (participantTest) => { currency: 'EUR', isActive: 1, createdDate: new Date(), - currencyList: ['EUR'] + currencyList: ['EUR'], + isProxy: 0 } ] const participantCurrencyResult = [ @@ -195,7 +202,7 @@ Test('Participant service', async (participantTest) => { participantFixtures.forEach((participant, index) => { participantMap.set(index + 1, participantResult[index]) Db.participant.insert.withArgs({ participant }).returns(index) - ParticipantModelCached.create.withArgs({ name: participant.name }).returns((index + 1)) + ParticipantModelCached.create.withArgs({ name: participant.name, isProxy: !!participant.isProxy }).returns((index + 1)) ParticipantModelCached.getByName.withArgs(participant.name).returns(participantResult[index]) ParticipantModelCached.getById.withArgs(index).returns(participantResult[index]) ParticipantModelCached.update.withArgs(participant, 1).returns((index + 1)) @@ -250,7 +257,7 @@ Test('Participant service', async (participantTest) => { }) await participantTest.test('create false participant', async (assert) => { - const falseParticipant = { name: 'fsp3' } + const falseParticipant = { name: 'fsp3', isProxy: false } ParticipantModelCached.create.withArgs(falseParticipant).throws(new Error()) try { await Service.create(falseParticipant) diff --git a/test/unit/domain/position/abort.test.js b/test/unit/domain/position/abort.test.js new file mode 100644 index 000000000..aa332d694 --- /dev/null +++ b/test/unit/domain/position/abort.test.js @@ -0,0 +1,693 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + + -------------- + + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const { Enum } = require('@mojaloop/central-services-shared') +const Sinon = require('sinon') +const { processPositionAbortBin } = require('../../../../src/domain/position/abort') + +const abortMessage1 = { + value: { + from: 'payeefsp1', + to: 'payerfsp1', + id: 'a0000001-0000-0000-0000-000000000000', + content: { + uriParams: { + id: 'a0000001-0000-0000-0000-000000000000' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'payerfsp1', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'payeefsp1' + }, + payload: { + errorInformation: { + errorCode: '5104', + errorDescription: 'Payee Rejected' + } + }, + context: { + cyrilResult: { + positionChanges: [ + { + isFxTransferStateChange: false, + transferId: 'a0000001-0000-0000-0000-000000000000', + notifyTo: 'payerfsp1', + participantCurrencyId: 1, + amount: -10 + }, + { + isFxTransferStateChange: true, + commitRequestId: 'b0000001-0000-0000-0000-000000000000', + notifyTo: 'fxp1', + participantCurrencyId: 2, + amount: -10 + } + ] + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'a0000001-0000-0000-0000-000000000000', + event: { + type: 'position', + action: 'abort', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '5104', + description: 'Payee Rejected' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'abort', + source: 'payeefsp1', + destination: 'payerfsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const abortMessage2 = { + value: { + from: 'payeefsp1', + to: 'payerfsp1', + id: 'a0000002-0000-0000-0000-000000000000', + content: { + uriParams: { + id: 'a0000002-0000-0000-0000-000000000000' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'payerfsp1', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'payeefsp1' + }, + payload: { + errorInformation: { + errorCode: '5104', + errorDescription: 'Payee Rejected' + } + }, + context: { + cyrilResult: { + positionChanges: [ + { + isFxTransferStateChange: false, + transferId: 'a0000002-0000-0000-0000-000000000000', + notifyTo: 'payerfsp1', + participantCurrencyId: 1, + amount: -10 + } + ] + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'a0000002-0000-0000-0000-000000000000', + event: { + type: 'position', + action: 'abort', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '5104', + description: 'Payee Rejected' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'abort', + source: 'payeefsp1', + destination: 'payerfsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const fxAbortMessage1 = { + value: { + from: 'fxp1', + to: 'payerfsp1', + id: 'c0000001-0000-0000-0000-000000000000', + content: { + uriParams: { + id: 'c0000001-0000-0000-0000-000000000000' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'payerfsp1', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'fxp1' + }, + payload: { + errorInformation: { + errorCode: '5104', + errorDescription: 'FXP Rejected' + } + }, + context: { + cyrilResult: { + positionChanges: [ + { + isFxTransferStateChange: true, + commitRequestId: 'c0000001-0000-0000-0000-000000000000', + notifyTo: 'fxp1', + participantCurrencyId: 1, + amount: -10 + }, + { + isFxTransferStateChange: false, + transferId: 'd0000001-0000-0000-0000-000000000000', + notifyTo: 'payerfsp1', + participantCurrencyId: 1, + amount: -10 + } + ] + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'c0000001-0000-0000-0000-000000000000', + event: { + type: 'position', + action: 'fx-abort', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '5104', + description: 'Payee Rejected' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'fx-abort', + source: 'fxp1', + destination: 'payerfsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const fxAbortMessage2 = { + value: { + from: 'fxp1', + to: 'payerfsp1', + id: 'c0000002-0000-0000-0000-000000000000', + content: { + uriParams: { + id: 'c0000002-0000-0000-0000-000000000000' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'payerfsp1', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'fxp1' + }, + payload: { + errorInformation: { + errorCode: '5104', + errorDescription: 'FXP Rejected' + } + }, + context: { + cyrilResult: { + positionChanges: [ + { + isFxTransferStateChange: true, + commitRequestId: 'c0000002-0000-0000-0000-000000000000', + notifyTo: 'fxp1', + participantCurrencyId: 1, + amount: -10 + } + ] + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'c0000002-0000-0000-0000-000000000000', + event: { + type: 'position', + action: 'fx-abort', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '5104', + description: 'Payee Rejected' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'fx-abort', + source: 'fxp1', + destination: 'payerfsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const span = {} + +const getAbortBinItems = () => { + const binItems = [ + { + message: JSON.parse(JSON.stringify(abortMessage1)), + span, + decodedPayload: {} + }, + { + message: JSON.parse(JSON.stringify(abortMessage2)), + span, + decodedPayload: {} + } + ] + return binItems +} + +const getFxAbortBinItems = () => { + const binItems = [ + { + message: JSON.parse(JSON.stringify(fxAbortMessage1)), + span, + decodedPayload: {} + }, + { + message: JSON.parse(JSON.stringify(fxAbortMessage2)), + span, + decodedPayload: {} + } + ] + return binItems +} + +Test('abort domain', positionIndexTest => { + let sandbox + + positionIndexTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + t.end() + }) + + positionIndexTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + positionIndexTest.test('processPositionAbortBin should', processPositionAbortBinTest => { + processPositionAbortBinTest.test('produce abort message for transfers not in the right transfer state', async (test) => { + const binItems = getAbortBinItems() + try { + await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'a0000001-0000-0000-0000-000000000000': 'INVALID_STATE', + 'a0000002-0000-0000-0000-000000000000': 'INVALID_STATE' + }, + accumulatedFxTransferStates: { + 'b0000001-0000-0000-0000-000000000000': 'INVALID_STATE' + }, + isFx: false + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce abort messages with correct states but invalid cyrilResult', async (test) => { + const binItems = getAbortBinItems() + binItems[0].message.value.content.context = { + cyrilResult: 'INVALID' + } + try { + await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'a0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'a0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'b0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: false + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce abort messages with correct states and proper cyrilResult.', async (test) => { + const binItems = getAbortBinItems() + try { + const processedResult = await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'a0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'a0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'b0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: false + } + ) + test.pass('Error not thrown') + test.equal(processedResult.notifyMessages.length, 1) + test.equal(processedResult.followupMessages.length, 1) + test.equal(processedResult.accumulatedPositionChanges.length, 2) + test.equal(processedResult.accumulatedPositionChanges[0].value, -10) + test.equal(processedResult.accumulatedTransferStates[abortMessage1.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedTransferStates[abortMessage2.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedTransferStateChanges[0].transferId, abortMessage1.value.id) + test.equal(processedResult.accumulatedTransferStateChanges[1].transferId, abortMessage2.value.id) + test.equal(processedResult.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedPositionValue, -20) + } catch (e) { + test.fail('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce abort messages with correct states and proper cyrilResult with a single message. expecting one position to be adjusted and one followup message', async (test) => { + const binItems = getAbortBinItems() + binItems.splice(1, 1) + try { + const processedResult = await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'a0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'a0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'b0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: false + } + ) + test.pass('Error not thrown') + test.equal(processedResult.notifyMessages.length, 0) + test.equal(processedResult.followupMessages.length, 1) + test.equal(processedResult.accumulatedPositionChanges.length, 1) + test.equal(processedResult.accumulatedPositionChanges[0].value, -10) + test.equal(processedResult.accumulatedTransferStates[abortMessage1.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedTransferStateChanges[0].transferId, abortMessage1.value.id) + test.equal(processedResult.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedPositionValue, -10) + } catch (e) { + test.fail('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('skip position changes if changePositions is false', async (test) => { + const binItems = getAbortBinItems() + try { + const processedResult = await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'a0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'a0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: false, + changePositions: false + } + ) + test.equal(processedResult.accumulatedPositionChanges.length, 0) + test.equal(processedResult.accumulatedPositionValue, 0) + test.equal(processedResult.accumulatedTransferStateChanges.length, 2) + processedResult.accumulatedTransferStateChanges.forEach(transferStateChange => test.equal(transferStateChange.transferStateId, Enum.Transfers.TransferInternalState.ABORTED_ERROR)) + processedResult.accumulatedTransferStates[abortMessage1.value.id] = Enum.Transfers.TransferInternalState.ABORTED_ERROR + processedResult.accumulatedTransferStates[abortMessage2.value.id] = Enum.Transfers.TransferInternalState.ABORTED_ERROR + } catch (e) { + test.fail('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.end() + }) + + positionIndexTest.test('processPositionAbortBin with FX should', processPositionAbortBinTest => { + processPositionAbortBinTest.test('produce fx-abort message for fxTransfers not in the right transfer state', async (test) => { + const binItems = getFxAbortBinItems() + try { + await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd0000001-0000-0000-0000-000000000000': 'INVALID_STATE' + }, + accumulatedFxTransferStates: { + 'c0000001-0000-0000-0000-000000000000': 'INVALID_STATE', + 'c0000002-0000-0000-0000-000000000000': 'INVALID_STATE' + }, + isFx: true + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce fx-abort messages with correct states but invalid cyrilResult', async (test) => { + const binItems = getFxAbortBinItems() + binItems[0].message.value.content.context = { + cyrilResult: 'INVALID' + } + try { + await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'c0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'c0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: true + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce abort messages with correct states and proper cyrilResult.', async (test) => { + const binItems = getFxAbortBinItems() + try { + const processedResult = await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'c0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'c0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: true + } + ) + test.pass('Error not thrown') + test.equal(processedResult.notifyMessages.length, 1) + test.equal(processedResult.followupMessages.length, 1) + test.equal(processedResult.accumulatedPositionChanges.length, 2) + test.equal(processedResult.accumulatedPositionChanges[0].value, -10) + test.equal(processedResult.accumulatedFxTransferStates[fxAbortMessage1.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedFxTransferStates[fxAbortMessage2.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedPositionValue, -20) + } catch (e) { + test.fail('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.test('produce abort messages with correct states and proper cyrilResult with a single message. expecting one position to be adjusted and one followup message', async (test) => { + const binItems = getFxAbortBinItems() + binItems.splice(1, 1) + try { + const processedResult = await processPositionAbortBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + accumulatedFxTransferStates: { + 'c0000001-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR, + 'c0000002-0000-0000-0000-000000000000': Enum.Transfers.TransferInternalState.RECEIVED_ERROR + }, + isFx: true + } + ) + test.pass('Error not thrown') + test.equal(processedResult.notifyMessages.length, 0) + test.equal(processedResult.followupMessages.length, 1) + test.equal(processedResult.accumulatedPositionChanges.length, 1) + test.equal(processedResult.accumulatedPositionChanges[0].value, -10) + test.equal(processedResult.accumulatedFxTransferStates[fxAbortMessage1.value.id], Enum.Transfers.TransferInternalState.ABORTED_ERROR) + test.equal(processedResult.accumulatedPositionValue, -10) + } catch (e) { + test.fail('Error thrown') + } + test.end() + }) + + processPositionAbortBinTest.end() + }) + + positionIndexTest.end() +}) diff --git a/test/unit/domain/position/binProcessor.test.js b/test/unit/domain/position/binProcessor.test.js index 16159cacd..e216096b5 100644 --- a/test/unit/domain/position/binProcessor.test.js +++ b/test/unit/domain/position/binProcessor.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -60,7 +60,7 @@ const prepareTransfers = [ ...prepareTransfersBin2 ] -const fulfillTransfers = [ +const fulfilTransfers = [ '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', '33d42717-1dc9-4224-8c9b-45aab4fe6457', 'f33add51-38b1-4715-9876-83d8a08c485d', @@ -69,8 +69,17 @@ const fulfillTransfers = [ 'fe332218-07d6-4f00-8399-76671594697a' ] +const timeoutReservedTransfers = [ + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5' +] + +const fxTimeoutReservedTransfers = [ + 'ed6848e0-e2a8-45b0-9f98-59a2ffba8c10' +] + Test('BinProcessor', async (binProcessorTest) => { let sandbox + binProcessorTest.beforeEach(async test => { sandbox = Sinon.createSandbox() sandbox.stub(BatchPositionModel) @@ -79,10 +88,18 @@ Test('BinProcessor', async (binProcessorTest) => { sandbox.stub(participantFacade) const prepareTransfersStates = Object.fromEntries(prepareTransfers.map((transferId) => [transferId, { transferStateChangeId: 1, transferStateId: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE }])) - const fulfillTransfersStates = Object.fromEntries(fulfillTransfers.map((transferId) => [transferId, { transferStateChangeId: 1, transferStateId: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL }])) + const fulfilTransfersStates = Object.fromEntries(fulfilTransfers.map((transferId) => [transferId, { transferStateChangeId: 1, transferStateId: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL }])) + const timeoutReservedTransfersStates = Object.fromEntries(timeoutReservedTransfers.map((transferId) => [transferId, { transferStateChangeId: 1, transferStateId: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT }])) + const fxTimeoutReservedTransfersStates = Object.fromEntries(fxTimeoutReservedTransfers.map((commitRequestId) => [commitRequestId, { transferStateChangeId: 1, transferStateId: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT }])) + BatchPositionModel.getLatestTransferStateChangesByTransferIdList.returns({ ...prepareTransfersStates, - ...fulfillTransfersStates + ...fulfilTransfersStates, + ...timeoutReservedTransfersStates + }) + + BatchPositionModel.getLatestFxTransferStateChangesByCommitRequestIdList.returns({ + ...fxTimeoutReservedTransfersStates }) BatchPositionModelCached.getParticipantCurrencyByIds.returns([ @@ -363,6 +380,18 @@ Test('BinProcessor', async (binProcessorTest) => { }, 'fe332218-07d6-4f00-8399-76671594697a': { amount: -2 + }, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': { + amount: -50 + } + }) + + BatchPositionModel.getReservedPositionChangesByCommitRequestIds.returns({ + 'ed6848e0-e2a8-45b0-9f98-59a2ffba8c10': { + 15: { + value: 100, + change: 100 + } } }) @@ -412,8 +441,8 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - binProcessorTest.test('binProcessor should', prepareActionTest => { - prepareActionTest.test('processBins should process a bin of positions and return the expected results', async (test) => { + binProcessorTest.test('binProcessor should', processBinsTest => { + processBinsTest.test('processBins should process a bin of positions and return the expected results', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -434,7 +463,7 @@ Test('BinProcessor', async (binProcessorTest) => { const result = await BinProcessor.processBins(sampleBins, trx) // Assert on result.notifyMessages - test.equal(result.notifyMessages.length, 13, 'processBins should return the expected number of notify messages') + test.equal(result.notifyMessages.length, 15, 'processBins should return the expected number of notify messages') // Assert on result.limitAlarms // test.equal(result.limitAlarms.length, 1, 'processBin should return the expected number of limit alarms') @@ -447,8 +476,8 @@ Test('BinProcessor', async (binProcessorTest) => { // Assert on DB update for position values of all accounts in each function call test.deepEqual(BatchPositionModel.updateParticipantPosition.getCalls().map(call => call.args), [ - [{}, 7, 0, 0], - [{}, 15, 2, 0] + [{}, 7, -50, 0], + [{}, 15, -98, 0] ], 'updateParticipantPosition should be called with the expected arguments') // TODO: Assert on DB bulk insert of transferStateChanges in each function call @@ -457,7 +486,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should handle prepare messages', async (test) => { + processBinsTest.test('processBins should handle prepare messages', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -479,6 +508,10 @@ Test('BinProcessor', async (binProcessorTest) => { sampleBinsDeepCopy[15].commit = [] sampleBinsDeepCopy[7].reserve = [] sampleBinsDeepCopy[15].reserve = [] + sampleBinsDeepCopy[7]['timeout-reserved'] = [] + sampleBinsDeepCopy[15]['timeout-reserved'] = [] + sampleBinsDeepCopy[7]['fx-timeout-reserved'] = [] + sampleBinsDeepCopy[15]['fx-timeout-reserved'] = [] const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) // Assert on result.notifyMessages @@ -505,7 +538,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should handle commit messages', async (test) => { + processBinsTest.test('processBins should handle commit messages', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -526,6 +559,10 @@ Test('BinProcessor', async (binProcessorTest) => { sampleBinsDeepCopy[15].prepare = [] sampleBinsDeepCopy[7].reserve = [] sampleBinsDeepCopy[15].reserve = [] + sampleBinsDeepCopy[7]['timeout-reserved'] = [] + sampleBinsDeepCopy[15]['timeout-reserved'] = [] + sampleBinsDeepCopy[7]['fx-timeout-reserved'] = [] + sampleBinsDeepCopy[15]['fx-timeout-reserved'] = [] const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) // Assert on result.notifyMessages @@ -550,7 +587,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should handle reserve messages', async (test) => { + processBinsTest.test('processBins should handle reserve messages', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -571,6 +608,10 @@ Test('BinProcessor', async (binProcessorTest) => { sampleBinsDeepCopy[15].prepare = [] sampleBinsDeepCopy[7].commit = [] sampleBinsDeepCopy[15].commit = [] + sampleBinsDeepCopy[7]['timeout-reserved'] = [] + sampleBinsDeepCopy[15]['timeout-reserved'] = [] + sampleBinsDeepCopy[7]['fx-timeout-reserved'] = [] + sampleBinsDeepCopy[15]['fx-timeout-reserved'] = [] const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) // Assert on result.notifyMessages @@ -595,7 +636,105 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should throw error if any accountId cannot be matched to atleast one participantCurrencyId', async (test) => { + processBinsTest.test('processBins should handle timeout-reserved messages', async (test) => { + const sampleParticipantLimitReturnValues = [ + { + participantId: 2, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + }, + { + participantId: 3, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + } + ] + participantFacade.getParticipantLimitByParticipantCurrencyLimit.returns(sampleParticipantLimitReturnValues.shift()) + const sampleBinsDeepCopy = JSON.parse(JSON.stringify(sampleBins)) + sampleBinsDeepCopy[7].prepare = [] + sampleBinsDeepCopy[15].prepare = [] + sampleBinsDeepCopy[7].commit = [] + sampleBinsDeepCopy[15].commit = [] + sampleBinsDeepCopy[7].reserve = [] + sampleBinsDeepCopy[15].reserve = [] + sampleBinsDeepCopy[7]['fx-timeout-reserved'] = [] + sampleBinsDeepCopy[15]['fx-timeout-reserved'] = [] + const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) + + // Assert on result.notifyMessages + test.equal(result.notifyMessages.length, 1, 'processBins should return 1 messages') + + // TODO: What if there are no position changes in a batch? + // Assert on number of function calls for DB update on position value + // test.ok(BatchPositionModel.updateParticipantPosition.notCalled, 'updateParticipantPosition should not be called') + + // TODO: Assert on number of function calls for DB bulk insert of transferStateChanges + // TODO: Assert on number of function calls for DB bulk insert of positionChanges + + // Assert on DB update for position values of all accounts in each function call + test.deepEqual(BatchPositionModel.updateParticipantPosition.getCalls().map(call => call.args), [ + [{}, 7, -50, 0], + [{}, 15, 0, 0] + ], 'updateParticipantPosition should be called with the expected arguments') + + // TODO: Assert on DB bulk insert of transferStateChanges in each function call + // TODO: Assert on DB bulk insert of positionChanges in each function call + + test.end() + }) + + processBinsTest.test('processBins should handle fx-timeout-reserved messages', async (test) => { + const sampleParticipantLimitReturnValues = [ + { + participantId: 2, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + }, + { + participantId: 3, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + } + ] + participantFacade.getParticipantLimitByParticipantCurrencyLimit.returns(sampleParticipantLimitReturnValues.shift()) + const sampleBinsDeepCopy = JSON.parse(JSON.stringify(sampleBins)) + sampleBinsDeepCopy[7].prepare = [] + sampleBinsDeepCopy[15].prepare = [] + sampleBinsDeepCopy[7].commit = [] + sampleBinsDeepCopy[15].commit = [] + sampleBinsDeepCopy[7].reserve = [] + sampleBinsDeepCopy[15].reserve = [] + sampleBinsDeepCopy[7]['timeout-reserved'] = [] + sampleBinsDeepCopy[15]['timeout-reserved'] = [] + const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) + + // Assert on result.notifyMessages + test.equal(result.notifyMessages.length, 1, 'processBins should return 1 messages') + + // TODO: What if there are no position changes in a batch? + // Assert on number of function calls for DB update on position value + // test.ok(BatchPositionModel.updateParticipantPosition.notCalled, 'updateParticipantPosition should not be called') + + // TODO: Assert on number of function calls for DB bulk insert of transferStateChanges + // TODO: Assert on number of function calls for DB bulk insert of positionChanges + + // Assert on DB update for position values of all accounts in each function call + test.deepEqual(BatchPositionModel.updateParticipantPosition.getCalls().map(call => call.args), [ + [{}, 7, 0, 0], + [{}, 15, -100, 0] + ], 'updateParticipantPosition should be called with the expected arguments') + + // TODO: Assert on DB bulk insert of transferStateChanges in each function call + // TODO: Assert on DB bulk insert of positionChanges in each function call + + test.end() + }) + + processBinsTest.test('processBins should throw error if any accountId cannot be matched to atleast one participantCurrencyId', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -624,7 +763,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should throw error if no settlement model is found', async (test) => { + processBinsTest.test('processBins should throw error if no settlement model is found', async (test) => { SettlementModelCached.getAll.returns([]) const sampleParticipantLimitReturnValues = [ { @@ -650,7 +789,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should throw error if no default settlement model if currency model is missing', async (test) => { + processBinsTest.test('processBins should throw error if no default settlement model if currency model is missing', async (test) => { SettlementModelCached.getAll.returns([ { settlementModelId: 3, @@ -691,7 +830,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should use default settlement model if currency model is missing', async (test) => { + processBinsTest.test('processBins should use default settlement model if currency model is missing', async (test) => { SettlementModelCached.getAll.returns([ { settlementModelId: 2, @@ -727,7 +866,7 @@ Test('BinProcessor', async (binProcessorTest) => { const result = await BinProcessor.processBins(sampleBins, trx) // Assert on result.notifyMessages - test.equal(result.notifyMessages.length, 13, 'processBins should return 13 messages') + test.equal(result.notifyMessages.length, 15, 'processBins should return 15 messages') // TODO: What if there are no position changes in a batch? // Assert on number of function calls for DB update on position value @@ -738,8 +877,8 @@ Test('BinProcessor', async (binProcessorTest) => { // Assert on DB update for position values of all accounts in each function call test.deepEqual(BatchPositionModel.updateParticipantPosition.getCalls().map(call => call.args), [ - [{}, 7, 0, 0], - [{}, 15, 2, 0] + [{}, 7, -50, 0], + [{}, 15, -98, 0] ], 'updateParticipantPosition should be called with the expected arguments') // TODO: Assert on DB bulk insert of transferStateChanges in each function call @@ -748,7 +887,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should handle no binItems', async (test) => { + processBinsTest.test('processBins should handle no binItems', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -771,6 +910,10 @@ Test('BinProcessor', async (binProcessorTest) => { delete sampleBinsDeepCopy[15].commit delete sampleBinsDeepCopy[7].reserve delete sampleBinsDeepCopy[15].reserve + delete sampleBinsDeepCopy[7]['timeout-reserved'] + delete sampleBinsDeepCopy[15]['timeout-reserved'] + sampleBinsDeepCopy[7]['fx-timeout-reserved'] = [] + sampleBinsDeepCopy[15]['fx-timeout-reserved'] = [] const result = await BinProcessor.processBins(sampleBinsDeepCopy, trx) // Assert on result.notifyMessages @@ -795,7 +938,7 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.test('processBins should handle non supported bins', async (test) => { + processBinsTest.test('processBins should handle non supported bins', async (test) => { const sampleParticipantLimitReturnValues = [ { participantId: 2, @@ -823,14 +966,51 @@ Test('BinProcessor', async (binProcessorTest) => { test.end() }) - prepareActionTest.end() + + processBinsTest.test('processBins should process bins with accountId 0 differently', async (test) => { + const sampleParticipantLimitReturnValues = [ + { + participantId: 2, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + }, + { + participantId: 3, + currencyId: 'USD', + participantLimitTypeId: 1, + value: 1000000 + } + ] + participantFacade.getParticipantLimitByParticipantCurrencyLimit.returns(sampleParticipantLimitReturnValues.shift()) + const binsWithZeroId = JSON.parse(JSON.stringify(sampleBins)) + binsWithZeroId[0] = binsWithZeroId[15] + delete binsWithZeroId[15] + delete binsWithZeroId[7] + + const result = await BinProcessor.processBins(binsWithZeroId, trx) + + // Assert on result.notifyMessages + test.equal(result.notifyMessages.length, 6, 'processBins should return 6 messages') + + // Assert on number of function calls for DB update on position value + test.equal(BatchPositionModel.updateParticipantPosition.callCount, 0, 'updateParticipantPosition should not be called') + test.ok(BatchPositionModel.bulkInsertTransferStateChanges.calledOnce, 'bulkInsertTrasferStateChanges should be called once') + test.ok(BatchPositionModel.bulkInsertFxTransferStateChanges.calledOnce, 'bulkInsertFxTrasferStateChanges should be called once') + test.equal(BatchPositionModel.bulkInsertParticipantPositionChanges.callCount, 0, 'bulkInsertParticipantPositionChanges should not be called') + + test.end() + }) + + processBinsTest.end() }) + binProcessorTest.test('iterateThroughBins should', async (iterateThroughBinsTest) => { iterateThroughBinsTest.test('iterateThroughBins should call callback function for each message in bins', async (test) => { const spyCb = sandbox.spy() await BinProcessor.iterateThroughBins(sampleBins, spyCb) - test.equal(spyCb.callCount, 13, 'callback should be called 13 times') + test.equal(spyCb.callCount, 15, 'callback should be called 15 times') test.end() }) iterateThroughBinsTest.test('iterateThroughBins should call error callback function if callback function throws error', async (test) => { @@ -840,7 +1020,7 @@ Test('BinProcessor', async (binProcessorTest) => { spyCb.onThirdCall().throws() await BinProcessor.iterateThroughBins(sampleBins, spyCb, errorCb) - test.equal(spyCb.callCount, 13, 'callback should be called 13 times') + test.equal(spyCb.callCount, 15, 'callback should be called 15 times') test.equal(errorCb.callCount, 2, 'error callback should be called 2 times') test.end() }) @@ -849,10 +1029,11 @@ Test('BinProcessor', async (binProcessorTest) => { spyCb.onFirstCall().throws() await BinProcessor.iterateThroughBins(sampleBins, spyCb) - test.equal(spyCb.callCount, 13, 'callback should be called 13 times') + test.equal(spyCb.callCount, 15, 'callback should be called 15 times') test.end() }) iterateThroughBinsTest.end() }) + binProcessorTest.end() }) diff --git a/test/unit/domain/position/fulfil.test.js b/test/unit/domain/position/fulfil.test.js index 27cc40d62..4e55f2960 100644 --- a/test/unit/domain/position/fulfil.test.js +++ b/test/unit/domain/position/fulfil.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,343 +31,203 @@ const Test = require('tapes')(require('tape')) const { Enum } = require('@mojaloop/central-services-shared') const Sinon = require('sinon') const { processPositionFulfilBin } = require('../../../../src/domain/position/fulfil') +const { randomUUID } = require('crypto') +const Config = require('../../../../src/lib/config') -const transferMessage1 = { - value: { - from: 'perffsp1', - to: 'perffsp2', - id: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f', - content: { - uriParams: { - id: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f' - }, - headers: { - accept: 'application/vnd.interoperability.transfers+json;version=1.1', - 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1', - date: '2023-08-21T10:22:11.000Z', - 'fspiop-source': 'perffsp1', - 'fspiop-destination': 'perffsp2', - traceparent: '00-278414be0ce56adab6c6461b1196f7ec-c2639bb302a327f2-01', - tracestate: 'acmevendor=eyJzcGFuSWQiOiJjMjYzOWJiMzAyYTMyN2YyIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4In0=,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', - 'user-agent': 'axios/1.4.0', - 'content-length': '136', - 'accept-encoding': 'gzip, compress, deflate, br', - host: 'ml-api-adapter:3000', - connection: 'keep-alive' - }, - payload: 'data:application/vnd.interoperability.transfers+json;version=1.1;base64,eyJ0cmFuc2ZlclN0YXRlIjoiQ09NTUlUVEVEIiwiZnVsZmlsbWVudCI6ImxuWWU0cll3THRoV2J6aFZ5WDVjQXVEZkwxVWx3NFdkYVRneUdEUkV5c3ciLCJjb21wbGV0ZWRUaW1lc3RhbXAiOiIyMDIzLTA4LTIxVDEwOjIyOjExLjQ4MVoifQ==' - }, - type: 'application/json', - metadata: { - correlationId: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f', - event: { - type: 'position', - action: 'commit', - createdAt: '2023-08-21T10:22:11.481Z', - state: { - status: 'success', - code: 0, - description: 'action successful' - }, - id: 'ffa2969c-8b90-4fa7-97b3-6013b5937553' - }, - trace: { - service: 'cl_transfer_fulfil', - traceId: '278414be0ce56adab6c6461b1196f7ec', - spanId: '29dcf2b250cd22d1', - sampled: 1, - flags: '01', - parentSpanId: 'e038bfd263a0b4c0', - startTimestamp: '2023-08-21T10:23:31.357Z', - tags: { - tracestate: 'acmevendor=eyJzcGFuSWQiOiIyOWRjZjJiMjUwY2QyMmQxIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4IiwidGltZUFwaUZ1bGZpbCI6IjE2OTI2MTMzMzE0ODEifQ==,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', - transactionType: 'transfer', - transactionAction: 'fulfil', - transactionId: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f', - source: 'perffsp1', - destination: 'perffsp2' - }, - tracestates: { - acmevendor: { - spanId: '29dcf2b250cd22d1', - timeApiPrepare: '1692285908178', - timeApiFulfil: '1692613331481' +const constructTransferCallbackTestData = (payerFsp, payeeFsp, transferState, eventAction, amount, currency) => { + const transferId = randomUUID() + const payload = { + transferState, + fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', + completedTimestamp: '2023-08-21T10:22:11.481Z' + } + const transferInfo = { + transferId, + amount + } + const reservedActionTransferInfo = { + transferId, + amount, + currencyId: currency, + ilpCondition: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', + expirationDate: '2023-08-21T10:22:11.481Z', + createdDate: '2023-08-21T10:22:11.481Z', + completedTimestamp: '2023-08-21T10:22:11.481Z', + transferStateEnumeration: 'PREPARE', + fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', + extensionList: [] + } + const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64') + return { + transferInfo, + reservedActionTransferInfo, + decodedPayload: payload, + message: { + value: { + from: payerFsp, + to: payeeFsp, + id: transferId, + content: { + uriParams: { + id: transferId }, - tx_end2end_start_ts: '1692285908177', - tx_callback_start_ts: '1692613331481' - } - }, - 'protocol.createdAt': 1692613411360 - } - }, - size: 3489, - key: 51, - topic: 'topic-transfer-position', - offset: 4070, - partition: 0, - timestamp: 1694175690401 -} -const transferMessage2 = { - value: { - from: 'perffsp2', - to: 'perffsp1', - id: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', - content: { - uriParams: { - id: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62' - }, - headers: { - accept: 'application/vnd.interoperability.transfers+json;version=1.1', - 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1', - date: '2023-08-21T10:22:27.000Z', - 'fspiop-source': 'perffsp2', - 'fspiop-destination': 'perffsp1', - traceparent: '00-1fcd3843697316bd4dea096eb8b0f20d-242262bdec0c9c76-01', - tracestate: 'acmevendor=eyJzcGFuSWQiOiIyNDIyNjJiZGVjMGM5Yzc2IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTEyMDI3In0=,tx_end2end_start_ts=1692285912027,tx_callback_start_ts=1692613347073', - 'user-agent': 'axios/1.4.0', - 'content-length': '136', - 'accept-encoding': 'gzip, compress, deflate, br', - host: 'ml-api-adapter:3000', - connection: 'keep-alive' - }, - payload: 'data:application/vnd.interoperability.transfers+json;version=1.1;base64,eyJ0cmFuc2ZlclN0YXRlIjoiQ09NTUlUVEVEIiwiZnVsZmlsbWVudCI6ImxuWWU0cll3THRoV2J6aFZ5WDVjQXVEZkwxVWx3NFdkYVRneUdEUkV5c3ciLCJjb21wbGV0ZWRUaW1lc3RhbXAiOiIyMDIzLTA4LTIxVDEwOjIyOjI3LjA3M1oifQ==' - }, - type: 'application/json', - metadata: { - correlationId: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', - event: { - type: 'position', - action: 'commit', - createdAt: '2023-08-21T10:22:27.074Z', - state: { - status: 'success', - code: 0, - description: 'action successful' - }, - id: 'c16155a3-1807-470d-9386-ce46603ed875' - }, - trace: { - service: 'cl_transfer_fulfil', - traceId: '1fcd3843697316bd4dea096eb8b0f20d', - spanId: '5690c3dbd5bb1ee5', - sampled: 1, - flags: '01', - parentSpanId: '66055f3f76497fc9', - startTimestamp: '2023-08-21T10:23:45.332Z', - tags: { - tracestate: 'acmevendor=eyJzcGFuSWQiOiI1NjkwYzNkYmQ1YmIxZWU1IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTEyMDI3IiwidGltZUFwaUZ1bGZpbCI6IjE2OTI2MTMzNDcwNzQifQ==,tx_end2end_start_ts=1692285912027,tx_callback_start_ts=1692613347073', - transactionType: 'transfer', - transactionAction: 'fulfil', - transactionId: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', - source: 'perffsp2', - destination: 'perffsp1' - }, - tracestates: { - acmevendor: { - spanId: '5690c3dbd5bb1ee5', - timeApiPrepare: '1692285912027', - timeApiFulfil: '1692613347074' + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.1', + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1', + date: '2023-08-21T10:22:11.000Z', + 'fspiop-source': payerFsp, + 'fspiop-destination': payeeFsp, + traceparent: '00-278414be0ce56adab6c6461b1196f7ec-c2639bb302a327f2-01', + tracestate: 'acmevendor=eyJzcGFuSWQiOiJjMjYzOWJiMzAyYTMyN2YyIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4In0=,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', + 'user-agent': 'axios/1.4.0', + 'content-length': '136', + 'accept-encoding': 'gzip, compress, deflate, br', + host: 'ml-api-adapter:3000', + connection: 'keep-alive' }, - tx_end2end_start_ts: '1692285912027', - tx_callback_start_ts: '1692613347073' - } - }, - 'protocol.createdAt': 1692613425335 - } - }, - size: 3489, - key: 51, - topic: 'topic-transfer-position', - offset: 4073, - partition: 0, - timestamp: 1694175690401 -} -const transferMessage3 = { - value: { - from: 'perffsp1', - to: 'perffsp2', - id: '780a1e7c-f01e-47a4-8538-1a27fb690627', - content: { - uriParams: { - id: '780a1e7c-f01e-47a4-8538-1a27fb690627' - }, - headers: { - accept: 'application/vnd.interoperability.transfers+json;version=1.1', - 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1', - date: '2023-08-21T10:22:11.000Z', - 'fspiop-source': 'perffsp1', - 'fspiop-destination': 'perffsp2', - traceparent: '00-278414be0ce56adab6c6461b1196f7ec-c2639bb302a327f2-01', - tracestate: 'acmevendor=eyJzcGFuSWQiOiJjMjYzOWJiMzAyYTMyN2YyIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4In0=,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', - 'user-agent': 'axios/1.4.0', - 'content-length': '136', - 'accept-encoding': 'gzip, compress, deflate, br', - host: 'ml-api-adapter:3000', - connection: 'keep-alive' - }, - payload: 'data:application/vnd.interoperability.transfers+json;version=1.1;base64,eyJ0cmFuc2ZlclN0YXRlIjoiQ09NTUlUVEVEIiwiZnVsZmlsbWVudCI6ImxuWWU0cll3THRoV2J6aFZ5WDVjQXVEZkwxVWx3NFdkYVRneUdEUkV5c3ciLCJjb21wbGV0ZWRUaW1lc3RhbXAiOiIyMDIzLTA4LTIxVDEwOjIyOjExLjQ4MVoifQ==' - }, - type: 'application/json', - metadata: { - correlationId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - event: { - type: 'position', - action: 'reserve', - createdAt: '2023-08-21T10:22:11.481Z', - state: { - status: 'success', - code: 0, - description: 'action successful' - }, - id: 'ffa2969c-8b90-4fa7-97b3-6013b5937553' - }, - trace: { - service: 'cl_transfer_fulfil', - traceId: '278414be0ce56adab6c6461b1196f7ec', - spanId: '29dcf2b250cd22d1', - sampled: 1, - flags: '01', - parentSpanId: 'e038bfd263a0b4c0', - startTimestamp: '2023-08-21T10:23:31.357Z', - tags: { - tracestate: 'acmevendor=eyJzcGFuSWQiOiIyOWRjZjJiMjUwY2QyMmQxIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4IiwidGltZUFwaUZ1bGZpbCI6IjE2OTI2MTMzMzE0ODEifQ==,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', - transactionType: 'transfer', - transactionAction: 'fulfil', - transactionId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - source: 'perffsp1', - destination: 'perffsp2' + payload: 'data:application/vnd.interoperability.transfers+json;version=1.1;base64,' + base64Payload }, - tracestates: { - acmevendor: { + type: 'application/json', + metadata: { + correlationId: transferId, + event: { + type: 'position', + action: eventAction, + createdAt: '2023-08-21T10:22:11.481Z', + state: { + status: 'success', + code: 0, + description: 'action successful' + }, + id: 'ffa2969c-8b90-4fa7-97b3-6013b5937553' + }, + trace: { + service: 'cl_transfer_fulfil', + traceId: '278414be0ce56adab6c6461b1196f7ec', spanId: '29dcf2b250cd22d1', - timeApiPrepare: '1692285908178', - timeApiFulfil: '1692613331481' + sampled: 1, + flags: '01', + parentSpanId: 'e038bfd263a0b4c0', + startTimestamp: '2023-08-21T10:23:31.357Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiIyOWRjZjJiMjUwY2QyMmQxIiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4MTc4IiwidGltZUFwaUZ1bGZpbCI6IjE2OTI2MTMzMzE0ODEifQ==,tx_end2end_start_ts=1692285908177,tx_callback_start_ts=1692613331481', + transactionType: 'transfer', + transactionAction: 'fulfil', + transactionId: transferId, + source: payerFsp, + destination: payeeFsp + }, + tracestates: { + acmevendor: { + spanId: '29dcf2b250cd22d1', + timeApiPrepare: '1692285908178', + timeApiFulfil: '1692613331481' + }, + tx_end2end_start_ts: '1692285908177', + tx_callback_start_ts: '1692613331481' + } }, - tx_end2end_start_ts: '1692285908177', - tx_callback_start_ts: '1692613331481' + 'protocol.createdAt': 1692613411360 } }, - 'protocol.createdAt': 1692613411360 + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4070, + partition: 0, + timestamp: 1694175690401 } - }, - size: 3489, - key: 51, - topic: 'topic-transfer-position', - offset: 4070, - partition: 0, - timestamp: 1694175690401 + } } -const transferMessage4 = { - value: { - from: 'perffsp2', - to: 'perffsp1', - id: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - content: { - uriParams: { - id: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377' - }, - headers: { - accept: 'application/vnd.interoperability.transfers+json;version=1.1', - 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1', - date: '2023-08-21T10:22:27.000Z', - 'fspiop-source': 'perffsp2', - 'fspiop-destination': 'perffsp1', - traceparent: '00-1fcd3843697316bd4dea096eb8b0f20d-242262bdec0c9c76-01', - tracestate: 'acmevendor=eyJzcGFuSWQiOiIyNDIyNjJiZGVjMGM5Yzc2IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTEyMDI3In0=,tx_end2end_start_ts=1692285912027,tx_callback_start_ts=1692613347073', - 'user-agent': 'axios/1.4.0', - 'content-length': '136', - 'accept-encoding': 'gzip, compress, deflate, br', - host: 'ml-api-adapter:3000', - connection: 'keep-alive' - }, - payload: 'data:application/vnd.interoperability.transfers+json;version=1.1;base64,eyJ0cmFuc2ZlclN0YXRlIjoiQ09NTUlUVEVEIiwiZnVsZmlsbWVudCI6ImxuWWU0cll3THRoV2J6aFZ5WDVjQXVEZkwxVWx3NFdkYVRneUdEUkV5c3ciLCJjb21wbGV0ZWRUaW1lc3RhbXAiOiIyMDIzLTA4LTIxVDEwOjIyOjI3LjA3M1oifQ==' - }, - type: 'application/json', - metadata: { - correlationId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - event: { - type: 'position', - action: 'reserve', - createdAt: '2023-08-21T10:22:27.074Z', - state: { - status: 'success', - code: 0, - description: 'action successful' - }, - id: 'c16155a3-1807-470d-9386-ce46603ed875' - }, - trace: { - service: 'cl_transfer_fulfil', - traceId: '1fcd3843697316bd4dea096eb8b0f20d', - spanId: '5690c3dbd5bb1ee5', - sampled: 1, - flags: '01', - parentSpanId: '66055f3f76497fc9', - startTimestamp: '2023-08-21T10:23:45.332Z', - tags: { - tracestate: 'acmevendor=eyJzcGFuSWQiOiI1NjkwYzNkYmQ1YmIxZWU1IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTEyMDI3IiwidGltZUFwaUZ1bGZpbCI6IjE2OTI2MTMzNDcwNzQifQ==,tx_end2end_start_ts=1692285912027,tx_callback_start_ts=1692613347073', - transactionType: 'transfer', - transactionAction: 'fulfil', - transactionId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - source: 'perffsp2', - destination: 'perffsp1' + +const _constructContextForFx = (transferTestData, partialProcessed = false, patchNotifications = []) => { + return { + cyrilResult: { + isFx: true, + positionChanges: [ + { + isFxTransferStateChange: true, + commitRequestId: randomUUID(), + participantCurrencyId: '100', + amount: '10', + isDone: partialProcessed ? true : undefined }, - tracestates: { - acmevendor: { - spanId: '5690c3dbd5bb1ee5', - timeApiPrepare: '1692285912027', - timeApiFulfil: '1692613347074' - }, - tx_end2end_start_ts: '1692285912027', - tx_callback_start_ts: '1692613347073' + { + isFxTransferStateChange: false, + transferId: transferTestData.message.value.id, + participantCurrencyId: '101', + amount: transferTestData.transferInfo.amount } - }, - 'protocol.createdAt': 1692613425335 + ], + patchNotifications } - }, - size: 3489, - key: 51, - topic: 'topic-transfer-position', - offset: 4073, - partition: 0, - timestamp: 1694175690401 + } } + +const transferTestData1 = constructTransferCallbackTestData('perffsp1', 'perffsp2', 'COMMITTED', 'commit', '2.00', 'USD') +const transferTestData2 = constructTransferCallbackTestData('perffsp2', 'perffsp1', 'COMMITTED', 'commit', '2.00', 'USD') +const transferTestData3 = constructTransferCallbackTestData('perffsp1', 'perffsp2', 'RESERVED', 'reserve', '2.00', 'USD') +const transferTestData4 = constructTransferCallbackTestData('perffsp2', 'perffsp1', 'RESERVED', 'reserve', '2.00', 'USD') +// Fulfil messages those are linked to FX transfers +const transferTestData5 = constructTransferCallbackTestData('perffsp1', 'perffsp2', 'COMMITTED', 'commit', '2.00', 'USD') +transferTestData5.message.value.content.context = _constructContextForFx(transferTestData5, undefined, [{ + commitRequestId: randomUUID(), + fxpName: 'FXP1', + fulfilment: 'fulfilment', + completedTimestamp: new Date().toISOString() +}]) +const transferTestData6 = constructTransferCallbackTestData('perffsp2', 'perffsp1', 'COMMITTED', 'commit', '2.00', 'USD') +transferTestData6.message.value.content.context = _constructContextForFx(transferTestData6, undefined, [{ + commitRequestId: randomUUID(), + fxpName: 'FXP1', + fulfilment: 'fulfilment', + completedTimestamp: new Date().toISOString() +}]) +const transferTestData7 = constructTransferCallbackTestData('perffsp1', 'perffsp2', 'COMMITTED', 'commit', '2.00', 'USD') +transferTestData7.message.value.content.context = _constructContextForFx(transferTestData7, true) +const transferTestData8 = constructTransferCallbackTestData('perffsp2', 'perffsp1', 'COMMITTED', 'commit', '2.00', 'USD') +transferTestData8.message.value.content.context = _constructContextForFx(transferTestData8, true) + const span = {} const commitBinItems = [{ - message: transferMessage1, + message: transferTestData1.message, span, - decodedPayload: { - transferState: 'COMMITTED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - completedTimestamp: '2023-08-21T10:22:11.481Z' - } + decodedPayload: transferTestData1.decodedPayload }, { - message: transferMessage2, + message: transferTestData2.message, span, - decodedPayload: { - transferState: 'COMMITTED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - completedTimestamp: '2023-08-21T10:22:27.073Z' - } + decodedPayload: transferTestData2.decodedPayload }] const reserveBinItems = [{ - message: transferMessage3, + message: transferTestData3.message, span, - decodedPayload: { - transferState: 'RESERVED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - completedTimestamp: '2023-08-21T10:22:11.481Z' - } + decodedPayload: transferTestData3.decodedPayload }, { - message: transferMessage4, + message: transferTestData4.message, span, - decodedPayload: { - transferState: 'RESERVED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - completedTimestamp: '2023-08-21T10:22:27.073Z' - } + decodedPayload: transferTestData4.decodedPayload +}] +const commitWithFxBinItems = [{ + message: transferTestData5.message, + span, + decodedPayload: transferTestData5.decodedPayload +}, +{ + message: transferTestData6.message, + span, + decodedPayload: transferTestData6.decodedPayload +}] +const commitWithPartiallyProcessedFxBinItems = [{ + message: transferTestData7.message, + span, + decodedPayload: transferTestData7.decodedPayload +}, +{ + message: transferTestData8.message, + span, + decodedPayload: transferTestData8.decodedPayload }] Test('Fulfil domain', processPositionFulfilBinTest => { let sandbox @@ -380,22 +243,25 @@ Test('Fulfil domain', processPositionFulfilBinTest => { }) processPositionFulfilBinTest.test('should process a bin of position-commit messages', async (test) => { + const accumulatedTransferStates = { + [transferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData1.message.value.id]: transferTestData1.transferInfo, + [transferTestData2.message.value.id]: transferTestData2.transferInfo + } // Call the function const result = await processPositionFulfilBin( [commitBinItems, []], - 0, - 0, - { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL - }, { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': { - amount: 2.00 - }, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': { - amount: 2.00 - } + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [] } ) @@ -403,83 +269,53 @@ Test('Fulfil domain', processPositionFulfilBinTest => { test.equal(result.notifyMessages.length, 2) test.equal(result.accumulatedPositionValue, 4) test.equal(result.accumulatedPositionReservedValue, 0) - test.deepEqual(result.accumulatedTransferStateChanges, [ - { - transferId: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f', - transferStateId: 'COMMITTED', - reason: undefined - }, - { - transferId: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', - transferStateId: 'COMMITTED', - reason: undefined - } - ]) - test.deepEqual(result.accumulatedTransferStates, { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': 'COMMITTED', - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': 'COMMITTED' - }) - - test.equal(result.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData1.message.value.id) + test.equal(result.accumulatedTransferStateChanges[1].transferId, transferTestData2.message.value.id) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData1.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData1.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferTestData1.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData1.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[0].value, 2) - test.equal(result.accumulatedTransferStates[transferMessage1.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData1.message.value.id], Enum.Transfers.TransferState.COMMITTED) - test.equal(result.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData2.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData2.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferTestData2.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData2.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[1].value, 4) - test.equal(result.accumulatedTransferStates[transferMessage2.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData2.message.value.id], Enum.Transfers.TransferState.COMMITTED) test.end() }) processPositionFulfilBinTest.test('should process a bin of position-reserve messages', async (test) => { + const accumulatedTransferStates = { + [transferTestData3.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData4.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData3.message.value.id]: transferTestData3.transferInfo, + [transferTestData4.message.value.id]: transferTestData4.transferInfo + } + const reservedActionTransfers = { + [transferTestData3.message.value.id]: transferTestData3.reservedActionTransferInfo, + [transferTestData4.message.value.id]: transferTestData4.reservedActionTransferInfo + } // Call the function const result = await processPositionFulfilBin( [[], reserveBinItems], - 0, - 0, - { - '780a1e7c-f01e-47a4-8538-1a27fb690627': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL - }, - { - '780a1e7c-f01e-47a4-8538-1a27fb690627': { - amount: 2.00 - }, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': { - amount: 2.00 - } - }, { - '780a1e7c-f01e-47a4-8538-1a27fb690627': { - transferId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - amount: 2.00, - currencyId: 'USD', - ilpCondition: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - expirationDate: '2023-08-21T10:22:11.481Z', - createdDate: '2023-08-21T10:22:11.481Z', - completedTimestamp: '2023-08-21T10:22:11.481Z', - transferStateEnumeration: 'COMMITED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - extensionList: [] - }, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': { - transferId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - amount: 2.00, - currencyId: 'USD', - ilpCondition: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - expirationDate: '2023-08-21T10:22:11.481Z', - createdDate: '2023-08-21T10:22:11.481Z', - completedTimestamp: '2023-08-21T10:22:11.481Z', - transferStateEnumeration: 'COMMITED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - extensionList: [] - } + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers } ) @@ -487,91 +323,57 @@ Test('Fulfil domain', processPositionFulfilBinTest => { test.equal(result.notifyMessages.length, 2) test.equal(result.accumulatedPositionValue, 4) test.equal(result.accumulatedPositionReservedValue, 0) - test.deepEqual(result.accumulatedTransferStateChanges, [ - { - transferId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - transferStateId: 'COMMITTED', - reason: undefined - }, - { - transferId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - transferStateId: 'COMMITTED', - reason: undefined - } - ]) - test.deepEqual(result.accumulatedTransferStates, { - '780a1e7c-f01e-47a4-8538-1a27fb690627': 'COMMITTED', - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': 'COMMITTED' - }) - console.log(result.accumulatedTransferStates) - test.equal(result.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData3.message.value.id) + test.equal(result.accumulatedTransferStateChanges[1].transferId, transferTestData4.message.value.id) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData3.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData3.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferTestData3.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData3.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[0].value, 2) - test.equal(result.accumulatedTransferStates[transferMessage3.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData3.message.value.id], Enum.Transfers.TransferState.COMMITTED) - test.equal(result.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData4.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData4.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferTestData4.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData4.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[1].value, 4) - test.equal(result.accumulatedTransferStates[transferMessage4.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData4.message.value.id], Enum.Transfers.TransferState.COMMITTED) test.end() }) processPositionFulfilBinTest.test('should process a bin of position-reserve and position-commit messages', async (test) => { + const accumulatedTransferStates = { + [transferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData3.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData4.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData1.message.value.id]: transferTestData1.transferInfo, + [transferTestData2.message.value.id]: transferTestData2.transferInfo, + [transferTestData3.message.value.id]: transferTestData3.transferInfo, + [transferTestData4.message.value.id]: transferTestData4.transferInfo + } + const reservedActionTransfers = { + [transferTestData3.message.value.id]: transferTestData3.reservedActionTransferInfo, + [transferTestData4.message.value.id]: transferTestData4.reservedActionTransferInfo + } // Call the function const result = await processPositionFulfilBin( [commitBinItems, reserveBinItems], - 0, - 0, - { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, - '780a1e7c-f01e-47a4-8538-1a27fb690627': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': Enum.Transfers.TransferInternalState.RECEIVED_FULFIL - }, { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': { - amount: 2.00 - }, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': { - amount: 2.00 - }, - '780a1e7c-f01e-47a4-8538-1a27fb690627': { - amount: 2.00 - }, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': { - amount: 2.00 - } - }, - { - '780a1e7c-f01e-47a4-8538-1a27fb690627': { - transferId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - amount: 2.00, - currencyId: 'USD', - ilpCondition: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - expirationDate: '2023-08-21T10:22:11.481Z', - createdDate: '2023-08-21T10:22:11.481Z', - completedTimestamp: '2023-08-21T10:22:11.481Z', - transferStateEnumeration: 'COMMITED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - extensionList: [] - }, - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': { - transferId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - amount: 2.00, - currencyId: 'USD', - ilpCondition: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - expirationDate: '2023-08-21T10:22:11.481Z', - createdDate: '2023-08-21T10:22:11.481Z', - completedTimestamp: '2023-08-21T10:22:11.481Z', - transferStateEnumeration: 'COMMITED', - fulfilment: 'lnYe4rYwLthWbzhVyX5cAuDfL1Ulw4WdaTgyGDREysw', - extensionList: [] - } + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers } ) @@ -579,110 +381,285 @@ Test('Fulfil domain', processPositionFulfilBinTest => { test.equal(result.notifyMessages.length, 4) test.equal(result.accumulatedPositionValue, 8) test.equal(result.accumulatedPositionReservedValue, 0) - test.deepEqual(result.accumulatedTransferStateChanges, [ - { - transferId: '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f', - transferStateId: 'COMMITTED', - reason: undefined - }, - { - transferId: '4830fa00-0c2a-4de1-9640-5ad4e68f5f62', - transferStateId: 'COMMITTED', - reason: undefined - }, - { - transferId: '780a1e7c-f01e-47a4-8538-1a27fb690627', - transferStateId: 'COMMITTED', - reason: undefined - }, - { - transferId: '0a4834e7-7e4c-47e8-8dcb-f3f68031d377', - transferStateId: 'COMMITTED', - reason: undefined - } - ]) - test.deepEqual(result.accumulatedTransferStates, { - '780a1e7c-f01e-47a4-8538-1a27fb690627': 'COMMITTED', - '0a4834e7-7e4c-47e8-8dcb-f3f68031d377': 'COMMITTED', - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': 'COMMITTED', - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': 'COMMITTED' - }) - console.log(result.accumulatedPositionChanges) - test.equal(result.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData1.message.value.id) + test.equal(result.accumulatedTransferStateChanges[1].transferId, transferTestData2.message.value.id) + test.equal(result.accumulatedTransferStateChanges[2].transferId, transferTestData3.message.value.id) + test.equal(result.accumulatedTransferStateChanges[3].transferId, transferTestData4.message.value.id) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[2].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[3].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData1.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData1.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferTestData1.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData1.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[0].value, 2) - test.equal(result.accumulatedTransferStates[transferMessage1.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData1.message.value.id], Enum.Transfers.TransferState.COMMITTED) - test.equal(result.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData2.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData2.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferTestData2.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData2.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[1].value, 4) - test.equal(result.accumulatedTransferStates[transferMessage2.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData2.message.value.id], Enum.Transfers.TransferState.COMMITTED) - test.equal(result.notifyMessages[2].message.content.headers.accept, transferMessage1.value.content.headers.accept) - test.equal(result.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[2].message.content.headers['fspiop-source'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[2].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) + test.equal(result.notifyMessages[2].message.content.headers.accept, transferTestData3.message.value.content.headers.accept) + test.equal(result.notifyMessages[2].message.content.headers['fspiop-destination'], transferTestData3.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[2].message.content.headers['fspiop-source'], transferTestData3.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[2].message.content.headers['content-type'], transferTestData3.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[2].value, 6) - test.equal(result.accumulatedTransferStates[transferMessage3.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData3.message.value.id], Enum.Transfers.TransferState.COMMITTED) - test.equal(result.notifyMessages[3].message.content.headers.accept, transferMessage2.value.content.headers.accept) - test.equal(result.notifyMessages[3].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-destination']) - test.equal(result.notifyMessages[3].message.content.headers['fspiop-source'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[3].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) + test.equal(result.notifyMessages[3].message.content.headers.accept, transferTestData4.message.value.content.headers.accept) + test.equal(result.notifyMessages[3].message.content.headers['fspiop-destination'], transferTestData4.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[3].message.content.headers['fspiop-source'], transferTestData4.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[3].message.content.headers['content-type'], transferTestData4.message.value.content.headers['content-type']) test.equal(result.accumulatedPositionChanges[3].value, 8) - test.equal(result.accumulatedTransferStates[transferMessage4.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData4.message.value.id], Enum.Transfers.TransferState.COMMITTED) test.end() }) processPositionFulfilBinTest.test('should abort if fulfil is incorrect state', async (test) => { + const accumulatedTransferStates = { + [transferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.INVALID, + [transferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.INVALID + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData1.message.value.id]: transferTestData1.transferInfo, + [transferTestData2.message.value.id]: transferTestData2.transferInfo + } // Call the function const result = await processPositionFulfilBin( [commitBinItems, []], - 0, - 0, { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': Enum.Transfers.TransferInternalState.INVALID, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': Enum.Transfers.TransferInternalState.INVALID - }, + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [] + } + ) + + // Assert the expected results + test.equal(result.notifyMessages.length, 2) + test.equal(result.accumulatedPositionValue, 0) + test.equal(result.accumulatedPositionReservedValue, 0) + test.equal(result.accumulatedTransferStateChanges.length, 0) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData1.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData1.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData1.message.value.content.headers['content-type']) + test.equal(result.accumulatedTransferStates[transferTestData1.message.value.id], Enum.Transfers.TransferInternalState.INVALID) + + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData2.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData2.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData2.message.value.content.headers['content-type']) + test.equal(result.accumulatedTransferStates[transferTestData2.message.value.id], Enum.Transfers.TransferInternalState.INVALID) + + test.end() + }) + + processPositionFulfilBinTest.test('should abort if some fulfil messages are in incorrect state', async (test) => { + const accumulatedTransferStates = { + [transferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.INVALID, + [transferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData1.message.value.id]: transferTestData1.transferInfo, + [transferTestData2.message.value.id]: transferTestData2.transferInfo + } + // Call the function + const result = await processPositionFulfilBin( + [commitBinItems, []], { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': { - amount: 2.00 - }, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': { - amount: 2.00 - } + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [] + } + ) + + // Assert the expected results + test.equal(result.notifyMessages.length, 2) + test.equal(result.accumulatedPositionValue, 2) + test.equal(result.accumulatedPositionReservedValue, 0) + test.equal(result.accumulatedTransferStateChanges.length, 1) + test.equal(result.accumulatedPositionChanges.length, 1) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData2.message.value.id) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData1.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData1.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData1.message.value.content.headers['content-type']) + test.equal(result.accumulatedTransferStates[transferTestData1.message.value.id], Enum.Transfers.TransferInternalState.INVALID) + + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData2.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData2.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferTestData2.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData2.message.value.content.headers['content-type']) + test.equal(result.accumulatedTransferStates[transferTestData2.message.value.id], Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.accumulatedPositionChanges[0].value, 2) + + test.end() + }) + + processPositionFulfilBinTest.test('should skip position changes if changePosition is false', async (test) => { + const accumulatedTransferStates = { + [transferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData1.message.value.id]: transferTestData1.transferInfo, + [transferTestData2.message.value.id]: transferTestData2.transferInfo + } + // Call the function + const result = await processPositionFulfilBin( + [commitBinItems, []], + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [], + changePositions: false } ) // Assert the expected results test.equal(result.notifyMessages.length, 2) test.equal(result.accumulatedPositionValue, 0) + test.equal(result.accumulatedTransferStateChanges.length, 2) + test.equal(result.accumulatedPositionChanges.length, 0) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData1.message.value.id) + test.equal(result.accumulatedTransferStateChanges[1].transferId, transferTestData2.message.value.id) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData1.message.value.id], Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStates[transferTestData2.message.value.id], Enum.Transfers.TransferState.COMMITTED) + + test.end() + }) + + // FX tests + + processPositionFulfilBinTest.test('should process a bin of position-commit messages involved in fx transfers', async (test) => { + const accumulatedTransferStates = { + [transferTestData5.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData6.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData5.message.value.id]: transferTestData5.transferInfo, + [transferTestData6.message.value.id]: transferTestData6.transferInfo + } + // Call the function + const result = await processPositionFulfilBin( + [commitWithFxBinItems, []], + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [] + } + ) + + // Assert the expected results + test.equal(result.notifyMessages.length, 2) + test.equal(result.followupMessages.length, 2) + test.equal(result.accumulatedPositionValue, 20) test.equal(result.accumulatedPositionReservedValue, 0) - test.deepEqual(result.accumulatedTransferStateChanges, []) - test.deepEqual(result.accumulatedTransferStates, + test.equal(result.accumulatedTransferStateChanges.length, 0) + test.equal(result.accumulatedFxTransferStateChanges.length, 2) + + test.equal(result.accumulatedFxTransferStateChanges[0].commitRequestId, transferTestData5.message.value.content.context.cyrilResult.positionChanges[0].commitRequestId) + test.equal(result.accumulatedFxTransferStateChanges[1].commitRequestId, transferTestData6.message.value.content.context.cyrilResult.positionChanges[0].commitRequestId) + test.equal(result.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.followupMessages[0].message.content.context.cyrilResult.isFx, true) + test.ok(result.followupMessages[0].message.content.context.cyrilResult.positionChanges[0].isDone) + test.notOk(result.followupMessages[0].message.content.context.cyrilResult.positionChanges[1].isDone) + test.equal(result.followupMessages[0].messageKey, '101') + test.equal(result.accumulatedPositionChanges[0].value, 10) + test.equal(result.accumulatedTransferStates[transferTestData5.message.value.id], Enum.Transfers.TransferInternalState.RECEIVED_FULFIL) + + test.equal(result.followupMessages[1].message.content.context.cyrilResult.isFx, true) + test.ok(result.followupMessages[1].message.content.context.cyrilResult.positionChanges[0].isDone) + test.notOk(result.followupMessages[1].message.content.context.cyrilResult.positionChanges[1].isDone) + test.equal(result.followupMessages[1].messageKey, '101') + test.equal(result.accumulatedPositionChanges[1].value, 20) + test.equal(result.accumulatedTransferStates[transferTestData5.message.value.id], Enum.Transfers.TransferInternalState.RECEIVED_FULFIL) + + test.end() + }) + + processPositionFulfilBinTest.test('should process a bin of position-commit partial processed messages involved in fx transfers', async (test) => { + const accumulatedTransferStates = { + [transferTestData7.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL, + [transferTestData8.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL + } + const accumulatedFxTransferStates = {} + const transferInfoList = { + [transferTestData7.message.value.id]: transferTestData7.transferInfo, + [transferTestData8.message.value.id]: transferTestData8.transferInfo + } + // Call the function + const result = await processPositionFulfilBin( + [commitWithPartiallyProcessedFxBinItems, []], { - '68c8aa25-fe5b-4b1f-a0ab-ab890fe3ae7f': Enum.Transfers.TransferInternalState.INVALID, - '4830fa00-0c2a-4de1-9640-5ad4e68f5f62': Enum.Transfers.TransferInternalState.INVALID + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates, + accumulatedFxTransferStates, + transferInfoList, + reservedActionTransfers: [] } ) - test.equal(result.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) - test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) - test.equal(result.accumulatedTransferStates[transferMessage1.value.id], Enum.Transfers.TransferInternalState.INVALID) - - console.log(transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) - test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) - test.equal(result.accumulatedTransferStates[transferMessage2.value.id], Enum.Transfers.TransferInternalState.INVALID) + // Assert the expected results + test.equal(result.notifyMessages.length, 2) + test.equal(result.followupMessages.length, 0) + test.equal(result.accumulatedPositionValue, 4) + test.equal(result.accumulatedPositionReservedValue, 0) + test.equal(result.accumulatedTransferStateChanges.length, 2) + test.equal(result.accumulatedFxTransferStateChanges.length, 0) + + test.equal(result.accumulatedTransferStateChanges[0].transferId, transferTestData7.message.value.content.context.cyrilResult.positionChanges[1].transferId) + test.equal(result.accumulatedTransferStateChanges[1].transferId, transferTestData8.message.value.content.context.cyrilResult.positionChanges[1].transferId) + test.equal(result.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.COMMITTED) + test.equal(result.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[0].message.content.headers.accept, transferTestData7.message.value.content.headers.accept) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-destination'], transferTestData7.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[0].message.content.headers['fspiop-source'], transferTestData7.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[0].message.content.headers['content-type'], transferTestData7.message.value.content.headers['content-type']) + test.equal(result.accumulatedPositionChanges[0].value, 2) + test.equal(result.accumulatedTransferStates[transferTestData7.message.value.id], Enum.Transfers.TransferState.COMMITTED) + + test.equal(result.notifyMessages[1].message.content.headers.accept, transferTestData8.message.value.content.headers.accept) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-destination'], transferTestData8.message.value.content.headers['fspiop-destination']) + test.equal(result.notifyMessages[1].message.content.headers['fspiop-source'], transferTestData8.message.value.content.headers['fspiop-source']) + test.equal(result.notifyMessages[1].message.content.headers['content-type'], transferTestData8.message.value.content.headers['content-type']) + test.equal(result.accumulatedPositionChanges[1].value, 4) + test.equal(result.accumulatedTransferStates[transferTestData8.message.value.id], Enum.Transfers.TransferState.COMMITTED) test.end() }) diff --git a/test/unit/domain/position/fx-fulfil.test.js b/test/unit/domain/position/fx-fulfil.test.js new file mode 100644 index 000000000..81f2ac3b1 --- /dev/null +++ b/test/unit/domain/position/fx-fulfil.test.js @@ -0,0 +1,200 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const { Enum } = require('@mojaloop/central-services-shared') +const Sinon = require('sinon') +const { processPositionFxFulfilBin } = require('../../../../src/domain/position/fx-fulfil') +const { randomUUID } = require('crypto') +const Config = require('../../../../src/lib/config') + +const constructFxTransferCallbackTestData = (initiatingFsp, counterPartyFsp) => { + const commitRequestId = randomUUID() + const payload = { + fulfilment: 'WLctttbu2HvTsa1XWvUoGRcQozHsqeu9Ahl2JW9Bsu8', + completedTimestamp: '2024-04-19T14:06:08.936Z', + conversionState: 'RESERVED' + } + const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64') + return { + decodedPayload: payload, + message: { + value: { + from: counterPartyFsp, + to: initiatingFsp, + id: commitRequestId, + content: { + uriParams: { + id: commitRequestId + }, + headers: { + host: 'ml-api-adapter:3000', + 'content-length': 1314, + accept: 'application/vnd.interoperability.fxTransfers+json;version=2.0', + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0', + date: '2023-08-17T15:25:08.000Z', + 'fspiop-destination': initiatingFsp, + 'fspiop-source': counterPartyFsp, + traceparent: '00-e11ece8cc6ca3dc170a8ab693910d934-25d85755f1bc6898-01', + tracestate: 'tx_end2end_start_ts=1692285908510' + }, + payload: 'data:application/vnd.interoperability.fxTransfers+json;version=2.0;base64,' + base64Payload, + context: { + cyrilResult: {} + } + }, + type: 'application/json', + metadata: { + correlationId: '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf', + event: { + type: 'position', + action: 'fx-reserve', + createdAt: '2023-08-17T15:25:08.511Z', + state: { + status: 'success', + code: 0, + description: 'action successful' + }, + id: commitRequestId + }, + trace: { + service: 'cl_fx_transfer_fulfil', + traceId: 'e11ece8cc6ca3dc170a8ab693910d934', + spanId: '1a2c4baf99bdb2c6', + sampled: 1, + flags: '01', + parentSpanId: '3c5863bb3c2b4ecc', + startTimestamp: '2023-08-17T15:25:08.860Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiIxYTJjNGJhZjk5YmRiMmM2IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4NTEwIn0=,tx_end2end_start_ts=1692285908510', + transactionType: 'transfer', + transactionAction: 'fx-reserve', + transactionId: '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf', + source: counterPartyFsp, + destination: initiatingFsp, + initiatingFsp, + counterPartyFsp + }, + tracestates: { + acmevendor: { + spanId: '1a2c4baf99bdb2c6', + timeApiPrepare: '1692285908510' + }, + tx_end2end_start_ts: '1692285908510' + } + }, + 'protocol.createdAt': 1692285908866 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position-batch', + offset: 4070, + partition: 0, + timestamp: 1694175690401 + } + } +} + +const fxTransferCallbackTestData1 = constructFxTransferCallbackTestData('perffsp1', 'perffsp2') +const fxTransferCallbackTestData2 = constructFxTransferCallbackTestData('perffsp2', 'perffsp1') +const fxTransferCallbackTestData3 = constructFxTransferCallbackTestData('perffsp1', 'perffsp2') + +const span = {} +const reserveBinItems = [{ + message: fxTransferCallbackTestData1.message, + span, + decodedPayload: fxTransferCallbackTestData1.decodedPayload +}, +{ + message: fxTransferCallbackTestData2.message, + span, + decodedPayload: fxTransferCallbackTestData2.decodedPayload +}, +{ + message: fxTransferCallbackTestData3.message, + span, + decodedPayload: fxTransferCallbackTestData3.decodedPayload +}] +Test('Fx Fulfil domain', processPositionFxFulfilBinTest => { + let sandbox + + processPositionFxFulfilBinTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + t.end() + }) + + processPositionFxFulfilBinTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + processPositionFxFulfilBinTest.test('should process a bin of position-commit messages', async (test) => { + const accumulatedFxTransferStates = { + [fxTransferCallbackTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT, + [fxTransferCallbackTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT, + [fxTransferCallbackTestData3.message.value.id]: 'INVALID_STATE' + } + // Call the function + const processedMessages = await processPositionFxFulfilBin( + reserveBinItems, + { accumulatedFxTransferStates } + ) + + // Assert the expected results + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, fxTransferCallbackTestData1.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTransferCallbackTestData1.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], fxTransferCallbackTestData1.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTransferCallbackTestData1.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferCallbackTestData1.message.value.id], Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT) + + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, fxTransferCallbackTestData2.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTransferCallbackTestData2.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], fxTransferCallbackTestData2.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTransferCallbackTestData2.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferCallbackTestData2.message.value.id], Enum.Transfers.TransferInternalState.RECEIVED_FULFIL_DEPENDENT) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, fxTransferCallbackTestData3.message.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, fxTransferCallbackTestData3.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], fxTransferCallbackTestData3.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], fxTransferCallbackTestData3.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferCallbackTestData3.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges.length, 1) + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTransferCallbackTestData3.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.end() + }) + + processPositionFxFulfilBinTest.end() +}) diff --git a/test/unit/domain/position/fx-prepare.test.js b/test/unit/domain/position/fx-prepare.test.js new file mode 100644 index 000000000..cc449b9b7 --- /dev/null +++ b/test/unit/domain/position/fx-prepare.test.js @@ -0,0 +1,552 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const { Enum } = require('@mojaloop/central-services-shared') +const Sinon = require('sinon') +const { processFxPositionPrepareBin } = require('../../../../src/domain/position/fx-prepare') +const Logger = require('@mojaloop/central-services-logger') +const { randomUUID } = require('crypto') +const Config = require('../../../../src/lib/config') + +const constructFxTransferTestData = (initiatingFsp, counterPartyFsp, sourceAmount, sourceCurrency, targetAmount, targetCurrency) => { + const commitRequestId = randomUUID() + const determiningTransferId = randomUUID() + const payload = { + commitRequestId, + determiningTransferId, + initiatingFsp, + counterPartyFsp, + sourceAmount: { + currency: sourceCurrency, + amount: sourceAmount + }, + targetAmount: { + currency: targetCurrency, + amount: targetAmount + }, + condition: 'GRzLaTP7DJ9t4P-a_BA0WA9wzzlsugf00-Tn6kESAfM', + expiration: '2024-04-19T14:06:08.936Z' + } + const base64Payload = Buffer.from(JSON.stringify(payload)).toString('base64') + return { + decodedPayload: payload, + message: { + value: { + from: initiatingFsp, + to: counterPartyFsp, + id: commitRequestId, + content: { + uriParams: { + id: commitRequestId + }, + headers: { + host: 'ml-api-adapter:3000', + 'content-length': 1314, + accept: 'application/vnd.interoperability.fxTransfers+json;version=2.0', + 'content-type': 'application/vnd.interoperability.fxTransfers+json;version=2.0', + date: '2023-08-17T15:25:08.000Z', + 'fspiop-destination': counterPartyFsp, + 'fspiop-source': initiatingFsp, + traceparent: '00-e11ece8cc6ca3dc170a8ab693910d934-25d85755f1bc6898-01', + tracestate: 'tx_end2end_start_ts=1692285908510' + }, + payload: 'data:application/vnd.interoperability.fxTransfers+json;version=2.0;base64,' + base64Payload, + context: { + cyrilResult: { + participantName: initiatingFsp, + currencyId: sourceCurrency, + amount: sourceAmount + } + } + }, + type: 'application/json', + metadata: { + correlationId: '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf', + event: { + type: 'position', + action: 'fx-prepare', + createdAt: '2023-08-17T15:25:08.511Z', + state: { + status: 'success', + code: 0, + description: 'action successful' + }, + id: commitRequestId + }, + trace: { + service: 'cl_fx_transfer_prepare', + traceId: 'e11ece8cc6ca3dc170a8ab693910d934', + spanId: '1a2c4baf99bdb2c6', + sampled: 1, + flags: '01', + parentSpanId: '3c5863bb3c2b4ecc', + startTimestamp: '2023-08-17T15:25:08.860Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiIxYTJjNGJhZjk5YmRiMmM2IiwidGltZUFwaVByZXBhcmUiOiIxNjkyMjg1OTA4NTEwIn0=,tx_end2end_start_ts=1692285908510', + transactionType: 'transfer', + transactionAction: 'fx-prepare', + transactionId: '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf', + source: initiatingFsp, + destination: counterPartyFsp, + initiatingFsp, + counterPartyFsp + }, + tracestates: { + acmevendor: { + spanId: '1a2c4baf99bdb2c6', + timeApiPrepare: '1692285908510' + }, + tx_end2end_start_ts: '1692285908510' + } + }, + 'protocol.createdAt': 1692285908866 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position-batch', + offset: 4070, + partition: 0, + timestamp: 1694175690401 + } + } +} + +const sourceAmount = 5 +const fxTransferTestData1 = constructFxTransferTestData('perffsp1', 'perffsp2', sourceAmount.toString(), 'USD', '50', 'XXX') +const fxTransferTestData2 = constructFxTransferTestData('perffsp1', 'perffsp2', sourceAmount.toString(), 'USD', '50', 'XXX') +const fxTransferTestData3 = constructFxTransferTestData('perffsp1', 'perffsp2', sourceAmount.toString(), 'USD', '50', 'XXX') + +const span = {} +const binItems = [{ + message: fxTransferTestData1.message, + span, + decodedPayload: fxTransferTestData1.decodedPayload +}, +{ + message: fxTransferTestData2.message, + span, + decodedPayload: fxTransferTestData2.decodedPayload +}, +{ + message: fxTransferTestData3.message, + span, + decodedPayload: fxTransferTestData3.decodedPayload +}] + +Test('FX Prepare domain', positionIndexTest => { + let sandbox + + positionIndexTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + t.end() + }) + + positionIndexTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + positionIndexTest.test('processFxPositionPrepareBin should', changeParticipantPositionTest => { + changeParticipantPositionTest.test('produce abort message for transfers not in the right transfer state', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 900, // Participant limit value + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -1000, // Settlement participant position value + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, fxTransferTestData1.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTransferTestData1.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], fxTransferTestData1.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTransferTestData1.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData1.message.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, fxTransferTestData2.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTransferTestData2.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], fxTransferTestData2.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTransferTestData2.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData2.message.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, fxTransferTestData3.message.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, fxTransferTestData3.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], fxTransferTestData3.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], fxTransferTestData3.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData3.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTransferTestData1.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTransferTestData2.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].commitRequestId, fxTransferTestData3.message.value.id) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedPositionValue, sourceAmount * 2) + test.end() + }) + + changeParticipantPositionTest.test('produce abort message for when payer does not have enough liquidity', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 0, // Set low + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: 0, // No accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: 0, // Settlement participant position value + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + + test.equal(processedMessages.notifyMessages[0].message.content.uriParams.id, fxTransferTestData1.message.value.id) + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, fxTransferTestData1.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTransferTestData1.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTransferTestData1.message.value.content.headers['content-type']) + + test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorCode, '4001') + test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorDescription, 'Payer FSP insufficient liquidity') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData1.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.notifyMessages[1].message.content.uriParams.id, fxTransferTestData2.message.value.id) + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, fxTransferTestData2.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTransferTestData2.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTransferTestData2.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorCode, '4001') + test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorDescription, 'Payer FSP insufficient liquidity') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData2.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, fxTransferTestData3.message.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, fxTransferTestData3.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], fxTransferTestData3.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], fxTransferTestData3.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData3.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTransferTestData1.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTransferTestData2.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].commitRequestId, fxTransferTestData3.message.value.id) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedPositionValue, 0) + test.end() + }) + + changeParticipantPositionTest.test('produce abort message for when payer has reached their set payer limit', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 1000, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: 1000, // Position value has reached limit of 1000 + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -2000, // Payer has liquidity + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + + test.equal(processedMessages.notifyMessages[0].message.content.uriParams.id, fxTransferTestData1.message.value.id) + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, fxTransferTestData1.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTransferTestData1.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTransferTestData1.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorCode, '4200') + test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorDescription, 'Payer limit error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData1.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.notifyMessages[1].message.content.uriParams.id, fxTransferTestData2.message.value.id) + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, fxTransferTestData2.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTransferTestData2.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTransferTestData2.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorCode, '4200') + test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorDescription, 'Payer limit error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData2.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, fxTransferTestData3.message.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, fxTransferTestData3.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], fxTransferTestData3.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], fxTransferTestData3.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData3.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTransferTestData1.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTransferTestData2.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].commitRequestId, fxTransferTestData3.message.value.id) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + // Accumulated position value should not change from the input + test.equal(processedMessages.accumulatedPositionValue, 1000) + test.end() + }) + + changeParticipantPositionTest.test('produce reserved messages for valid transfer messages', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 10000, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -2000, // Payer has liquidity + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + + test.equal(processedMessages.accumulatedPositionChanges.length, 2) + + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, fxTransferTestData1.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTransferTestData1.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], fxTransferTestData1.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTransferTestData1.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[0].value, sourceAmount) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData1.message.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, fxTransferTestData2.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTransferTestData2.message.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], fxTransferTestData2.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTransferTestData2.message.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[1].value, sourceAmount * 2) + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData2.message.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, fxTransferTestData3.message.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, fxTransferTestData3.message.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], fxTransferTestData3.message.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], fxTransferTestData3.message.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') + test.equal(processedMessages.accumulatedFxTransferStates[fxTransferTestData3.message.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTransferTestData1.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTransferTestData2.message.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].commitRequestId, fxTransferTestData3.message.value.id) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[2].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedPositionValue, sourceAmount * 2) + test.end() + }) + + changeParticipantPositionTest.test('produce proper limit alarms', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: sourceAmount * 2, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -sourceAmount * 2, + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.limitAlarms.length, 2) + test.equal(processedMessages.accumulatedPositionValue, sourceAmount * 2) + test.end() + }) + + changeParticipantPositionTest.test('skip position changes if changePositions is false', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 10000, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const processedMessages = await processFxPositionPrepareBin( + binItems, + { + accumulatedPositionValue: -4, + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -2000, + participantLimit, + changePositions: false + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + test.equal(processedMessages.accumulatedPositionValue, -4) + test.end() + }) + + changeParticipantPositionTest.test('use targetAmount as transferAmount if cyrilResult currency equals targetAmount currency', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 10000, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const accumulatedFxTransferStates = { + [fxTransferTestData1.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData2.message.value.id]: Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + [fxTransferTestData3.message.value.id]: 'INVALID_STATE' + } + const cyrilResult = { + participantName: 'perffsp1', + currencyId: 'XXX', + amount: 50 + } + const binItemsWithModifiedCyrilResult = binItems.map(item => { + item.message.value.content.context.cyrilResult = cyrilResult + return item + }) + const processedMessages = await processFxPositionPrepareBin( + binItemsWithModifiedCyrilResult, + { + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates, + settlementParticipantPosition: -2000, + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.accumulatedPositionChanges.length, 2) + test.equal(processedMessages.accumulatedPositionChanges[0].value, 50) + test.equal(processedMessages.accumulatedPositionChanges[1].value, 100) + test.end() + }) + + changeParticipantPositionTest.end() + }) + + positionIndexTest.end() +}) diff --git a/test/unit/domain/position/fx-timeout-reserved.test.js b/test/unit/domain/position/fx-timeout-reserved.test.js new file mode 100644 index 000000000..cf8a173d7 --- /dev/null +++ b/test/unit/domain/position/fx-timeout-reserved.test.js @@ -0,0 +1,324 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Kevin Leyow + -------------- + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const { Enum } = require('@mojaloop/central-services-shared') +const Sinon = require('sinon') +const { processPositionFxTimeoutReservedBin } = require('../../../../src/domain/position/fx-timeout-reserved') + +// Fx timeout messages are still being written, use appropriate messages +const fxTimeoutMessage1 = { + value: { + from: 'perffsp1', + to: 'fxp', + id: 'd6a036a5-65a3-48af-a0c7-ee089c412ada', + content: { + uriParams: { + id: 'd6a036a5-65a3-48af-a0c7-ee089c412ada' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'fxp', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'perffsp1' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError (/home/kleyow/mojaloop/central-ledger/node_modules/@mojaloop/central-services-error-handling/src/factory.js:198:12) at CronJob.timeout (/home/kleyow/moj...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'd6a036a5-65a3-48af-a0c7-ee089c412ada', + event: { + type: 'position', + action: 'fx-timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'perffsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} +const fxTimeoutMessage2 = { + value: { + from: 'perffsp1', + to: 'fxp', + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + content: { + uriParams: { + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'fxp', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'perffsp1' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError (/home/kleyow/mojaloop/central-ledger/node_modules/@mojaloop/central-services-error-handling/src/factory.js:198:12) at CronJob.timeout (/home/kleyow/moj...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + event: { + type: 'position', + action: 'fx-timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'perffsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const span = {} +const binItems = [{ + message: fxTimeoutMessage1, + span, + decodedPayload: {} +}, +{ + message: fxTimeoutMessage2, + span, + decodedPayload: {} +}] + +Test('timeout reserved domain', positionIndexTest => { + let sandbox + + positionIndexTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + t.end() + }) + + positionIndexTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + positionIndexTest.test('processPositionFxTimeoutReservedBin should', changeParticipantPositionTest => { + changeParticipantPositionTest.test('produce abort message for transfers not in the right transfer state', async (test) => { + try { + await processPositionFxTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': 'INVALID_STATE', + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': 'INVALID_STATE' + }, + fetchedReservedPositionChangesByCommitRequestIds: {} + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + changeParticipantPositionTest.test('produce reserved messages/position changes for valid timeout messages', async (test) => { + const processedMessages = await processPositionFxTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT + }, + fetchedReservedPositionChangesByCommitRequestIds: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': { + 51: { + value: 10, + change: 10 + } + }, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': { + 51: { + value: 5, + change: 5 + } + } + } + } + ) + test.equal(processedMessages.notifyMessages.length, 2) + + test.equal(processedMessages.accumulatedPositionChanges.length, 2) + + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], fxTimeoutMessage1.value.to) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], fxTimeoutMessage1.value.from) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], fxTimeoutMessage1.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[0].value, -10) + test.equal(processedMessages.accumulatedFxTransferStates[fxTimeoutMessage1.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], fxTimeoutMessage2.value.to) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], fxTimeoutMessage1.value.from) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], fxTimeoutMessage2.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[1].value, -15) + test.equal(processedMessages.accumulatedFxTransferStates[fxTimeoutMessage2.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTimeoutMessage1.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTimeoutMessage2.value.id) + + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.accumulatedPositionValue, -15) + test.end() + }) + + changeParticipantPositionTest.test('skip position changes if changePositions is false', async (test) => { + const processedMessages = await processPositionFxTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedFxTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT + }, + fetchedReservedPositionChangesByCommitRequestIds: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': { + 51: { + value: 10 + } + }, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': { + 51: { + value: 5 + } + } + }, + changePositions: false + } + ) + test.equal(processedMessages.notifyMessages.length, 2) + test.equal(processedMessages.accumulatedPositionValue, 0) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].commitRequestId, fxTimeoutMessage1.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].commitRequestId, fxTimeoutMessage2.value.id) + test.equal(processedMessages.accumulatedFxTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedFxTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedFxTransferStates[fxTimeoutMessage1.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedFxTransferStates[fxTimeoutMessage2.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.end() + }) + + changeParticipantPositionTest.end() + }) + + positionIndexTest.end() +}) diff --git a/test/unit/domain/position/index.test.js b/test/unit/domain/position/index.test.js index ff8a5a6b6..c11661982 100644 --- a/test/unit/domain/position/index.test.js +++ b/test/unit/domain/position/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -51,6 +54,7 @@ Test('Position Service', positionIndexTest => { test.pass('Error not thrown') test.end() } catch (e) { + console.log(e) test.fail('Error Thrown') test.end() } @@ -67,6 +71,7 @@ Test('Position Service', positionIndexTest => { test.pass('Error not thrown') test.end() } catch (e) { + console.log(e) test.fail('Error Thrown') test.end() } diff --git a/test/unit/domain/position/prepare.test.js b/test/unit/domain/position/prepare.test.js index dbba431d0..a985f6b28 100644 --- a/test/unit/domain/position/prepare.test.js +++ b/test/unit/domain/position/prepare.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,6 +32,7 @@ const { Enum } = require('@mojaloop/central-services-shared') const Sinon = require('sinon') const { processPositionPrepareBin } = require('../../../../src/domain/position/prepare') const Logger = require('@mojaloop/central-services-logger') +const Config = require('../../../../src/lib/config') // Each transfer is for $2.00 USD const transferMessage1 = { @@ -323,32 +327,19 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', - isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: 'USD', - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 - } const processedMessages = await processPositionPrepareBin( binItems, - 0, // Accumulated position value - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - -1000, // Settlement participant position value - settlementModel, - participantLimit + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: -1000, // Settlement participant position value + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -367,7 +358,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') @@ -395,32 +386,19 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', - isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: 'USD', - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 - } const processedMessages = await processPositionPrepareBin( binItems, - 0, // No accumulated position value - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - 0, // Settlement participant position value - settlementModel, - participantLimit + accumulatedPositionValue: 0, // No accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: 0, // Settlement participant position value + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -429,7 +407,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[0].message.content.uriParams.id, transferMessage1.value.id) test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorCode, '4001') @@ -439,7 +417,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[1].message.content.uriParams.id, transferMessage2.value.id) test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorCode, '4001') test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorDescription, 'Payer FSP insufficient liquidity') @@ -448,7 +426,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') @@ -476,32 +454,19 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', - isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: 'USD', - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 - } const processedMessages = await processPositionPrepareBin( binItems, - 1000, // Position value has reached limit of 1000 - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - -2000, // Payer has liquidity - settlementModel, - participantLimit + accumulatedPositionValue: 1000, // Position value has reached limit of 1000 + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: -2000, // Payer has liquidity + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -510,7 +475,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[0].message.content.uriParams.id, transferMessage1.value.id) test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorCode, '4200') test.equal(processedMessages.notifyMessages[0].message.content.payload.errorInformation.errorDescription, 'Payer limit error') @@ -519,7 +484,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[1].message.content.uriParams.id, transferMessage2.value.id) test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorCode, '4200') test.equal(processedMessages.notifyMessages[1].message.content.payload.errorInformation.errorDescription, 'Payer limit error') @@ -528,7 +493,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') @@ -557,32 +522,19 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', - isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: 'USD', - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 - } const processedMessages = await processPositionPrepareBin( binItems, - -4, // Accumulated position value - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - 0, // Settlement participant position value - settlementModel, - participantLimit + accumulatedPositionValue: -4, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: 0, // Settlement participant position value + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -606,7 +558,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') @@ -624,7 +576,7 @@ Test('Prepare domain', positionIndexTest => { test.end() }) - changeParticipantPositionTest.test('produce reserved messages for valid transfer messages with default settlement model', async (test) => { + changeParticipantPositionTest.test('produce reserved messages for valid transfer messages related to fx transfers', async (test) => { const participantLimit = { participantCurrencyId: 1, participantLimitTypeId: 1, @@ -634,32 +586,91 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', + + // Modifying first transfer message to contain a context object with cyrilResult so that it is considered an FX transfer + const binItemsCopy = JSON.parse(JSON.stringify(binItems)) + binItemsCopy[0].message.value.content.context = { + cyrilResult: { + amount: 10 + } + } + const processedMessages = await processPositionPrepareBin( + binItemsCopy, + { + accumulatedPositionValue: -20, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: 0, // Settlement participant position value + participantLimit + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + + test.equal(processedMessages.accumulatedPositionChanges.length, 2) + + test.equal(processedMessages.notifyMessages[0].message.content.headers.accept, transferMessage1.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], transferMessage1.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], transferMessage1.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], transferMessage1.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[0].value, -10) + test.equal(processedMessages.accumulatedTransferStates[transferMessage1.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[1].message.content.headers.accept, transferMessage2.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], transferMessage2.value.content.headers['fspiop-destination']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], transferMessage2.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], transferMessage2.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[1].value, -8) + test.equal(processedMessages.accumulatedTransferStates[transferMessage2.value.id], Enum.Transfers.TransferState.RESERVED) + + test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) + test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) + test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') + test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') + test.equal(processedMessages.accumulatedTransferStates[transferMessage3.value.id], Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferId, transferMessage1.value.id) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferId, transferMessage2.value.id) + test.equal(processedMessages.accumulatedTransferStateChanges[2].transferId, transferMessage3.value.id) + + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferState.RESERVED) + test.equal(processedMessages.accumulatedTransferStateChanges[2].transferStateId, Enum.Transfers.TransferInternalState.ABORTED_REJECTED) + + test.equal(processedMessages.accumulatedPositionValue, -8) + test.end() + }) + + changeParticipantPositionTest.test('produce reserved messages for valid transfer messages with default settlement model', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 10000, isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: null, // Default settlement model is null currencyId - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 } const processedMessages = await processPositionPrepareBin( binItems, - -4, - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - 0, - settlementModel, - participantLimit + accumulatedPositionValue: -4, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: 0, + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -682,7 +693,7 @@ Test('Prepare domain', positionIndexTest => { test.equal(processedMessages.notifyMessages[2].message.content.uriParams.id, transferMessage3.value.id) test.equal(processedMessages.notifyMessages[2].message.content.headers.accept, transferMessage3.value.content.headers.accept) test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-destination'], transferMessage3.value.content.headers['fspiop-source']) - test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Enum.Http.Headers.FSPIOP.SWITCH.value) + test.equal(processedMessages.notifyMessages[2].message.content.headers['fspiop-source'], Config.HUB_NAME) test.equal(processedMessages.notifyMessages[2].message.content.headers['content-type'], transferMessage3.value.content.headers['content-type']) test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorCode, '2001') test.equal(processedMessages.notifyMessages[2].message.content.payload.errorInformation.errorDescription, 'Internal server error') @@ -710,32 +721,19 @@ Test('Prepare domain', positionIndexTest => { participantLimitId: 1, thresholdAlarmPercentage: 0.5 } - const settlementModel = { - settlementModelId: 1, - name: 'DEFERREDNET', - isActive: 1, - settlementGranularityId: 2, - settlementInterchangeId: 2, - settlementDelayId: 2, // 1 Immediate, 2 Deferred - currencyId: null, // Default settlement model is null currencyId - requireLiquidityCheck: 1, - ledgerAccountTypeId: 1, // 1 Position, 2 Settlement - autoPositionReset: 1, - adjustPosition: 0, - settlementAccountTypeId: 2 - } const processedMessages = await processPositionPrepareBin( binItems, - 0, - 0, { - '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, - '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' - }, - -4, - settlementModel, - participantLimit + accumulatedPositionValue: 0, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: -4, + participantLimit + } ) Logger.isInfoEnabled && Logger.info(processedMessages) test.equal(processedMessages.notifyMessages.length, 3) @@ -744,6 +742,38 @@ Test('Prepare domain', positionIndexTest => { test.end() }) + changeParticipantPositionTest.test('skip position changes if changePosition is false', async (test) => { + const participantLimit = { + participantCurrencyId: 1, + participantLimitTypeId: 1, + value: 10000, + isActive: 1, + createdBy: 'unknown', + participantLimitId: 1, + thresholdAlarmPercentage: 0.5 + } + const processedMessages = await processPositionPrepareBin( + binItems, + { + accumulatedPositionValue: -4, + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + '1cf6981b-25d8-4bd7-b9d9-b1c0fc8cdeaf': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '6c2c09c3-19b6-48ba-becc-cbdffcaadd7e': Enum.Transfers.TransferInternalState.RECEIVED_PREPARE, + '5dff336f-62c0-4619-92c6-9ccd7c8f0369': 'INVALID_STATE' + }, + settlementParticipantPosition: 0, + participantLimit, + changePositions: false + } + ) + Logger.isInfoEnabled && Logger.info(processedMessages) + test.equal(processedMessages.notifyMessages.length, 3) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + test.equal(processedMessages.accumulatedPositionValue, -4) + test.end() + }) + changeParticipantPositionTest.end() }) diff --git a/test/unit/domain/position/sampleBins.js b/test/unit/domain/position/sampleBins.js index 30cc2811d..1e914e22d 100644 --- a/test/unit/domain/position/sampleBins.js +++ b/test/unit/domain/position/sampleBins.js @@ -668,6 +668,84 @@ module.exports = { }, span: {} } + ], + 'timeout-reserved': [ + { + message: { + value: { + from: 'payerFsp69185571', + to: 'payeeFsp69186326', + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + content: { + uriParams: { + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'FSPIOP-Destination': 'payerFsp69185571', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'FSPIOP-Source': 'switch' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError (/home/kleyow/mojaloop/central-ledger/node_modules/@mojaloop/central-services-error-handling/src/factory.js:198:12) at CronJob.timeout (/home/kleyow/moj...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + event: { + type: 'position', + action: 'timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'payerFsp69185571' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 7, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 + }, + span: {} + } ] }, 15: { @@ -1096,6 +1174,84 @@ module.exports = { }, span: {} } + ], + 'fx-timeout-reserved': [ + { + message: { + value: { + from: 'perffsp2', + to: 'fxp', + id: 'ed6848e0-e2a8-45b0-9f98-59a2ffba8c10', + content: { + uriParams: { + id: 'ed6848e0-e2a8-45b0-9f98-59a2ffba8c10' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'fxp', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'perffsp2' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError (/home/kleyow/mojaloop/central-ledger/node_modules/@mojaloop/central-services-error-handling/src/factory.js:198:12) at CronJob.timeout (/home/kleyow/moj...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'd6a036a5-65a3-48af-a0c7-ee089c412ada', + event: { + type: 'position', + action: 'fx-timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'perffsp2' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 15, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 + }, + span: {} + } ] } } diff --git a/test/unit/domain/position/timeout-reserved.test.js b/test/unit/domain/position/timeout-reserved.test.js new file mode 100644 index 000000000..182b18c29 --- /dev/null +++ b/test/unit/domain/position/timeout-reserved.test.js @@ -0,0 +1,312 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Kevin Leyow + -------------- + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const { Enum } = require('@mojaloop/central-services-shared') +const Sinon = require('sinon') +const { processPositionTimeoutReservedBin } = require('../../../../src/domain/position/timeout-reserved') + +const timeoutMessage1 = { + value: { + from: 'perffsp1', + to: 'perffsp2', + id: 'd6a036a5-65a3-48af-a0c7-ee089c412ada', + content: { + uriParams: { + id: 'd6a036a5-65a3-48af-a0c7-ee089c412ada' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'perffsp2', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'perffsp1' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: 'd6a036a5-65a3-48af-a0c7-ee089c412ada', + event: { + type: 'position', + action: 'timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'perffsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} +const timeoutMessage2 = { + value: { + from: 'perffsp1', + to: 'perffsp2', + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + content: { + uriParams: { + id: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5' + }, + headers: { + accept: 'application/vnd.interoperability.transfers+json;version=1.0', + 'fspiop-destination': 'perffsp2', + 'Content-Type': 'application/vnd.interoperability.transfers+json;version=1.0', + date: 'Tue, 14 May 2024 00:13:15 GMT', + 'fspiop-source': 'perffsp1' + }, + payload: { + errorInformation: { + errorCode: '3303', + errorDescription: 'Transfer expired', + extensionList: { + extension: [ + { + key: 'cause', + value: 'FSPIOPError at Object.createFSPIOPError...' + } + ] + } + } + } + }, + type: 'application/vnd.interoperability.transfers+json;version=1.0', + metadata: { + correlationId: '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5', + event: { + type: 'position', + action: 'timeout-reserved', + createdAt: '2024-05-14T00:13:15.092Z', + state: { + status: 'error', + code: '3303', + description: 'Transfer expired' + }, + id: '1ef2f45c-f7a4-4b67-a0fc-7164ed43f0f1' + }, + trace: { + service: 'cl_transfer_timeout', + traceId: 'de8e410463b73e45203fc916d68cf98c', + spanId: 'bb0abd2ea5fdfbbd', + startTimestamp: '2024-05-14T00:13:15.092Z', + tags: { + tracestate: 'acmevendor=eyJzcGFuSWQiOiJiYjBhYmQyZWE1ZmRmYmJkIn0=', + transactionType: 'transfer', + transactionAction: 'timeout-received', + source: 'switch', + destination: 'perffsp1' + }, + tracestates: { + acmevendor: { + spanId: 'bb0abd2ea5fdfbbd' + } + } + }, + 'protocol.createdAt': 1715645595093 + } + }, + size: 3489, + key: 51, + topic: 'topic-transfer-position', + offset: 4073, + partition: 0, + timestamp: 1694175690401 +} + +const span = {} +const binItems = [{ + message: timeoutMessage1, + span, + decodedPayload: {} +}, +{ + message: timeoutMessage2, + span, + decodedPayload: {} +}] + +Test('timeout reserved domain', positionIndexTest => { + let sandbox + + positionIndexTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + t.end() + }) + + positionIndexTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + positionIndexTest.test('processPositionTimeoutReservedBin should', changeParticipantPositionTest => { + changeParticipantPositionTest.test('produce abort message for transfers not in the right transfer state', async (test) => { + try { + await processPositionTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': 'INVALID_STATE', + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': 'INVALID_STATE' + }, + transferInfoList: {} + } + ) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + + changeParticipantPositionTest.test('produce reserved messages/position changes for valid timeout messages', async (test) => { + const processedMessages = await processPositionTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT + }, + transferInfoList: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': { + amount: -10 + }, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': { + amount: -5 + } + } + } + ) + test.equal(processedMessages.notifyMessages.length, 2) + + test.equal(processedMessages.accumulatedPositionChanges.length, 2) + + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-destination'], timeoutMessage1.value.to) + test.equal(processedMessages.notifyMessages[0].message.content.headers['fspiop-source'], timeoutMessage1.value.from) + test.equal(processedMessages.notifyMessages[0].message.content.headers['content-type'], timeoutMessage1.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[0].value, -10) + test.equal(processedMessages.accumulatedTransferStates[timeoutMessage1.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-destination'], timeoutMessage2.value.to) + test.equal(processedMessages.notifyMessages[1].message.content.headers['fspiop-source'], timeoutMessage1.value.from) + test.equal(processedMessages.notifyMessages[1].message.content.headers['content-type'], timeoutMessage2.value.content.headers['content-type']) + test.equal(processedMessages.accumulatedPositionChanges[1].value, -15) + test.equal(processedMessages.accumulatedTransferStates[timeoutMessage2.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferId, timeoutMessage1.value.id) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferId, timeoutMessage2.value.id) + + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + + test.equal(processedMessages.accumulatedPositionValue, -15) + test.end() + }) + + changeParticipantPositionTest.test('skip position changes if changePositions is false', async (test) => { + const processedMessages = await processPositionTimeoutReservedBin( + binItems, + { + accumulatedPositionValue: 0, // Accumulated position value + accumulatedPositionReservedValue: 0, + accumulatedTransferStates: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT + }, + transferInfoList: { + 'd6a036a5-65a3-48af-a0c7-ee089c412ada': { + amount: -10 + }, + '7e3fa3f7-9a1b-4a81-83c9-5b41112dd7f5': { + amount: -5 + } + }, + changePositions: false + } + ) + test.equal(processedMessages.notifyMessages.length, 2) + test.equal(processedMessages.accumulatedPositionChanges.length, 0) + test.equal(processedMessages.accumulatedPositionValue, 0) + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferId, timeoutMessage1.value.id) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferId, timeoutMessage2.value.id) + test.equal(processedMessages.accumulatedTransferStateChanges[0].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedTransferStateChanges[1].transferStateId, Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedTransferStates[timeoutMessage1.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.equal(processedMessages.accumulatedTransferStates[timeoutMessage2.value.id], Enum.Transfers.TransferInternalState.EXPIRED_RESERVED) + test.end() + }) + + changeParticipantPositionTest.end() + }) + + positionIndexTest.end() +}) diff --git a/test/unit/domain/settlement/index.test.js b/test/unit/domain/settlement/index.test.js index 0b182d9d6..f70fcd1dc 100644 --- a/test/unit/domain/settlement/index.test.js +++ b/test/unit/domain/settlement/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/domain/timeout/index.test.js b/test/unit/domain/timeout/index.test.js index 8573ae25d..7b4cd10f4 100644 --- a/test/unit/domain/timeout/index.test.js +++ b/test/unit/domain/timeout/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -28,9 +31,11 @@ const Test = require('tapes')(require('tape')) const Sinon = require('sinon') const TimeoutService = require('../../../../src/domain/timeout') const TransferTimeoutModel = require('../../../../src/models/transfer/transferTimeout') +const FxTransferTimeoutModel = require('../../../../src/models/fxTransfer/fxTransferTimeout') const TransferFacade = require('../../../../src/models/transfer/facade') const SegmentModel = require('../../../../src/models/misc/segment') const TransferStateChangeModel = require('../../../../src/models/transfer/transferStateChange') +const FxTransferStateChangeModel = require('../../../../src/models/fxTransfer/stateChange') const Logger = require('@mojaloop/central-services-logger') Test('Timeout Service', timeoutTest => { @@ -39,8 +44,10 @@ Test('Timeout Service', timeoutTest => { timeoutTest.beforeEach(t => { sandbox = Sinon.createSandbox() sandbox.stub(TransferTimeoutModel) + sandbox.stub(FxTransferTimeoutModel) sandbox.stub(TransferFacade) sandbox.stub(TransferStateChangeModel) + sandbox.stub(FxTransferStateChangeModel) sandbox.stub(SegmentModel) t.end() }) @@ -82,6 +89,38 @@ Test('Timeout Service', timeoutTest => { getTimeoutSegmentTest.end() }) + timeoutTest.test('getFxTimeoutSegment should', getFxTimeoutSegmentTest => { + getFxTimeoutSegmentTest.test('return the segment', async (test) => { + try { + const params = { + segmentType: 'timeout', + enumeration: 0, + tableName: 'fxTransferStateChange' + } + + const segment = { + segmentId: 1, + segmentType: 'timeout', + enumeration: 0, + tableName: 'fxTransferStateChange', + value: 4, + changedDate: '2018-10-10 21:57:00' + } + + SegmentModel.getByParams.withArgs(params).returns(Promise.resolve(segment)) + const result = await TimeoutService.getFxTimeoutSegment() + test.deepEqual(result, segment, 'Results Match') + test.end() + } catch (e) { + Logger.error(e) + test.fail('Error Thrown') + test.end() + } + }) + + getFxTimeoutSegmentTest.end() + }) + timeoutTest.test('cleanupTransferTimeout should', cleanupTransferTimeoutTest => { cleanupTransferTimeoutTest.test('cleanup the timed out transfers and return the id', async (test) => { try { @@ -99,6 +138,23 @@ Test('Timeout Service', timeoutTest => { cleanupTransferTimeoutTest.end() }) + timeoutTest.test('cleanupFxTransferTimeout should', cleanupFxTransferTimeoutTest => { + cleanupFxTransferTimeoutTest.test('cleanup the timed out fx-transfers and return the id', async (test) => { + try { + FxTransferTimeoutModel.cleanup.returns(Promise.resolve(1)) + const result = await TimeoutService.cleanupFxTransferTimeout() + test.equal(result, 1, 'Results Match') + test.end() + } catch (e) { + Logger.error(e) + test.fail('Error Thrown') + test.end() + } + }) + + cleanupFxTransferTimeoutTest.end() + }) + timeoutTest.test('getLatestTransferStateChange should', getLatestTransferStateChangeTest => { getLatestTransferStateChangeTest.test('get the latest transfer state change id', async (test) => { try { @@ -117,6 +173,24 @@ Test('Timeout Service', timeoutTest => { getLatestTransferStateChangeTest.end() }) + timeoutTest.test('getLatestFxTransferStateChange should', getLatestFxTransferStateChangeTest => { + getLatestFxTransferStateChangeTest.test('get the latest fx-transfer state change id', async (test) => { + try { + const record = { fxTransferStateChangeId: 1 } + FxTransferStateChangeModel.getLatest.returns(Promise.resolve(record)) + const result = await TimeoutService.getLatestFxTransferStateChange() + test.equal(result, record, 'Results Match') + test.end() + } catch (e) { + Logger.error(e) + test.fail('Error Thrown') + test.end() + } + }) + + getLatestFxTransferStateChangeTest.end() + }) + timeoutTest.test('timeoutExpireReserved should', timeoutExpireReservedTest => { timeoutExpireReservedTest.test('timeout the reserved transactions which are expired', async (test) => { try { diff --git a/test/unit/domain/transactions/index.test.js b/test/unit/domain/transactions/index.test.js index 450062794..567226d51 100644 --- a/test/unit/domain/transactions/index.test.js +++ b/test/unit/domain/transactions/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/domain/transfer/index.test.js b/test/unit/domain/transfer/index.test.js index 730c527a0..d286bd193 100644 --- a/test/unit/domain/transfer/index.test.js +++ b/test/unit/domain/transfer/index.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -209,5 +212,35 @@ Test('Transfer Service', transferIndexTest => { logTransferErrorTest.end() }) + transferIndexTest.test('forwardedPrepare should', handlePayeeResponseTest => { + handlePayeeResponseTest.test('commit transfer', async (test) => { + try { + TransferFacade.updatePrepareReservedForwarded.returns(Promise.resolve()) + await TransferService.forwardedPrepare(payload.transferId) + test.pass() + test.end() + } catch (err) { + Logger.error(`handlePayeeResponse failed with error - ${err}`) + test.fail() + test.end() + } + }) + + handlePayeeResponseTest.test('throw error', async (test) => { + try { + TransferFacade.updatePrepareReservedForwarded.throws(new Error()) + await TransferService.forwardedPrepare(payload.transferId) + test.fail('Error not thrown') + test.end() + } catch (err) { + Logger.error(`handlePayeeResponse failed with error - ${err}`) + test.pass('Error thrown') + test.end() + } + }) + + handlePayeeResponseTest.end() + }) + transferIndexTest.end() }) diff --git a/test/unit/domain/transfer/transform.test.js b/test/unit/domain/transfer/transform.test.js index 1c9dc1dd5..346400ea5 100644 --- a/test/unit/domain/transfer/transform.test.js +++ b/test/unit/domain/transfer/transform.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -340,7 +343,8 @@ Test('Transform Service', transformTest => { toFulfilTest.test('throw error', async (test) => { try { const invalidTransfer = {} - TransformService.toFulfil(invalidTransfer) + const x = TransformService.toFulfil(invalidTransfer) + console.log(x) test.fail('should throw') test.end() } catch (e) { diff --git a/test/unit/handlers/admin/handler.test.js b/test/unit/handlers/admin/handler.test.js index 92539f4cb..fdb8522a6 100644 --- a/test/unit/handlers/admin/handler.test.js +++ b/test/unit/handlers/admin/handler.test.js @@ -11,6 +11,7 @@ const Logger = require('@mojaloop/central-services-logger') const Comparators = require('@mojaloop/central-services-shared').Util.Comparators const TransferService = require('../../../../src/domain/transfer') const Db = require('../../../../src/lib/db') +const ProxyCache = require('#src/lib/proxyCache') const Enum = require('@mojaloop/central-services-shared').Enum const TransferState = Enum.Transfers.TransferState const TransferInternalState = Enum.Transfers.TransferInternalState @@ -299,6 +300,10 @@ Test('Admin handler', adminHandlerTest => { adminHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) sandbox.stub(KafkaConsumer.prototype, 'constructor').resolves() sandbox.stub(KafkaConsumer.prototype, 'connect').resolves() sandbox.stub(KafkaConsumer.prototype, 'consume').resolves() @@ -406,7 +411,8 @@ Test('Admin handler', adminHandlerTest => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = sandbox.stub() - trxStub.rollback = sandbox.stub() + trxStub.rollback = () => Promise.reject(new Error('DB Error')) + Consumer.isConsumerAutoCommitEnabled.withArgs(topicName).throws(new Error()) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -434,7 +440,7 @@ Test('Admin handler', adminHandlerTest => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = sandbox.stub() - trxStub.rollback = sandbox.stub() + trxStub.rollback = () => Promise.reject(new Error('DB Error')) Consumer.isConsumerAutoCommitEnabled.withArgs(topicName).throws(new Error()) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) diff --git a/test/unit/handlers/api/handler.test.js b/test/unit/handlers/api/handler.test.js index 33087ecc0..8bd52e8e0 100644 --- a/test/unit/handlers/api/handler.test.js +++ b/test/unit/handlers/api/handler.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -29,6 +32,7 @@ const Sinon = require('sinon') const Handler = require('../../../../src/handlers/api/routes') const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const MigrationLockModel = require('../../../../src/models/misc/migrationLock') +const ProxyCache = require('#src/lib/proxyCache') function createRequest (routes) { const value = routes || [] @@ -61,6 +65,11 @@ Test('route handler', (handlerTest) => { // Arrange sandbox.stub(MigrationLockModel, 'getIsMigrationLocked').returns(false) sandbox.stub(Consumer, 'isConnected').returns(Promise.resolve()) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub(), + healthCheck: sandbox.stub().returns(Promise.resolve(true)) + }) const jp = require('jsonpath') const healthHandler = jp.query(Handler, '$[?(@.path=="/health")]') diff --git a/test/unit/handlers/bulk/get/handler.test.js b/test/unit/handlers/bulk/get/handler.test.js index df076356e..6ece83626 100644 --- a/test/unit/handlers/bulk/get/handler.test.js +++ b/test/unit/handlers/bulk/get/handler.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -30,6 +30,7 @@ const { randomUUID } = require('crypto') const Sinon = require('sinon') const Proxyquire = require('proxyquire') +const ProxyCache = require('#src/lib/proxyCache') const Test = require('tapes')(require('tape')) const EventSdk = require('@mojaloop/event-sdk') const Kafka = require('@mojaloop/central-services-shared').Util.Kafka @@ -152,6 +153,10 @@ Test('Bulk Transfer GET handler', getHandlerTest => { getHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) SpanStub = { audit: sandbox.stub().callsFake(), error: sandbox.stub().callsFake(), diff --git a/test/unit/handlers/bulk/prepare/handler.test.js b/test/unit/handlers/bulk/prepare/handler.test.js index 554a70721..32217b6a1 100644 --- a/test/unit/handlers/bulk/prepare/handler.test.js +++ b/test/unit/handlers/bulk/prepare/handler.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -43,6 +43,7 @@ const BulkTransferService = require('#src/domain/bulkTransfer/index') const BulkTransferModel = require('#src/models/bulkTransfer/bulkTransfer') const BulkTransferModels = require('@mojaloop/object-store-lib').Models.BulkTransfer const ilp = require('#src/models/transfer/ilpPacket') +const ProxyCache = require('#src/lib/proxyCache') // Sample Bulk Transfer Message received by the Bulk API Adapter const fspiopBulkTransferMsg = { @@ -159,6 +160,10 @@ Test('Bulk Transfer PREPARE handler', handlerTest => { handlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) SpanStub = { audit: sandbox.stub().callsFake(), error: sandbox.stub().callsFake(), diff --git a/test/unit/handlers/index.test.js b/test/unit/handlers/index.test.js index 684803972..e89036b8d 100644 --- a/test/unit/handlers/index.test.js +++ b/test/unit/handlers/index.test.js @@ -7,6 +7,7 @@ const Proxyquire = require('proxyquire') const Plugin = require('../../../src/handlers/api/plugin') const MetricsPlugin = require('../../../src/api/metrics/plugin') const Logger = require('@mojaloop/central-services-logger') +const ProxyCache = require('#src/lib/proxyCache') Test('cli', async (cliTest) => { let sandbox @@ -35,9 +36,12 @@ Test('cli', async (cliTest) => { commanderTest.beforeEach(test => { sandbox = Sinon.createSandbox() - + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) SetupStub = { - initialize: sandbox.stub().returns(Promise.resolve()) + initialize: sandbox.stub().resolves() } process.argv = [] diff --git a/test/unit/handlers/positions/handler.test.js b/test/unit/handlers/positions/handler.test.js index 4b7aa8d53..2384f341b 100644 --- a/test/unit/handlers/positions/handler.test.js +++ b/test/unit/handlers/positions/handler.test.js @@ -7,6 +7,8 @@ const Validator = require('../../../../src/handlers/transfers/validator') const TransferService = require('../../../../src/domain/transfer') const PositionService = require('../../../../src/domain/position') const SettlementModelCached = require('../../../../src/models/settlement/settlementModelCached') +const ParticipantFacade = require('../../../../src/models/participant/facade') +const ParticipantCachedModel = require('../../../../src/models/participant/participantCached') const MainUtil = require('@mojaloop/central-services-shared').Util const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const KafkaConsumer = Consumer.Consumer @@ -20,6 +22,7 @@ const Clone = require('lodash').clone const TransferState = Enum.Transfers.TransferState const TransferInternalState = Enum.Transfers.TransferInternalState const Proxyquire = require('proxyquire') +const ProxyCache = require('#src/lib/proxyCache') const transfer = { transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', @@ -141,6 +144,10 @@ Test('Position handler', transferHandlerTest => { transferHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) SpanStub = { audit: sandbox.stub().callsFake(), error: sandbox.stub().callsFake(), @@ -178,6 +185,8 @@ Test('Position handler', transferHandlerTest => { sandbox.stub(PositionService) sandbox.stub(TransferStateChange) sandbox.stub(SettlementModelCached) + sandbox.stub(ParticipantFacade) + sandbox.stub(ParticipantCachedModel) Kafka.transformAccountToTopicName.returns(topicName) Kafka.produceGeneralMessage.resolves() test.end() @@ -733,6 +742,8 @@ Test('Position handler', transferHandlerTest => { Kafka.transformGeneralTopicName.returns(topicName) Kafka.getKafkaConfig.returns(config) TransferStateChange.saveTransferStateChange.resolves(true) + ParticipantFacade.getByNameAndCurrency.resolves({ participantCurrencyId: 1 }) + ParticipantCachedModel.getByName.resolves({ participantId: 1 }) TransferService.getTransferInfoToChangePosition.resolves({ transferStateId: 'INVALID_STATE' }) const m = Object.assign({}, MainUtil.clone(messages[0])) m.value.metadata.event.action = transferEventAction.TIMEOUT_RESERVED diff --git a/test/unit/handlers/positions/handlerBatch.test.js b/test/unit/handlers/positions/handlerBatch.test.js index 84e480b07..12173a68e 100644 --- a/test/unit/handlers/positions/handlerBatch.test.js +++ b/test/unit/handlers/positions/handlerBatch.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -40,6 +40,7 @@ const SettlementModelCached = require('../../../../src/models/settlement/settlem const Enum = require('@mojaloop/central-services-shared').Enum const Proxyquire = require('proxyquire') const Logger = require('@mojaloop/central-services-logger') +const ProxyCache = require('#src/lib/proxyCache') const topicName = 'topic-transfer-position-batch' @@ -53,6 +54,7 @@ const prepareMessageValue = { payload: {} } } + const commitMessageValue = { metadata: { event: { @@ -128,6 +130,10 @@ Test('Position handler', positionBatchHandlerTest => { positionBatchHandlerTest.beforeEach(test => { sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) SpanStub = { audit: sandbox.stub().callsFake(), error: sandbox.stub().callsFake(), @@ -223,7 +229,8 @@ Test('Position handler', positionBatchHandlerTest => { BatchPositionModel.startDbTransaction.returns(trxStub) sandbox.stub(BinProcessor) BinProcessor.processBins.resolves({ - notifyMessages: messages.map((i) => ({ binItem: { message: i, span: SpanStub }, message: { metadata: { event: { state: { status: 'success' } } } } })) + notifyMessages: messages.map((i) => ({ binItem: { message: i, span: SpanStub }, message: { metadata: { event: { state: { status: 'success' } } } } })), + followupMessages: [] }) BinProcessor.iterateThroughBins.restore() @@ -413,7 +420,8 @@ Test('Position handler', positionBatchHandlerTest => { Kafka.proceed.returns(true) BinProcessor.processBins.resolves({ - notifyMessages: [{ binItem: { message: messages[0], span: SpanStub }, message: { metadata: { event: { state: 'success' } } } }] + notifyMessages: [{ binItem: { message: messages[0], span: SpanStub }, message: { metadata: { event: { state: 'success' } } } }], + followupMessages: [] }) // Act @@ -447,7 +455,89 @@ Test('Position handler', positionBatchHandlerTest => { Kafka.proceed.returns(true) BinProcessor.processBins.resolves({ - notifyMessages: [{ binItem: { message: messages[0], span: SpanStub }, message: { metadata: { event: { state: { status: 'error' } } } } }] + notifyMessages: [{ binItem: { message: messages[0], span: SpanStub }, message: { metadata: { event: { state: { status: 'error' } } } } }], + followupMessages: [] + }) + + // Act + try { + await allTransferHandlers.positions(null, messages[0]) + test.ok(BatchPositionModel.startDbTransaction.calledOnce, 'startDbTransaction should be called once') + // Need an easier way to do partial matching... + delete BinProcessor.processBins.getCall(0).args[0][1001].prepare[0].histTimerMsgEnd + test.deepEqual(BinProcessor.processBins.getCall(0).args[0][1001].prepare[0], expectedBins[1001].prepare[0]) + test.equal(BinProcessor.processBins.getCall(0).args[1], trxStub) + const expectedLastMessageToCommit = messages[messages.length - 1] + test.equal(Kafka.proceed.getCall(0).args[1].message.offset, expectedLastMessageToCommit.offset, 'kafkaProceed should be called with the correct offset') + test.equal(SpanStub.audit.callCount, 1, 'span.audit should be called one time') + test.equal(SpanStub.finish.callCount, 1, 'span.finish should be called one time') + test.ok(trxStub.commit.calledOnce, 'trx.commit should be called once') + test.ok(trxStub.rollback.notCalled, 'trx.rollback should not be called') + test.equal(Kafka.produceGeneralMessage.callCount, 1, 'produceGeneralMessage should be one time to produce kafka notification events') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[5], Enum.Events.EventStatus.FAILURE, 'produceGeneralMessage should be called with eventStatus as Enum.Events.EventStatus.FAILURE') + test.end() + } catch (err) { + Logger.info(err) + test.fail('Error should not be thrown') + test.end() + } + }) + + positionsTest.test('calls Kafka.produceGeneralMessage for followup messages', async test => { + // Arrange + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Kafka.getKafkaConfig.returns(config) + Kafka.proceed.returns(true) + + BinProcessor.processBins.resolves({ + notifyMessages: [], + followupMessages: messages.map((i) => ({ binItem: { message: i, messageKey: '100', span: SpanStub }, message: { metadata: { event: { state: { status: 'success' } } } } })) + }) + + // Act + try { + await allTransferHandlers.positions(null, messages) + test.ok(BatchPositionModel.startDbTransaction.calledOnce, 'startDbTransaction should be called once') + // Need an easier way to do partial matching... + delete BinProcessor.processBins.getCall(0).args[0][1001].commit[0].histTimerMsgEnd + delete BinProcessor.processBins.getCall(0).args[0][1001].prepare[0].histTimerMsgEnd + delete BinProcessor.processBins.getCall(0).args[0][1001].prepare[1].histTimerMsgEnd + delete BinProcessor.processBins.getCall(0).args[0][1002].commit[0].histTimerMsgEnd + delete BinProcessor.processBins.getCall(0).args[0][1002].prepare[0].histTimerMsgEnd + test.deepEqual(BinProcessor.processBins.getCall(0).args[0][1001].commit, expectedBins[1001].commit) + test.deepEqual(BinProcessor.processBins.getCall(0).args[0][1001].prepare, expectedBins[1001].prepare) + test.deepEqual(BinProcessor.processBins.getCall(0).args[0][1002].commit, expectedBins[1002].commit) + test.deepEqual(BinProcessor.processBins.getCall(0).args[0][1002].prepare, expectedBins[1002].prepare) + test.equal(BinProcessor.processBins.getCall(0).args[1], trxStub) + const expectedLastMessageToCommit = messages[messages.length - 1] + test.equal(Kafka.proceed.getCall(0).args[1].message.offset, expectedLastMessageToCommit.offset, 'kafkaProceed should be called with the correct offset') + test.equal(SpanStub.audit.callCount, 5, 'span.audit should be called five times') + test.equal(SpanStub.finish.callCount, 5, 'span.finish should be called five times') + test.ok(trxStub.commit.calledOnce, 'trx.commit should be called once') + test.ok(trxStub.rollback.notCalled, 'trx.rollback should not be called') + test.equal(Kafka.produceGeneralMessage.callCount, 5, 'produceGeneralMessage should be five times to produce kafka notification events') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[2], Enum.Events.Event.Type.POSITION, 'produceGeneralMessage should be called with eventType POSITION') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[3], Enum.Events.Event.Action.PREPARE, 'produceGeneralMessage should be called with eventAction PREPARE') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[5], Enum.Events.EventStatus.SUCCESS, 'produceGeneralMessage should be called with eventStatus as Enum.Events.EventStatus.SUCCESS') + test.end() + } catch (err) { + Logger.info(err) + test.fail('Error should not be thrown') + test.end() + } + }) + + positionsTest.test('calls Kafka.produceGeneralMessage for followup messages with correct eventStatus if event is a failure event', async test => { + // Arrange + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Kafka.getKafkaConfig.returns(config) + Kafka.proceed.returns(true) + + BinProcessor.processBins.resolves({ + notifyMessages: [], + followupMessages: [{ binItem: { message: messages[0], messageKey: '100', span: SpanStub }, message: { metadata: { event: { state: { status: 'error' } } } } }] }) // Act @@ -465,6 +555,8 @@ Test('Position handler', positionBatchHandlerTest => { test.ok(trxStub.commit.calledOnce, 'trx.commit should be called once') test.ok(trxStub.rollback.notCalled, 'trx.rollback should not be called') test.equal(Kafka.produceGeneralMessage.callCount, 1, 'produceGeneralMessage should be one time to produce kafka notification events') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[2], Enum.Events.Event.Type.POSITION, 'produceGeneralMessage should be called with eventType POSITION') + test.equal(Kafka.produceGeneralMessage.getCall(0).args[3], Enum.Events.Event.Action.PREPARE, 'produceGeneralMessage should be called with eventAction PREPARE') test.equal(Kafka.produceGeneralMessage.getCall(0).args[5], Enum.Events.EventStatus.FAILURE, 'produceGeneralMessage should be called with eventStatus as Enum.Events.EventStatus.FAILURE') test.end() } catch (err) { diff --git a/test/unit/handlers/register.test.js b/test/unit/handlers/register.test.js index 7da1df0e5..1a0f81f7c 100644 --- a/test/unit/handlers/register.test.js +++ b/test/unit/handlers/register.test.js @@ -12,6 +12,7 @@ const BulkProcessingHandlers = require('../../../src/handlers/bulk/processing/ha const BulkFulfilHandlers = require('../../../src/handlers/bulk/fulfil/handler') const BulkGetHandlers = require('../../../src/handlers/bulk/get/handler') const Proxyquire = require('proxyquire') +const ProxyCache = require('#src/lib/proxyCache') Test('handlers', handlersTest => { let sandbox @@ -26,6 +27,10 @@ Test('handlers', handlersTest => { sandbox.stub(BulkProcessingHandlers, 'registerAllHandlers').returns(Promise.resolve(true)) sandbox.stub(BulkFulfilHandlers, 'registerAllHandlers').returns(Promise.resolve(true)) sandbox.stub(BulkGetHandlers, 'registerAllHandlers').returns(Promise.resolve(true)) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) test.end() }) diff --git a/test/unit/handlers/timeouts/handler.test.js b/test/unit/handlers/timeouts/handler.test.js index 23bae6f14..b941c36d6 100644 --- a/test/unit/handlers/timeouts/handler.test.js +++ b/test/unit/handlers/timeouts/handler.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -36,6 +36,7 @@ const CronJob = require('cron').CronJob const TimeoutService = require('../../../../src/domain/timeout') const Config = require('../../../../src/lib/config') const { randomUUID } = require('crypto') +const ProxyCache = require('#src/lib/proxyCache') const Enum = require('@mojaloop/central-services-shared').Enum const Utility = require('@mojaloop/central-services-shared').Util.Kafka @@ -49,6 +50,10 @@ Test('Timeout handler', TimeoutHandlerTest => { sandbox.stub(CronJob.prototype, 'constructor').returns(Promise.resolve()) sandbox.stub(CronJob.prototype, 'start').returns(Promise.resolve(true)) sandbox.stub(CronJob.prototype, 'stop').returns(Promise.resolve(true)) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) Config.HANDLERS_TIMEOUT_DISABLED = false test.end() }) @@ -66,14 +71,18 @@ Test('Timeout handler', TimeoutHandlerTest => { const latestTransferStateChangeMock = { transferStateChangeId: 20 } - const resultMock = [ + const latestFxTransferStateChangeMock = { + fxTransferStateChangeId: 20 + } + const transferTimeoutListMock = [ { transferId: randomUUID(), bulkTransferId: null, payerFsp: 'dfsp1', payeeFsp: 'dfsp2', transferStateId: Enum.Transfers.TransferInternalState.EXPIRED_PREPARED, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 }, { transferId: randomUUID(), @@ -81,7 +90,8 @@ Test('Timeout handler', TimeoutHandlerTest => { payerFsp: 'dfsp1', payeeFsp: 'dfsp2', transferStateId: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 }, { transferId: randomUUID(), @@ -89,7 +99,8 @@ Test('Timeout handler', TimeoutHandlerTest => { payerFsp: 'dfsp2', payeeFsp: 'dfsp1', transferStateId: Enum.Transfers.TransferState.COMMITTED, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 }, { transferId: randomUUID(), @@ -97,7 +108,8 @@ Test('Timeout handler', TimeoutHandlerTest => { payerFsp: 'dfsp1', payeeFsp: 'dfsp2', transferStateId: Enum.Transfers.TransferInternalState.EXPIRED_PREPARED, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 }, { transferId: randomUUID(), @@ -105,7 +117,8 @@ Test('Timeout handler', TimeoutHandlerTest => { payerFsp: 'dfsp1', payeeFsp: 'dfsp2', transferStateId: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 }, { transferId: randomUUID(), @@ -113,20 +126,49 @@ Test('Timeout handler', TimeoutHandlerTest => { payerFsp: 'dfsp2', payeeFsp: 'dfsp1', transferStateId: Enum.Transfers.TransferState.COMMITTED, - payerParticipantCurrencyId: 0 + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 } ] + const fxTransferTimeoutListMock = [ + { + commitRequestId: randomUUID(), + initiatingFsp: 'dfsp1', + counterPartyFsp: 'dfsp2', + transferStateId: Enum.Transfers.TransferInternalState.EXPIRED_PREPARED, + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 + }, + { + commitRequestId: randomUUID(), + initiatingFsp: 'dfsp1', + counterPartyFsp: 'dfsp2', + transferStateId: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT, + payerParticipantCurrencyId: 0, + effectedParticipantCurrencyId: 0 + } + ] + const resultMock = { + transferTimeoutList: transferTimeoutListMock, + fxTransferTimeoutList: fxTransferTimeoutListMock + } let expected = { cleanup: 1, + fxCleanup: 1, intervalMin: 10, intervalMax: 20, - result: resultMock + fxIntervalMin: 10, + fxIntervalMax: 20, + ...resultMock } timeoutTest.test('perform timeout', async (test) => { TimeoutService.getTimeoutSegment = sandbox.stub().returns(timeoutSegmentMock) + TimeoutService.getFxTimeoutSegment = sandbox.stub().returns(timeoutSegmentMock) TimeoutService.cleanupTransferTimeout = sandbox.stub().returns(1) + TimeoutService.cleanupFxTransferTimeout = sandbox.stub().returns(1) TimeoutService.getLatestTransferStateChange = sandbox.stub().returns(latestTransferStateChangeMock) + TimeoutService.getLatestFxTransferStateChange = sandbox.stub().returns(latestFxTransferStateChangeMock) TimeoutService.timeoutExpireReserved = sandbox.stub().returns(resultMock) Utility.produceGeneralMessage = sandbox.stub() @@ -140,21 +182,65 @@ Test('Timeout handler', TimeoutHandlerTest => { } } test.deepEqual(result, expected, 'Expected result is returned') - test.equal(Utility.produceGeneralMessage.callCount, 4, 'Four different messages were produced') + test.equal(Utility.produceGeneralMessage.callCount, 6, '6 messages were produced') + test.end() + }) + + timeoutTest.test('perform timeout with single messages', async (test) => { + const resultMock1 = { + transferTimeoutList: transferTimeoutListMock[0], + fxTransferTimeoutList: fxTransferTimeoutListMock[0] + } + + TimeoutService.getTimeoutSegment = sandbox.stub().returns(timeoutSegmentMock) + TimeoutService.getFxTimeoutSegment = sandbox.stub().returns(timeoutSegmentMock) + TimeoutService.cleanupTransferTimeout = sandbox.stub().returns(1) + TimeoutService.cleanupFxTransferTimeout = sandbox.stub().returns(1) + TimeoutService.getLatestTransferStateChange = sandbox.stub().returns(latestTransferStateChangeMock) + TimeoutService.getLatestFxTransferStateChange = sandbox.stub().returns(latestFxTransferStateChangeMock) + TimeoutService.timeoutExpireReserved = sandbox.stub().returns(resultMock1) + Utility.produceGeneralMessage = sandbox.stub() + + const result = await TimeoutHandler.timeout() + const produceGeneralMessageCalls = Utility.produceGeneralMessage.getCalls() + + for (const message of produceGeneralMessageCalls) { + if (message.args[2] === 'position') { + // Check message key matches payer account id + test.equal(message.args[6], '0') + } + } + + const expected1 = { + ...expected, + ...resultMock1 + } + test.deepEqual(result, expected1, 'Expected result is returned') + test.equal(Utility.produceGeneralMessage.callCount, 2, '2 messages were produced') test.end() }) timeoutTest.test('perform timeout when no data is present in segment table', async (test) => { TimeoutService.getTimeoutSegment = sandbox.stub().returns(null) + TimeoutService.getFxTimeoutSegment = sandbox.stub().returns(null) TimeoutService.cleanupTransferTimeout = sandbox.stub().returns(1) + TimeoutService.cleanupFxTransferTimeout = sandbox.stub().returns(1) TimeoutService.getLatestTransferStateChange = sandbox.stub().returns(null) - TimeoutService.timeoutExpireReserved = sandbox.stub().returns(resultMock[0]) + TimeoutService.getLatestFxTransferStateChange = sandbox.stub().returns(null) + const resultMock1 = { + transferTimeoutList: null, + fxTransferTimeoutList: null + } + TimeoutService.timeoutExpireReserved = sandbox.stub().returns(resultMock1) Utility.produceGeneralMessage = sandbox.stub() expected = { cleanup: 1, + fxCleanup: 1, intervalMin: 0, intervalMax: 0, - result: resultMock[0] + fxIntervalMin: 0, + fxIntervalMax: 0, + ...resultMock1 } const result = await TimeoutHandler.timeout() @@ -191,6 +277,31 @@ Test('Timeout handler', TimeoutHandlerTest => { } }) + timeoutTest.test('handle fx message errors', async (test) => { + const resultMock1 = { + transferTimeoutList: [], + fxTransferTimeoutList: fxTransferTimeoutListMock[0] + } + TimeoutService.timeoutExpireReserved = sandbox.stub().returns(resultMock1) + + TimeoutService.getTimeoutSegment = sandbox.stub().returns(null) + TimeoutService.getFxTimeoutSegment = sandbox.stub().returns(timeoutSegmentMock) + TimeoutService.cleanupTransferTimeout = sandbox.stub().returns(1) + TimeoutService.cleanupFxTransferTimeout = sandbox.stub().returns(1) + TimeoutService.getLatestTransferStateChange = sandbox.stub().returns(null) + TimeoutService.getLatestFxTransferStateChange = sandbox.stub().returns(latestFxTransferStateChangeMock) + Utility.produceGeneralMessage = sandbox.stub().throws() + + try { + await TimeoutHandler.timeout() + test.error('Exception expected') + test.end() + } catch (err) { + test.pass('Error thrown') + test.end() + } + }) + timeoutTest.end() }) diff --git a/test/unit/handlers/transfers/FxFulfilService.test.js b/test/unit/handlers/transfers/FxFulfilService.test.js new file mode 100644 index 000000000..b3d69e3d8 --- /dev/null +++ b/test/unit/handlers/transfers/FxFulfilService.test.js @@ -0,0 +1,207 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +/* eslint-disable object-property-newline */ +const Sinon = require('sinon') +const Test = require('tapes')(require('tape')) +const { Db } = require('@mojaloop/database-lib') +const { Enum, Util } = require('@mojaloop/central-services-shared') +const { Consumer, Producer } = require('@mojaloop/central-services-stream').Util + +const Cyril = require('../../../../src/domain/fx/cyril') +const FxFulfilService = require('../../../../src/handlers/transfers/FxFulfilService') +const fspiopErrorFactory = require('../../../../src/shared/fspiopErrorFactory') +const Validator = require('../../../../src/handlers/transfers/validator') +const FxTransferModel = require('../../../../src/models/fxTransfer') +const Config = require('../../../../src/lib/config') +const { ERROR_MESSAGES } = require('../../../../src/shared/constants') +const { logger } = require('../../../../src/shared/logger') +const ProxyCache = require('#src/lib/proxyCache') + +const fixtures = require('../../../fixtures') +const mocks = require('./mocks') +const { checkErrorPayload } = require('#test/util/helpers') + +const { Kafka, Comparators, Hash } = Util +const { Action } = Enum.Events.Event +const { TOPICS } = fixtures + +const log = logger +// const functionality = Type.NOTIFICATION + +Test('FxFulfilService Tests -->', fxFulfilTest => { + let sandbox + let span + let producer + + const createFxFulfilServiceWithTestData = (message) => { + const { + commitRequestId, + payload, + type, + action, + kafkaTopic + } = FxFulfilService.decodeKafkaMessage(message) + + const params = { + message, + kafkaTopic, + span, + decodedPayload: payload, + consumer: Consumer, + producer: Producer + } + const service = new FxFulfilService({ + log, Config, Comparators, Validator, FxTransferModel, Kafka, params + }) + + return { + service, + commitRequestId, payload, type, action + } + } + + fxFulfilTest.beforeEach(test => { + sandbox = Sinon.createSandbox() + producer = sandbox.stub(Producer) + sandbox.stub(Consumer, 'isConsumerAutoCommitEnabled').returns(true) + sandbox.stub(Db) + sandbox.stub(FxTransferModel.fxTransfer) + sandbox.stub(FxTransferModel.duplicateCheck) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) + sandbox.stub(Cyril) + Cyril.processFxAbortMessage.returns({ + positionChanges: [{ + participantCurrencyId: 1 + }] + }) + span = mocks.createTracerStub(sandbox).SpanStub + test.end() + }) + + fxFulfilTest.afterEach(test => { + sandbox.restore() + test.end() + }) + + fxFulfilTest.test('getDuplicateCheckResult Method Tests -->', methodTest => { + methodTest.test('should detect duplicate fulfil request [action: fx-commit]', async t => { + const action = Action.FX_COMMIT + const metadata = fixtures.fulfilMetadataDto({ action }) + const message = fixtures.fxFulfilKafkaMessageDto({ metadata }) + const { + service, + commitRequestId, payload + } = createFxFulfilServiceWithTestData(message) + + FxTransferModel.duplicateCheck.getFxTransferFulfilmentDuplicateCheck.resolves({ hash: Hash.generateSha256(payload) }) + FxTransferModel.duplicateCheck.saveFxTransferFulfilmentDuplicateCheck.resolves() + FxTransferModel.duplicateCheck.getFxTransferErrorDuplicateCheck.rejects(new Error('Should not be called')) + FxTransferModel.duplicateCheck.saveFxTransferErrorDuplicateCheck.rejects(new Error('Should not be called')) + + const dupCheckResult = await service.getDuplicateCheckResult({ commitRequestId, payload, action }) + t.ok(dupCheckResult.hasDuplicateId) + t.ok(dupCheckResult.hasDuplicateHash) + t.end() + }) + + methodTest.test('should detect error duplicate fulfil request [action: fx-abort]', async t => { + const action = Action.FX_ABORT + const metadata = fixtures.fulfilMetadataDto({ action }) + const message = fixtures.fxFulfilKafkaMessageDto({ metadata }) + const { + service, + commitRequestId, payload + } = createFxFulfilServiceWithTestData(message) + + FxTransferModel.duplicateCheck.getFxTransferFulfilmentDuplicateCheck.rejects(new Error('Should not be called')) + FxTransferModel.duplicateCheck.saveFxTransferFulfilmentDuplicateCheck.rejects(new Error('Should not be called')) + FxTransferModel.duplicateCheck.getFxTransferErrorDuplicateCheck.resolves({ hash: Hash.generateSha256(payload) }) + FxTransferModel.duplicateCheck.saveFxTransferErrorDuplicateCheck.resolves() + + const dupCheckResult = await service.getDuplicateCheckResult({ commitRequestId, payload, action }) + t.ok(dupCheckResult.hasDuplicateId) + t.ok(dupCheckResult.hasDuplicateHash) + t.end() + }) + + methodTest.end() + }) + + fxFulfilTest.test('validateFulfilment Method Tests -->', methodTest => { + methodTest.test('should pass fulfilment validation', async t => { + const { service } = createFxFulfilServiceWithTestData(fixtures.fxFulfilKafkaMessageDto()) + const transfer = { + ilpCondition: fixtures.CONDITION, + counterPartyFspTargetParticipantCurrencyId: 123 + } + const payload = { fulfilment: fixtures.FULFILMENT } + + const isOk = await service.validateFulfilment(transfer, payload) + t.true(isOk) + t.end() + }) + + methodTest.test('should process wrong fulfilment', async t => { + Db.getKnex.resolves({ + transaction: sandbox.stub + }) + FxTransferModel.fxTransfer.saveFxFulfilResponse.restore() // to call real saveFxFulfilResponse impl. + + const { service } = createFxFulfilServiceWithTestData(fixtures.fxFulfilKafkaMessageDto()) + const transfer = { + ilpCondition: fixtures.CONDITION, + initiatingFspName: fixtures.DFSP1_ID, + counterPartyFspTargetParticipantCurrencyId: 123 + } + const payload = { fulfilment: 'wrongFulfilment' } + + try { + await service.validateFulfilment(transfer, payload) + t.fail('Should throw fxInvalidFulfilment error') + } catch (err) { + t.equal(err.message, ERROR_MESSAGES.fxInvalidFulfilment) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.ok(topicConfig.topicName === TOPICS.transferPosition || topicConfig.topicName === TOPICS.transferPositionBatch) + t.equal(topicConfig.key, String(1)) + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.to, fixtures.DFSP1_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_ABORT_VALIDATION) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.fxInvalidFulfilment()) + } + t.end() + }) + + methodTest.end() + }) + + fxFulfilTest.end() +}) diff --git a/test/unit/handlers/transfers/fxFulfilHandler.test.js b/test/unit/handlers/transfers/fxFulfilHandler.test.js new file mode 100644 index 000000000..de7faf44c --- /dev/null +++ b/test/unit/handlers/transfers/fxFulfilHandler.test.js @@ -0,0 +1,532 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Gates Foundation + - Name Surname + + * Georgi Georgiev + * Rajiv Mothilal + * Miguel de Barros + * Deon Botha + * Shashikant Hirugade + + -------------- + ******/ +'use strict' + +const Sinon = require('sinon') +const Test = require('tapes')(require('tape')) +const Proxyquire = require('proxyquire') + +const { Util, Enum } = require('@mojaloop/central-services-shared') +const { Consumer, Producer } = require('@mojaloop/central-services-stream').Util + +const FxFulfilService = require('../../../../src/handlers/transfers/FxFulfilService') +const ParticipantPositionChangesModel = require('../../../../src/models/position/participantPositionChanges') +const fxTransferModel = require('../../../../src/models/fxTransfer') +const TransferFacade = require('../../../../src/models/transfer/facade') +const Validator = require('../../../../src/handlers/transfers/validator') +const TransferObjectTransform = require('../../../../src/domain/transfer/transform') +const fspiopErrorFactory = require('../../../../src/shared/fspiopErrorFactory') +const { logger } = require('../../../../src/shared/logger') + +const { checkErrorPayload } = require('../../../util/helpers') +const fixtures = require('../../../fixtures') +const mocks = require('./mocks') +const ProxyCache = require('#src/lib/proxyCache') + +const { Kafka, Comparators } = Util +const { Action, Type } = Enum.Events.Event +const { TransferState } = Enum.Transfers +const { TOPICS } = fixtures + +let transferHandlers + +Test('FX Transfer Fulfil handler -->', fxFulfilTest => { + let sandbox + let producer + + fxFulfilTest.beforeEach(test => { + sandbox = Sinon.createSandbox() + producer = sandbox.stub(Producer) + + const { TracerStub } = mocks.createTracerStub(sandbox) + const EventSdkStub = { + Tracer: TracerStub + } + transferHandlers = Proxyquire('../../../../src/handlers/transfers/handler', { + '@mojaloop/event-sdk': EventSdkStub + }) + + sandbox.stub(Comparators) + sandbox.stub(Validator) + sandbox.stub(fxTransferModel.fxTransfer) + sandbox.stub(fxTransferModel.watchList) + sandbox.stub(ParticipantPositionChangesModel) + sandbox.stub(TransferFacade) + sandbox.stub(TransferObjectTransform, 'toFulfil') + sandbox.stub(Consumer, 'getConsumer').returns({ + commitMessageSync: async () => true + }) + sandbox.stub(Consumer, 'isConsumerAutoCommitEnabled').returns(false) + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) + test.end() + }) + + fxFulfilTest.afterEach(test => { + sandbox.restore() + test.end() + }) + + fxFulfilTest.test('should return true in case of wrong message format', async (test) => { + const logError = sandbox.stub(logger, 'error') + const result = await transferHandlers.fulfil(null, {}) + test.ok(result) + test.ok(logError.calledOnce) + test.ok(logError.lastCall.firstArg.includes("Cannot read properties of undefined (reading 'metadata')")) + test.end() + }) + + fxFulfilTest.test('commitRequestId not found -->', async (test) => { + const from = fixtures.DFSP1_ID + const to = fixtures.DFSP2_ID + const notFoundError = fspiopErrorFactory.fxTransferNotFound() + let message + + test.beforeEach((t) => { + message = fixtures.fxFulfilKafkaMessageDto({ + from, + to, + metadata: fixtures.fulfilMetadataDto({ action: Action.FX_RESERVE }) + }) + fxTransferModel.fxTransfer.getByIdLight.resolves(null) + t.end() + }) + + test.test('should call Kafka.proceed with proper fspiopError', async (t) => { + sandbox.stub(Kafka, 'proceed') + const result = await transferHandlers.fulfil(null, message) + + t.ok(result) + t.ok(Kafka.proceed.calledOnce) + const [, params, opts] = Kafka.proceed.lastCall.args + t.equal(params.message, message) + t.equal(params.kafkaTopic, message.topic) + t.deepEqual(opts.eventDetail, { + functionality: 'notification', + action: Action.FX_RESERVE + }) + t.true(opts.fromSwitch) + checkErrorPayload(t)(opts.fspiopError, notFoundError) + t.end() + }) + + test.test('should produce proper kafka error message', async (t) => { + const result = await transferHandlers.fulfil(null, message) + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(topicConfig.topicName, TOPICS.notificationEvent) // check if we have appropriate task/test for FX notification handler + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.to, from) + t.equal(messageProtocol.metadata, message.value.metadata) + t.equal(messageProtocol.id, message.value.id) + t.equal(messageProtocol.content.uriParams, message.value.content.uriParams) + checkErrorPayload(t)(messageProtocol.content.payload, notFoundError) + t.end() + }) + + test.end() + }) + + fxFulfilTest.test('should throw fxValidation error if source-header does not match counterPartyFsp-field from DB', async (t) => { + const initiatingFsp = fixtures.DFSP1_ID + const counterPartyFsp = fixtures.FXP_ID + const fxTransferPayload = fixtures.fxTransferDto({ initiatingFsp, counterPartyFsp }) + const fxTransferDetailsFromDb = fixtures.fxtGetAllDetailsByCommitRequestIdDto(fxTransferPayload) + + fxTransferModel.fxTransfer.getAllDetailsByCommitRequestId.resolves(fxTransferDetailsFromDb) + fxTransferModel.fxTransfer.saveFxFulfilResponse.resolves({}) + fxTransferModel.fxTransfer.getByCommitRequestId.resolves(fxTransferDetailsFromDb) + fxTransferModel.fxTransfer.getByDeterminingTransferId.resolves([]) + fxTransferModel.fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.resolves(fxTransferDetailsFromDb) + const mockPositionChanges = [ + { participantCurrencyId: 1, value: 100 } + ] + ParticipantPositionChangesModel.getReservedPositionChangesByCommitRequestId.resolves([]) + ParticipantPositionChangesModel.getReservedPositionChangesByTransferId.resolves(mockPositionChanges) + TransferFacade.getById.resolves({ payerfsp: 'testpayer' }) + + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_RESERVE }) + const content = fixtures.fulfilContentDto({ + from: 'wrongCounterPartyId', + to: initiatingFsp + }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ content, metadata }) + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_ABORT_VALIDATION) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.fxHeaderSourceValidationError()) + t.ok(topicConfig.topicName === TOPICS.transferPosition || topicConfig.topicName === TOPICS.transferPositionBatch) + t.end() + }) + + fxFulfilTest.test('should detect invalid event type', async (t) => { + const type = 'wrongType' + const action = Action.FX_RESERVE + const metadata = fixtures.fulfilMetadataDto({ type, action }) + const content = fixtures.fulfilContentDto({ + to: fixtures.DFSP1_ID, + from: fixtures.FXP_ID + }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata, content }) + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + fxTransferModel.fxTransfer.getAllDetailsByCommitRequestId.resolves(fxTransferDetails) + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.metadata.event.action, action) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.invalidEventType(type)) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.test('should process case with invalid fulfilment', async (t) => { + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves(fxTransferDetails) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + const initiatingFsp = fixtures.DFSP1_ID + const counterPartyFsp = fixtures.FXP_ID + const fxTransferPayload = fixtures.fxTransferDto({ initiatingFsp, counterPartyFsp }) + const fxTransferDetailsFromDb = fixtures.fxtGetAllDetailsByCommitRequestIdDto(fxTransferPayload) + fxTransferModel.fxTransfer.getByCommitRequestId.resolves(fxTransferDetailsFromDb) + fxTransferModel.fxTransfer.getByDeterminingTransferId.resolves([]) + fxTransferModel.fxTransfer.getAllDetailsByCommitRequestIdForProxiedFxTransfer.resolves(fxTransferDetailsFromDb) + const mockPositionChanges = [ + { participantCurrencyId: 1, value: 100 } + ] + ParticipantPositionChangesModel.getReservedPositionChangesByCommitRequestId.resolves([]) + ParticipantPositionChangesModel.getReservedPositionChangesByTransferId.resolves(mockPositionChanges) + TransferFacade.getById.resolves({ payerfsp: 'testpayer' }) + + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_RESERVE }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + Validator.validateFulfilCondition.returns(false) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.metadata.event.action, Action.FX_ABORT_VALIDATION) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.fxInvalidFulfilment()) + t.ok(topicConfig.topicName === TOPICS.transferPosition || topicConfig.topicName === TOPICS.transferPositionBatch) + t.equal(topicConfig.key, String(1)) + t.end() + }) + + fxFulfilTest.test('should detect invalid fxTransfer state', async (t) => { + const transferState = 'wrongState' + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({ transferState }) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_RESERVE }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_RESERVE) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.fxTransferNonReservedState()) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.test('should detect expired fxTransfer', async (t) => { + const expirationDate = new Date(Date.now() - 1000 ** 3) + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({ expirationDate }) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateTransferState').resolves() + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_RESERVE }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_RESERVE) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.fxTransferExpired()) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.test('should skip message with fxReject action', async (t) => { + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves(fxTransferDetails) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateTransferState').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateExpirationDate').resolves() + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_REJECT }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.notCalled) + t.end() + }) + + fxFulfilTest.test('should process error callback with fxAbort action', async (t) => { + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves(fxTransferDetails) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateTransferState').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateExpirationDate').resolves() + sandbox.stub(FxFulfilService.prototype, 'processFxAbort').resolves() + + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + const errorInfo = fixtures.errorInfoDto() + const content = fixtures.fulfilContentDto({ payload: errorInfo }) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_ABORT }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ content, metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(FxFulfilService.prototype.processFxAbort.calledOnce) + t.end() + }) + + fxFulfilTest.test('should process fxFulfil callback - just skip message if no commitRequestId in watchList', async (t) => { + // todo: clarify this behaviuor + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves(fxTransferDetails) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateTransferState').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateExpirationDate').resolves() + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + fxTransferModel.watchList.getItemInWatchListByCommitRequestId.resolves(null) + const metadata = fixtures.fulfilMetadataDto({ action: Action.FX_COMMIT }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.notCalled) + t.end() + }) + + fxFulfilTest.test('should process fxFulfil callback (commitRequestId is in watchList)', async (t) => { + const fxTransferDetails = fixtures.fxtGetAllDetailsByCommitRequestIdDto() + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves(fxTransferDetails) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateEventType').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateFulfilment').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateTransferState').resolves() + sandbox.stub(FxFulfilService.prototype, 'validateExpirationDate').resolves() + sandbox.stub(FxFulfilService.prototype, 'getDuplicateCheckResult').resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: false, + hasDuplicateHash: false + }) + Validator.validateFulfilCondition.returns(true) + fxTransferModel.fxTransfer.getAllDetailsByCommitRequestId.resolves(fxTransferDetails) + fxTransferModel.watchList.getItemInWatchListByCommitRequestId.resolves(fixtures.watchListItemDto()) + + const action = Action.FX_RESERVE + const metadata = fixtures.fulfilMetadataDto({ action }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.metadata.event.action, action) + t.deepEqual(messageProtocol.metadata.event.state, fixtures.metadataEventStateDto()) + t.deepEqual(messageProtocol.content, kafkaMessage.value.content) + // t.equal(topicConfig.topicName, TOPICS.transferPositionBatch) + // TODO: Need to check if the following assertion is correct + t.equal(topicConfig.topicName, TOPICS.transferPosition) + t.equal(topicConfig.key, String(fxTransferDetails.counterPartyFspSourceParticipantCurrencyId)) + t.end() + }) + + fxFulfilTest.test('should detect that duplicate hash was modified', async (t) => { + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: true, + hasDuplicateHash: false + }) + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({}) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + + const action = Action.FX_COMMIT + const metadata = fixtures.fulfilMetadataDto({ action }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_FULFIL_DUPLICATE) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopErrorFactory.noFxDuplicateHash()) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.test('should process duplication if fxTransfer state is COMMITTED', async (t) => { + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: true, + hasDuplicateHash: true + }) + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({ transferStateEnumeration: TransferState.COMMITTED }) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + + const action = Action.FX_COMMIT + const metadata = fixtures.fulfilMetadataDto({ action }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.content.payload, undefined) + t.equal(messageProtocol.metadata.event.action, Action.FX_FULFIL_DUPLICATE) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.test('should just skip processing duplication if fxTransfer state is RESERVED/RECEIVED', async (t) => { + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: true, + hasDuplicateHash: true + }) + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({ transferStateEnumeration: TransferState.RESERVED }) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + + const action = Action.FX_RESERVE + const metadata = fixtures.fulfilMetadataDto({ action }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.notCalled) + // todo: clarify, if it's expected behaviour + t.end() + }) + + fxFulfilTest.test('should process duplication if fxTransfer has invalid state', async (t) => { + Comparators.duplicateCheckComparator.resolves({ + hasDuplicateId: true, + hasDuplicateHash: true + }) + const transferStateEnumeration = TransferState.SETTLED + sandbox.stub(FxFulfilService.prototype, 'getFxTransferDetails').resolves({ transferStateEnumeration }) + sandbox.stub(FxFulfilService.prototype, 'validateHeaders').resolves() + + const action = Action.FX_COMMIT + const type = Type.FULFIL + const metadata = fixtures.fulfilMetadataDto({ action, type }) + const kafkaMessage = fixtures.fxFulfilKafkaMessageDto({ metadata }) + + const result = await transferHandlers.fulfil(null, kafkaMessage) + + t.ok(result) + t.ok(producer.produceMessage.calledOnce) + const [messageProtocol, topicConfig] = producer.produceMessage.lastCall.args + t.equal(messageProtocol.from, fixtures.SWITCH_ID) + t.equal(messageProtocol.metadata.event.action, Action.FX_RESERVE) + const fspiopError = fspiopErrorFactory.invalidFxTransferState({ + transferStateEnum: transferStateEnumeration, + type, + action + }) + checkErrorPayload(t)(messageProtocol.content.payload, fspiopError) + t.equal(topicConfig.topicName, TOPICS.notificationEvent) + t.end() + }) + + fxFulfilTest.end() +}) diff --git a/test/unit/handlers/transfers/handler.test.js b/test/unit/handlers/transfers/handler.test.js index cd8677adb..5c86e2bee 100644 --- a/test/unit/handlers/transfers/handler.test.js +++ b/test/unit/handlers/transfers/handler.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -32,27 +32,33 @@ ******/ 'use strict' +const { randomUUID } = require('crypto') const Sinon = require('sinon') const Test = require('tapes')(require('tape')) +const Proxyquire = require('proxyquire') + const Kafka = require('@mojaloop/central-services-shared').Util.Kafka -const Validator = require('../../../../src/handlers/transfers/validator') -const TransferService = require('../../../../src/domain/transfer') -const TransferObjectTransform = require('../../../../src/domain/transfer/transform') const MainUtil = require('@mojaloop/central-services-shared').Util const Time = require('@mojaloop/central-services-shared').Util.Time -const ilp = require('../../../../src/models/transfer/ilpPacket') -const { randomUUID } = require('crypto') -const KafkaConsumer = require('@mojaloop/central-services-stream').Kafka.Consumer -const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const Enum = require('@mojaloop/central-services-shared').Enum +const Comparators = require('@mojaloop/central-services-shared').Util.Comparators +const KafkaConsumer = require('@mojaloop/central-services-stream').Kafka.Consumer +const { Consumer } = require('@mojaloop/central-services-stream').Util const EventSdk = require('@mojaloop/event-sdk') + +const Validator = require('../../../../src/handlers/transfers/validator') +const TransferService = require('../../../../src/domain/transfer') +const Participant = require('../../../../src/domain/participant') +const Cyril = require('../../../../src/domain/fx/cyril') +const TransferObjectTransform = require('../../../../src/domain/transfer/transform') +const ilp = require('../../../../src/models/transfer/ilpPacket') +const ProxyCache = require('#src/lib/proxyCache') + +const { getMessagePayloadOrThrow } = require('../../../util/helpers') +const mocks = require('./mocks') + const TransferState = Enum.Transfers.TransferState const TransferInternalState = Enum.Transfers.TransferInternalState -const Comparators = require('@mojaloop/central-services-shared').Util.Comparators -const Proxyquire = require('proxyquire') -const { getMessagePayloadOrThrow } = require('../../../util/helpers') -const Participant = require('../../../../src/domain/participant') -const Config = require('../../../../src/lib/config') const transfer = { transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', @@ -235,601 +241,96 @@ const config = { } } -const configAutocommit = { - options: { - mode: 2, - batchSize: 1, - pollFrequency: 10, - recursiveTimeout: 100, - messageCharset: 'utf8', - messageAsJSON: true, - sync: true, - consumeTimeout: 1000 - }, - rdkafkaConf: { - 'client.id': 'kafka-test', - debug: 'all', - 'group.id': 'central-ledger-kafka', - 'metadata.broker.list': 'localhost:9092', - 'enable.auto.commit': true - } -} - const command = () => { } -const error = () => { - throw new Error() -} - let SpanStub let allTransferHandlers +let prepare +let createRemittanceEntity -const participants = ['testName1', 'testName2'] - -Test('Transfer handler', transferHandlerTest => { - let sandbox - - transferHandlerTest.beforeEach(test => { - sandbox = Sinon.createSandbox() - SpanStub = { - audit: sandbox.stub().callsFake(), - error: sandbox.stub().callsFake(), - finish: sandbox.stub().callsFake(), - debug: sandbox.stub().callsFake(), - info: sandbox.stub().callsFake(), - getChild: sandbox.stub().returns(SpanStub), - setTags: sandbox.stub().callsFake() - } - - const TracerStub = { - extractContextFromMessage: sandbox.stub().callsFake(() => { - return {} - }), - createChildSpanFromContext: sandbox.stub().callsFake(() => { - return SpanStub - }) - } - - const EventSdkStub = { - Tracer: TracerStub - } - - allTransferHandlers = Proxyquire('../../../../src/handlers/transfers/handler', { - '@mojaloop/event-sdk': EventSdkStub - }) - - sandbox.stub(KafkaConsumer.prototype, 'constructor').returns(Promise.resolve()) - sandbox.stub(KafkaConsumer.prototype, 'connect').returns(Promise.resolve()) - sandbox.stub(KafkaConsumer.prototype, 'consume').returns(Promise.resolve()) - sandbox.stub(KafkaConsumer.prototype, 'commitMessageSync').returns(Promise.resolve()) - sandbox.stub(Comparators) - sandbox.stub(Validator) - sandbox.stub(TransferService) - sandbox.stub(Consumer, 'getConsumer').returns({ - commitMessageSync: async function () { - return true - } - }) - sandbox.stub(Consumer, 'isConsumerAutoCommitEnabled').returns(false) - sandbox.stub(ilp) - sandbox.stub(Kafka) - sandbox.stub(MainUtil.StreamingProtocol) - sandbox.stub(TransferObjectTransform, 'toTransfer') - sandbox.stub(TransferObjectTransform, 'toFulfil') - sandbox.stub(Participant, 'getAccountByNameAndCurrency').callsFake((...args) => { - if (args[0] === transfer.payerFsp) { - return { - participantCurrencyId: 0 - } - } - if (args[0] === transfer.payeeFsp) { - return { - participantCurrencyId: 1 - } - } - }) - Kafka.produceGeneralMessage.returns(Promise.resolve()) - test.end() - }) - - transferHandlerTest.afterEach(test => { - sandbox.restore() - test.end() - }) - - transferHandlerTest.test('prepare should', prepareTest => { - prepareTest.test('persist transfer to database when messages is an array', async (test) => { - const localMessages = MainUtil.clone(messages) - // here copy - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - const kafkaCallOne = Kafka.proceed.getCall(0) - test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) - test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) - test.equal(kafkaCallOne.args[2].messageKey, '0') - test.equal(kafkaCallOne.args[2].topicNameOverride, null) - test.equal(result, true) - test.end() - }) - - prepareTest.test('use topic name override if specified in config', async (test) => { - Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP.POSITION.PREPARE = 'topic-test-override' - const localMessages = MainUtil.clone(messages) - // here copy - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - const kafkaCallOne = Kafka.proceed.getCall(0) - test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) - test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) - test.equal(kafkaCallOne.args[2].messageKey, '0') - test.equal(kafkaCallOne.args[2].topicNameOverride, 'topic-test-override') - test.equal(result, true) - delete Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP.POSITION.PREPARE - test.end() - }) - - prepareTest.test('persist transfer to database when messages is an array - consumer throws error', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Consumer.getConsumer.throws(new Error()) - Kafka.transformAccountToTopicName.returns(topicName) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - const kafkaCallOne = Kafka.proceed.getCall(0) - test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) - test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) - test.equal(kafkaCallOne.args[2].messageKey, '0') - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate found but without transferState', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.getByIdLight.returns(Promise.resolve(null)) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate found but without transferState - autocommit is enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.getByIdLight.returns(Promise.resolve(null)) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate found but without transferState - kafka autocommit enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, configAutocommit, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.getByIdLight.returns(Promise.resolve(null)) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate found and transferState is COMMITTED', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getByIdLight.withArgs(transfer.transferId).returns(Promise.resolve(transferReturn)) - TransferObjectTransform.toTransfer.withArgs(transferReturn).returns(transfer) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate found and transferState is ABORTED_REJECTED', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'ABORTED' })) - TransferService.getById.withArgs(transfer.transferId).returns(Promise.resolve(transferReturn)) - - TransferObjectTransform.toFulfil.withArgs(transferReturn).returns(fulfil) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('do nothing when duplicate found and transferState is RECEIVED', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'RECEIVED' })) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('do nothing when duplicate found and transferState is RECEIVED', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'unknown' })) - localMessages[0].value.metadata.event.action = 'unknown' - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('do nothing when duplicate found and transferState is RESERVED', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'RESERVED' })) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate transfer id found but hash doesnt match', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: true - })) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send callback when duplicate transfer id found but hash doesnt match - kafka autocommit enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, configAutocommit, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: true, - hasDuplicateHash: false - })) - - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('persist transfer to database when single message sent', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages[0]) - test.equal(result, true) - test.end() - }) - - prepareTest.test('persist transfer to database when BULK_PREPARE single message sent', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages[1]) - test.equal(result, true) - test.end() - }) - - prepareTest.test('persist transfer to database when single message sent - autocommit is enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages[0]) - test.equal(result, true) - test.end() - }) - - prepareTest.test('persist transfer to database when single message sent - kafka autocommit enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, configAutocommit, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages[0]) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send notification when validation successful but duplicate error thrown by prepare', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.throws(new Error()) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send notification when validation successful but duplicate error thrown by prepare - kafka autocommit enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, configAutocommit, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) - TransferService.prepare.throws(new Error()) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('fail validation and persist INVALID transfer to database and insert transferError', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - MainUtil.StreamingProtocol.createEventState.returns(messageProtocol.metadata.event.state) - Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) - TransferService.getById.returns(Promise.resolve(null)) - TransferService.prepare.returns(Promise.resolve(true)) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('fail validation and persist INVALID transfer to database and insert transferError -kafka autocommit enabled', async (test) => { - await Consumer.createHandler(topicName, configAutocommit, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - MainUtil.StreamingProtocol.createEventState.returns(messageProtocol.metadata.event.state) - Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) - TransferService.prepare.returns(Promise.resolve(true)) - - const result = await allTransferHandlers.prepare(null, messages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send notification when validation failed and duplicate error thrown by prepare', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) - TransferService.prepare.throws(new Error()) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() - }) - - prepareTest.test('send notification when validation failed and duplicate error thrown by prepare - kafka autocommit enabled', async (test) => { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, configAutocommit, command) - Consumer.isConsumerAutoCommitEnabled.returns(true) - Kafka.transformAccountToTopicName.returns(topicName) - Kafka.proceed.returns(true) - Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) - TransferService.prepare.throws(new Error()) - TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) - TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) - Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ - hasDuplicateId: false, - hasDuplicateHash: false - })) - const result = await allTransferHandlers.prepare(null, localMessages) - test.equal(result, true) - test.end() +const participants = ['testName1', 'testName2'] + +const cyrilStub = async (payload) => ({ + participantName: payload.payerFsp, + currencyId: payload.amount.currency, + amount: payload.amount.amount +}) + +Test('Transfer handler', transferHandlerTest => { + let sandbox + + transferHandlerTest.beforeEach(test => { + sandbox = Sinon.createSandbox() + sandbox.stub(ProxyCache, 'getCache').returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() }) + sandbox.stub(ProxyCache, 'getProxyParticipantAccountDetails').resolves({ inScheme: true, participantCurrencyId: 1 }) + sandbox.stub(ProxyCache, 'checkSameCreditorDebtorProxy').resolves(false) + const stubs = mocks.createTracerStub(sandbox) + SpanStub = stubs.SpanStub - prepareTest.test('log an error when consumer not found', async (test) => { - try { - const localMessages = MainUtil.clone(messages) - await Consumer.createHandler(topicName, config, command) - Kafka.transformAccountToTopicName.returns('invalid-topic') - await allTransferHandlers.prepare(null, localMessages) - const expectedState = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, '2001', 'Internal server error') - const args = SpanStub.finish.getCall(0).args - test.ok(args[0].length > 0) - test.deepEqual(args[1], expectedState) - test.end() - } catch (e) { - test.fail('Error Thrown') - test.end() + const EventSdkStub = { + Tracer: stubs.TracerStub + } + + createRemittanceEntity = Proxyquire('../../../../src/handlers/transfers/createRemittanceEntity', { + '../../domain/fx/cyril': { + getParticipantAndCurrencyForTransferMessage: cyrilStub, + getParticipantAndCurrencyForFxTransferMessage: cyrilStub } }) + prepare = Proxyquire('../../../../src/handlers/transfers/prepare', { + '@mojaloop/event-sdk': EventSdkStub, + './createRemittanceEntity': createRemittanceEntity + }) + allTransferHandlers = Proxyquire('../../../../src/handlers/transfers/handler', { + '@mojaloop/event-sdk': EventSdkStub, + './prepare': prepare + }) - prepareTest.test('throw an error when an error is thrown from Kafka', async (test) => { - try { - await allTransferHandlers.prepare(error, null) - test.fail('No Error Thrown') - test.end() - } catch (e) { - test.pass('Error Thrown') - test.end() + sandbox.stub(KafkaConsumer.prototype, 'constructor').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'connect').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'consume').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'commitMessageSync').returns(Promise.resolve()) + sandbox.stub(Comparators) + sandbox.stub(Validator) + sandbox.stub(TransferService) + sandbox.stub(Cyril) + Cyril.processFulfilMessage.returns({ + isFx: false + }) + sandbox.stub(Consumer, 'getConsumer').returns({ + commitMessageSync: async function () { + return true + } + }) + sandbox.stub(Consumer, 'isConsumerAutoCommitEnabled').returns(false) + sandbox.stub(ilp) + sandbox.stub(Kafka) + sandbox.stub(MainUtil.StreamingProtocol) + sandbox.stub(TransferObjectTransform, 'toTransfer') + sandbox.stub(TransferObjectTransform, 'toFulfil') + sandbox.stub(Participant, 'getAccountByNameAndCurrency').callsFake((...args) => { + if (args[0] === transfer.payerFsp) { + return { + participantCurrencyId: 0 + } + } + if (args[0] === transfer.payeeFsp) { + return { + participantCurrencyId: 1 + } } }) + Kafka.produceGeneralMessage.returns(Promise.resolve()) + test.end() + }) - prepareTest.end() + transferHandlerTest.afterEach(test => { + sandbox.restore() + test.end() }) transferHandlerTest.test('register getTransferHandler should', registerTransferhandler => { @@ -1128,6 +629,12 @@ Test('Transfer handler', transferHandlerTest => { })) Validator.validateFulfilCondition.returns(false) Kafka.proceed.returns(true) + Cyril.processAbortMessage.returns({ + isFx: false, + positionChanges: [{ + participantCurrencyId: 1 + }] + }) // Act const result = await allTransferHandlers.fulfil(null, localfulfilMessages) @@ -1472,6 +979,115 @@ Test('Transfer handler', transferHandlerTest => { const localfulfilMessages = MainUtil.clone(fulfilMessages) await Consumer.createHandler(topicName, config, command) Kafka.transformGeneralTopicName.returns(topicName) + + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'dfsp1', + transferState: TransferState.RESERVED + })) + ilp.update.returns(Promise.resolve()) + Validator.validateFulfilCondition.returns(true) + localfulfilMessages[0].value.content.headers['fspiop-source'] = 'dfsp2' + localfulfilMessages[0].value.content.headers['fspiop-destination'] = 'dfsp1' + localfulfilMessages[0].value.content.payload.fulfilment = 'condition' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, localfulfilMessages[0].value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.fulfil(null, localfulfilMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.COMMIT) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(result, true) + test.end() + }) + + fulfilTest.test('produce message to position topic when validations pass with RESERVED_FORWARDED state', async (test) => { + const localfulfilMessages = MainUtil.clone(fulfilMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'proxyFsp', + transferState: TransferInternalState.RESERVED_FORWARDED + })) + ilp.update.returns(Promise.resolve()) + Validator.validateFulfilCondition.returns(true) + localfulfilMessages[0].value.content.headers['fspiop-source'] = 'dfsp2' + localfulfilMessages[0].value.content.headers['fspiop-destination'] = 'proxyFsp' + localfulfilMessages[0].value.content.payload.fulfilment = 'condition' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, localfulfilMessages[0].value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.fulfil(null, localfulfilMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.COMMIT) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(result, true) + test.end() + }) + + fulfilTest.test('fail if event type is not fulfil', async (test) => { + const localfulfilMessages = MainUtil.clone(fulfilMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'dfsp1', + transferState: TransferState.RESERVED + })) + ilp.update.returns(Promise.resolve()) + Validator.validateFulfilCondition.returns(true) + localfulfilMessages[0].value.content.headers['fspiop-source'] = 'dfsp2' + localfulfilMessages[0].value.content.headers['fspiop-destination'] = 'dfsp1' + localfulfilMessages[0].value.content.payload.fulfilment = 'condition' + localfulfilMessages[0].value.metadata.event.type = 'invalid_event_type' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, localfulfilMessages[0].value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.fulfil(null, localfulfilMessages) + + test.equal(result, true) + test.end() + }) + + fulfilTest.test('produce message to position topic when validations pass if Cyril result is fx enabled', async (test) => { + const localfulfilMessages = MainUtil.clone(fulfilMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Cyril.processFulfilMessage.returns({ + isFx: true, + positionChanges: [{ + participantCurrencyId: 1 + }] + }) + TransferService.getById.returns(Promise.resolve({ condition: 'condition', payeeFsp: 'dfsp2', @@ -1502,6 +1118,80 @@ Test('Transfer handler', transferHandlerTest => { test.end() }) + fulfilTest.test('produce message to position topic when validations pass if Cyril result is fx enabled on RESERVED_FORWARDED transfer state', async (test) => { + const localfulfilMessages = MainUtil.clone(fulfilMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Cyril.processFulfilMessage.returns({ + isFx: true, + positionChanges: [{ + participantCurrencyId: 1 + }] + }) + + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'dfsp1', + transferState: TransferInternalState.RESERVED_FORWARDED + })) + ilp.update.returns(Promise.resolve()) + Validator.validateFulfilCondition.returns(true) + localfulfilMessages[0].value.content.headers['fspiop-source'] = 'dfsp2' + localfulfilMessages[0].value.content.headers['fspiop-destination'] = 'dfsp1' + localfulfilMessages[0].value.content.payload.fulfilment = 'condition' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, localfulfilMessages[0].value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.fulfil(null, localfulfilMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.COMMIT) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(result, true) + test.end() + }) + + fulfilTest.test('fail when Cyril result contains no positionChanges', async (test) => { + const localfulfilMessages = MainUtil.clone(fulfilMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Cyril.processFulfilMessage.returns({ + isFx: true, + positionChanges: [] + }) + + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'dfsp1', + transferState: TransferState.RESERVED + })) + ilp.update.returns(Promise.resolve()) + Validator.validateFulfilCondition.returns(true) + localfulfilMessages[0].value.content.headers['fspiop-source'] = 'dfsp2' + localfulfilMessages[0].value.content.headers['fspiop-destination'] = 'dfsp1' + localfulfilMessages[0].value.content.payload.fulfilment = 'condition' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, localfulfilMessages[0].value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.fulfil(null, localfulfilMessages) + test.equal(result, true) + test.end() + }) + fulfilTest.test('produce message to position topic when validations pass and action is RESERVE', async (test) => { const localfulfilMessages = MainUtil.clone(fulfilMessages) localfulfilMessages[0].value.metadata.event.action = 'reserve' @@ -2213,6 +1903,36 @@ Test('Transfer handler', transferHandlerTest => { test.end() }) + fulfilTest.test('set transfer ABORTED when valid errorInformation is provided from RESERVED_FORWARDED state', async (test) => { + const invalidEventMessage = MainUtil.clone(fulfilMessages)[0] + await Consumer.createHandler(topicName, config, command) + Kafka.transformGeneralTopicName.returns(topicName) + Validator.validateFulfilCondition.returns(true) + TransferService.getById.returns(Promise.resolve({ + condition: 'condition', + payeeFsp: 'dfsp2', + payerFsp: 'dfsp1', + transferState: TransferInternalState.RESERVED_FORWARDED + })) + TransferService.handlePayeeResponse.returns(Promise.resolve({ transferErrorRecord: { errorCode: '5000', errorDescription: 'error text' } })) + invalidEventMessage.value.metadata.event.action = 'abort' + invalidEventMessage.value.content.payload = errInfo + invalidEventMessage.value.content.headers['fspiop-source'] = 'dfsp2' + invalidEventMessage.value.content.headers['fspiop-destination'] = 'dfsp1' + Kafka.proceed.returns(true) + + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, invalidEventMessage.value.content.payload).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.fulfil(null, invalidEventMessage) + test.equal(result, true) + test.end() + }) + fulfilTest.test('log error', async (test) => { // TODO: extend and enable unit test const invalidEventMessage = MainUtil.clone(fulfilMessages)[0] await Consumer.createHandler(topicName, config, command) @@ -2289,6 +2009,7 @@ Test('Transfer handler', transferHandlerTest => { transferHandlerTest.test('reject should', rejectTest => { rejectTest.test('throw', async (test) => { try { + // todo: clarify, what the test is about? await allTransferHandlers.reject() test.fail('No Error Thrown') test.end() diff --git a/test/unit/handlers/transfers/mocks.js b/test/unit/handlers/transfers/mocks.js new file mode 100644 index 000000000..9b87cc865 --- /dev/null +++ b/test/unit/handlers/transfers/mocks.js @@ -0,0 +1,60 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + + * Mojaloop Foundation + - Name Surname + + * Infitx + - Vijay Kumar Guthi + - Kevin Leyow + - Kalin Krustev + - Steven Oderayi + - Eugen Klymniuk + + -------------- + + ******/ + +const createTracerStub = (sandbox, context = {}) => { + /* eslint-disable prefer-const */ + let SpanStub + SpanStub = { + audit: sandbox.stub().callsFake(), + error: sandbox.stub().callsFake(), + finish: sandbox.stub().callsFake(), + debug: sandbox.stub().callsFake(), + info: sandbox.stub().callsFake(), + getChild: sandbox.stub().returns(SpanStub), + setTags: sandbox.stub().callsFake(), + injectContextToMessage: sandbox.stub().callsFake(msg => msg) + } + + const TracerStub = { + extractContextFromMessage: sandbox.stub().callsFake(() => context), + createChildSpanFromContext: sandbox.stub().callsFake(() => SpanStub) + } + + return { TracerStub, SpanStub } +} + +module.exports = { + createTracerStub +} diff --git a/test/unit/handlers/transfers/prepare.test.js b/test/unit/handlers/transfers/prepare.test.js new file mode 100644 index 000000000..5117d87b5 --- /dev/null +++ b/test/unit/handlers/transfers/prepare.test.js @@ -0,0 +1,1696 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + +* Gates Foundation +- Name Surname + +* Georgi Georgiev +* Rajiv Mothilal +* Miguel de Barros +* Deon Botha +* Shashikant Hirugade + +-------------- +******/ +'use strict' + +const Sinon = require('sinon') +const Test = require('tapes')(require('tape')) +const Kafka = require('@mojaloop/central-services-shared').Util.Kafka +const ErrorHandler = require('@mojaloop/central-services-error-handling') +const Validator = require('../../../../src/handlers/transfers/validator') +const TransferService = require('../../../../src/domain/transfer') +const FxTransferService = require('../../../../src/domain/fx') +const Cyril = require('../../../../src/domain/fx/cyril') +const TransferObjectTransform = require('../../../../src/domain/transfer/transform') +const MainUtil = require('@mojaloop/central-services-shared').Util +const ilp = require('../../../../src/models/transfer/ilpPacket') +const { randomUUID } = require('crypto') +const KafkaConsumer = require('@mojaloop/central-services-stream').Kafka.Consumer +const Consumer = require('@mojaloop/central-services-stream').Util.Consumer +const Enum = require('@mojaloop/central-services-shared').Enum +const EventSdk = require('@mojaloop/event-sdk') +const Comparators = require('@mojaloop/central-services-shared').Util.Comparators +const Proxyquire = require('proxyquire') +const Participant = require('../../../../src/domain/participant') +const Config = require('../../../../src/lib/config') +const fxTransferModel = require('../../../../src/models/fxTransfer') +const fxDuplicateCheck = require('../../../../src/models/fxTransfer/duplicateCheck') +const fxTransferStateChange = require('../../../../src/models/fxTransfer/stateChange') +const ProxyCache = require('../../../../src/lib/proxyCache') +const TransferModel = require('../../../../src/models/transfer/transfer') + +const { Action } = Enum.Events.Event + +const transfer = { + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + payerFsp: 'dfsp1', + payeeFsp: 'dfsp2', + amount: { + currency: 'USD', + amount: '433.88' + }, + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: '2016-05-24T08:38:08.699-04:00', + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } +} + +const fxTransfer = { + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + determiningTransferId: 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + initiatingFsp: 'fx_dfsp1', + counterPartyFsp: 'fx_dfsp2', + sourceAmount: { + currency: 'USD', + amount: '433.88' + }, + targetAmount: { + currency: 'EUR', + amount: '200.00' + } +} +const transferReturn = { + transferId: 'b51ec534-ee48-4575-b6a9-ead2955b8999', + amount: { + currency: 'USD', + amount: '433.88' + }, + transferState: 'COMMITTED', + transferStateEnumeration: 'COMMITTED', + completedTimestamp: '2016-05-15T18:44:38.000Z', + ilpPacket: 'AYIBgQAAAAAAAASwNGxldmVsb25lLmRmc3AxLm1lci45T2RTOF81MDdqUUZERmZlakgyOVc4bXFmNEpLMHlGTFGCAUBQU0svMS4wCk5vbmNlOiB1SXlweUYzY3pYSXBFdzVVc05TYWh3CkVuY3J5cHRpb246IG5vbmUKUGF5bWVudC1JZDogMTMyMzZhM2ItOGZhOC00MTYzLTg0NDctNGMzZWQzZGE5OGE3CgpDb250ZW50LUxlbmd0aDogMTM1CkNvbnRlbnQtVHlwZTogYXBwbGljYXRpb24vanNvbgpTZW5kZXItSWRlbnRpZmllcjogOTI4MDYzOTEKCiJ7XCJmZWVcIjowLFwidHJhbnNmZXJDb2RlXCI6XCJpbnZvaWNlXCIsXCJkZWJpdE5hbWVcIjpcImFsaWNlIGNvb3BlclwiLFwiY3JlZGl0TmFtZVwiOlwibWVyIGNoYW50XCIsXCJkZWJpdElkZW50aWZpZXJcIjpcIjkyODA2MzkxXCJ9IgA', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: '2016-05-24T08:38:08.699-04:00', + fulfilment: 'uz0FAeutW6o8Mz7OmJh8ALX6mmsZCcIDOqtE01eo4uI', + extensionList: [{ + key: 'key1', + value: 'value1' + }] +} + +const fulfil = { + fulfilment: 'oAKAAA', + completedTimestamp: '2018-10-24T08:38:08.699-04:00', + transferState: 'COMMITTED', + extensionList: { + extension: [ + { + key: 'key1', + value: 'value1' + }, + { + key: 'key2', + value: 'value2' + } + ] + } +} + +const messageProtocol = { + id: randomUUID(), + from: transfer.payerFsp, + to: transfer.payeeFsp, + type: 'application/json', + content: { + headers: { 'fspiop-destination': transfer.payerFsp, 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' }, + uriParams: { id: transfer.transferId }, + payload: transfer + }, + metadata: { + event: { + id: randomUUID(), + type: 'prepare', + action: 'prepare', + createdAt: new Date(), + state: { + status: 'success', + code: 0 + } + } + }, + pp: '' +} + +const fxMessageProtocol = { + id: randomUUID(), + from: fxTransfer.initiatingFsp, + to: fxTransfer.counterPartyFsp, + type: 'application/json', + content: { + headers: { + 'fspiop-destination': fxTransfer.initiatingFsp, + 'content-type': 'application/vnd.interoperability.transfers+json;version=1.1' + }, + uriParams: { id: fxTransfer.commitRequestId }, + payload: fxTransfer + }, + metadata: { + event: { + id: randomUUID(), + type: 'fx-prepare', + action: Action.FX_PREPARE, + createdAt: new Date(), + state: { + status: 'success', + code: 0 + } + } + }, + pp: '' +} + +const messageForwardedProtocol = { + id: randomUUID(), + from: '', + to: '', + type: 'application/json', + content: { + uriParams: { id: transfer.transferId }, + payload: { + proxyId: '', + transferId: transfer.transferId + } + }, + metadata: { + event: { + id: randomUUID(), + type: 'prepare', + action: 'forwarded', + createdAt: new Date(), + state: { + status: 'success', + code: 0 + } + } + }, + pp: '' +} + +const messageFxForwardedProtocol = { + id: randomUUID(), + from: '', + to: '', + type: 'application/json', + content: { + uriParams: { id: fxTransfer.commitRequestId }, + payload: { + proxyId: '', + commitRequestId: fxTransfer.commitRequestId + } + }, + metadata: { + event: { + id: randomUUID(), + type: 'prepare', + action: 'fx-forwarded', + createdAt: new Date(), + state: { + status: 'success', + code: 0 + } + } + }, + pp: '' +} + +const messageProtocolBulkPrepare = MainUtil.clone(messageProtocol) +messageProtocolBulkPrepare.metadata.event.action = 'bulk-prepare' +const messageProtocolBulkCommit = MainUtil.clone(messageProtocol) +messageProtocolBulkCommit.metadata.event.action = 'bulk-commit' + +const topicName = 'topic-test' + +const messages = [ + { + topic: topicName, + value: messageProtocol + }, + { + topic: topicName, + value: messageProtocolBulkPrepare + } +] + +const fxMessages = [ + { + topic: topicName, + value: fxMessageProtocol + } +] + +const forwardedMessages = [ + { + topic: topicName, + value: messageForwardedProtocol + } +] + +const fxForwardedMessages = [ + { + topic: topicName, + value: messageFxForwardedProtocol + } +] + +const config = { + options: { + mode: 2, + batchSize: 1, + pollFrequency: 10, + recursiveTimeout: 100, + messageCharset: 'utf8', + messageAsJSON: true, + sync: true, + consumeTimeout: 1000 + }, + rdkafkaConf: { + 'client.id': 'kafka-test', + debug: 'all', + 'group.id': 'central-ledger-kafka', + 'metadata.broker.list': 'localhost:9092', + 'enable.auto.commit': false + } +} + +const configAutocommit = { + options: { + mode: 2, + batchSize: 1, + pollFrequency: 10, + recursiveTimeout: 100, + messageCharset: 'utf8', + messageAsJSON: true, + sync: true, + consumeTimeout: 1000 + }, + rdkafkaConf: { + 'client.id': 'kafka-test', + debug: 'all', + 'group.id': 'central-ledger-kafka', + 'metadata.broker.list': 'localhost:9092', + 'enable.auto.commit': true + } +} + +const command = () => { +} + +const error = () => { + throw new Error() +} + +let SpanStub +let allTransferHandlers +let prepare +let createRemittanceEntity + +const cyrilStub = async (payload) => { + if (payload.determiningTransferId) { + return { + participantName: payload.initiatingFsp, + currencyId: payload.targetAmount.currency, + amount: payload.targetAmount.amount + } + } + if (payload.transferId === fxTransfer.determiningTransferId) { + return { + participantName: 'proxyAR', + currencyId: fxTransfer.targetAmount.currency, + amount: fxTransfer.targetAmount.amount + } + } + return { + participantName: payload.payerFsp, + currencyId: payload.amount.currency, + amount: payload.amount.amount + } +} + +Test('Transfer handler', transferHandlerTest => { + let sandbox + let getProxyCacheStub + let getFSPProxyStub + let checkSameCreditorDebtorProxyStub + + transferHandlerTest.beforeEach(test => { + sandbox = Sinon.createSandbox() + getProxyCacheStub = sandbox.stub(ProxyCache, 'getCache') + getProxyCacheStub.returns({ + connect: sandbox.stub(), + disconnect: sandbox.stub() + }) + SpanStub = { + audit: sandbox.stub().callsFake(), + error: sandbox.stub().callsFake(), + finish: sandbox.stub().callsFake(), + debug: sandbox.stub().callsFake(), + info: sandbox.stub().callsFake(), + getChild: sandbox.stub().returns(SpanStub), + setTags: sandbox.stub().callsFake() + } + + const TracerStub = { + extractContextFromMessage: sandbox.stub().callsFake(() => { + return {} + }), + createChildSpanFromContext: sandbox.stub().callsFake(() => { + return SpanStub + }) + } + + const EventSdkStub = { + Tracer: TracerStub + } + + createRemittanceEntity = Proxyquire('../../../../src/handlers/transfers/createRemittanceEntity', { + '../../domain/fx/cyril': { + getParticipantAndCurrencyForTransferMessage: cyrilStub, + getParticipantAndCurrencyForFxTransferMessage: cyrilStub, + getPositionParticipant: cyrilStub + } + }) + prepare = Proxyquire('../../../../src/handlers/transfers/prepare', { + '@mojaloop/event-sdk': EventSdkStub, + './createRemittanceEntity': createRemittanceEntity + }) + allTransferHandlers = Proxyquire('../../../../src/handlers/transfers/handler', { + '@mojaloop/event-sdk': EventSdkStub, + './prepare': prepare + }) + + sandbox.stub(KafkaConsumer.prototype, 'constructor').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'connect').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'consume').returns(Promise.resolve()) + sandbox.stub(KafkaConsumer.prototype, 'commitMessageSync').returns(Promise.resolve()) + sandbox.stub(Comparators) + sandbox.stub(Validator) + sandbox.stub(TransferService) + sandbox.stub(FxTransferService) + sandbox.stub(fxTransferModel.fxTransfer) + sandbox.stub(fxTransferModel.watchList) + sandbox.stub(fxDuplicateCheck) + sandbox.stub(fxTransferStateChange) + sandbox.stub(Cyril) + sandbox.stub(TransferModel) + Cyril.processFulfilMessage.returns({ + isFx: false + }) + sandbox.stub(Consumer, 'getConsumer').returns({ + commitMessageSync: async function () { + return true + } + }) + sandbox.stub(Consumer, 'isConsumerAutoCommitEnabled').returns(false) + sandbox.stub(ilp) + sandbox.stub(Kafka) + sandbox.stub(MainUtil.StreamingProtocol) + sandbox.stub(TransferObjectTransform, 'toTransfer') + sandbox.stub(TransferObjectTransform, 'toFulfil') + sandbox.stub(Participant, 'getAccountByNameAndCurrency').callsFake((...args) => { + // Avoid using a participantCurrencyId of 0 as this is used to represent a + // special proxy case where no action is to take place in the position handler + if (args[0] === transfer.payerFsp) { + return { + participantCurrencyId: 1 + } + } + if (args[0] === fxTransfer.initiatingFsp) { + return { + participantCurrencyId: 2 + } + } + if (args[0] === transfer.payeeFsp || args[0] === fxTransfer.counterPartyFsp) { + return { + participantCurrencyId: 3 + } + } + if (args[0] === fxTransfer.counterPartyFsp) { + return { + participantCurrencyId: 4 + } + } + if (args[0] === 'ProxyAR') { + return { + participantCurrencyId: 5 + } + } + if (args[0] === 'ProxyRB') { + return { + participantCurrencyId: 6 + } + } + }) + Kafka.produceGeneralMessage.returns(Promise.resolve()) + Config.PROXY_CACHE_CONFIG.enabled = true + getFSPProxyStub = sandbox.stub(ProxyCache, 'getFSPProxy') + checkSameCreditorDebtorProxyStub = sandbox.stub(ProxyCache, 'checkSameCreditorDebtorProxy') + getFSPProxyStub.withArgs(transfer.payerFsp).returns({ + inScheme: true, + proxyId: null + }) + getFSPProxyStub.withArgs(transfer.payeeFsp).returns({ + inScheme: true, + proxyId: null + }) + getFSPProxyStub.withArgs(fxTransfer.initiatingFsp).returns({ + inScheme: true, + proxyId: null + }) + getFSPProxyStub.withArgs(fxTransfer.counterPartyFsp).returns({ + inScheme: true, + proxyId: null + }) + checkSameCreditorDebtorProxyStub.resolves(false) + test.end() + }) + + transferHandlerTest.afterEach(test => { + sandbox.restore() + test.end() + }) + + transferHandlerTest.test('prepare should', prepareTest => { + prepareTest.test('persist transfer to database when messages is an array', async (test) => { + const localMessages = MainUtil.clone(messages) + // here copy + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(result, true) + test.end() + }) + + prepareTest.test('fail when messages array is empty', async (test) => { + const localMessages = [] + // here copy + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + try { + await allTransferHandlers.prepare(null, localMessages) + test.fail('Error not thrown') + test.end() + } catch (err) { + test.ok(err instanceof Error) + test.end() + } + }) + + prepareTest.test('use topic name override if specified in config', async (test) => { + Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP.POSITION.PREPARE = 'topic-test-override' + const localMessages = MainUtil.clone(messages) + // here copy + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(kafkaCallOne.args[2].topicNameOverride, 'topic-test-override') + test.equal(result, true) + delete Config.KAFKA_CONFIG.EVENT_TYPE_ACTION_TOPIC_MAP.POSITION.PREPARE + test.end() + }) + + prepareTest.test('persist transfer to database when messages is an array - consumer throws error', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Consumer.getConsumer.throws(new Error()) + Kafka.transformAccountToTopicName.returns(topicName) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(result, true) + test.end() + }) + + // Not sure why all these tests have conditions on transferState. + // `prepare` does not currently have any code that checks transferState. + prepareTest.test('send callback when duplicate found but without transferState', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getByIdLight.returns(Promise.resolve(null)) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate found but without transferState - autocommit is enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getByIdLight.returns(Promise.resolve(null)) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate found but without transferState - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, configAutocommit, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.getByIdLight.returns(Promise.resolve(null)) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve(null)) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate found and transferState is COMMITTED', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getByIdLight.withArgs(transfer.transferId).returns(Promise.resolve(transferReturn)) + TransferObjectTransform.toTransfer.withArgs(transferReturn).returns(transfer) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate found and transferState is ABORTED_REJECTED', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'ABORTED' })) + TransferService.getById.withArgs(transfer.transferId).returns(Promise.resolve(transferReturn)) + + TransferObjectTransform.toFulfil.withArgs(transferReturn).returns(fulfil) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('do nothing when duplicate found and transferState is RECEIVED', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'RECEIVED' })) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('do nothing when duplicate found and transferState is RECEIVED', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'unknown' })) + localMessages[0].value.metadata.event.action = 'unknown' + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('do nothing when duplicate found and transferState is RESERVED', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + TransferService.getTransferStateChange.withArgs(transfer.transferId).returns(Promise.resolve({ enumeration: 'RESERVED' })) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate transfer id found but hash doesnt match', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send callback when duplicate transfer id found but hash doesnt match - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('persist transfer to database when single message sent', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages[0]) + test.equal(result, true) + test.end() + }) + + prepareTest.test('persist transfer to database when BULK_PREPARE single message sent', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages[1]) + test.equal(result, true) + test.end() + }) + + prepareTest.test('persist transfer to database when single message sent - autocommit is enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages[0]) + test.equal(result, true) + test.end() + }) + + prepareTest.test('persist transfer to database when single message sent - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, configAutocommit, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages[0]) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send notification when validation successful but duplicate error thrown by prepare', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.throws(new Error()) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send notification when validation successful but duplicate error thrown by prepare - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.throws(new Error()) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('fail validation and persist INVALID transfer to database and insert transferError', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + MainUtil.StreamingProtocol.createEventState.returns(messageProtocol.metadata.event.state) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + TransferService.getById.returns(Promise.resolve(null)) + TransferService.prepare.returns(Promise.resolve(true)) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('fail validation and persist INVALID transfer to database and insert transferError -kafka autocommit enabled', async (test) => { + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + MainUtil.StreamingProtocol.createEventState.returns(messageProtocol.metadata.event.state) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + + const result = await allTransferHandlers.prepare(null, messages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send notification when validation failed and duplicate error thrown by prepare', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + TransferService.prepare.throws(new Error()) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('send notification when validation failed and duplicate error thrown by prepare - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + TransferService.prepare.throws(new Error()) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + test.equal(result, true) + test.end() + }) + + prepareTest.test('log an error when consumer not found', async (test) => { + try { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns('invalid-topic') + await allTransferHandlers.prepare(null, localMessages) + const expectedState = new EventSdk.EventStateMetadata(EventSdk.EventStatusType.failed, '2001', 'Internal server error') + const args = SpanStub.finish.getCall(0).args + test.ok(args[0].length > 0) + test.deepEqual(args[1], expectedState) + test.end() + } catch (e) { + test.fail('Error Thrown') + test.end() + } + }) + + prepareTest.test('throw an error when an error is thrown from Kafka', async (test) => { + try { + await allTransferHandlers.prepare(error, null) + test.fail('No Error Thrown') + test.end() + } catch (e) { + test.pass('Error Thrown') + test.end() + } + }) + + prepareTest.test('produce error for unexpected state when receiving fowarded event message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + TransferService.getById.returns(Promise.resolve({ transferState: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT })) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, forwardedMessages[0]) + test.equal(Kafka.proceed.getCall(0).args[2].fspiopError.errorInformation.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.code) + test.equal(result, true) + test.end() + }) + + prepareTest.test('produce error on transfer not found when receiving forwarded event message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + TransferService.getById.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, forwardedMessages[0]) + test.equal(result, true) + test.equal(Kafka.proceed.getCall(0).args[2].fspiopError.errorInformation.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.ID_NOT_FOUND.code) + test.end() + }) + + prepareTest.test('produce error for unexpected state when receiving fx-fowarded event message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + FxTransferService.getByIdLight.returns(Promise.resolve({ fxTransferState: Enum.Transfers.TransferInternalState.RESERVED_TIMEOUT })) + + const result = await allTransferHandlers.prepare(null, fxForwardedMessages[0]) + test.equal(Kafka.proceed.getCall(0).args[2].fspiopError.errorInformation.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.INTERNAL_SERVER_ERROR.code) + test.equal(result, true) + test.end() + }) + + prepareTest.test('produce error on transfer not found when receiving fx-forwarded event message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + FxTransferService.getByIdLight.returns(Promise.resolve(null)) + + const result = await allTransferHandlers.prepare(null, fxForwardedMessages[0]) + test.equal(result, true) + test.equal(Kafka.proceed.getCall(0).args[2].fspiopError.errorInformation.errorCode, ErrorHandler.Enums.FSPIOPErrorCodes.ID_NOT_FOUND.code) + test.end() + }) + + prepareTest.end() + }) + + transferHandlerTest.test('prepare proxy scenarios should', prepareProxyTest => { + prepareProxyTest.test(` + handle scenario scheme A: POST /fxTransfer call I.e. Debtor: Payer DFSP → Creditor: Proxy AR + Payer DFSP postion account must be updated (reserved) + substitute creditor(counterpartyFsp) if not in scheme and found in proxy cache for /fxTransfers msg`, async (test) => { + // In this the counter party is not in scheme and is found in the proxy cache + getFSPProxyStub.withArgs(fxTransfer.counterPartyFsp).returns({ + inScheme: false, + proxyId: 'ProxyAR' + }) + + // Stub underlying methods for determiningTransferCheckResult + // so that proper currency validation lists are returned + TransferModel.getById.resolves(null) + + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + // Payer DFSP postion account must be updated (reserved) + // The generated position message should be keyed with the initiatingFsp participant currency id + // which is `payerFsp` in this case + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + test.equal(kafkaCallOne.args[2].messageKey, '2') + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + + // `to` `from` and `initiatingFsp` and `counterPartyFsp` is message should be the original values + test.equal(kafkaCallOne.args[1].message.value.from, 'fx_dfsp1') + test.equal(kafkaCallOne.args[1].message.value.to, 'fx_dfsp2') + test.equal(kafkaCallOne.args[1].decodedPayload.initiatingFsp, 'fx_dfsp1') + test.equal(kafkaCallOne.args[1].decodedPayload.counterPartyFsp, 'fx_dfsp2') + test.end() + }) + + prepareProxyTest.test(` + should handle Scheme A: POST /transfer call I.e. Debtor: Proxy AR → Creditor: Proxy AR + Do nothing + produce message with key=0 if both proxies for debtor and creditor are the same in /transfers msg`, async (test) => { + // Stub payee with same proxy + getFSPProxyStub.withArgs(transfer.payeeFsp).returns({ + inScheme: false, + proxyId: 'proxyAR' + }) + getFSPProxyStub.withArgs(fxTransfer.counterPartyFsp).returns({ + inScheme: false, + proxyId: 'proxyAR' + }) + checkSameCreditorDebtorProxyStub.resolves(true) + // Stub watchlist to mimic that transfer is part of fxTransfer + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve([{ + fxTransferId: 1 + }])) + + const localMessages = MainUtil.clone(messages) + localMessages[0].value.content.payload.transferId = 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c' + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages[0]) + const kafkaCallOne = Kafka.proceed.getCall(0) + + // Do nothing is represented by the position message with key=0 + test.equal(kafkaCallOne.args[2].messageKey, '0') + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + + // `to` `from` and `payerFsp` and `payeeFsp` is message should be the original values + test.equal(kafkaCallOne.args[1].message.value.from, 'dfsp1') + test.equal(kafkaCallOne.args[1].message.value.to, 'dfsp2') + test.equal(kafkaCallOne.args[1].decodedPayload.payerFsp, 'dfsp1') + test.equal(kafkaCallOne.args[1].decodedPayload.payeeFsp, 'dfsp2') + test.end() + }) + + prepareProxyTest.test(` + should handle Scheme R: POST /fxTransfer call I.e. Debtor: Proxy AR → Creditor: FXP + Proxy AR position account in source currency must be updated (reserved) + substitute debtor(initiatingFsp) if not in scheme and found in proxy cache for /fxTransfers msg`, async (test) => { + // In this the initiatingFsp is not in scheme and is found in the proxy cache + getFSPProxyStub.withArgs(fxTransfer.initiatingFsp).returns({ + inScheme: false, + proxyId: 'ProxyAR' + }) + + // Stub underlying methods for determiningTransferCheckResult + // so that proper currency validation lists are returned + TransferModel.getById.resolves(null) + + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + // The generated position message should be keyed with the proxy participant currency id + // which is `initiatingFspProxy` in this case + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + test.equal(kafkaCallOne.args[2].messageKey, '5') + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + + // `to` `from` and `initiatingFsp` and `counterPartyFsp` is message should be the original values + test.equal(kafkaCallOne.args[1].message.value.from, 'fx_dfsp1') + test.equal(kafkaCallOne.args[1].message.value.to, 'fx_dfsp2') + test.equal(kafkaCallOne.args[1].decodedPayload.initiatingFsp, 'fx_dfsp1') + test.equal(kafkaCallOne.args[1].decodedPayload.counterPartyFsp, 'fx_dfsp2') + + test.end() + }) + + prepareProxyTest.test(` + should handle Scheme R: POST /Transfer call I.e. Debtor: FXP → Creditor: Proxy RB + FXP position account in targed currency must be updated (reserved) + substitute creditor(payeeFsp) if not in scheme and found in proxy cache for /fxTransfers msg`, async (test) => { + // Stub payee with same proxy + getFSPProxyStub.withArgs(transfer.payeeFsp).returns({ + inScheme: false, + proxyId: 'ProxyRB' + }) + + // Stub watchlist to mimic that transfer is part of fxTransfer + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve({ fxTransferId: 1 })) + + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages[0]) + const kafkaCallOne = Kafka.proceed.getCall(0) + + // The generated position message should be keyed with the fxp participant currency id + // which is payerFsp in this case (naming here is confusing due reusing payload) + test.equal(kafkaCallOne.args[2].messageKey, '1') + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + + // `to` `from` and `payerFsp` and `payeeFsp` is message should be the original values + test.equal(kafkaCallOne.args[1].message.value.from, 'dfsp1') + test.equal(kafkaCallOne.args[1].message.value.to, 'dfsp2') + test.equal(kafkaCallOne.args[1].decodedPayload.payerFsp, 'dfsp1') + test.equal(kafkaCallOne.args[1].decodedPayload.payeeFsp, 'dfsp2') + + test.end() + }) + + prepareProxyTest.test(` + should handle Scheme B: POST /transfer call I.e. Debtor: Proxy RB → Creditor: Payee DFSP + Proxy RB postion account must be updated (reserved) + substitute debtor(payerFsp) if not in scheme and found in proxy cache for /transfers msg`, async (test) => { + // Stub payee with same proxy + getFSPProxyStub.withArgs(transfer.payerFsp).returns({ + inScheme: false, + proxyId: 'ProxyRB' + }) + + // Scheme B has no visibility that this is part of an fxTransfer + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(null) + + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages[0]) + const kafkaCallOne = Kafka.proceed.getCall(0) + + // The generated position message should be keyed with the payerFsp's proxy + test.equal(kafkaCallOne.args[2].messageKey, '6') + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + + // `to` `from` and `payerFsp` and `payeeFsp` is message should be the original values + test.equal(kafkaCallOne.args[1].message.value.from, 'dfsp1') + test.equal(kafkaCallOne.args[1].message.value.to, 'dfsp2') + test.equal(kafkaCallOne.args[1].decodedPayload.payerFsp, 'dfsp1') + test.equal(kafkaCallOne.args[1].decodedPayload.payeeFsp, 'dfsp2') + test.end() + }) + + prepareProxyTest.test('throw error if debtor(payer) if not in scheme and not found in proxy cache in /transfers msg', async (test) => { + getFSPProxyStub.withArgs(transfer.payerFsp).returns({ + inScheme: false, + proxyId: null + }) + getFSPProxyStub.withArgs(transfer.payeeFsp).returns({ + inScheme: false, + proxyId: 'payeeProxy' + }) + + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + try { + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + test.end() + } catch (e) { + test.fail() + test.end() + } + }) + + prepareProxyTest.test('throw error if creditor(payee) if not in scheme and not found in proxy cache in /transfers msg', async (test) => { + getFSPProxyStub.withArgs(transfer.payerFsp).returns({ + inScheme: false, + proxyId: 'payerProxy' + }) + getFSPProxyStub.withArgs(transfer.payeeFsp).returns({ + inScheme: false, + proxyId: null + }) + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + TransferService.prepare.returns(Promise.resolve(true)) + TransferService.getTransferDuplicateCheck.returns(Promise.resolve(null)) + TransferService.saveTransferDuplicateCheck.returns(Promise.resolve(null)) + fxTransferModel.watchList.getItemsInWatchListByDeterminingTransferId.returns(Promise.resolve(null)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + try { + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + test.end() + } catch (e) { + test.fail() + test.end() + } + }) + + prepareProxyTest.test('throw error if debtor(initiatingFsp) if not in scheme and not found in proxy cache in /fxTransfers msg', async (test) => { + getFSPProxyStub.withArgs(fxTransfer.initiatingFsp).returns({ + inScheme: false, + proxyId: null + }) + getFSPProxyStub.withArgs(fxTransfer.counterPartyFsp).returns({ + inScheme: false, + proxyId: 'counterPartyFspProxy' + }) + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + try { + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.end() + } catch (e) { + test.fail() + test.end() + } + }) + + prepareProxyTest.test('throw error if debtor(counterpartyFsp) if not in scheme and not found in proxy cache in /fxTransfers msg', async (test) => { + getFSPProxyStub.withArgs(fxTransfer.initiatingFsp).returns({ + inScheme: false, + proxyId: 'initiatingFspProxy' + }) + getFSPProxyStub.withArgs(fxTransfer.counterPartyFsp).returns({ + inScheme: false, + proxyId: null + }) + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + try { + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.end() + } catch (e) { + test.fail() + test.end() + } + }) + + prepareProxyTest.test('update reserved transfer on forwarded prepare message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + TransferService.getById.returns(Promise.resolve({ transferState: Enum.Transfers.TransferInternalState.RESERVED })) + Comparators.duplicateCheckComparator.withArgs(transfer.transferId, transfer).returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + const result = await allTransferHandlers.prepare(null, forwardedMessages[0]) + test.ok(TransferService.forwardedPrepare.called) + test.equal(result, true) + test.end() + }) + + prepareProxyTest.test('update reserved fxTransfer on fx-forwarded prepare message', async (test) => { + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + FxTransferService.getByIdLight.returns(Promise.resolve({ fxTransferState: Enum.Transfers.TransferInternalState.RESERVED })) + const result = await allTransferHandlers.prepare(null, fxForwardedMessages[0]) + test.ok(FxTransferService.forwardedFxPrepare.called) + test.equal(result, true) + test.end() + }) + + prepareProxyTest.end() + }) + + transferHandlerTest.test('processDuplication', processDuplicationTest => { + processDuplicationTest.test('return undefined hasDuplicateId is falsey', async (test) => { + const result = await prepare.processDuplication({ + duplication: { + hasDuplicateId: false + } + }) + test.equal(result, undefined) + test.end() + }) + + processDuplicationTest.test('throw error if action is BULK_PREPARE', async (test) => { + try { + await prepare.processDuplication({ + duplication: { + hasDuplicateId: true, + hasDuplicateHash: true + }, + location: { module: 'PrepareHandler', method: '', path: '' }, + action: Action.BULK_PREPARE + }) + test.fail('Error not thrown') + } catch (e) { + test.pass('Error thrown') + } + test.end() + }) + processDuplicationTest.end() + }) + + transferHandlerTest.test('payer initiated conversion fxPrepare should', fxPrepareTest => { + fxPrepareTest.test('persist fxtransfer to database when messages is an array', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.ok(Validator.validatePrepare.called) + test.ok(fxTransferModel.fxTransfer.savePreparedRequest.called) + test.ok(Comparators.duplicateCheckComparator.called) + test.end() + }) + + fxPrepareTest.test('persist transfer to database when messages is an array - consumer throws error', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + Consumer.getConsumer.throws(new Error()) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.ok(Validator.validatePrepare.called) + test.ok(fxTransferModel.fxTransfer.savePreparedRequest.called) + test.ok(Comparators.duplicateCheckComparator.called) + test.end() + }) + + fxPrepareTest.test('send callback when duplicate found', async (test) => { + const localMessages = MainUtil.clone(messages) + await Consumer.createHandler(topicName, config, command) + + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: true, + hasDuplicateHash: true + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + + test.equal(result, true) + test.end() + }) + + fxPrepareTest.test('persist transfer to database when single message sent', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: true, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages[0]) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.POSITION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.ok(Validator.validatePrepare.called) + test.ok(fxTransferModel.fxTransfer.savePreparedRequest.called) + test.ok(Comparators.duplicateCheckComparator.called) + test.end() + }) + + fxPrepareTest.test('send notification when validation failed and duplicate error thrown by prepare', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.throws(new Error()) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + // Is this not supposed to be FX_PREPARE? + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + test.end() + }) + + fxPrepareTest.test('send notification when validation failed and duplicate error thrown by prepare - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.throws(new Error()) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + // Is this not supposed to be FX_PREPARE? + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.PREPARE) + test.equal(result, true) + test.end() + }) + + fxPrepareTest.test('fail validation and persist INVALID transfer to database and insert transferError', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, config, command) + + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.ok(Validator.validatePrepare.called) + test.ok(fxTransferModel.fxTransfer.savePreparedRequest.called) + test.ok(Comparators.duplicateCheckComparator.called) + test.end() + }) + + fxPrepareTest.test('fail validation and persist INVALID transfer to database and insert transferError - kafka autocommit enabled', async (test) => { + const localMessages = MainUtil.clone(fxMessages) + await Consumer.createHandler(topicName, configAutocommit, command) + Consumer.isConsumerAutoCommitEnabled.returns(true) + Kafka.transformAccountToTopicName.returns(topicName) + Kafka.proceed.returns(true) + Validator.validatePrepare.returns({ validationPassed: false, reasons: [] }) + fxTransferModel.fxTransfer.savePreparedRequest.returns(Promise.resolve(true)) + Comparators.duplicateCheckComparator.returns(Promise.resolve({ + hasDuplicateId: false, + hasDuplicateHash: false + })) + + const result = await allTransferHandlers.prepare(null, localMessages) + const kafkaCallOne = Kafka.proceed.getCall(0) + + test.equal(kafkaCallOne.args[2].eventDetail.functionality, Enum.Events.Event.Type.NOTIFICATION) + test.equal(kafkaCallOne.args[2].eventDetail.action, Enum.Events.Event.Action.FX_PREPARE) + test.equal(result, true) + test.ok(Validator.validatePrepare.called) + test.ok(fxTransferModel.fxTransfer.savePreparedRequest.called) + test.ok(Comparators.duplicateCheckComparator.called) + test.end() + }) + fxPrepareTest.end() + }) + transferHandlerTest.end() +}) diff --git a/test/unit/handlers/transfers/validator.test.js b/test/unit/handlers/transfers/validator.test.js index 64e3c9d1a..e24cbd635 100644 --- a/test/unit/handlers/transfers/validator.test.js +++ b/test/unit/handlers/transfers/validator.test.js @@ -4,12 +4,16 @@ const Test = require('tapes')(require('tape')) const Sinon = require('sinon') const Participant = require('../../../../src/domain/participant') const Transfer = require('../../../../src/domain/transfer') +const FxTransferModel = require('../../../../src/models/fxTransfer') const Validator = require('../../../../src/handlers/transfers/validator') const CryptoConditions = require('../../../../src/cryptoConditions') const Enum = require('@mojaloop/central-services-shared').Enum let payload let headers +let fxPayload +let fxHeaders +let determiningTransferCheckResult Test('transfer validator', validatorTest => { let sandbox @@ -39,14 +43,47 @@ Test('transfer validator', validatorTest => { ] } } + fxPayload = { + commitRequestId: '88622a75-5bde-4da4-a6cc-f4cd23b268c4', + determiningTransferId: 'c05c3f31-33b5-4e33-8bfd-7c3a2685fb6c', + condition: 'YlK5TZyhflbXaDRPtR5zhCu8FrbgvrQwwmzuH0iQ0AI', + expiration: new Date((new Date()).getTime() + (24 * 60 * 60 * 1000)), // tomorrow + initiatingFsp: 'fx_dfsp1', + counterPartyFsp: 'fx_dfsp2', + sourceAmount: { + currency: 'USD', + amount: '433.88' + }, + targetAmount: { + currency: 'EUR', + amount: '200.00' + } + } headers = { 'fspiop-source': 'dfsp1', 'fspiop-destination': 'dfsp2' } + fxHeaders = { + 'fspiop-source': 'fx_dfsp1', + 'fspiop-destination': 'fx_dfsp2' + } + determiningTransferCheckResult = { + participantCurrencyValidationList: [ + { + participantName: 'dfsp1', + currencyId: 'USD' + }, + { + participantName: 'dfsp2', + currencyId: 'USD' + } + ] + } sandbox = Sinon.createSandbox() sandbox.stub(Participant) sandbox.stub(CryptoConditions, 'validateCondition') sandbox.stub(Transfer, 'getTransferParticipant') + sandbox.stub(FxTransferModel.fxTransfer, 'getFxTransferParticipant') test.end() }) @@ -61,7 +98,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) CryptoConditions.validateCondition.returns(true) - const { validationPassed } = await Validator.validatePrepare(payload, headers) + const { validationPassed } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, true) test.end() }) @@ -75,7 +112,7 @@ Test('transfer validator', validatorTest => { validatePrepareTest.test('fail validation when FSPIOP-Source doesnt match Payer', async (test) => { const headersModified = { 'fspiop-source': 'dfsp2' } - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headersModified) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headersModified, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['FSPIOP-Source header should match Payer']) test.end() @@ -86,7 +123,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) CryptoConditions.validateCondition.throws(new Error()) - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Condition validation failed']) test.end() @@ -97,7 +134,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) payload.condition = null - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Condition is required for a conditional transfer']) test.end() @@ -109,7 +146,7 @@ Test('transfer validator', validatorTest => { CryptoConditions.validateCondition.returns(true) payload.expiration = '1971-11-24T08:38:08.699-04:00' - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Expiration date 1971-11-24T12:38:08.699Z is already in the past']) test.end() @@ -121,7 +158,7 @@ Test('transfer validator', validatorTest => { CryptoConditions.validateCondition.returns(true) payload.expiration = null - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Expiration is required for conditional transfer']) test.end() @@ -133,7 +170,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) CryptoConditions.validateCondition.returns(true) - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Participant dfsp2 not found']) test.end() @@ -145,7 +182,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) CryptoConditions.validateCondition.returns(true) - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Participant dfsp2 is inactive']) test.end() @@ -158,7 +195,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.withArgs('dfsp2', 'USD', Enum.Accounts.LedgerAccountType.POSITION).returns(Promise.resolve(null)) CryptoConditions.validateCondition.returns(true) - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Participant dfsp2 USD account not found']) test.end() @@ -171,7 +208,7 @@ Test('transfer validator', validatorTest => { Participant.getAccountByNameAndCurrency.withArgs('dfsp2', 'USD', Enum.Accounts.LedgerAccountType.POSITION).returns(Promise.resolve({ currencyIsActive: false })) CryptoConditions.validateCondition.returns(true) - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Participant dfsp2 USD account is inactive']) test.end() @@ -183,7 +220,7 @@ Test('transfer validator', validatorTest => { CryptoConditions.validateCondition.returns(true) payload.amount.amount = '123.12345' - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Amount 123.12345 exceeds allowed scale of 4']) test.end() @@ -195,7 +232,7 @@ Test('transfer validator', validatorTest => { CryptoConditions.validateCondition.returns(true) payload.payeeFsp = payload.payerFsp - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Payer FSP and Payee FSP should be different, unless on-us tranfers are allowed by the Scheme']) test.end() @@ -207,12 +244,24 @@ Test('transfer validator', validatorTest => { CryptoConditions.validateCondition.returns(true) payload.amount.amount = '123456789012345.6789' - const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers) + const { validationPassed, reasons } = await Validator.validatePrepare(payload, headers, false, determiningTransferCheckResult) test.equal(validationPassed, false) test.deepEqual(reasons, ['Amount 123456789012345.6789 exceeds allowed precision of 18']) test.end() }) + validatePrepareTest.test('select variables based on prepare is fx', async (test) => { + Participant.getByName.returns(Promise.resolve({ isActive: true })) + Participant.getAccountByNameAndCurrency.returns(Promise.resolve({ currencyIsActive: true })) + CryptoConditions.validateCondition.returns(true) + + const { validationPassed } = await Validator.validatePrepare(fxPayload, fxHeaders, true, determiningTransferCheckResult) + test.equal(validationPassed, true) + test.ok(Participant.getByName.calledWith('fx_dfsp1')) + test.ok(Participant.getByName.calledWith('fx_dfsp2')) + test.end() + }) + validatePrepareTest.end() }) @@ -294,5 +343,29 @@ Test('transfer validator', validatorTest => { validateParticipantTransferIdTest.end() }) + validatorTest.test('validateParticipantForCommitRequestId should', validateParticipantForCommitRequestIdTest => { + validateParticipantForCommitRequestIdTest.test('validate the CommitRequestId belongs to the requesting fsp', async (test) => { + const participantName = 'fsp1' + const commitRequestId = '88416f4c-68a3-4819-b8e0-c23b27267cd5' + FxTransferModel.fxTransfer.getFxTransferParticipant.withArgs(participantName, commitRequestId).returns(Promise.resolve([1])) + + const result = await Validator.validateParticipantForCommitRequestId(participantName, commitRequestId) + test.equal(result, true, 'results match') + test.end() + }) + + validateParticipantForCommitRequestIdTest.test('validate the CommitRequestId belongs to the requesting fsp return false for no match', async (test) => { + const participantName = 'fsp1' + const commitRequestId = '88416f4c-68a3-4819-b8e0-c23b27267cd5' + FxTransferModel.fxTransfer.getFxTransferParticipant.withArgs(participantName, commitRequestId).returns(Promise.resolve([])) + + const result = await Validator.validateParticipantForCommitRequestId(participantName, commitRequestId) + test.equal(result, false, 'results match') + test.end() + }) + + validateParticipantForCommitRequestIdTest.end() + }) + validatorTest.end() }) diff --git a/test/unit/lib/cachingOfEnums.test.js b/test/unit/lib/cachingOfEnums.test.js index cb5036a10..26ceefbcd 100644 --- a/test/unit/lib/cachingOfEnums.test.js +++ b/test/unit/lib/cachingOfEnums.test.js @@ -3,10 +3,13 @@ License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -14,7 +17,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/lib/config.test.js b/test/unit/lib/config.test.js index 2e03c4199..5fd3c685f 100644 --- a/test/unit/lib/config.test.js +++ b/test/unit/lib/config.test.js @@ -42,17 +42,5 @@ Test('Config should', configTest => { test.end() }) - configTest.test('evaluate MONGODB_DISABLED to a boolean if a string', async function (test) { - console.log(Defaults) - const DefaultsStub = { ...Defaults } - DefaultsStub.MONGODB.DISABLED = 'true' - const Config = Proxyquire('../../../src/lib/config', { - '../../config/default.json': DefaultsStub - }) - - test.ok(Config.MONGODB_DISABLED === true) - test.end() - }) - configTest.end() }) diff --git a/test/unit/lib/enum.test.js b/test/unit/lib/enum.test.js index 17c26e973..dc7fa7e65 100644 --- a/test/unit/lib/enum.test.js +++ b/test/unit/lib/enum.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/lib/enumCached.test.js b/test/unit/lib/enumCached.test.js index 81a4bf3f3..123561337 100644 --- a/test/unit/lib/enumCached.test.js +++ b/test/unit/lib/enumCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/lib/healthCheck/subServiceHealth.test.js b/test/unit/lib/healthCheck/subServiceHealth.test.js index a02515f99..9ebadd2d7 100644 --- a/test/unit/lib/healthCheck/subServiceHealth.test.js +++ b/test/unit/lib/healthCheck/subServiceHealth.test.js @@ -1,8 +1,8 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at http://www.apache.org/licenses/LICENSE-2.0 @@ -15,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . @@ -37,21 +37,23 @@ const { statusEnum, serviceName } = require('@mojaloop/central-services-shared') const MigrationLockModel = require('../../../../src/models/misc/migrationLock') const Consumer = require('@mojaloop/central-services-stream').Util.Consumer const Logger = require('@mojaloop/central-services-logger') +const ProxyCache = require('#src/lib/proxyCache') const { getSubServiceHealthBroker, - getSubServiceHealthDatastore + getSubServiceHealthDatastore, + getSubServiceHealthProxyCache } = require('../../../../src/lib/healthCheck/subServiceHealth.js') Test('SubServiceHealth test', subServiceHealthTest => { let sandbox - + let proxyCacheStub subServiceHealthTest.beforeEach(t => { sandbox = Sinon.createSandbox() sandbox.stub(Consumer, 'getListOfTopics') sandbox.stub(Consumer, 'isConnected') sandbox.stub(Logger, 'isDebugEnabled').value(true) - + proxyCacheStub = sandbox.stub(ProxyCache, 'getCache') t.end() }) @@ -151,5 +153,38 @@ Test('SubServiceHealth test', subServiceHealthTest => { datastoreTest.end() }) + subServiceHealthTest.test('getSubServiceHealthProxyCache', proxyCacheTest => { + proxyCacheTest.test('Reports up when not health', async test => { + // Arrange + proxyCacheStub.returns({ + healthCheck: sandbox.stub().returns(Promise.resolve(true)) + }) + const expected = { name: 'proxyCache', status: statusEnum.OK } + + // Act + const result = await getSubServiceHealthProxyCache() + + // Assert + test.deepEqual(result, expected, 'getSubServiceHealthBroker should match expected result') + test.end() + }) + + proxyCacheTest.test('Reports down when not health', async test => { + // Arrange + proxyCacheStub.returns({ + healthCheck: sandbox.stub().returns(Promise.resolve(false)) + }) + const expected = { name: 'proxyCache', status: statusEnum.DOWN } + + // Act + const result = await getSubServiceHealthProxyCache() + + // Assert + test.deepEqual(result, expected, 'getSubServiceHealthBroker should match expected result') + test.end() + }) + proxyCacheTest.end() + }) + subServiceHealthTest.end() }) diff --git a/test/unit/lib/proxyCache.test.js b/test/unit/lib/proxyCache.test.js new file mode 100644 index 000000000..ab8407760 --- /dev/null +++ b/test/unit/lib/proxyCache.test.js @@ -0,0 +1,182 @@ +'use strict' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') +const Proxyquire = require('proxyquire') +const ParticipantService = require('../../../src/domain/participant') +const Config = require('../../../src/lib/config') + +const connectStub = Sinon.stub() +const disconnectStub = Sinon.stub() +const lookupProxyByDfspIdStub = Sinon.stub() +lookupProxyByDfspIdStub.withArgs('existingDfspId1').resolves('proxyId') +lookupProxyByDfspIdStub.withArgs('existingDfspId2').resolves('proxyId') +lookupProxyByDfspIdStub.withArgs('existingDfspId3').resolves('proxyId1') +lookupProxyByDfspIdStub.withArgs('nonExistingDfspId1').resolves(null) +lookupProxyByDfspIdStub.withArgs('nonExistingDfspId2').resolves(null) + +const createProxyCacheStub = Sinon.stub().returns({ + connect: connectStub, + disconnect: disconnectStub, + lookupProxyByDfspId: lookupProxyByDfspIdStub +}) +const ProxyCache = Proxyquire('../../../src/lib/proxyCache', { + '@mojaloop/inter-scheme-proxy-cache-lib': { + createProxyCache: createProxyCacheStub + } +}) + +Test('Proxy Cache test', async (proxyCacheTest) => { + let sandbox + + proxyCacheTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + sandbox.stub(Config.PROXY_CACHE_CONFIG, 'type') + sandbox.stub(Config.PROXY_CACHE_CONFIG, 'proxyConfig') + sandbox.stub(ParticipantService) + t.end() + }) + + proxyCacheTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + await proxyCacheTest.test('connect', async (connectTest) => { + await connectTest.test('connect to cache with lazyConnect', async (test) => { + await ProxyCache.connect() + test.ok(connectStub.calledOnce) + test.end() + }) + + await connectTest.test('connect to cache with default config if not redis storage type', async (test) => { + await ProxyCache.reset() + connectStub.resetHistory() + createProxyCacheStub.resetHistory() + Config.PROXY_CACHE_CONFIG.type = 'mysql' + await ProxyCache.connect() + test.ok(connectStub.calledOnce) + const secondArg = createProxyCacheStub.getCall(0).args[1] + test.ok(secondArg.lazyConnect === undefined) + test.end() + }) + + connectTest.end() + }) + + await proxyCacheTest.test('disconnect', async (disconnectTest) => { + await disconnectTest.test('disconnect from cache', async (test) => { + await ProxyCache.disconnect() + test.pass() + test.end() + }) + + disconnectTest.end() + }) + + await proxyCacheTest.test('getCache', async (getCacheTest) => { + await getCacheTest.test('resolve proxy id if participant not in scheme and proxyId is in cache', async (test) => { + await ProxyCache.getCache() + test.pass() + test.end() + }) + getCacheTest.end() + }) + + await proxyCacheTest.test('getFSPProxy', async (getFSPProxyTest) => { + await getFSPProxyTest.test('resolve proxy id if participant not in scheme and proxyId is in cache', async (test) => { + ParticipantService.getByName.returns(Promise.resolve(null)) + const dfspId = 'existingDfspId1' + const result = await ProxyCache.getFSPProxy(dfspId) + + test.deepEqual(result, { inScheme: false, proxyId: 'proxyId', name: dfspId }) + test.end() + }) + + await getFSPProxyTest.test('resolve proxy id if participant not in scheme and proxyId is not cache', async (test) => { + ParticipantService.getByName.returns(Promise.resolve(null)) + const dsfpId = 'nonExistingDfspId1' + const result = await ProxyCache.getFSPProxy(dsfpId) + + test.deepEqual(result, { inScheme: false, proxyId: null, name: dsfpId }) + test.end() + }) + + await getFSPProxyTest.test('not resolve proxyId if participant is in scheme', async (test) => { + ParticipantService.getByName.returns(Promise.resolve({ participantId: 1 })) + const result = await ProxyCache.getFSPProxy('existingDfspId1') + + test.deepEqual(result, { inScheme: true, proxyId: null, name: 'existingDfspId1' }) + test.end() + }) + + getFSPProxyTest.end() + }) + + await proxyCacheTest.test('checkSameCreditorDebtorProxy', async (checkSameCreditorDebtorProxyTest) => { + await checkSameCreditorDebtorProxyTest.test('resolve true if proxy of debtor and creditor are truth and the same', async (test) => { + const result = await ProxyCache.checkSameCreditorDebtorProxy('existingDfspId1', 'existingDfspId2') + test.deepEqual(result, true) + test.end() + }) + + await checkSameCreditorDebtorProxyTest.test('resolve false if proxy of debtor and creditor are truth and different', async (test) => { + const result = await ProxyCache.checkSameCreditorDebtorProxy('existingDfspId1', 'existingDfspId3') + test.deepEqual(result, false) + test.end() + }) + + await checkSameCreditorDebtorProxyTest.test('resolve false if proxy of debtor and creditor are same but falsy', async (test) => { + const result = await ProxyCache.checkSameCreditorDebtorProxy('nonExistingDfspId1', 'nonExistingDfspId1') + test.deepEqual(result, false) + test.end() + }) + + checkSameCreditorDebtorProxyTest.end() + }) + + await proxyCacheTest.test('getProxyParticipantAccountDetails', async (getProxyParticipantAccountDetailsTest) => { + await getProxyParticipantAccountDetailsTest.test('resolve participantCurrencyId if participant is in scheme', async (test) => { + ParticipantService.getByName.returns(Promise.resolve({ participantId: 1 })) + ParticipantService.getAccountByNameAndCurrency.returns(Promise.resolve({ participantCurrencyId: 123 })) + const result = await ProxyCache.getProxyParticipantAccountDetails('nonExistingDfspId1', 'XXX') + test.deepEqual(result, { inScheme: true, participantCurrencyId: 123 }) + test.end() + }) + + await getProxyParticipantAccountDetailsTest.test('resolve participantCurrencyId of the proxy if participant is not in scheme', async (test) => { + ParticipantService.getByName.returns(Promise.resolve(null)) + ParticipantService.getAccountByNameAndCurrency.returns(Promise.resolve({ participantCurrencyId: 456 })) + const result = await ProxyCache.getProxyParticipantAccountDetails('existingDfspId1', 'XXX') + test.deepEqual(result, { inScheme: false, participantCurrencyId: 456 }) + test.end() + }) + + await getProxyParticipantAccountDetailsTest.test('resolve null if participant is in scheme and there is no account', async (test) => { + ParticipantService.getByName.returns(Promise.resolve({ participantId: 1 })) + ParticipantService.getAccountByNameAndCurrency.returns(Promise.resolve(null)) + const result = await ProxyCache.getProxyParticipantAccountDetails('nonExistingDfspId1', 'XXX') + test.deepEqual(result, { inScheme: true, participantCurrencyId: null }) + test.end() + }) + + await getProxyParticipantAccountDetailsTest.test('resolve null if participant is not in scheme and also there is no proxy in cache', async (test) => { + ParticipantService.getByName.returns(Promise.resolve(null)) + const result = await ProxyCache.getProxyParticipantAccountDetails('nonExistingDfspId1', 'XXX') + test.deepEqual(result, { inScheme: false, participantCurrencyId: null }) + test.end() + }) + + await getProxyParticipantAccountDetailsTest.test('resolve null if participant is not in scheme and proxy exists but no account', async (test) => { + ParticipantService.getByName.returns(Promise.resolve(null)) + ParticipantService.getAccountByNameAndCurrency.returns(Promise.resolve(null)) + const result = await ProxyCache.getProxyParticipantAccountDetails('existingDfspId1', 'XXX') + test.deepEqual(result, { inScheme: false, participantCurrencyId: null }) + test.end() + }) + + getProxyParticipantAccountDetailsTest.end() + }) + + proxyCacheTest.end() +}) diff --git a/test/unit/lib/requestLogger.test.js b/test/unit/lib/requestLogger.test.js index b455c5382..d6a4e4ad4 100644 --- a/test/unit/lib/requestLogger.test.js +++ b/test/unit/lib/requestLogger.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/lib/urlparser.test.js b/test/unit/lib/urlparser.test.js index 15adc169b..b97f25803 100644 --- a/test/unit/lib/urlparser.test.js +++ b/test/unit/lib/urlparser.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/fxTransfer/duplicateCheck.test.js b/test/unit/models/fxTransfer/duplicateCheck.test.js new file mode 100644 index 000000000..529c7cd38 --- /dev/null +++ b/test/unit/models/fxTransfer/duplicateCheck.test.js @@ -0,0 +1,257 @@ +'use strict' + +const Db = require('../../../../src/lib/db') +const Test = require('tapes')(require('tape')) +const sinon = require('sinon') +const duplicateCheck = require('../../../../src/models/fxTransfer/duplicateCheck') +const { TABLE_NAMES } = require('../../../../src/shared/constants') + +Test('DuplicateCheck', async (duplicateCheckTest) => { + let sandbox + + duplicateCheckTest.beforeEach(t => { + sandbox = sinon.createSandbox() + Db.fxTransferDuplicateCheck = { + insert: sandbox.stub(), + findOne: sandbox.stub(), + find: sandbox.stub() + } + Db.fxTransferErrorDuplicateCheck = { + insert: sandbox.stub(), + findOne: sandbox.stub(), + find: sandbox.stub() + } + Db.fxTransferFulfilmentDuplicateCheck = { + insert: sandbox.stub(), + findOne: sandbox.stub(), + find: sandbox.stub() + } + Db.from = (table) => { + return { + ...Db[table] + } + } + t.end() + }) + + duplicateCheckTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + duplicateCheckTest.test('getFxTransferDuplicateCheck should retrieve the record from fxTransferDuplicateCheck table if present', async (test) => { + const commitRequestId = '123456789' + const expectedRecord = { id: 1, commitRequestId, hash: 'abc123' } + + // Mock the Db.from().findOne() method to return the expected record + Db.from(TABLE_NAMES.fxTransferDuplicateCheck).findOne.resolves(expectedRecord) + + try { + const result = await duplicateCheck.getFxTransferDuplicateCheck(commitRequestId) + + test.deepEqual(result, expectedRecord, 'Should return the expected record') + test.ok(Db.from(TABLE_NAMES.fxTransferDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('getFxTransferDuplicateCheck should throw an error if Db.from().findOne() fails', async (test) => { + const commitRequestId = '123456789' + const expectedError = new Error('Database error') + + // Mock the Db.from().findOne() method to throw an error + Db.from(TABLE_NAMES.fxTransferDuplicateCheck).findOne.throws(expectedError) + + try { + await duplicateCheck.getFxTransferDuplicateCheck(commitRequestId) + + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferDuplicateCheck should insert a record into fxTransferDuplicateCheck table', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedId = 1 + + // Mock the Db.from().insert() method to return the expected id + Db.from(TABLE_NAMES.fxTransferDuplicateCheck).insert.resolves(expectedId) + + try { + const result = await duplicateCheck.saveFxTransferDuplicateCheck(commitRequestId, hash) + + test.equal(result, expectedId, 'Should return the expected id') + test.ok(Db.from(TABLE_NAMES.fxTransferDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferDuplicateCheck should throw an error if Db.from().insert() fails', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedError = new Error('Database error') + + // Mock the Db.from().insert() method to throw an error + Db.from(TABLE_NAMES.fxTransferDuplicateCheck).insert.throws(expectedError) + + try { + await duplicateCheck.saveFxTransferDuplicateCheck(commitRequestId, hash) + + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + + test.end() + } + }) + + duplicateCheckTest.test('getFxTransferErrorDuplicateCheck should retrieve the record from fxTransferErrorDuplicateCheck table if present', async (test) => { + const commitRequestId = '123456789' + const expectedRecord = { id: 1, commitRequestId, hash: 'abc123' } + // Mock the Db.from().findOne() method to return the expected record + Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).findOne.resolves(expectedRecord) + try { + const result = await duplicateCheck.getFxTransferErrorDuplicateCheck(commitRequestId) + test.deepEqual(result, expectedRecord, 'Should return the expected record') + test.ok(Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('getFxTransferErrorDuplicateCheck should throw an error if Db.from().findOne() fails', async (test) => { + const commitRequestId = '123456789' + const expectedError = new Error('Database error') + // Mock the Db.from().findOne() method to throw an error + Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).findOne.throws(expectedError) + try { + await duplicateCheck.getFxTransferErrorDuplicateCheck(commitRequestId) + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferErrorDuplicateCheck should insert a record into fxTransferErrorDuplicateCheck table', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedId = 1 + // Mock the Db.from().insert() method to return the expected id + Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).insert.resolves(expectedId) + try { + const result = await duplicateCheck.saveFxTransferErrorDuplicateCheck(commitRequestId, hash) + test.equal(result, expectedId, 'Should return the expected id') + test.ok(Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferErrorDuplicateCheck should throw an error if Db.from().insert() fails', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedError = new Error('Database error') + // Mock the Db.from().insert() method to throw an error + Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).insert.throws(expectedError) + try { + await duplicateCheck.saveFxTransferErrorDuplicateCheck(commitRequestId, hash) + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferErrorDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + test.end() + } + }) + + duplicateCheckTest.test('getFxTransferFulfilmentDuplicateCheck should retrieve the record from fxTransferFulfilmentDuplicateCheck table if present', async (test) => { + const commitRequestId = '123456789' + const expectedRecord = { id: 1, commitRequestId, hash: 'abc123' } + // Mock the Db.from().findOne() method to return the expected record + Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).findOne.resolves(expectedRecord) + try { + const result = await duplicateCheck.getFxTransferFulfilmentDuplicateCheck(commitRequestId) + test.deepEqual(result, expectedRecord, 'Should return the expected record') + test.ok(Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('getFxTransferFulfilmentDuplicateCheck should throw an error if Db.from().findOne() fails', async (test) => { + const commitRequestId = '123456789' + const expectedError = new Error('Database error') + // Mock the Db.from().findOne() method to throw an error + Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).findOne.throws(expectedError) + try { + await duplicateCheck.getFxTransferFulfilmentDuplicateCheck(commitRequestId) + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).findOne.calledOnceWith({ commitRequestId }), 'Should call Db.from().findOne() with the correct parameters') + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferFulfilmentDuplicateCheck should insert a record into fxTransferFulfilmentDuplicateCheck table', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedId = 1 + // Mock the Db.from().insert() method to return the expected id + Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).insert.resolves(expectedId) + try { + const result = await duplicateCheck.saveFxTransferFulfilmentDuplicateCheck(commitRequestId, hash) + test.equal(result, expectedId, 'Should return the expected id') + test.ok(Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + test.end() + } catch (error) { + test.fail(`Error thrown: ${error}`) + test.end() + } + }) + + duplicateCheckTest.test('saveFxTransferFulfilmentDuplicateCheck should throw an error if Db.from().insert() fails', async (test) => { + const commitRequestId = '123456789' + const hash = 'abc123' + const expectedError = new Error('Database error') + // Mock the Db.from().insert() method to throw an error + Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).insert.throws(expectedError) + try { + await duplicateCheck.saveFxTransferFulfilmentDuplicateCheck(commitRequestId, hash) + test.fail('Should throw an error') + test.end() + } catch (error) { + test.equal(error.message, expectedError.message, 'Should throw the expected error') + test.ok(Db.from(TABLE_NAMES.fxTransferFulfilmentDuplicateCheck).insert.calledOnceWith({ commitRequestId, hash }), 'Should call Db.from().insert() with the correct parameters') + test.end() + } + }) + + duplicateCheckTest.end() +}) diff --git a/test/unit/models/fxTransfer/watchList.test.js b/test/unit/models/fxTransfer/watchList.test.js new file mode 100644 index 000000000..630002317 --- /dev/null +++ b/test/unit/models/fxTransfer/watchList.test.js @@ -0,0 +1,77 @@ +'use strict' + +const Db = require('../../../../src/lib/db') +const Test = require('tapes')(require('tape')) +const sinon = require('sinon') +const watchList = require('../../../../src/models/fxTransfer/watchList') +const { TABLE_NAMES } = require('../../../../src/shared/constants') + +Test('Transfer facade', async (watchListTest) => { + let sandbox + + watchListTest.beforeEach(t => { + sandbox = sinon.createSandbox() + Db.fxWatchList = { + insert: sandbox.stub(), + findOne: sandbox.stub(), + find: sandbox.stub() + } + Db.from = (table) => { + return { + ...Db[table] + } + } + t.end() + }) + + watchListTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + await watchListTest.test('getItemInWatchListByCommitRequestId should return the item in watch list', async (t) => { + const commitRequestId = '123456' + const expectedItem = { commitRequestId: '123456', amount: 100 } + + // Mock the database findOne method + Db.from(TABLE_NAMES.fxWatchList).findOne.returns(expectedItem) + + const result = await watchList.getItemInWatchListByCommitRequestId(commitRequestId) + + t.deepEqual(result, expectedItem, 'Should return the expected item') + t.ok(Db.from(TABLE_NAMES.fxWatchList).findOne.calledOnceWithExactly({ commitRequestId }), 'Should call findOne method with the correct arguments') + + t.end() + }) + + await watchListTest.test('getItemsInWatchListByDeterminingTransferId should return the items in watch list', async (t) => { + const determiningTransferId = '789012' + const expectedItems = [ + { determiningTransferId: '789012', amount: 200 }, + { determiningTransferId: '789012', amount: 300 } + ] + + // Mock the database find method + Db.from(TABLE_NAMES.fxWatchList).find.returns(expectedItems) + + const result = await watchList.getItemsInWatchListByDeterminingTransferId(determiningTransferId) + + t.deepEqual(result, expectedItems, 'Should return the expected items') + t.ok(Db.from(TABLE_NAMES.fxWatchList).find.calledOnceWithExactly({ determiningTransferId }), 'Should call find method with the correct arguments') + t.end() + }) + + await watchListTest.test('addToWatchList should add the record to the watch list', async (t) => { + const record = { commitRequestId: '123456', amount: 100 } + + // Mock the database insert method + Db.from(TABLE_NAMES.fxWatchList).insert.returns() + + await watchList.addToWatchList(record) + + t.ok(Db.from(TABLE_NAMES.fxWatchList).insert.calledOnceWithExactly(record), 'Should call insert method with the correct arguments') + t.end() + }) + + watchListTest.end() +}) diff --git a/test/unit/models/ilpPackets/ilpPacket.test.js b/test/unit/models/ilpPackets/ilpPacket.test.js index bd3bb811f..b40a5d86c 100644 --- a/test/unit/models/ilpPackets/ilpPacket.test.js +++ b/test/unit/models/ilpPackets/ilpPacket.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/ledgerAccountType/ledgerAccountType.test.js b/test/unit/models/ledgerAccountType/ledgerAccountType.test.js index 02afdcde4..a536e406d 100644 --- a/test/unit/models/ledgerAccountType/ledgerAccountType.test.js +++ b/test/unit/models/ledgerAccountType/ledgerAccountType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -187,14 +190,14 @@ Test('ledgerAccountType model', async (ledgerAccountTypeTest) => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - const trxSpyCommit = sandbox.spy(trxStub, 'commit', ['get']) + sandbox.spy(trxStub, 'commit') knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -219,62 +222,16 @@ Test('ledgerAccountType model', async (ledgerAccountTypeTest) => { knexStub.select = selectStub await Model.create(ledgerAccountType.name, ledgerAccountType.description, ledgerAccountType.isActive, ledgerAccountType.isSettleable) - test.equal(trxSpyCommit.get.calledOnce, true, 'commit the transaction if no transaction is passed') + test.equal(knexStub.transaction.calledOnce, true, 'call knex.transaction() no transaction is passed') test.end() } catch (err) { test.fail(`should not have thrown an error ${err}`) test.end() } }) - await ledgerAccountTypeTest.test('create should', async (test) => { - let trxStub - let trxSpyRollBack - const ledgerAccountType = { - name: 'POSITION', - description: 'A single account for each currency with which the hub operates. The account is "held" by the Participant representing the hub in the switch', - isActive: 1, - isSettleable: true - } - try { - sandbox.stub(Db, 'getKnex') - const knexStub = sandbox.stub() - trxStub = { - get commit () { - - }, - get rollback () { - - } - } - trxSpyRollBack = sandbox.spy(trxStub, 'rollback', ['get']) - - knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) - Db.getKnex.returns(knexStub) - const transactingStub = sandbox.stub() - const insertStub = sandbox.stub() - transactingStub.resolves() - knexStub.insert = insertStub.returns({ transacting: transactingStub }) - const selectStub = sandbox.stub() - const fromStub = sandbox.stub() - const whereStub = sandbox.stub() - transactingStub.rejects(new Error()) - whereStub.returns({ transacting: transactingStub }) - fromStub.returns({ whereStub }) - knexStub.select = selectStub.returns({ from: fromStub }) - - await Model.create(ledgerAccountType.name, ledgerAccountType.description, ledgerAccountType.isActive, ledgerAccountType.isSettleable) - test.fail('have thrown an error') - test.end() - } catch (err) { - test.pass('throw an error') - test.equal(trxSpyRollBack.get.calledOnce, true, 'rollback the transaction if no transaction is passed') - test.end() - } - }) await ledgerAccountTypeTest.test('create should', async (test) => { let trxStub - let trxSpyRollBack const ledgerAccountType = { name: 'POSITION', @@ -286,14 +243,13 @@ Test('ledgerAccountType model', async (ledgerAccountTypeTest) => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - trxSpyRollBack = sandbox.spy(trxStub, 'rollback', ['get']) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -314,7 +270,6 @@ Test('ledgerAccountType model', async (ledgerAccountTypeTest) => { test.end() } catch (err) { test.pass('throw an error') - test.equal(trxSpyRollBack.get.calledOnce, false, 'not rollback the transaction if transaction is passed') test.end() } }) diff --git a/test/unit/models/misc/migrationLock.test.js b/test/unit/models/misc/migrationLock.test.js index c749e6883..4dcbbf8e3 100644 --- a/test/unit/models/misc/migrationLock.test.js +++ b/test/unit/models/misc/migrationLock.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/misc/segment.test.js b/test/unit/models/misc/segment.test.js index 271a96076..92afea9bf 100644 --- a/test/unit/models/misc/segment.test.js +++ b/test/unit/models/misc/segment.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/externalParticipant.test.js b/test/unit/models/participant/externalParticipant.test.js new file mode 100644 index 000000000..51037811b --- /dev/null +++ b/test/unit/models/participant/externalParticipant.test.js @@ -0,0 +1,125 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ +process.env.LOG_LEVEL = 'debug' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') + +const model = require('#src/models/participant/externalParticipant') +const Db = require('#src/lib/db') +const { TABLE_NAMES, DB_ERROR_CODES } = require('#src/shared/constants') + +const { tryCatchEndTest } = require('#test/util/helpers') +const { mockExternalParticipantDto } = require('#test/fixtures') + +const EP_TABLE = TABLE_NAMES.externalParticipant + +const isFSPIOPError = (err, message) => err.name === 'FSPIOPError' && + err.message === message && + err.cause.includes(message) + +Test('externalParticipant Model Tests -->', (epmTest) => { + let sandbox + + epmTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + + const dbStub = sandbox.stub(Db) + Db.from = table => dbStub[table] + Db[EP_TABLE] = { + insert: sandbox.stub(), + findOne: sandbox.stub(), + find: sandbox.stub(), + destroy: sandbox.stub() + } + t.end() + }) + + epmTest.afterEach(t => { + sandbox.restore() + t.end() + }) + + epmTest.test('should create externalParticipant in DB', tryCatchEndTest(async (t) => { + const data = mockExternalParticipantDto({ id: null, createdDate: null }) + Db[EP_TABLE].insert.withArgs(data).resolves(true) + const result = await model.create(data) + t.ok(result) + })) + + epmTest.test('should return null in case duplicateEntry error', tryCatchEndTest(async (t) => { + Db[EP_TABLE].insert.rejects({ code: DB_ERROR_CODES.duplicateEntry }) + const result = await model.create({}) + t.equals(result, null) + })) + + epmTest.test('should reformat DB error into SPIOPError on create', tryCatchEndTest(async (t) => { + const dbError = new Error('DB error') + Db[EP_TABLE].insert.rejects(dbError) + const err = await model.create({}) + .catch(e => e) + t.true(isFSPIOPError(err, dbError.message)) + })) + + epmTest.test('should get externalParticipant by name from DB', tryCatchEndTest(async (t) => { + const data = mockExternalParticipantDto() + Db[EP_TABLE].findOne.withArgs({ name: data.name }).resolves(data) + const result = await model.getByName(data.name) + t.deepEqual(result, data) + })) + + epmTest.test('should get externalParticipant by id', tryCatchEndTest(async (t) => { + const id = 'id123' + const data = { name: 'extFsp', proxyId: '123' } + Db[EP_TABLE].findOne.withArgs({ externalParticipantId: id }).resolves(data) + const result = await model.getById(id) + t.deepEqual(result, data) + })) + + epmTest.test('should get all externalParticipants by id', tryCatchEndTest(async (t) => { + const ep = mockExternalParticipantDto() + Db[EP_TABLE].find.withArgs({}).resolves([ep]) + const result = await model.getAll() + t.deepEqual(result, [ep]) + })) + + epmTest.test('should delete externalParticipant record by name', tryCatchEndTest(async (t) => { + const name = 'extFsp' + Db[EP_TABLE].destroy.withArgs({ name }).resolves(true) + const result = await model.destroyByName(name) + t.ok(result) + })) + + epmTest.test('should delete externalParticipant record by id', tryCatchEndTest(async (t) => { + const id = 123 + Db[EP_TABLE].destroy.withArgs({ externalParticipantId: id }).resolves(true) + const result = await model.destroyById(id) + t.ok(result) + })) + + epmTest.end() +}) diff --git a/test/unit/models/participant/externalParticipantCached.test.js b/test/unit/models/participant/externalParticipantCached.test.js new file mode 100644 index 000000000..459abee26 --- /dev/null +++ b/test/unit/models/participant/externalParticipantCached.test.js @@ -0,0 +1,141 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ +process.env.CLEDG_CACHE__CACHE_ENABLED = 'true' +process.env.CLEDG_CACHE__EXPIRES_IN_MS = `${120 * 1000}` +process.env.LOG_LEVEL = 'debug' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') + +const model = require('#src/models/participant/externalParticipantCached') +const cache = require('#src/lib/cache') +const db = require('#src/lib/db') +const { TABLE_NAMES } = require('#src/shared/constants') + +const { tryCatchEndTest } = require('#test/util/helpers') +const { mockExternalParticipantDto } = require('#test/fixtures') + +const EP_TABLE = TABLE_NAMES.externalParticipant + +Test('externalParticipantCached Model Tests -->', (epCachedTest) => { + let sandbox + + const name = `extFsp-${Date.now()}` + const mockEpList = [ + mockExternalParticipantDto({ name, createdDate: null }) + ] + + epCachedTest.beforeEach(async t => { + sandbox = Sinon.createSandbox() + + const dbStub = sandbox.stub(db) + db.from = table => dbStub[table] + db[EP_TABLE] = { + find: sandbox.stub().resolves(mockEpList), + findOne: sandbox.stub(), + insert: sandbox.stub(), + destroy: sandbox.stub() + } + + model.initialize() + await cache.initCache() + t.end() + }) + + epCachedTest.afterEach(async t => { + sandbox.restore() + await cache.destroyCache() + cache.dropClients() + t.end() + }) + + epCachedTest.test('should return undefined if no data by query in cache', tryCatchEndTest(async (t) => { + const fakeName = `${Date.now()}` + const data = await model.getById(fakeName) + t.equal(data, undefined) + })) + + epCachedTest.test('should get externalParticipant by name from cache', tryCatchEndTest(async (t) => { + // db[EP_TABLE].find = sandbox.stub() + const data = await model.getByName(name) + t.deepEqual(data, mockEpList[0]) + })) + + epCachedTest.test('should get externalParticipant by ID from cache', tryCatchEndTest(async (t) => { + const id = mockEpList[0].externalParticipantId + const data = await model.getById(id) + t.deepEqual(data, mockEpList[0]) + })) + + epCachedTest.test('should get all externalParticipants from cache', tryCatchEndTest(async (t) => { + const data = await model.getAll() + t.deepEqual(data, mockEpList) + })) + + epCachedTest.test('should invalidate cache', tryCatchEndTest(async (t) => { + let data = await model.getByName(name) + t.deepEqual(data, mockEpList[0]) + + await model.invalidateCache() + + db[EP_TABLE].find = sandbox.stub().resolves([]) + data = await model.getByName(name) + t.equal(data, undefined) + })) + + epCachedTest.test('should invalidate cache during create', tryCatchEndTest(async (t) => { + await model.create({}) + + db[EP_TABLE].find = sandbox.stub().resolves([]) + const data = await model.getByName(name) + t.equal(data, undefined) + })) + + epCachedTest.test('should invalidate cache during destroyById', tryCatchEndTest(async (t) => { + let data = await model.getByName(name) + t.deepEqual(data, mockEpList[0]) + + await model.destroyById('id') + + db[EP_TABLE].find = sandbox.stub().resolves([]) + data = await model.getByName(name) + t.equal(data, undefined) + })) + + epCachedTest.test('should invalidate cache during destroyByName', tryCatchEndTest(async (t) => { + let data = await model.getByName(name) + t.deepEqual(data, mockEpList[0]) + + await model.destroyByName('name') + + db[EP_TABLE].find = sandbox.stub().resolves([]) + data = await model.getByName(name) + t.equal(data, undefined) + })) + + epCachedTest.end() +}) diff --git a/test/unit/models/participant/facade.test.js b/test/unit/models/participant/facade.test.js index 8f77c3969..c46e21165 100644 --- a/test/unit/models/participant/facade.test.js +++ b/test/unit/models/participant/facade.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -42,8 +45,12 @@ const Enum = require('@mojaloop/central-services-shared').Enum const ParticipantModel = require('../../../../src/models/participant/participantCached') const ParticipantCurrencyModel = require('../../../../src/models/participant/participantCurrencyCached') const ParticipantLimitModel = require('../../../../src/models/participant/participantLimitCached') +const externalParticipantCachedModel = require('../../../../src/models/participant/externalParticipantCached') const SettlementModel = require('../../../../src/models/settlement/settlementModel') +const fixtures = require('#test/fixtures') +const { tryCatchEndTest } = require('#test/util/helpers') + Test('Participant facade', async (facadeTest) => { let sandbox @@ -55,8 +62,10 @@ Test('Participant facade', async (facadeTest) => { sandbox.stub(ParticipantCurrencyModel, 'invalidateParticipantCurrencyCache') sandbox.stub(ParticipantLimitModel, 'getByParticipantCurrencyId') sandbox.stub(ParticipantLimitModel, 'invalidateParticipantLimitCache') + sandbox.stub(externalParticipantCachedModel, 'getByName') + sandbox.stub(externalParticipantCachedModel, 'create') sandbox.stub(SettlementModel, 'getAll') - sandbox.stub(Cache) + sandbox.stub(Cache, 'isCacheEnabled') Db.participant = { query: sandbox.stub() } @@ -274,6 +283,98 @@ Test('Participant facade', async (facadeTest) => { } }) + await facadeTest.test('getByIDAndCurrency (cache off)', async (assert) => { + try { + const builderStub = sandbox.stub() + Db.participant.query.callsArgWith(0, builderStub) + builderStub.where = sandbox.stub() + + builderStub.where.returns({ + andWhere: sandbox.stub().returns({ + andWhere: sandbox.stub().returns({ + innerJoin: sandbox.stub().returns({ + select: sandbox.stub().returns({ + first: sandbox.stub().returns(participant) + }) + }) + }) + }) + }) + + const result = await Model.getByIDAndCurrency(1, 'USD', Enum.Accounts.LedgerAccountType.POSITION) + assert.deepEqual(result, participant) + assert.end() + } catch (err) { + Logger.error(`getByIDAndCurrency failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await facadeTest.test('getByIDAndCurrency (cache off)', async (assert) => { + try { + const builderStub = sandbox.stub() + Db.participant.query.callsArgWith(0, builderStub) + builderStub.where = sandbox.stub() + + builderStub.where.returns({ + andWhere: sandbox.stub().returns({ + andWhere: sandbox.stub().returns({ + innerJoin: sandbox.stub().returns({ + select: sandbox.stub().returns({ + first: sandbox.stub().returns({ + andWhere: sandbox.stub().returns(participant) + }) + }) + }) + }) + }) + }) + + const result = await Model.getByIDAndCurrency(1, 'USD', Enum.Accounts.LedgerAccountType.POSITION, true) + assert.deepEqual(result, participant) + assert.end() + } catch (err) { + Logger.error(`getByIDAndCurrency failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await facadeTest.test('getByIDAndCurrency should throw error when participant not found (cache off)', async (assert) => { + try { + Db.participant.query.throws(new Error('message')) + await Model.getByIDAndCurrency(1, 'USD', Enum.Accounts.LedgerAccountType.POSITION, true) + assert.fail('should throw') + assert.end() + } catch (err) { + Logger.error(`getByIDAndCurrency failed with error - ${err}`) + assert.pass('Error thrown') + assert.end() + } + }) + + await facadeTest.test('getByIDAndCurrency (cache on)', async (assert) => { + try { + Cache.isCacheEnabled.returns(true) + + ParticipantModel.getById.withArgs(participant.participantId).returns(participant) + ParticipantCurrencyModel.findOneByParams.withArgs({ + participantId: participant.participantId, + currencyId: participant.currency, + ledgerAccountTypeId: Enum.Accounts.LedgerAccountType.POSITION + }).returns(participant) + + const result = await Model.getByIDAndCurrency(participant.participantId, participant.currency, Enum.Accounts.LedgerAccountType.POSITION) + assert.deepEqual(result, participant) + assert.end() + } catch (err) { + Logger.error(`getByIDAndCurrency failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + await facadeTest.test('getEndpoint', async (assert) => { try { const builderStub = sandbox.stub() @@ -1765,14 +1866,14 @@ Test('Participant facade', async (facadeTest) => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - const trxSpyCommit = sandbox.spy(trxStub, 'commit', ['get']) + const trxSpyCommit = sandbox.spy(trxStub, 'commit') knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -1795,7 +1896,7 @@ Test('Participant facade', async (facadeTest) => { test.equal(whereNotStub.lastCall.args[0], 'participant.name', 'filter on participants name') test.equal(whereNotStub.lastCall.args[1], 'Hub', 'filter out the Hub') test.equal(transactingStub.lastCall.args[0], trxStub, 'run as transaction') - test.equal(trxSpyCommit.get.calledOnce, false, 'not commit the transaction if transaction is passed') + test.equal(trxSpyCommit.called, false, 'not commit the transaction if transaction is passed') test.deepEqual(response, participantsWithCurrencies, 'return participants with currencies') test.end() } catch (err) { @@ -1810,14 +1911,13 @@ Test('Participant facade', async (facadeTest) => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - const trxSpyCommit = sandbox.spy(trxStub, 'commit', ['get']) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -1840,7 +1940,6 @@ Test('Participant facade', async (facadeTest) => { test.equal(whereNotStub.lastCall.args[0], 'participant.name', 'filter on participants name') test.equal(whereNotStub.lastCall.args[1], 'Hub', 'filter out the Hub') test.equal(transactingStub.lastCall.args[0], trxStub, 'run as transaction') - test.equal(trxSpyCommit.get.calledOnce, true, 'commit the transaction if no transaction is passed') test.deepEqual(response, participantsWithCurrencies, 'return participants with currencies') test.end() @@ -1858,7 +1957,7 @@ Test('Participant facade', async (facadeTest) => { const knexStub = sandbox.stub() trxStub = sandbox.stub() trxStub.commit = sandbox.stub() - trxStub.rollback = sandbox.stub() + trxStub.rollback = () => Promise.reject(new Error('DB Error')) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) const transactingStub = sandbox.stub() @@ -1877,7 +1976,6 @@ Test('Participant facade', async (facadeTest) => { test.end() } catch (err) { test.pass('throw an error') - test.equal(trxStub.rollback.callCount, 0, 'not rollback the transaction if transaction is passed') test.end() } }) @@ -1895,5 +1993,39 @@ Test('Participant facade', async (facadeTest) => { } }) + facadeTest.test('getExternalParticipantIdByNameOrCreate method Tests -->', (getEpMethodTest) => { + getEpMethodTest.test('should return null in case of any error inside the method', tryCatchEndTest(async (t) => { + externalParticipantCachedModel.getByName = sandbox.stub().throws(new Error('Error occurred')) + const data = fixtures.mockExternalParticipantDto() + const result = await Model.getExternalParticipantIdByNameOrCreate(data) + t.equal(result, null) + })) + + getEpMethodTest.test('should return null if proxyParticipant not found', tryCatchEndTest(async (t) => { + ParticipantModel.getByName = sandbox.stub().resolves(null) + const result = await Model.getExternalParticipantIdByNameOrCreate({}) + t.equal(result, null) + })) + + getEpMethodTest.test('should return cached externalParticipant id', tryCatchEndTest(async (t) => { + const cachedEp = fixtures.mockExternalParticipantDto() + externalParticipantCachedModel.getByName = sandbox.stub().resolves(cachedEp) + const id = await Model.getExternalParticipantIdByNameOrCreate(cachedEp.name) + t.equal(id, cachedEp.externalParticipantId) + })) + + getEpMethodTest.test('should create and return new externalParticipant id', tryCatchEndTest(async (t) => { + const newEp = fixtures.mockExternalParticipantDto() + externalParticipantCachedModel.getByName = sandbox.stub().resolves(null) + externalParticipantCachedModel.create = sandbox.stub().resolves(newEp.externalParticipantId) + ParticipantModel.getByName = sandbox.stub().resolves({}) // to get proxy participantId + + const id = await Model.getExternalParticipantIdByNameOrCreate(newEp) + t.equal(id, newEp.externalParticipantId) + })) + + getEpMethodTest.end() + }) + await facadeTest.end() }) diff --git a/test/unit/models/participant/participant.test.js b/test/unit/models/participant/participant.test.js index 0105f176e..e0c254d06 100644 --- a/test/unit/models/participant/participant.test.js +++ b/test/unit/models/participant/participant.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -42,6 +45,7 @@ Test('Participant model', async (participantTest) => { name: 'fsp1z', currency: 'USD', isActive: 1, + isProxy: false, createdDate: new Date() }, { @@ -49,6 +53,7 @@ Test('Participant model', async (participantTest) => { name: 'fsp2', currency: 'EUR', isActive: 1, + isProxy: true, createdDate: new Date() } ] @@ -97,7 +102,8 @@ Test('Participant model', async (participantTest) => { try { Db.participant.insert.withArgs({ name: participantFixtures[0].name, - createdBy: 'unknown' + createdBy: 'unknown', + isProxy: false }).returns(1) const result = await Model.create(participantFixtures[0]) assert.equal(result, 1, ` returns ${result}`) @@ -113,7 +119,8 @@ Test('Participant model', async (participantTest) => { try { Db.participant.insert.withArgs({ name: participantFixtures[0].name, - createdBy: 'unknown' + createdBy: 'unknown', + isProxy: false }).throws(new Error()) const result = await Model.create(participantFixtures[0]) test.equal(result, 1, ` returns ${result}`) diff --git a/test/unit/models/participant/participantCached.test.js b/test/unit/models/participant/participantCached.test.js index 0e5b3aaab..2ba483989 100644 --- a/test/unit/models/participant/participantCached.test.js +++ b/test/unit/models/participant/participantCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/participantCurrency.test.js b/test/unit/models/participant/participantCurrency.test.js index 55c3bc218..414bd7594 100644 --- a/test/unit/models/participant/participantCurrency.test.js +++ b/test/unit/models/participant/participantCurrency.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/participantCurrencyCached.test.js b/test/unit/models/participant/participantCurrencyCached.test.js index 3d70f78b1..3b4fa7cfc 100644 --- a/test/unit/models/participant/participantCurrencyCached.test.js +++ b/test/unit/models/participant/participantCurrencyCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/participantLimit.test.js b/test/unit/models/participant/participantLimit.test.js index 7d3845746..61c61815f 100644 --- a/test/unit/models/participant/participantLimit.test.js +++ b/test/unit/models/participant/participantLimit.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/participantLimitCached.test.js b/test/unit/models/participant/participantLimitCached.test.js index 8486c147e..2a137f602 100644 --- a/test/unit/models/participant/participantLimitCached.test.js +++ b/test/unit/models/participant/participantLimitCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/participant/participantPosition.test.js b/test/unit/models/participant/participantPosition.test.js index 0c6f24dfe..f9c261ca9 100644 --- a/test/unit/models/participant/participantPosition.test.js +++ b/test/unit/models/participant/participantPosition.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -203,14 +206,13 @@ Test('Participant Position model', async (participantPositionTest) => { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() const trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - const trxSpyCommit = sandbox.spy(trxStub, 'commit', ['get']) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -238,7 +240,6 @@ Test('Participant Position model', async (participantPositionTest) => { test.deepEqual(batchInsertStub.lastCall.args[1], participantPositions, 'all records should be inserted') test.equal(transactingStub.callCount, 1, 'make the database calls as transaction') test.equal(transactingStub.lastCall.args[0], trxStub, 'run as transaction') - test.equal(trxSpyCommit.get.calledOnce, true, 'commit the transaction if no transaction is passed') test.end() } catch (err) { @@ -250,20 +251,18 @@ Test('Participant Position model', async (participantPositionTest) => { await participantPositionTest.test('createParticipantPositionRecords should', async (test) => { let trxStub - let trxSpyRollBack try { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - trxSpyRollBack = sandbox.spy(trxStub, 'rollback', ['get']) knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) Db.getKnex.returns(knexStub) @@ -291,27 +290,24 @@ Test('Participant Position model', async (participantPositionTest) => { test.end() } catch (err) { test.pass('throw an error') - test.equal(trxSpyRollBack.get.calledOnce, true, 'rollback the transaction if no transaction is passed') test.end() } }) await participantPositionTest.test('createParticipantCurrencyRecords should', async (test) => { let trxStub - let trxSpyRollBack try { sandbox.stub(Db, 'getKnex') const knexStub = sandbox.stub() trxStub = { - get commit () { + commit () { }, - get rollback () { - + rollback () { + return Promise.reject(new Error('DB error')) } } - trxSpyRollBack = sandbox.spy(trxStub, 'rollback', ['get']) knexStub.transaction = sandbox.stub().callsArgWith(0, [trxStub, true]) Db.getKnex.returns(knexStub) @@ -339,7 +335,6 @@ Test('Participant Position model', async (participantPositionTest) => { test.end() } catch (err) { test.pass('throw an error') - test.equal(trxSpyRollBack.get.calledOnce, false, 'not rollback the transaction if transaction is passed') test.end() } }) diff --git a/test/unit/models/participant/participantPositionChange.test.js b/test/unit/models/participant/participantPositionChange.test.js index ddc2ef7ce..8b8fe2ba2 100644 --- a/test/unit/models/participant/participantPositionChange.test.js +++ b/test/unit/models/participant/participantPositionChange.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/position/batch.test.js b/test/unit/models/position/batch.test.js index cd2ce3656..83b251b92 100644 --- a/test/unit/models/position/batch.test.js +++ b/test/unit/models/position/batch.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -555,5 +558,44 @@ Test('Batch model', async (positionBatchTest) => { } }) + await positionBatchTest.test('getReservedPositionChangesByCommitRequestIds', async (test) => { + try { + sandbox.stub(Db, 'getKnex') + + const knexStub = sandbox.stub() + const trxStub = sandbox.stub() + trxStub.commit = sandbox.stub() + knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) + Db.getKnex.returns(knexStub) + + knexStub.returns({ + transacting: sandbox.stub().returns({ + whereIn: sandbox.stub().returns({ + where: sandbox.stub().returns({ + leftJoin: sandbox.stub().returns({ + select: sandbox.stub().returns([{ + 1: { + 2: { + value: 1 + } + } + }]) + }) + }) + }) + }) + }) + + await Model.getReservedPositionChangesByCommitRequestIds(trxStub, [1, 2], 3, 4) + test.pass('completed successfully') + test.ok(knexStub.withArgs('fxTransferStateChange').calledOnce, 'knex called with fxTransferStateChange once') + test.end() + } catch (err) { + Logger.error(`getReservedPositionChangesByCommitRequestIds failed with error - ${err}`) + test.fail() + test.end() + } + }) + positionBatchTest.end() }) diff --git a/test/unit/models/position/batchCached.test.js b/test/unit/models/position/batchCached.test.js index 14e820faf..16e397d38 100644 --- a/test/unit/models/position/batchCached.test.js +++ b/test/unit/models/position/batchCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/position/facade.test.js b/test/unit/models/position/facade.test.js index 6feb81af9..dfc9124c7 100644 --- a/test/unit/models/position/facade.test.js +++ b/test/unit/models/position/facade.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -217,7 +220,14 @@ Test('Position facade', async (positionFacadeTest) => { type: 'application/json', content: { header: '', - payload: transfer + payload: transfer, + context: { + cyrilResult: { + participantName: 'dfsp1', + currencyId: 'USD', + amount: '100' + } + } }, metadata: { event: { @@ -312,11 +322,11 @@ Test('Position facade', async (positionFacadeTest) => { transacting: sandbox.stub().returns({ forUpdate: sandbox.stub().returns({ whereIn: sandbox.stub().returns({ - select: sandbox.stub().returns(Promise.resolve()) + select: sandbox.stub().resolves() }) }), where: sandbox.stub().returns({ - update: sandbox.stub().returns(Promise.resolve()), + update: sandbox.stub().resolves(), orderBy: sandbox.stub().returns({ first: sandbox.stub().resolves(Object.assign({}, transferStateChange)) }) @@ -398,11 +408,11 @@ Test('Position facade', async (positionFacadeTest) => { transacting: sandbox.stub().returns({ forUpdate: sandbox.stub().returns({ whereIn: sandbox.stub().returns({ - select: sandbox.stub().returns(Promise.resolve()) + select: sandbox.stub().resolves() }) }), where: sandbox.stub().returns({ - update: sandbox.stub().returns(Promise.resolve()), + update: sandbox.stub().resolves(), orderBy: sandbox.stub().returns({ first: sandbox.stub().resolves(Object.assign({}, transferStateChange)) }) @@ -481,11 +491,11 @@ Test('Position facade', async (positionFacadeTest) => { transacting: sandbox.stub().returns({ forUpdate: sandbox.stub().returns({ whereIn: sandbox.stub().returns({ - select: sandbox.stub().returns(Promise.resolve()) + select: sandbox.stub().resolves() }) }), where: sandbox.stub().returns({ - update: sandbox.stub().returns(Promise.resolve()), + update: sandbox.stub().resolves(), orderBy: sandbox.stub().returns({ first: sandbox.stub().resolves(incorrectTransferStateChange) }) @@ -591,11 +601,11 @@ Test('Position facade', async (positionFacadeTest) => { transacting: sandbox.stub().returns({ forUpdate: sandbox.stub().returns({ whereIn: sandbox.stub().returns({ - select: sandbox.stub().returns(Promise.resolve()) + select: sandbox.stub().resolves() }) }), where: sandbox.stub().returns({ - update: sandbox.stub().returns(Promise.resolve()), + update: sandbox.stub().resolves(), orderBy: sandbox.stub().returns({ first: sandbox.stub().resolves(MainUtil.clone(transferStateChange)) }) @@ -680,11 +690,11 @@ Test('Position facade', async (positionFacadeTest) => { transacting: sandbox.stub().returns({ forUpdate: sandbox.stub().returns({ whereIn: sandbox.stub().returns({ - select: sandbox.stub().returns(Promise.resolve()) + select: sandbox.stub().resolves() }) }), where: sandbox.stub().returns({ - update: sandbox.stub().returns(Promise.resolve()), + update: sandbox.stub().resolves(), orderBy: sandbox.stub().returns({ first: sandbox.stub().resolves(MainUtil.clone(transferStateChange)) }) diff --git a/test/unit/models/position/participantPosition.test.js b/test/unit/models/position/participantPosition.test.js index 71d2b5076..437c53b1c 100644 --- a/test/unit/models/position/participantPosition.test.js +++ b/test/unit/models/position/participantPosition.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/position/participantPositionChanges.test.js b/test/unit/models/position/participantPositionChanges.test.js new file mode 100644 index 000000000..3e6b063e7 --- /dev/null +++ b/test/unit/models/position/participantPositionChanges.test.js @@ -0,0 +1,116 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Vijaya Kumar Guthi + -------------- + ******/ + +'use strict' + +const Test = require('tapes')(require('tape')) +const Sinon = require('sinon') +const Db = require('../../../../src/lib/db') +const Logger = require('@mojaloop/central-services-logger') +const Model = require('../../../../src/models/position/participantPositionChanges') + +Test('participantPositionChanges model', async (participantPositionChangesTest) => { + let sandbox + + participantPositionChangesTest.beforeEach(t => { + sandbox = Sinon.createSandbox() + sandbox.stub(Db, 'getKnex') + const knexStub = sandbox.stub() + knexStub.returns({ + where: sandbox.stub().returns({ + where: sandbox.stub().returns({ + innerJoin: sandbox.stub().returns({ + select: sandbox.stub().resolves({}) + }) + }) + }) + }) + Db.getKnex.returns(knexStub) + + t.end() + }) + + participantPositionChangesTest.afterEach(t => { + sandbox.restore() + + t.end() + }) + + await participantPositionChangesTest.test('getReservedPositionChangesByCommitRequestId', async (assert) => { + try { + const commitRequestId = 'b0000001-0000-0000-0000-000000000000' + const result = await Model.getReservedPositionChangesByCommitRequestId(commitRequestId) + assert.deepEqual(result, {}, `returns ${result}`) + assert.end() + } catch (err) { + Logger.error(`getReservedPositionChangesByCommitRequestId failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantPositionChangesTest.test('getReservedPositionChangesByTransferId', async (assert) => { + try { + const transferId = 'a0000001-0000-0000-0000-000000000000' + const result = await Model.getReservedPositionChangesByTransferId(transferId) + assert.deepEqual(result, {}, `returns ${result}`) + assert.end() + } catch (err) { + Logger.error(`getReservedPositionChangesByTransferId failed with error - ${err}`) + assert.fail() + assert.end() + } + }) + + await participantPositionChangesTest.test('getReservedPositionChangesByCommitRequestId throws an error', async (assert) => { + try { + Db.getKnex.returns(Promise.reject(new Error('Test Error'))) + const commitRequestId = 'b0000001-0000-0000-0000-000000000000' + await Model.getReservedPositionChangesByCommitRequestId(commitRequestId) + assert.fail() + assert.end() + } catch (err) { + assert.pass('Error thrown') + assert.end() + } + }) + + await participantPositionChangesTest.test('getReservedPositionChangesByTransferId throws an error', async (assert) => { + try { + Db.getKnex.returns(Promise.reject(new Error('Test Error'))) + const transferId = 'a0000001-0000-0000-0000-000000000000' + await Model.getReservedPositionChangesByTransferId(transferId) + assert.fail() + assert.end() + } catch (err) { + assert.pass('Error thrown') + assert.end() + } + }) + + participantPositionChangesTest.end() +}) diff --git a/test/unit/models/settlement/settlementModel.test.js b/test/unit/models/settlement/settlementModel.test.js index 12bc36d7c..ef9ffb8df 100644 --- a/test/unit/models/settlement/settlementModel.test.js +++ b/test/unit/models/settlement/settlementModel.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/settlement/settlementModelCached.test.js b/test/unit/models/settlement/settlementModelCached.test.js index 24c8e7dbb..ba7586d7b 100644 --- a/test/unit/models/settlement/settlementModelCached.test.js +++ b/test/unit/models/settlement/settlementModelCached.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/facade-withMockKnex.test.js b/test/unit/models/transfer/facade-withMockKnex.test.js new file mode 100644 index 000000000..da403d623 --- /dev/null +++ b/test/unit/models/transfer/facade-withMockKnex.test.js @@ -0,0 +1,103 @@ +/***** + License + -------------- + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + + Contributors + -------------- + This is the official list of the Mojaloop project contributors for this file. + Names of the original copyright holders (individuals or organizations) + should be listed with a '*' in the first column. People who have + contributed from an organization can be listed under the organization + that actually holds the copyright for their contributions (see the + Mojaloop Foundation for an example). Those individuals should have + their names indented and be marked with a '-'. Email address can be added + optionally within square brackets . + * Gates Foundation + - Name Surname + + * Eugen Klymniuk + -------------- + **********/ + +const Database = require('@mojaloop/database-lib/src/database') + +const Test = require('tapes')(require('tape')) +const knex = require('knex') +const mockKnex = require('mock-knex') +const Proxyquire = require('proxyquire') + +const config = require('#src/lib/config') +const { tryCatchEndTest } = require('#test/util/helpers') + +let transferFacade + +Test('Transfer facade Tests (with mockKnex) -->', async (transferFacadeTest) => { + const db = new Database() + db._knex = knex(config.DATABASE) + mockKnex.mock(db._knex) + + await db.connect(config.DATABASE) + + // we need to override the singleton Db (from ../lib/db), coz it was already modified by other unit-tests! + transferFacade = Proxyquire('#src/models/transfer/facade', { + '../../lib/db': db, + './transferExtension': Proxyquire('#src/models/transfer/transferExtension', { '../../lib/db': db }) + }) + + let tracker // allow to catch and respond to DB queries: https://www.npmjs.com/package/mock-knex#tracker + + await transferFacadeTest.beforeEach(async t => { + tracker = mockKnex.getTracker() + tracker.install() + t.end() + }) + + await transferFacadeTest.afterEach(t => { + tracker.uninstall() + t.end() + }) + + await transferFacadeTest.test('getById Method Tests -->', (getByIdTest) => { + getByIdTest.test('should find zero records', tryCatchEndTest(async (t) => { + const id = Date.now() + + tracker.on('query', (query) => { + if (query.bindings[0] === id && query.method === 'first') { + return query.response(null) + } + query.reject(new Error('Mock DB error')) + }) + const result = await transferFacade.getById(id) + t.equal(result, null, 'no transfers were found') + })) + + getByIdTest.test('should find transfer by id', tryCatchEndTest(async (t) => { + const id = Date.now() + const mockExtensionList = [id] + + tracker.on('query', (q) => { + if (q.step === 1 && q.method === 'first' && q.bindings[0] === id) { + return q.response({}) + } + if (q.step === 2 && q.method === 'select') { // TransferExtensionModel.getByTransferId() call + return q.response(mockExtensionList) + } + q.reject(new Error('Mock DB error')) + }) + + const result = await transferFacade.getById(id) + t.ok(result, 'transfers is found') + t.deepEqual(result.extensionList, mockExtensionList) + })) + + getByIdTest.end() + }) + + await transferFacadeTest.end() +}) diff --git a/test/unit/models/transfer/facade.test.js b/test/unit/models/transfer/facade.test.js index 8740f3a62..4e56a7fe6 100644 --- a/test/unit/models/transfer/facade.test.js +++ b/test/unit/models/transfer/facade.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -39,6 +42,7 @@ const Enum = require('@mojaloop/central-services-shared').Enum const TransferEventAction = Enum.Events.Event.Action // const Proxyquire = require('proxyquire') const ParticipantFacade = require('../../../../src/models/participant/facade') +const ParticipantModelCached = require('../../../../src/models/participant/participantCached') const Time = require('@mojaloop/central-services-shared').Util.Time const { randomUUID } = require('crypto') const cloneDeep = require('lodash').cloneDeep @@ -94,6 +98,11 @@ Test('Transfer facade', async (transferFacadeTest) => { transferFacadeTest.beforeEach(t => { sandbox = Sinon.createSandbox() + const findStub = sandbox.stub().returns([{ + createdDate: now, + participantId: 1, + name: 'test' + }]) Db.transfer = { insert: sandbox.stub(), find: sandbox.stub(), @@ -115,10 +124,22 @@ Test('Transfer facade', async (transferFacadeTest) => { query: sandbox.stub() } Db.from = (table) => { - return Db[table] + return { + ...Db[table], + find: findStub + } } clock = Sinon.useFakeTimers(now.getTime()) sandbox.stub(ParticipantFacade, 'getByNameAndCurrency') + sandbox.stub(ParticipantModelCached, 'getByName') + ParticipantModelCached.getByName.returns(Promise.resolve({ + participantId: 0, + name: 'fsp1', + currency: 'USD', + isActive: 1, + createdDate: new Date(), + currencyList: ['USD'] + })) t.end() }) @@ -128,236 +149,6 @@ Test('Transfer facade', async (transferFacadeTest) => { t.end() }) - await transferFacadeTest.test('getById should return transfer by id', async (test) => { - try { - const transferId1 = 't1' - const transferId2 = 't2' - const extensions = cloneDeep(transferExtensions) - const transfers = [ - { transferId: transferId1, extensionList: extensions }, - { transferId: transferId2, errorCode: 5105, transferStateEnumeration: Enum.Transfers.TransferState.ABORTED, extensionList: [{ key: 'key1', value: 'value1' }, { key: 'key2', value: 'value2' }, { key: 'cause', value: '5105: undefined' }], isTransferReadModel: true } - ] - - const builderStub = sandbox.stub() - const whereRawPc1 = sandbox.stub() - const whereRawPc2 = sandbox.stub() - const payerTransferStub = sandbox.stub() - const payerRoleTypeStub = sandbox.stub() - const payerCurrencyStub = sandbox.stub() - const payerParticipantStub = sandbox.stub() - const payeeTransferStub = sandbox.stub() - const payeeRoleTypeStub = sandbox.stub() - const payeeCurrencyStub = sandbox.stub() - const payeeParticipantStub = sandbox.stub() - const ilpPacketStub = sandbox.stub() - const stateChangeStub = sandbox.stub() - const stateStub = sandbox.stub() - const transferFulfilmentStub = sandbox.stub() - const transferErrorStub = sandbox.stub() - - const selectStub = sandbox.stub() - const orderByStub = sandbox.stub() - const firstStub = sandbox.stub() - - builderStub.where = sandbox.stub() - - Db.transfer.query.callsArgWith(0, builderStub) - Db.transfer.query.returns(transfers[0]) - - builderStub.where.returns({ - whereRaw: whereRawPc1.returns({ - whereRaw: whereRawPc2.returns({ - innerJoin: payerTransferStub.returns({ - innerJoin: payerRoleTypeStub.returns({ - innerJoin: payerCurrencyStub.returns({ - innerJoin: payerParticipantStub.returns({ - innerJoin: payeeTransferStub.returns({ - innerJoin: payeeRoleTypeStub.returns({ - innerJoin: payeeCurrencyStub.returns({ - innerJoin: payeeParticipantStub.returns({ - innerJoin: ilpPacketStub.returns({ - leftJoin: stateChangeStub.returns({ - leftJoin: stateStub.returns({ - leftJoin: transferFulfilmentStub.returns({ - leftJoin: transferErrorStub.returns({ - select: selectStub.returns({ - orderBy: orderByStub.returns({ - first: firstStub.returns(transfers[0]) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - - sandbox.stub(transferExtensionModel, 'getByTransferId') - transferExtensionModel.getByTransferId.returns(extensions) - - const found = await TransferFacade.getById(transferId1) - test.equal(found, transfers[0]) - test.ok(builderStub.where.withArgs({ - 'transfer.transferId': transferId1, - 'tprt1.name': 'PAYER_DFSP', - 'tprt2.name': 'PAYEE_DFSP' - }).calledOnce) - test.ok(whereRawPc1.withArgs('pc1.currencyId = transfer.currencyId').calledOnce) - test.ok(whereRawPc2.withArgs('pc2.currencyId = transfer.currencyId').calledOnce) - test.ok(payerTransferStub.withArgs('transferParticipant AS tp1', 'tp1.transferId', 'transfer.transferId').calledOnce) - test.ok(payerRoleTypeStub.withArgs('transferParticipantRoleType AS tprt1', 'tprt1.transferParticipantRoleTypeId', 'tp1.transferParticipantRoleTypeId').calledOnce) - test.ok(payerCurrencyStub.withArgs('participantCurrency AS pc1', 'pc1.participantCurrencyId', 'tp1.participantCurrencyId').calledOnce) - test.ok(payerParticipantStub.withArgs('participant AS da', 'da.participantId', 'pc1.participantId').calledOnce) - test.ok(payeeTransferStub.withArgs('transferParticipant AS tp2', 'tp2.transferId', 'transfer.transferId').calledOnce) - test.ok(payeeRoleTypeStub.withArgs('transferParticipantRoleType AS tprt2', 'tprt2.transferParticipantRoleTypeId', 'tp2.transferParticipantRoleTypeId').calledOnce) - test.ok(payeeCurrencyStub.withArgs('participantCurrency AS pc2', 'pc2.participantCurrencyId', 'tp2.participantCurrencyId').calledOnce) - test.ok(payeeParticipantStub.withArgs('participant AS ca', 'ca.participantId', 'pc2.participantId').calledOnce) - test.ok(ilpPacketStub.withArgs('ilpPacket AS ilpp', 'ilpp.transferId', 'transfer.transferId').calledOnce) - test.ok(stateChangeStub.withArgs('transferStateChange AS tsc', 'tsc.transferId', 'transfer.transferId').calledOnce) - test.ok(stateStub.withArgs('transferState AS ts', 'ts.transferStateId', 'tsc.transferStateId').calledOnce) - test.ok(transferFulfilmentStub.withArgs('transferFulfilment AS tf', 'tf.transferId', 'transfer.transferId').calledOnce) - test.ok(transferErrorStub.withArgs('transferError as te', 'te.transferId', 'transfer.transferId').calledOnce) - test.ok(selectStub.withArgs( - 'transfer.*', - 'transfer.currencyId AS currency', - 'pc1.participantCurrencyId AS payerParticipantCurrencyId', - 'tp1.amount AS payerAmount', - 'da.participantId AS payerParticipantId', - 'da.name AS payerFsp', - 'pc2.participantCurrencyId AS payeeParticipantCurrencyId', - 'tp2.amount AS payeeAmount', - 'ca.participantId AS payeeParticipantId', - 'ca.name AS payeeFsp', - 'tsc.transferStateChangeId', - 'tsc.transferStateId AS transferState', - 'tsc.reason AS reason', - 'tsc.createdDate AS completedTimestamp', - 'ts.enumeration as transferStateEnumeration', - 'ts.description as transferStateDescription', - 'ilpp.value AS ilpPacket', - 'transfer.ilpCondition AS condition', - 'tf.ilpFulfilment AS fulfilment' - ).calledOnce) - test.ok(orderByStub.withArgs('tsc.transferStateChangeId', 'desc').calledOnce) - test.ok(firstStub.withArgs().calledOnce) - - Db.transfer.query.returns(transfers[1]) - builderStub.where.returns({ - whereRaw: whereRawPc1.returns({ - whereRaw: whereRawPc2.returns({ - innerJoin: payerTransferStub.returns({ - innerJoin: payerRoleTypeStub.returns({ - innerJoin: payerCurrencyStub.returns({ - innerJoin: payerParticipantStub.returns({ - innerJoin: payeeTransferStub.returns({ - innerJoin: payeeRoleTypeStub.returns({ - innerJoin: payeeCurrencyStub.returns({ - innerJoin: payeeParticipantStub.returns({ - innerJoin: ilpPacketStub.returns({ - leftJoin: stateChangeStub.returns({ - leftJoin: stateStub.returns({ - leftJoin: transferFulfilmentStub.returns({ - leftJoin: transferErrorStub.returns({ - select: selectStub.returns({ - orderBy: orderByStub.returns({ - first: firstStub.returns(transfers[1]) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - const found2 = await TransferFacade.getById(transferId2) - // TODO: extend testing for the current code branch - test.deepEqual(found2, transfers[1]) - - transferExtensionModel.getByTransferId.returns(null) - const found3 = await TransferFacade.getById(transferId2) - // TODO: extend testing for the current code branch - test.equal(found3, transfers[1]) - test.end() - } catch (err) { - Logger.error(`getById failed with error - ${err}`) - test.fail() - test.end() - } - }) - - await transferFacadeTest.test('getById should find zero records', async (test) => { - try { - const transferId1 = 't1' - const builderStub = sandbox.stub() - Db.transfer.query.callsArgWith(0, builderStub) - builderStub.where = sandbox.stub() - builderStub.where.returns({ - whereRaw: sandbox.stub().returns({ - whereRaw: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - select: sandbox.stub().returns({ - orderBy: sandbox.stub().returns({ - first: sandbox.stub().returns(null) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - const found = await TransferFacade.getById(transferId1) - test.equal(found, null, 'no transfers were found') - test.end() - } catch (err) { - Logger.error(`getById failed with error - ${err}`) - test.fail('Error thrown') - test.end() - } - }) - await transferFacadeTest.test('getById should throw an error', async (test) => { try { const transferId1 = 't1' @@ -732,6 +523,7 @@ Test('Transfer facade', async (transferFacadeTest) => { const builderStub = sandbox.stub() const transferStateChange = sandbox.stub() + const transferStub = sandbox.stub() const selectStub = sandbox.stub() const orderByStub = sandbox.stub() const firstStub = sandbox.stub() @@ -742,9 +534,11 @@ Test('Transfer facade', async (transferFacadeTest) => { builderStub.where.returns({ innerJoin: transferStateChange.returns({ - select: selectStub.returns({ - orderBy: orderByStub.returns({ - first: firstStub.returns(transfer) + innerJoin: transferStub.returns({ + select: selectStub.returns({ + orderBy: orderByStub.returns({ + first: firstStub.returns(transfer) + }) }) }) }) @@ -760,6 +554,7 @@ Test('Transfer facade', async (transferFacadeTest) => { test.ok(transferStateChange.withArgs('transferStateChange AS tsc', 'tsc.transferId', 'transferParticipant.transferId').calledOnce) test.ok(selectStub.withArgs( 'transferParticipant.*', + 't.currencyId', 'tsc.transferStateId', 'tsc.reason' ).calledOnce) @@ -1435,6 +1230,9 @@ Test('Transfer facade', async (transferFacadeTest) => { const segmentId = 1 const intervalMin = 1 const intervalMax = 10 + const fxSegmentId = 1 + const fxIntervalMin = 1 + const fxIntervalMax = 10 const knexStub = sandbox.stub() sandbox.stub(Db, 'getKnex').returns(knexStub) @@ -1442,7 +1240,7 @@ Test('Transfer facade', async (transferFacadeTest) => { knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) knexStub.from = sandbox.stub().throws(new Error('Custom error')) - await TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax) + await TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax, fxSegmentId, fxIntervalMin, fxIntervalMax) test.fail('Error not thrown!') test.end() } catch (err) { @@ -1452,120 +1250,6 @@ Test('Transfer facade', async (transferFacadeTest) => { } }) - await timeoutExpireReservedTest.test('perform timeout successfully', async test => { - try { - let segmentId - const intervalMin = 1 - const intervalMax = 10 - const expectedResult = 1 - - const knexStub = sandbox.stub() - sandbox.stub(Db, 'getKnex').returns(knexStub) - const trxStub = sandbox.stub() - knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) - const context = sandbox.stub() - context.from = sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - whereNull: sandbox.stub().returns({ - whereIn: sandbox.stub().returns({ - select: sandbox.stub() - }) - }) - }), - where: sandbox.stub().returns({ - andWhere: sandbox.stub().returns({ - select: sandbox.stub() - }) - }) - }) - }) - }) - context.on = sandbox.stub().returns({ - andOn: sandbox.stub().returns({ - andOn: sandbox.stub() - }) - }) - knexStub.returns({ - select: sandbox.stub().returns({ - max: sandbox.stub().returns({ - where: sandbox.stub().returns({ - andWhere: sandbox.stub().returns({ - groupBy: sandbox.stub().returns({ - as: sandbox.stub() - }) - }) - }), - innerJoin: sandbox.stub().returns({ - groupBy: sandbox.stub().returns({ - as: sandbox.stub() - }) - }) - }) - }), - transacting: sandbox.stub().returns({ - insert: sandbox.stub(), - where: sandbox.stub().returns({ - update: sandbox.stub() - }) - }), - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().callsArgOn(1, context).returns({ - innerJoin: sandbox.stub().callsArgOn(1, context).returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - innerJoin: sandbox.stub().returns({ - leftJoin: sandbox.stub().returns({ - where: sandbox.stub().returns({ - select: sandbox.stub().returns( - Promise.resolve(expectedResult) - ) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - }) - knexStub.raw = sandbox.stub() - knexStub.from = sandbox.stub().returns({ - transacting: sandbox.stub().returns({ - insert: sandbox.stub().callsArgOn(0, context) - }) - }) - - let result - try { - segmentId = 0 - result = await TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax) - test.equal(result, expectedResult, 'Expected result returned') - } catch (err) { - Logger.error(`timeoutExpireReserved failed with error - ${err}`) - test.fail() - } - try { - segmentId = 1 - await TransferFacade.timeoutExpireReserved(segmentId, intervalMin, intervalMax) - test.equal(result, expectedResult, 'Expected result returned.') - } catch (err) { - Logger.error(`timeoutExpireReserved failed with error - ${err}`) - test.fail() - } - test.end() - } catch (err) { - Logger.error(`timeoutExpireReserved failed with error - ${err}`) - test.fail() - test.end() - } - }) - await timeoutExpireReservedTest.end() } catch (err) { Logger.error(`transferFacadeTest failed with error - ${err}`) @@ -1723,7 +1407,14 @@ Test('Transfer facade', async (transferFacadeTest) => { transferStateId: Enum.Transfers.TransferInternalState.ABORTED_REJECTED, ledgerAccountTypeId: 2 } - const trxStub = sandbox.stub() + const trxStub = { + commit () { + + }, + rollback () { + return Promise.reject(new Error('DB error')) + } + } const knexStub = sandbox.stub() knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) @@ -1821,7 +1512,14 @@ Test('Transfer facade', async (transferFacadeTest) => { transferStateId: 'RECEIVED_PREPARE', ledgerAccountTypeId: 2 } - const trxStub = sandbox.stub() + const trxStub = { + commit () { + + }, + rollback () { + return Promise.reject(new Error('DB error')) + } + } const knexStub = sandbox.stub() knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) @@ -1950,6 +1648,13 @@ Test('Transfer facade', async (transferFacadeTest) => { knexStub.withArgs('participantCurrency').returns({ select: sandbox.stub().returns({ where: sandbox.stub().returns({ + first: sandbox.stub().returns({ + transacting: sandbox.stub().returns( + Promise.resolve({ + participantId: 1 + }) + ) + }), andWhere: sandbox.stub().returns({ first: sandbox.stub().returns({ transacting: sandbox.stub().returns( @@ -1967,7 +1672,7 @@ Test('Transfer facade', async (transferFacadeTest) => { const result = await TransferFacade.reconciliationTransferPrepare(payload, transactionTimestamp, enums, trxStub) test.equal(result, 0, 'Result for successful operation returned') test.equal(knexStub.withArgs('transfer').callCount, 1) - test.equal(knexStub.withArgs('participantCurrency').callCount, 1) + test.equal(knexStub.withArgs('participantCurrency').callCount, 2) test.equal(knexStub.withArgs('transferParticipant').callCount, 2) test.equal(knexStub.withArgs('transferStateChange').callCount, 1) test.equal(knexStub.withArgs('transferExtension').callCount, 3) @@ -2020,6 +1725,11 @@ Test('Transfer facade', async (transferFacadeTest) => { knexStub.returns({ select: sandbox.stub().returns({ where: sandbox.stub().returns({ + first: sandbox.stub().returns({ + transacting: sandbox.stub().returns({ + participantId: 1 + }) + }), andWhere: sandbox.stub().returns({ first: sandbox.stub().returns({ transacting: sandbox.stub().returns({ @@ -2067,6 +1777,13 @@ Test('Transfer facade', async (transferFacadeTest) => { knexStub.withArgs('participantCurrency').returns({ select: sandbox.stub().returns({ where: sandbox.stub().returns({ + first: sandbox.stub().returns({ + transacting: sandbox.stub().returns( + Promise.resolve({ + participantId: 1 + }) + ) + }), andWhere: sandbox.stub().returns({ first: sandbox.stub().returns({ transacting: sandbox.stub().returns( @@ -2199,7 +1916,14 @@ Test('Transfer facade', async (transferFacadeTest) => { } const transactionTimestamp = Time.getUTCString(now) - const trxStub = sandbox.stub() + const trxStub = { + commit () { + + }, + rollback () { + return Promise.reject(new Error('DB error')) + } + } const knexStub = sandbox.stub() knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) sandbox.stub(Db, 'getKnex').returns(knexStub) @@ -2223,7 +1947,14 @@ Test('Transfer facade', async (transferFacadeTest) => { } const transactionTimestamp = Time.getUTCString(now) - const trxStub = sandbox.stub() + const trxStub = { + commit () { + + }, + rollback () { + return Promise.reject(new Error('DB error')) + } + } const knexStub = sandbox.stub() knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) sandbox.stub(Db, 'getKnex').returns(knexStub) @@ -2524,7 +2255,7 @@ Test('Transfer facade', async (transferFacadeTest) => { const trxStub = sandbox.stub() trxStub.commit = sandbox.stub() - trxStub.rollback = sandbox.stub() + trxStub.rollback = () => Promise.reject(new Error('DB Error')) const knexStub = sandbox.stub() knexStub.transaction = sandbox.stub().callsArgWith(0, trxStub) sandbox.stub(Db, 'getKnex').returns(knexStub) @@ -2557,7 +2288,6 @@ Test('Transfer facade', async (transferFacadeTest) => { const participantName = 'fsp1' const transferId = '88416f4c-68a3-4819-b8e0-c23b27267cd5' const builderStub = sandbox.stub() - const participantCurrencyStub = sandbox.stub() const transferParticipantStub = sandbox.stub() const selectStub = sandbox.stub() @@ -2565,10 +2295,8 @@ Test('Transfer facade', async (transferFacadeTest) => { Db.participant.query.callsArgWith(0, builderStub) builderStub.where.returns({ - innerJoin: participantCurrencyStub.returns({ - innerJoin: transferParticipantStub.returns({ - select: selectStub.returns([1]) - }) + innerJoin: transferParticipantStub.returns({ + select: selectStub.returns([1]) }) }) @@ -2577,11 +2305,9 @@ Test('Transfer facade', async (transferFacadeTest) => { test.ok(builderStub.where.withArgs({ 'participant.name': participantName, 'tp.transferId': transferId, - 'participant.isActive': 1, - 'pc.isActive': 1 + 'participant.isActive': 1 }).calledOnce, 'query builder called once') - test.ok(participantCurrencyStub.withArgs('participantCurrency AS pc', 'pc.participantId', 'participant.participantId').calledOnce, 'participantCurrency inner joined') - test.ok(transferParticipantStub.withArgs('transferParticipant AS tp', 'tp.participantCurrencyId', 'pc.participantCurrencyId').calledOnce, 'transferParticipant inner joined') + test.ok(transferParticipantStub.withArgs('transferParticipant AS tp', 'tp.participantId', 'participant.participantId').calledOnce, 'transferParticipant inner joined') test.ok(selectStub.withArgs( 'tp.*' ).calledOnce, 'select all columns from transferParticipant') diff --git a/test/unit/models/transfer/ilpPacket.test.js b/test/unit/models/transfer/ilpPacket.test.js index 6db16850c..f26c1640a 100644 --- a/test/unit/models/transfer/ilpPacket.test.js +++ b/test/unit/models/transfer/ilpPacket.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transfer.test.js b/test/unit/models/transfer/transfer.test.js index 895cbccd4..a6e700b10 100644 --- a/test/unit/models/transfer/transfer.test.js +++ b/test/unit/models/transfer/transfer.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferDuplicateCheck.test.js b/test/unit/models/transfer/transferDuplicateCheck.test.js index 8e8694596..ec3115676 100644 --- a/test/unit/models/transfer/transferDuplicateCheck.test.js +++ b/test/unit/models/transfer/transferDuplicateCheck.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -109,7 +112,6 @@ Test('TransferDuplicateCheck model', async (TransferDuplicateCheckTest) => { await Model.saveTransferDuplicateCheck(transferId, hash) test.fail(' should throw') test.end() - test.end() } catch (err) { test.pass('Error thrown') test.end() diff --git a/test/unit/models/transfer/transferError.test.js b/test/unit/models/transfer/transferError.test.js index 01a4a4a71..766fdfe2f 100644 --- a/test/unit/models/transfer/transferError.test.js +++ b/test/unit/models/transfer/transferError.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferErrorDuplicateCheck.test.js b/test/unit/models/transfer/transferErrorDuplicateCheck.test.js index c20e5abc4..9b535484d 100644 --- a/test/unit/models/transfer/transferErrorDuplicateCheck.test.js +++ b/test/unit/models/transfer/transferErrorDuplicateCheck.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferExtension.test.js b/test/unit/models/transfer/transferExtension.test.js index 3aeabd156..6e080e779 100644 --- a/test/unit/models/transfer/transferExtension.test.js +++ b/test/unit/models/transfer/transferExtension.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferFulfilment.test.js b/test/unit/models/transfer/transferFulfilment.test.js index a047c15f3..2c2c101bc 100644 --- a/test/unit/models/transfer/transferFulfilment.test.js +++ b/test/unit/models/transfer/transferFulfilment.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferFulfilmentDuplicateCheck.test.js b/test/unit/models/transfer/transferFulfilmentDuplicateCheck.test.js index c71768e42..1954ee4ad 100644 --- a/test/unit/models/transfer/transferFulfilmentDuplicateCheck.test.js +++ b/test/unit/models/transfer/transferFulfilmentDuplicateCheck.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferParticipant.test.js b/test/unit/models/transfer/transferParticipant.test.js index 46d0d1eb1..3a5ae22eb 100644 --- a/test/unit/models/transfer/transferParticipant.test.js +++ b/test/unit/models/transfer/transferParticipant.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferStateChange.test.js b/test/unit/models/transfer/transferStateChange.test.js index 8dd6b16b1..c4dc2c59d 100644 --- a/test/unit/models/transfer/transferStateChange.test.js +++ b/test/unit/models/transfer/transferStateChange.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/models/transfer/transferTimeout.test.js b/test/unit/models/transfer/transferTimeout.test.js index a202c8d21..904797880 100644 --- a/test/unit/models/transfer/transferTimeout.test.js +++ b/test/unit/models/transfer/transferTimeout.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/amountType.test.js b/test/unit/seeds/amountType.test.js index 6ae9eb472..513d728c4 100644 --- a/test/unit/seeds/amountType.test.js +++ b/test/unit/seeds/amountType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/balanceOfPayments.test.js b/test/unit/seeds/balanceOfPayments.test.js index 83287ec4f..2142fefb9 100644 --- a/test/unit/seeds/balanceOfPayments.test.js +++ b/test/unit/seeds/balanceOfPayments.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/bulkProcessingState.test.js b/test/unit/seeds/bulkProcessingState.test.js index 4063f39a2..a1d60973e 100644 --- a/test/unit/seeds/bulkProcessingState.test.js +++ b/test/unit/seeds/bulkProcessingState.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/bulkTransferState.test.js b/test/unit/seeds/bulkTransferState.test.js index 4a7d8e8f4..d214954a0 100644 --- a/test/unit/seeds/bulkTransferState.test.js +++ b/test/unit/seeds/bulkTransferState.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/currency.test.js b/test/unit/seeds/currency.test.js index 16213c1bc..47f9ef7ac 100644 --- a/test/unit/seeds/currency.test.js +++ b/test/unit/seeds/currency.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/endpointType.test.js b/test/unit/seeds/endpointType.test.js index b29f11ede..956d86e06 100644 --- a/test/unit/seeds/endpointType.test.js +++ b/test/unit/seeds/endpointType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/ledgerAccountType.test.js b/test/unit/seeds/ledgerAccountType.test.js index 10c0e3e4e..f645f8d91 100644 --- a/test/unit/seeds/ledgerAccountType.test.js +++ b/test/unit/seeds/ledgerAccountType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/ledgerEntryType.test.js b/test/unit/seeds/ledgerEntryType.test.js index e3f77b6a8..67ac8fd89 100644 --- a/test/unit/seeds/ledgerEntryType.test.js +++ b/test/unit/seeds/ledgerEntryType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/participant.test.js b/test/unit/seeds/participant.test.js index 74e1dcc78..fec78e4e6 100644 --- a/test/unit/seeds/participant.test.js +++ b/test/unit/seeds/participant.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -47,7 +50,7 @@ Test('Participant ', async (participantTest) => { knexStub.returns({ insert: sandbox.stub().returns({ onConflict: sandbox.stub().returns({ - ignore: sandbox.stub().returns(true) + merge: sandbox.stub().returns(true) }) }) }) diff --git a/test/unit/seeds/participantLimitType.test.js b/test/unit/seeds/participantLimitType.test.js index c393c4772..0dfa116b1 100644 --- a/test/unit/seeds/participantLimitType.test.js +++ b/test/unit/seeds/participantLimitType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/partyIdentifierType.test.js b/test/unit/seeds/partyIdentifierType.test.js index fe7af5084..7f8ec06ed 100644 --- a/test/unit/seeds/partyIdentifierType.test.js +++ b/test/unit/seeds/partyIdentifierType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/partyType.test.js b/test/unit/seeds/partyType.test.js index 473f86004..0912573f0 100644 --- a/test/unit/seeds/partyType.test.js +++ b/test/unit/seeds/partyType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/settlementState.test.js b/test/unit/seeds/settlementState.test.js index 5917e2654..9a53c378a 100644 --- a/test/unit/seeds/settlementState.test.js +++ b/test/unit/seeds/settlementState.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/settlementWindow1State.test.js b/test/unit/seeds/settlementWindow1State.test.js index 7d37962e3..eff3dfd58 100644 --- a/test/unit/seeds/settlementWindow1State.test.js +++ b/test/unit/seeds/settlementWindow1State.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/settlementWindow2Open.test.js b/test/unit/seeds/settlementWindow2Open.test.js index 137f012a5..47bf1c72b 100644 --- a/test/unit/seeds/settlementWindow2Open.test.js +++ b/test/unit/seeds/settlementWindow2Open.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/transactionInitiator.test.js b/test/unit/seeds/transactionInitiator.test.js index f39527e60..da8324827 100644 --- a/test/unit/seeds/transactionInitiator.test.js +++ b/test/unit/seeds/transactionInitiator.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/transactionInitiatorType.test.js b/test/unit/seeds/transactionInitiatorType.test.js index c0b56a6d7..3d152d9ea 100644 --- a/test/unit/seeds/transactionInitiatorType.test.js +++ b/test/unit/seeds/transactionInitiatorType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/transactionScenario.test.js b/test/unit/seeds/transactionScenario.test.js index 66d95c3a5..72700d219 100644 --- a/test/unit/seeds/transactionScenario.test.js +++ b/test/unit/seeds/transactionScenario.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/transferParticipantRoleType.test.js b/test/unit/seeds/transferParticipantRoleType.test.js index fe2369101..e269e9f2f 100644 --- a/test/unit/seeds/transferParticipantRoleType.test.js +++ b/test/unit/seeds/transferParticipantRoleType.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/seeds/transferState.test.js b/test/unit/seeds/transferState.test.js index f1c6c2398..f0d0fa6c9 100644 --- a/test/unit/seeds/transferState.test.js +++ b/test/unit/seeds/transferState.test.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation diff --git a/test/unit/shared/setup.test.js b/test/unit/shared/setup.test.js index 81e646356..3613151a8 100644 --- a/test/unit/shared/setup.test.js +++ b/test/unit/shared/setup.test.js @@ -15,10 +15,12 @@ Test('setup', setupTest => { let oldMongoDbHost let oldMongoDbPort let oldMongoDbDatabase + let oldProxyCacheEnabled let mongoDbUri const hostName = 'http://test.com' let Setup let DbStub + let ProxyCacheStub let CacheStub let ObjStoreStub // let ObjStoreStubThrows @@ -36,7 +38,7 @@ Test('setup', setupTest => { sandbox = Sinon.createSandbox() processExitStub = sandbox.stub(process, 'exit') PluginsStub = { - registerPlugins: sandbox.stub().returns(Promise.resolve()) + registerPlugins: sandbox.stub().resolves() } serverStub = { @@ -59,22 +61,32 @@ Test('setup', setupTest => { } requestLoggerStub = { - logRequest: sandbox.stub().returns(Promise.resolve()), - logResponse: sandbox.stub().returns(Promise.resolve()) + logRequest: sandbox.stub().resolves(), + logResponse: sandbox.stub().resolves() } DbStub = { + connect: sandbox.stub().resolves(), + disconnect: sandbox.stub().resolves() + } + + ProxyCacheStub = { connect: sandbox.stub().returns(Promise.resolve()), - disconnect: sandbox.stub().returns(Promise.resolve()) + getCache: sandbox.stub().returns( + { + connect: sandbox.stub().returns(Promise.resolve(true)), + disconnect: sandbox.stub().returns(Promise.resolve(true)) + } + ) } CacheStub = { - initCache: sandbox.stub().returns(Promise.resolve()) + initCache: sandbox.stub().resolves() } ObjStoreStub = { Db: { - connect: sandbox.stub().returns(Promise.resolve()), + connect: sandbox.stub().resolves(), Mongoose: { set: sandbox.stub() } @@ -89,34 +101,35 @@ Test('setup', setupTest => { uuidStub = sandbox.stub() MigratorStub = { - migrate: sandbox.stub().returns(Promise.resolve()) + migrate: sandbox.stub().resolves() } RegisterHandlersStub = { - registerAllHandlers: sandbox.stub().returns(Promise.resolve()), + registerAllHandlers: sandbox.stub().resolves(), transfers: { - registerPrepareHandler: sandbox.stub().returns(Promise.resolve()), - registerGetHandler: sandbox.stub().returns(Promise.resolve()), - registerFulfilHandler: sandbox.stub().returns(Promise.resolve()) - // registerRejectHandler: sandbox.stub().returns(Promise.resolve()) + registerPrepareHandler: sandbox.stub().resolves(), + registerGetHandler: sandbox.stub().resolves(), + registerFulfilHandler: sandbox.stub().resolves() + // registerRejectHandler: sandbox.stub().resolves() }, positions: { - registerPositionHandler: sandbox.stub().returns(Promise.resolve()) + registerPositionHandler: sandbox.stub().resolves() }, positionsBatch: { - registerPositionHandler: sandbox.stub().returns(Promise.resolve()) + registerPositionHandler: sandbox.stub().resolves() }, timeouts: { - registerAllHandlers: sandbox.stub().returns(Promise.resolve()), - registerTimeoutHandler: sandbox.stub().returns(Promise.resolve()) + registerAllHandlers: sandbox.stub().resolves(), + registerTimeoutHandler: sandbox.stub().resolves() }, admin: { - registerAdminHandlers: sandbox.stub().returns(Promise.resolve()) + registerAdminHandlers: sandbox.stub().resolves() }, bulk: { - registerBulkPrepareHandler: sandbox.stub().returns(Promise.resolve()), - registerBulkFulfilHandler: sandbox.stub().returns(Promise.resolve()), - registerBulkProcessingHandler: sandbox.stub().returns(Promise.resolve()) + registerBulkPrepareHandler: sandbox.stub().resolves(), + registerBulkFulfilHandler: sandbox.stub().resolves(), + registerBulkProcessingHandler: sandbox.stub().resolves(), + registerBulkGetHandler: sandbox.stub().resolves() } } const ConfigStub = Config @@ -130,6 +143,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -147,12 +161,14 @@ Test('setup', setupTest => { oldMongoDbHost = Config.MONGODB_HOST oldMongoDbPort = Config.MONGODB_PORT oldMongoDbDatabase = Config.MONGODB_DATABASE + oldProxyCacheEnabled = Config.PROXY_CACHE_CONFIG.enabled Config.HOSTNAME = hostName Config.MONGODB_HOST = 'testhost' Config.MONGODB_PORT = '1111' Config.MONGODB_USER = 'user' Config.MONGODB_PASSWORD = 'pass' Config.MONGODB_DATABASE = 'mlos' + Config.PROXY_CACHE_CONFIG.enabled = true mongoDbUri = MongoUriBuilder({ username: Config.MONGODB_USER, password: Config.MONGODB_PASSWORD, @@ -173,6 +189,7 @@ Test('setup', setupTest => { Config.MONGODB_USER = oldMongoDbUsername Config.MONGODB_PASSWORD = oldMongoDbPassword Config.MONGODB_DATABASE = oldMongoDbDatabase + Config.PROXY_CACHE_CONFIG.enabled = oldProxyCacheEnabled test.end() }) @@ -193,6 +210,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -245,6 +263,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -361,6 +380,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -394,6 +414,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -428,6 +449,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -464,6 +486,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, @@ -547,6 +570,11 @@ Test('setup', setupTest => { enabled: true } + const bulkGetHandler = { + type: 'bulkget', + enabled: true + } + const unknownHandler = { type: 'undefined', enabled: true @@ -563,6 +591,7 @@ Test('setup', setupTest => { bulkBrepareHandler, bulkFulfilHandler, bulkProcessingHandler, + bulkGetHandler, unknownHandler // rejectHandler ] @@ -578,6 +607,7 @@ Test('setup', setupTest => { test.ok(RegisterHandlersStub.bulk.registerBulkPrepareHandler.called) test.ok(RegisterHandlersStub.bulk.registerBulkFulfilHandler.called) test.ok(RegisterHandlersStub.bulk.registerBulkProcessingHandler.called) + test.ok(RegisterHandlersStub.bulk.registerBulkGetHandler.called) test.ok(processExitStub.called) test.end() }).catch(err => { @@ -706,6 +736,7 @@ Test('setup', setupTest => { }, '../handlers/register': RegisterHandlersStub, '../lib/db': DbStub, + '../lib/proxyCache': ProxyCacheStub, '../lib/cache': CacheStub, '@mojaloop/object-store-lib': ObjStoreStub, '../lib/migrator': MigratorStub, diff --git a/test/util/helpers.js b/test/util/helpers.js index c17ccd91e..ece6ee496 100644 --- a/test/util/helpers.js +++ b/test/util/helpers.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation @@ -24,7 +27,10 @@ 'use strict' +const { FSPIOPError } = require('@mojaloop/central-services-error-handling').Factory const Logger = require('@mojaloop/central-services-logger') +const Config = require('#src/lib/config') +const { logger } = require('#src/shared/logger/index') /* Helper Functions */ @@ -167,12 +173,34 @@ function getMessagePayloadOrThrow (message) { } } +const checkErrorPayload = test => (actualPayload, expectedFspiopError) => { + if (!(expectedFspiopError instanceof FSPIOPError)) { + throw new TypeError('Not a FSPIOPError') + } + const { errorCode, errorDescription } = expectedFspiopError.toApiErrorObject(Config.ERROR_HANDLING).errorInformation + test.equal(actualPayload.errorInformation?.errorCode, errorCode, 'errorCode matches') + test.equal(actualPayload.errorInformation?.errorDescription, errorDescription, 'errorDescription matches') +} + +// to use as a wrapper on Tape tests +const tryCatchEndTest = (testFn) => async (t) => { + try { + await testFn(t) + } catch (err) { + logger.error(`error in test "${t.name}":`, err) + t.fail(`${t.name} failed due to error: ${err?.message}`) + } + t.end() +} + module.exports = { + checkErrorPayload, currentEventLoopEnd, createRequest, sleepPromise, unwrapResponse, waitFor, wrapWithRetries, - getMessagePayloadOrThrow + getMessagePayloadOrThrow, + tryCatchEndTest } diff --git a/test/util/randomTransfers.js b/test/util/randomTransfers.js index ee13f5b35..9f28ab0a6 100644 --- a/test/util/randomTransfers.js +++ b/test/util/randomTransfers.js @@ -1,10 +1,13 @@ /***** License -------------- - Copyright © 2017 Bill & Melinda Gates Foundation - The Mojaloop files are made available by the Bill & Melinda Gates Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + Copyright © 2020-2024 Mojaloop Foundation + The Mojaloop files are made available by the Mojaloop Foundation under the Apache License, Version 2.0 (the "License") and you may not use these files except in compliance with the License. You may obtain a copy of the License at + http://www.apache.org/licenses/LICENSE-2.0 + Unless required by applicable law or agreed to in writing, the Mojaloop files are distributed on an "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the License for the specific language governing permissions and limitations under the License. + Contributors -------------- This is the official list of the Mojaloop project contributors for this file. @@ -12,7 +15,7 @@ should be listed with a '*' in the first column. People who have contributed from an organization can be listed under the organization that actually holds the copyright for their contributions (see the - Gates Foundation organization for an example). Those individuals should have + Mojaloop Foundation for an example). Those individuals should have their names indented and be marked with a '-'. Email address can be added optionally within square brackets . * Gates Foundation

a5;al>p;#eN^dSUWVNyyp(>VUCo5qU_sB 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 literal 0 HcmV?d00001 diff --git a/documentation/state-diagrams/transfer-ML-spec-states-diagram.png b/documentation/state-diagrams/transfer-ML-spec-states-diagram.png new file mode 100644 index 0000000000000000000000000000000000000000..2313c91ccfd3dbfc8d790d625e5bd912c5f13345 GIT binary patch literal 24596 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