@mojaloop/sdk-scheme-adapter 24.10.11 → 24.11.0-snapshot.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (20) hide show
  1. package/.yarn/cache/@mojaloop-central-services-error-handling-npm-13.1.2-680b8f106e-1357eefd24.zip +0 -0
  2. package/.yarn/cache/{@mojaloop-sdk-standard-components-npm-19.16.9-b012b439ba-7a4f57c3e7.zip → @mojaloop-sdk-standard-components-npm-19.17.0-0519957b97-95fef19fc4.zip} +0 -0
  3. package/.yarn/cache/{@types-node-npm-24.4.0-8e03fde567-8ed3a165f8.zip → @types-node-npm-24.5.1-e2de7d4e53-1dd21dffe0.zip} +0 -0
  4. package/.yarn/cache/@types-retry-npm-0.12.5-f1986a76a6-3fb6bf9183.zip +0 -0
  5. package/.yarn/cache/{ts-jest-npm-29.4.1-ab76d85d32-6aed48232c.zip → ts-jest-npm-29.4.2-1fc50073bc-09494224db.zip} +0 -0
  6. package/.yarn/cache/{undici-types-npm-7.11.0-7389c6cf1c-0cb7230eb4.zip → undici-types-npm-7.12.0-af0c725921-4a0f927c98.zip} +0 -0
  7. package/.yarn/install-state.gz +0 -0
  8. package/modules/api-svc/package.json +5 -3
  9. package/modules/api-svc/src/InboundServer/handlers.js +26 -8
  10. package/modules/api-svc/src/config.js +17 -1
  11. package/modules/api-svc/src/lib/model/InboundTransfersModel.js +142 -2
  12. package/modules/api-svc/test/__mocks__/redis.js +5 -2
  13. package/modules/api-svc/test/unit/inboundApi/handlers-iso20022.test.js +3 -1
  14. package/modules/api-svc/test/unit/inboundApi/handlers.test.js +4 -2
  15. package/modules/api-svc/test/unit/lib/model/InboundTransfersModel.test.js +482 -117
  16. package/modules/outbound-command-event-handler/package.json +3 -3
  17. package/modules/outbound-domain-event-handler/package.json +3 -3
  18. package/modules/private-shared-lib/package.json +3 -3
  19. package/package.json +3 -3
  20. package/{sbom-v24.10.10.csv → sbom-v24.10.11.csv} +190 -190
Binary file
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mojaloop/sdk-scheme-adapter-api-svc",
3
- "version": "21.0.0-snapshot.57",
3
+ "version": "21.0.0-snapshot.58",
4
4
  "description": "An adapter for connecting to Mojaloop API enabled switches.",
5
5
  "main": "src/index.js",
6
6
  "types": "src/index.d.ts",
@@ -65,7 +65,7 @@
65
65
  "dependencies": {
66
66
  "@koa/cors": "5.0.0",
67
67
  "@mojaloop/api-snippets": "18.1.1",
68
- "@mojaloop/central-services-error-handling": "13.1.1",
68
+ "@mojaloop/central-services-error-handling": "13.1.2",
69
69
  "@mojaloop/central-services-logger": "11.9.3",
70
70
  "@mojaloop/central-services-metrics": "12.7.1",
71
71
  "@mojaloop/central-services-shared": "18.33.0",
@@ -73,7 +73,7 @@
73
73
  "@mojaloop/logging-bc-client-lib": "0.5.8",
74
74
  "@mojaloop/ml-schema-transformer-lib": "2.7.8",
75
75
  "@mojaloop/sdk-scheme-adapter-private-shared-lib": "workspace:^",
76
- "@mojaloop/sdk-standard-components": "19.16.9",
76
+ "@mojaloop/sdk-standard-components": "19.17.0",
77
77
  "ajv": "8.17.1",
78
78
  "axios": "1.12.2",
79
79
  "body-parser": "2.2.0",
@@ -97,6 +97,7 @@
97
97
  "promise-timeout": "1.3.0",
98
98
  "random-word-slugs": "0.1.7",
99
99
  "redis": "5.8.2",
100
+ "retry": "^0.13.1",
100
101
  "uuidv4": "6.2.13",
101
102
  "ws": "8.18.3"
102
103
  },
@@ -105,6 +106,7 @@
105
106
  "@babel/preset-env": "7.28.3",
106
107
  "@redocly/openapi-cli": "1.0.0-beta.95",
107
108
  "@types/jest": "30.0.0",
109
+ "@types/retry": "^0",
108
110
  "axios-mock-adapter": "2.1.0",
109
111
  "babel-jest": "30.1.2",
110
112
  "eslint": "9.15.0",
@@ -729,12 +729,22 @@ const patchTransfersById = async (ctx) => {
729
729
  // use the transfers model to execute asynchronous stages with the switch
730
730
  const model = createInboundTransfersModel(ctx);
731
731
 
732
- // sends notification to the payee fsp
733
- const response = await model.sendNotificationToPayee(req.data, idValue);
732
+ // kick off an asynchronous operation to handle the request
733
+ (async () => {
734
+ try {
735
+ // sends notification to the payee fsp
736
+ const response = await model.sendNotificationToPayee(req.data, idValue);
737
+
738
+ // log the result
739
+ ctx.state.logger.isDebugEnabled && ctx.state.logger.push({ response }).
740
+ debug('Inbound transfers model handled PATCH /transfers/{ID} request');
741
+ } catch (err) {
742
+ ctx.state.logger.isErrorEnabled && ctx.state.logger.push({ err }).error('Error handling PATCH /transfers/{ID}');
743
+ }
744
+ })();
734
745
 
735
- // log the result
736
- ctx.state.logger.isDebugEnabled && ctx.state.logger.push({response}).
737
- debug('Inbound transfers model handled PATCH /transfers/{ID} request');
746
+ ctx.response.status = ReturnCodes.OK.CODE;
747
+ ctx.response.body = '';
738
748
  };
739
749
 
740
750
  /**
@@ -1080,10 +1090,18 @@ const patchFxTransfersById = async (ctx) => {
1080
1090
  const data = { ...ctx.request.body };
1081
1091
  const idValue = ctx.state.path.params.ID;
1082
1092
 
1083
- const model = createInboundTransfersModel(ctx);
1084
- const response = await model.sendFxPutNotificationToBackend(data, idValue);
1093
+ (async () => {
1094
+ const model = createInboundTransfersModel(ctx);
1095
+ try {
1096
+ const response = await model.sendFxPutNotificationToBackend(data, idValue);
1097
+ ctx.state.logger.push({ response }).debug('Inbound Transfers model handled PATCH /fxTransfers/{ID} request');
1098
+ } catch (err) {
1099
+ ctx.state.logger.push({ err }).error('Error handling PATCH /fxTransfers/{ID}');
1100
+ }
1101
+ })();
1085
1102
 
1086
- ctx.state.logger.push({ response }).debug('Inbound Transfers model handled PATCH /fxTransfers/{ID} request');
1103
+ ctx.response.status = ReturnCodes.OK.CODE;
1104
+ ctx.response.body = '';
1087
1105
  };
1088
1106
 
1089
1107
  /**
@@ -264,5 +264,21 @@ module.exports = {
264
264
 
265
265
  // Redis key ttl when stored in the cache, if value is used as zero it will
266
266
  // persist throughout the session , value used is in seconds
267
- redisCacheTtl: env.get('REDIS_CACHE_TTL').default('0').asInt()
267
+ redisCacheTtl: env.get('REDIS_CACHE_TTL').default('0').asInt(),
268
+
269
+ backendRequestRetry: {
270
+ enabled: env.get('BACKEND_REQUEST_RETRY_ENABLED').default('true').asBool(),
271
+ maxRetries: env.get('BACKEND_REQUEST_RETRY_MAX_RETRIES').default('5').asIntPositive(),
272
+ retryDelayMs: env.get('BACKEND_REQUEST_RETRY_DELAY_MS').default('1000').asIntPositive(),
273
+ maxRetryDelayMs: env.get('BACKEND_REQUEST_RETRY_MAX_DELAY_MS').default('10000').asIntPositive(),
274
+ backoffFactor: env.get('BACKEND_REQUEST_RETRY_BACKOFF_FACTOR').default('2').asIntPositive(),
275
+ },
276
+ getTransferRequestRetry: {
277
+ enabled: env.get('GET_TRANSFER_REQUEST_RETRY_ENABLED').default('false').asBool(),
278
+ maxRetries: env.get('GET_TRANSFER_REQUEST_RETRY_MAX_RETRIES').default('3').asIntPositive(),
279
+ retryDelayMs: env.get('GET_TRANSFER_REQUEST_RETRY_DELAY_MS').default('1000').asIntPositive(),
280
+ maxRetryDelayMs: env.get('GET_TRANSFER_REQUEST_RETRY_MAX_DELAY_MS').default('10000').asIntPositive(),
281
+ backoffFactor: env.get('GET_TRANSFER_REQUEST_RETRY_BACKOFF_FACTOR').default('2').asIntPositive(),
282
+ },
283
+ patchNotificationGraceTimeMs: env.get('PATCH_NOTIFICATION_GRACE_TIME_MS').default('15000').asIntPositive(),
268
284
  };
@@ -30,6 +30,7 @@ const safeStringify = require('fast-safe-stringify');
30
30
  const { MojaloopRequests, Ilp, Errors } = require('@mojaloop/sdk-standard-components');
31
31
  const FSPIOPTransferStateEnum = require('@mojaloop/central-services-shared').Enum.Transfers.TransferState;
32
32
  const FSPIOPBulkTransferStateEnum = require('@mojaloop/central-services-shared').Enum.Transfers.BulkTransferState;
33
+ const retry = require('retry');
33
34
 
34
35
  const dto = require('../dto');
35
36
  const shared = require('./lib/shared');
@@ -52,6 +53,9 @@ class InboundTransfersModel {
52
53
  this._reserveNotification = config.reserveNotification;
53
54
  this._allowDifferentTransferTransactionId = config.allowDifferentTransferTransactionId;
54
55
  this._supportedCurrencies = config.supportedCurrencies;
56
+ this._patchNotificationGraceTimeMs = config.patchNotificationGraceTimeMs;
57
+ this._getTransferRequestRetry = config.getTransferRequestRetry;
58
+ this._backendRequestRetry = config.backendRequestRetry;
55
59
 
56
60
  this._mojaloopRequests = new MojaloopRequests({
57
61
  logger: this._logger,
@@ -489,6 +493,60 @@ class InboundTransfersModel {
489
493
  this.data.currentState = response.transferState || (this._reserveNotification ? SDKStateEnum.RESERVED : SDKStateEnum.COMPLETED);
490
494
 
491
495
  await this._save();
496
+
497
+ // --- PATCH NOTIFICATION TIMER LOGIC ---
498
+ // Set a timer to trigger GET /transfers/{ID} if sendNotificationToPayee is not called in time
499
+ if (this._patchNotificationGraceTimeMs > 0) {
500
+ const transferId = prepareRequest.transferId;
501
+ const cacheKey = `patchNotificationSent_${transferId}`;
502
+ // Mark as not notified yet
503
+ await this._cache.set(cacheKey, false, Math.ceil(this._patchNotificationGraceTimeMs / 1000) + 5);
504
+
505
+ setTimeout(async () => {
506
+ try {
507
+ const notified = await this._cache.get(cacheKey);
508
+
509
+ if (!notified) {
510
+ // Subscribe to transfer callback channel
511
+ const transferKey = `tf_${transferId}`;
512
+ const unsubscribeTimeout = this._getTransferRequestRetry?.retryDelayMs || 1000;
513
+ let gotCallback = false;
514
+
515
+ // Subscribe for one message with a timeout
516
+ const messagePromise = this._cache.subscribeToOneMessageWithTimer(transferKey, Math.ceil(unsubscribeTimeout / 1000));
517
+ // Kick off GET /transfers/{ID}
518
+ await this._mojaloopRequests.getTransfers(transferId, sourceFspId, headers);
519
+
520
+ // Retry logic
521
+ let attempts = 0;
522
+ const maxAttempts = (this._getTransferRequestRetry?.maxRetries || 3);
523
+ const retryDelay = this._getTransferRequestRetry?.retryDelayMs || 1000;
524
+ while (attempts < maxAttempts && !gotCallback) {
525
+ try {
526
+ const message = await messagePromise;
527
+ if (message && message.data) {
528
+ gotCallback = true;
529
+ // Mark as notified to prevent duplicate notification
530
+ await this._cache.set(cacheKey, true, 60);
531
+ // Send notification to payee
532
+ await this.sendNotificationToPayee(message.data, transferId);
533
+ break;
534
+ }
535
+ } catch (err) {
536
+ this._logger.isErrorEnabled && this._logger.push({ err, transferId }).error('Error while waiting for transfer callback in patch notification grace timer logic');
537
+ }
538
+ attempts++;
539
+ if (!gotCallback && attempts < maxAttempts) {
540
+ await this._mojaloopRequests.getTransfers(transferId, sourceFspId, headers);
541
+ await new Promise(r => setTimeout(r, retryDelay));
542
+ }
543
+ }
544
+ }
545
+ } catch (err) {
546
+ this._logger.isErrorEnabled && this._logger.push({ err, transferId }).error('Error in patch notification grace timer logic');
547
+ }
548
+ }, this._patchNotificationGraceTimeMs);
549
+ }
492
550
  return res;
493
551
  } catch(err) {
494
552
  this._logger.isErrorEnabled && this._logger.push({ err }).error(`Error in prepareTransfer: ${prepareRequest?.transferId}`);
@@ -982,7 +1040,47 @@ class InboundTransfersModel {
982
1040
  };
983
1041
  log.verbose('sendFxPutNotificationToBackend body sent to cc: ', { responseBody });
984
1042
 
985
- const res = await this._backendRequests.putFxTransfersNotification(responseBody, conversionId);
1043
+ const { enabled, maxRetries, retryDelayMs, maxRetryDelayMs, backoffFactor } = this._backendRequestRetry || {};
1044
+ let res;
1045
+ const shouldRetry = enabled !== false; // default to true if not set
1046
+
1047
+ if (shouldRetry) {
1048
+ const operation = retry.operation({
1049
+ retries: maxRetries || 5,
1050
+ factor: backoffFactor || 2,
1051
+ minTimeout: retryDelayMs || 1000,
1052
+ maxTimeout: maxRetryDelayMs || 10000,
1053
+ });
1054
+
1055
+ await new Promise((resolve) => {
1056
+ operation.attempt(async (currentAttempt) => {
1057
+ try {
1058
+ res = await this._backendRequests.putFxTransfersNotification(responseBody, conversionId);
1059
+ if ((res && (res.status === 200 || res.statusCode === 200)) || res === true) {
1060
+ return resolve();
1061
+ }
1062
+ log.warn(`putFxTransfersNotification attempt ${currentAttempt} failed, retrying...`);
1063
+ if (!operation.retry(new Error('Non-200 response'))) {
1064
+ resolve();
1065
+ }
1066
+ } catch (err) {
1067
+ log.warn(`putFxTransfersNotification attempt ${currentAttempt} threw error, retrying...`, err);
1068
+ if (!operation.retry(err)) {
1069
+ resolve();
1070
+ }
1071
+ }
1072
+ });
1073
+ });
1074
+
1075
+ if ((res && (res.status !== 200 && res.statusCode !== 200)) && res !== true) {
1076
+ log.error(`putFxTransfersNotification failed after ${operation.attempts()} attempts`);
1077
+ }
1078
+ } else {
1079
+ res = await this._backendRequests.putFxTransfersNotification(responseBody, conversionId);
1080
+ if ((res && (res.status !== 200 && res.statusCode !== 200)) && res !== true) {
1081
+ log.error('putFxTransfersNotification failed');
1082
+ }
1083
+ }
986
1084
  return res;
987
1085
  } catch (err) {
988
1086
  log.error('error in sendFxPutNotificationToBackend: ', err);
@@ -1025,7 +1123,49 @@ class InboundTransfersModel {
1025
1123
 
1026
1124
  await this._save();
1027
1125
 
1028
- const res = await this._backendRequests.putTransfersNotification(this.data, transferId);
1126
+ const { enabled, maxRetries, retryDelayMs, maxRetryDelayMs, backoffFactor } = this._backendRequestRetry || {};
1127
+ let res;
1128
+ const shouldRetry = enabled !== false; // default to true if not set
1129
+
1130
+ if (shouldRetry) {
1131
+ const operation = retry.operation({
1132
+ retries: maxRetries || 5,
1133
+ factor: backoffFactor || 2,
1134
+ minTimeout: retryDelayMs || 1000,
1135
+ maxTimeout: maxRetryDelayMs || 10000,
1136
+ });
1137
+
1138
+ await new Promise((resolve) => {
1139
+ operation.attempt(async (currentAttempt) => {
1140
+ try {
1141
+ res = await this._backendRequests.putTransfersNotification(this.data, transferId);
1142
+ if ((res && (res.status === 200 || res.statusCode === 200)) || res === true) {
1143
+ const cacheKey = `patchNotificationSent_${transferId}`;
1144
+ await this._cache.set(cacheKey, true, 60);
1145
+ return resolve();
1146
+ }
1147
+ this._logger.warn(`putTransfersNotification attempt ${currentAttempt} failed, retrying...`);
1148
+ if (!operation.retry(new Error('Non-200 response'))) {
1149
+ resolve();
1150
+ }
1151
+ } catch (err) {
1152
+ this._logger.warn(`putTransfersNotification attempt ${currentAttempt} threw error, retrying...`, err);
1153
+ if (!operation.retry(err)) {
1154
+ resolve();
1155
+ }
1156
+ }
1157
+ });
1158
+ });
1159
+
1160
+ if ((res && (res.status !== 200 && res.statusCode !== 200)) && res !== true) {
1161
+ this._logger.error(`putTransfersNotification failed after ${operation.attempts()} attempts`);
1162
+ }
1163
+ } else {
1164
+ res = await this._backendRequests.putTransfersNotification(this.data, transferId);
1165
+ if ((res && (res.status !== 200 && res.statusCode !== 200)) && res !== true) {
1166
+ this._logger.error('putTransfersNotification failed');
1167
+ }
1168
+ }
1029
1169
  return res;
1030
1170
  } catch (err) {
1031
1171
  this._logger.isErrorEnabled && this._logger.push({ err, transferId }).error(`Error notifying backend of final transfer state equal to: ${body.transferState}`);
@@ -56,8 +56,11 @@ class RedisClient extends redisMock.RedisClient {
56
56
  process.nextTick(() => this.events.emit(...args));
57
57
  }
58
58
 
59
- set(...args) {
60
- return promisify(super.set.bind(this))(...args);
59
+ /**
60
+ * Note: This Redis mock implementation does not support options like TTL (time-to-live).
61
+ */
62
+ set(key, value) {
63
+ return promisify(super.set.bind(this))(key, value);
61
64
  }
62
65
 
63
66
  get(...args) {
@@ -315,7 +315,7 @@ describe('Inbound API handlers transforming incoming ISO20022 message bodies', (
315
315
 
316
316
  });
317
317
 
318
- test('calls `prepareTransfer` with the expected arguments.', async () => {
318
+ test('calls `prepareTransfer` with the expected arguments and sets 200 status.', async () => {
319
319
  const transferRequestSpy = jest.spyOn(Model.prototype, 'sendNotificationToPayee');
320
320
 
321
321
  await expect(handlers['/transfers/{ID}'].patch(mockContext)).resolves.toBe(undefined);
@@ -324,6 +324,8 @@ describe('Inbound API handlers transforming incoming ISO20022 message bodies', (
324
324
  expect(transferRequestSpy.mock.calls[0][0]).not.toBeUndefined();
325
325
  expect(transferRequestSpy.mock.calls[0][0]).not.toEqual(isoBodies.patchTransfersRequest);
326
326
  expect(transferRequestSpy.mock.calls[0][0].transferState).toBe('COMMITTED');
327
+ expect(mockContext.response.status).toBe(200);
328
+ console.log('Response body:', mockContext.response);
327
329
  });
328
330
  });
329
331
 
@@ -624,12 +624,13 @@ describe('Inbound API handlers:', () => {
624
624
  };
625
625
  });
626
626
 
627
- test('calls `model.sendNotificationToPayee with expected arguments', async () => {
627
+ test('calls `model.sendNotificationToPayee` with expected arguments and responds 200', async () => {
628
628
  const notificationSpy = jest.spyOn(Model.prototype, 'sendNotificationToPayee');
629
629
 
630
630
  await expect(handlers['/transfers/{ID}'].patch(mockNotificationMessage)).resolves.toBe(undefined);
631
631
  expect(notificationSpy).toHaveBeenCalledTimes(1);
632
632
  expect(notificationSpy.mock.calls[0][1]).toBe(mockNotificationMessage.state.path.params.ID);
633
+ expect(mockNotificationMessage.response.status).toBe(200);
633
634
  });
634
635
 
635
636
  });
@@ -1075,12 +1076,13 @@ describe('Inbound API handlers:', () => {
1075
1076
  };
1076
1077
  });
1077
1078
 
1078
- test('calls `model.sendFxPutNotificationToBackend with expected arguments', async () => {
1079
+ test('calls `model.sendFxPutNotificationToBackend` with expected arguments and responds 200', async () => {
1079
1080
  const notificationSpy = jest.spyOn(Model.prototype, 'sendFxPutNotificationToBackend');
1080
1081
 
1081
1082
  await expect(handlers['/fxTransfers/{ID}'].patch(mockNotificationMessage)).resolves.toBe(undefined);
1082
1083
  expect(notificationSpy).toHaveBeenCalledTimes(1);
1083
1084
  expect(notificationSpy.mock.calls[0][1]).toBe(mockNotificationMessage.state.path.params.ID);
1085
+ expect(mockNotificationMessage.response.status).toBe(200);
1084
1086
  });
1085
1087
 
1086
1088
  });