@moneypot/hub 1.19.1 → 1.19.4

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.
package/README.md CHANGED
@@ -80,6 +80,7 @@ You should always keep your hub server up to date as soon as possible. Never ins
80
80
  ### 1.19.x
81
81
 
82
82
  - All of hub's internal graphql-schema-modifying plugins have been moved to hub's set of required plugins.
83
+ - Added `FAILED` take request auto-handling
83
84
 
84
85
  ### 1.18.x
85
86
 
@@ -114,6 +114,8 @@ export type DbTakeRequest = {
114
114
  mp_transfer_status: DbTransferStatusKind | null;
115
115
  transfer_needs_completion: boolean;
116
116
  transfer_completion_attempted_at: Date | null;
117
+ transfer_failure_count: number;
118
+ transfer_first_failure_at: Date | null;
117
119
  refunded_at: Date | null;
118
120
  updated_at: Date;
119
121
  };
@@ -0,0 +1,11 @@
1
+ ALTER TABLE hub.take_request
2
+ ADD COLUMN IF NOT EXISTS transfer_failure_count int DEFAULT 0,
3
+ ADD COLUMN IF NOT EXISTS transfer_first_failure_at timestamptz;
4
+
5
+ CREATE INDEX IF NOT EXISTS take_request_transfer_retry_idx
6
+ ON hub.take_request(casino_id, status, transfer_first_failure_at)
7
+ WHERE mp_transfer_id IS NULL AND status = 'PROCESSING';
8
+
9
+ CREATE INDEX IF NOT EXISTS take_request_failed_refund_idx
10
+ ON hub.take_request(casino_id, status, refunded_at)
11
+ WHERE mp_transfer_id IS NULL AND status = 'FAILED' AND refunded_at IS NULL;
@@ -61,6 +61,11 @@ export function startWebsocketProcessor({ casinoId, graphqlUrl, signal, controll
61
61
  return true;
62
62
  },
63
63
  keepAlive: 1_000,
64
+ retryWait: async (retries) => {
65
+ const baseDelay = Math.min(1000 * Math.pow(2, retries), 30_000);
66
+ const jitter = Math.floor(Math.random() * 10_000);
67
+ await new Promise((resolve) => setTimeout(resolve, baseDelay + jitter));
68
+ },
64
69
  });
65
70
  client.on("connected", () => {
66
71
  logger.info(`[websocketProcessor] Connected to websocket ${graphqlUrl}`);
@@ -37,4 +37,7 @@ export declare function createPreset({ plugins, exportSchemaSDLPath, extraPgSche
37
37
  context: ServerContext;
38
38
  enableChat: boolean;
39
39
  enablePlayground: boolean;
40
- }): GraphileConfig.Preset;
40
+ }): {
41
+ preset: GraphileConfig.Preset;
42
+ pgService: GraphileConfig.PgServiceConfiguration<"@dataplan/pg/adaptors/pg">;
43
+ };
@@ -91,15 +91,14 @@ export function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, abo
91
91
  if (enableChat) {
92
92
  mutablePlugins.push(HubChatCreateUserMessagePlugin, HubChatCreateSystemMessagePlugin, HubChatSubscriptionPlugin, HubChatMuteUserPlugin, HubChatUnmuteUserPlugin, HubChatAfterIdConditionPlugin, HubChatModManagementPlugin);
93
93
  }
94
+ const pgService = makePgService({
95
+ connectionString: config.DATABASE_URL,
96
+ schemas: [...extraPgSchemas, "hub"],
97
+ });
94
98
  const preset = {
95
99
  extends: [PostGraphileAmberPreset],
96
100
  disablePlugins: ["NodePlugin"],
97
- pgServices: [
98
- makePgService({
99
- connectionString: config.DATABASE_URL,
100
- schemas: [...extraPgSchemas, "hub"],
101
- }),
102
- ],
101
+ pgServices: [pgService],
103
102
  schema: {
104
103
  dontSwallowErrors: true,
105
104
  exportSchemaSDLPath,
@@ -175,7 +174,7 @@ export function createPreset({ plugins, exportSchemaSDLPath, extraPgSchemas, abo
175
174
  },
176
175
  plugins: mutablePlugins,
177
176
  };
178
- return preset;
177
+ return { preset, pgService };
179
178
  }
180
179
  function getSessionIdFromWebsocketCtx(ws) {
181
180
  const value = ws.normalizedConnectionParams?.authorization;
@@ -46,7 +46,7 @@ function createExpressServer(context) {
46
46
  }
47
47
  export function createHubServer({ configureApp, plugins, exportSchemaSDLPath, extraPgSchemas, abortSignal, context, enableChat, enablePlayground, port, }) {
48
48
  const expressServer = createExpressServer(context);
49
- const preset = createPreset({
49
+ const { preset, pgService } = createPreset({
50
50
  plugins: plugins ?? defaultPlugins,
51
51
  exportSchemaSDLPath,
52
52
  extraPgSchemas: extraPgSchemas ?? [],
@@ -78,10 +78,12 @@ export function createHubServer({ configureApp, plugins, exportSchemaSDLPath, ex
78
78
  });
79
79
  },
80
80
  shutdown: async () => {
81
+ nodeServer.closeAllConnections();
82
+ await pgService.release?.();
83
+ await pgl.release();
81
84
  await new Promise((resolve) => {
82
85
  nodeServer.close(() => resolve());
83
86
  });
84
- await pgl.release();
85
87
  },
86
88
  };
87
89
  }
@@ -25,3 +25,8 @@ export declare function completeTransfer({ mpTakeRequestId, takeRequestId, mpTra
25
25
  casinoId: string;
26
26
  pool: pg.Pool;
27
27
  }): Promise<void>;
28
+ export declare function processFailedRequestRefunds({ casinoId, abortSignal, pool, }: {
29
+ casinoId: string;
30
+ abortSignal: AbortSignal;
31
+ pool: pg.Pool;
32
+ }): Promise<void>;
@@ -193,6 +193,7 @@ export async function processTakeRequests({ abortSignal, controllerId, casinoId,
193
193
  pool,
194
194
  });
195
195
  await processStuckRequests({ casinoId, graphqlClient, abortSignal, pool });
196
+ await processFailedRequestRefunds({ casinoId, abortSignal, pool });
196
197
  }
197
198
  async function fetchPendingTakeRequests(graphqlClient, controllerId) {
198
199
  const result = await graphqlClient.request(MP_PAGINATE_PENDING_TAKE_REQUESTS, {
@@ -382,6 +383,7 @@ async function attemptTransfer({ pgClient, takeRequestId, mpTakeRequestId, mpExp
382
383
  }
383
384
  const result = transferResult.transferCurrencyExperienceToUser.result;
384
385
  let transferId = null;
386
+ let needsImmediateRefund = false;
385
387
  let resultStatus = LocalTakeRequestStatus.PROCESSING;
386
388
  switch (result.__typename) {
387
389
  case "TransferSuccess":
@@ -400,6 +402,7 @@ async function attemptTransfer({ pgClient, takeRequestId, mpTakeRequestId, mpExp
400
402
  }
401
403
  else {
402
404
  resultStatus = LocalTakeRequestStatus.FAILED;
405
+ needsImmediateRefund = true;
403
406
  }
404
407
  break;
405
408
  }
@@ -409,8 +412,8 @@ async function attemptTransfer({ pgClient, takeRequestId, mpTakeRequestId, mpExp
409
412
  await pgClient.query({
410
413
  text: `
411
414
  UPDATE hub.take_request
412
- SET status = $1,
413
- mp_transfer_id = $2,
415
+ SET status = $1,
416
+ mp_transfer_id = $2,
414
417
  mp_transfer_status = $3,
415
418
  transfer_needs_completion = $4,
416
419
  mp_status = $5
@@ -427,18 +430,61 @@ async function attemptTransfer({ pgClient, takeRequestId, mpTakeRequestId, mpExp
427
430
  takeRequestId,
428
431
  ],
429
432
  });
433
+ if (needsImmediateRefund) {
434
+ logger.info({ takeRequestId }, `[attemptTransfer] Take request already terminal on MP, refunding`);
435
+ await refundTakeRequest(pgClient, takeRequestId);
436
+ }
430
437
  return transferId;
431
438
  }
432
439
  catch (error) {
433
- logger.error(error, `[attemptTransfer] Error`);
440
+ logger.error(error, `[attemptTransfer] Error creating transfer`);
441
+ const errorMessage = error instanceof Error ? error.message : "Unknown error";
442
+ const shouldRetry = error instanceof Error && isNetworkError(error);
434
443
  await pgClient.query({
435
444
  text: `
436
445
  UPDATE hub.take_request
437
- SET status = $1
438
- WHERE id = $2
446
+ SET transfer_failure_count = COALESCE(transfer_failure_count, 0) + 1,
447
+ transfer_first_failure_at = COALESCE(transfer_first_failure_at, now()),
448
+ debug = COALESCE(debug, '') || ' | Transfer attempt failed: ' || $2::text
449
+ WHERE id = $1
439
450
  `,
440
- values: [LocalTakeRequestStatus.FAILED, takeRequestId],
451
+ values: [takeRequestId, errorMessage.slice(0, 200)],
441
452
  });
453
+ if (!shouldRetry) {
454
+ logger.info({ takeRequestId }, `[attemptTransfer] Non-retryable error, marking as FAILED and refunding`);
455
+ await pgClient.query({
456
+ text: `
457
+ UPDATE hub.take_request
458
+ SET status = $1,
459
+ debug = COALESCE(debug, '') || ' | Non-retryable error, refunding'
460
+ WHERE id = $2
461
+ `,
462
+ values: [LocalTakeRequestStatus.FAILED, takeRequestId],
463
+ });
464
+ await refundTakeRequest(pgClient, takeRequestId);
465
+ return null;
466
+ }
467
+ const TRANSFER_RETRY_TIMEOUT_SECONDS = 30;
468
+ const takeRequestAfterAttempt = await pgClient
469
+ .query({
470
+ text: `SELECT transfer_first_failure_at FROM hub.take_request WHERE id = $1`,
471
+ values: [takeRequestId],
472
+ })
473
+ .then(exactlyOneRow);
474
+ const elapsedMs = Date.now() - takeRequestAfterAttempt.transfer_first_failure_at.getTime();
475
+ if (elapsedMs >= TRANSFER_RETRY_TIMEOUT_SECONDS * 1000) {
476
+ logger.error({ takeRequestId, elapsedMs }, `[attemptTransfer] Retry timeout (${TRANSFER_RETRY_TIMEOUT_SECONDS}s) reached, marking as FAILED and refunding`);
477
+ await pgClient.query({
478
+ text: `
479
+ UPDATE hub.take_request
480
+ SET status = $1,
481
+ debug = COALESCE(debug, '') || ' | Transfer retry timeout reached, refunding'
482
+ WHERE id = $2
483
+ `,
484
+ values: [LocalTakeRequestStatus.FAILED, takeRequestId],
485
+ });
486
+ await refundTakeRequest(pgClient, takeRequestId);
487
+ }
442
488
  return null;
443
489
  }
444
490
  }
@@ -720,7 +766,10 @@ async function processStuckRequests({ casinoId, graphqlClient, abortSignal, pool
720
766
  WHERE casino_id = $1
721
767
  AND status = $2
722
768
  AND mp_transfer_id IS NULL
723
- AND updated_at < now() - interval '5 minutes'
769
+ AND (
770
+ transfer_first_failure_at IS NULL
771
+ OR transfer_first_failure_at < now() - interval '10 seconds'
772
+ )
724
773
  LIMIT 10
725
774
  `,
726
775
  values: [casinoId, LocalTakeRequestStatus.PROCESSING],
@@ -741,6 +790,41 @@ async function processStuckRequests({ casinoId, graphqlClient, abortSignal, pool
741
790
  });
742
791
  }
743
792
  }
793
+ export async function processFailedRequestRefunds({ casinoId, abortSignal, pool, }) {
794
+ if (abortSignal.aborted) {
795
+ return;
796
+ }
797
+ const failedRequests = await pool
798
+ .query({
799
+ text: `
800
+ SELECT id, mp_take_request_id
801
+ FROM hub.take_request
802
+ WHERE casino_id = $1
803
+ AND status = $2
804
+ AND mp_transfer_id IS NULL
805
+ AND refunded_at IS NULL
806
+ AND reserved_amount > 0
807
+ LIMIT 10
808
+ `,
809
+ values: [casinoId, LocalTakeRequestStatus.FAILED],
810
+ })
811
+ .then((result) => result.rows);
812
+ if (failedRequests.length > 0) {
813
+ logger.info(`[processFailedRequestRefunds] Found ${failedRequests.length} FAILED requests needing refunds`);
814
+ }
815
+ for (const request of failedRequests) {
816
+ if (abortSignal.aborted) {
817
+ break;
818
+ }
819
+ await withPgPoolTransaction(pool, async (pgClient) => {
820
+ await PgAdvisoryLock.forMpTakeRequestProcessing(pgClient, {
821
+ mpTakeRequestId: request.mp_take_request_id,
822
+ casinoId,
823
+ });
824
+ await refundTakeRequest(pgClient, request.id);
825
+ });
826
+ }
827
+ }
744
828
  async function loadRequiredEntities(pgClient, params) {
745
829
  const { type, currencyKey, casinoId } = params;
746
830
  const dbCurrency = await pgClient
@@ -926,6 +1010,90 @@ async function updateTakeRequestStatus(pgClient, takeRequestId, status) {
926
1010
  values: [status, takeRequestId],
927
1011
  });
928
1012
  }
1013
+ async function refundTakeRequest(pgClient, takeRequestId) {
1014
+ const takeRequestData = await pgClient
1015
+ .query({
1016
+ text: `
1017
+ SELECT *
1018
+ FROM hub.take_request
1019
+ WHERE id = $1
1020
+ FOR UPDATE
1021
+ `,
1022
+ values: [takeRequestId],
1023
+ })
1024
+ .then(exactlyOneRow);
1025
+ if (takeRequestData.refunded_at) {
1026
+ logger.debug({ takeRequestId }, `[refundTakeRequest] Already refunded, skipping`);
1027
+ return false;
1028
+ }
1029
+ if (takeRequestData.reserved_amount <= 0) {
1030
+ logger.debug({ takeRequestId }, `[refundTakeRequest] No reserved amount to refund`);
1031
+ return false;
1032
+ }
1033
+ const dbLockedBalance = await pgClient
1034
+ .query({
1035
+ text: `
1036
+ SELECT id, amount
1037
+ FROM hub.balance
1038
+ WHERE user_id = $1
1039
+ AND experience_id = $2
1040
+ AND casino_id = $3
1041
+ AND currency_key = $4
1042
+ FOR UPDATE
1043
+ `,
1044
+ values: [
1045
+ takeRequestData.user_id,
1046
+ takeRequestData.experience_id,
1047
+ takeRequestData.casino_id,
1048
+ takeRequestData.currency_key,
1049
+ ],
1050
+ })
1051
+ .then(maybeOneRow);
1052
+ if (!dbLockedBalance) {
1053
+ logger.error({ takeRequestId, takeRequestData }, `[refundTakeRequest] Balance record not found - cannot refund`);
1054
+ await pgClient.query({
1055
+ text: `
1056
+ UPDATE hub.take_request
1057
+ SET debug = COALESCE(debug, '') || ' | Refund failed: balance record not found'
1058
+ WHERE id = $1
1059
+ `,
1060
+ values: [takeRequestId],
1061
+ });
1062
+ return false;
1063
+ }
1064
+ await pgClient.query({
1065
+ text: `
1066
+ UPDATE hub.balance
1067
+ SET amount = amount + $1
1068
+ WHERE id = $2
1069
+ `,
1070
+ values: [takeRequestData.reserved_amount, dbLockedBalance.id],
1071
+ });
1072
+ await insertAuditLog(pgClient, "player-balance", {
1073
+ balanceId: dbLockedBalance.id,
1074
+ balanceOld: dbLockedBalance.amount,
1075
+ balanceNew: dbLockedBalance.amount + takeRequestData.reserved_amount,
1076
+ balanceDelta: takeRequestData.reserved_amount,
1077
+ action: "hub:take_request:refund",
1078
+ refType: "hub.take_request",
1079
+ refId: takeRequestId,
1080
+ });
1081
+ await pgClient.query({
1082
+ text: `
1083
+ UPDATE hub.take_request
1084
+ SET refunded_at = now(),
1085
+ debug = COALESCE(debug, '') || $2,
1086
+ updated_at = now()
1087
+ WHERE id = $1
1088
+ `,
1089
+ values: [
1090
+ takeRequestId,
1091
+ ` | Refunded ${takeRequestData.reserved_amount} base units of ${takeRequestData.currency_key}`,
1092
+ ],
1093
+ });
1094
+ logger.info({ takeRequestId, amount: takeRequestData.reserved_amount }, `[refundTakeRequest] Successfully refunded`);
1095
+ return true;
1096
+ }
929
1097
  function isNetworkError(e) {
930
1098
  return (e.message?.includes("ECONNREFUSED") ||
931
1099
  e.message?.includes("ECONNRESET") ||
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moneypot/hub",
3
- "version": "1.19.1",
3
+ "version": "1.19.4",
4
4
  "author": "moneypot.com",
5
5
  "homepage": "https://moneypot.com/hub",
6
6
  "keywords": [
@@ -48,47 +48,50 @@
48
48
  "build-dashboard": "cd ./dashboard && pnpm run build && rm -rf ../dist/dashboard && cp -Rv dist ../dist/dashboard",
49
49
  "test:startup": "dropdb --if-exists hub-test && createdb hub-test && SUPERUSER_DATABASE_URL=postgres://localhost:5432/hub-test DATABASE_URL=postgres://localhost:5432/hub-test tsx src/test-startup.ts",
50
50
  "test": "vitest run",
51
- "test:watch": "vitest"
51
+ "test:watch": "vitest",
52
+ "test:bail": "pnpm test -- --bail --silent --reporter=basic 2>&1 | head -25"
52
53
  },
53
54
  "dependencies": {
54
- "@graphile-contrib/pg-omit-archived": "^4.0.0-rc.1",
55
+ "@graphile-contrib/pg-omit-archived": "4.0.0-rc.1",
55
56
  "@moneypot/hash-herald": "^1.0.0",
56
57
  "@moneypot/pg-upgrade-schema": "^2.1.0",
57
- "@noble/curves": "^1.5.0",
58
58
  "dotenv": "^17.2.3",
59
- "express": "^5.0.1",
60
- "graphql": "^16.8.1",
61
- "graphql-request": "^7.0.0",
62
- "graphql-ws": "^6.0.5",
63
- "jose": "^6.0.11",
64
- "pg": "^8.12.0",
65
- "pg-connection-string": "^2.6.4",
59
+ "express": "^5.2.1",
60
+ "graphql": "^16.12.0",
61
+ "graphql-request": "^7.4.0",
62
+ "graphql-ws": "^6.0.6",
63
+ "jose": "^6.1.3",
64
+ "pg": "^8.16.3",
65
+ "pg-connection-string": "^2.9.1",
66
66
  "pino": "^10.1.0",
67
- "postgraphile": "^5.0.0-rc.3",
68
- "tsafe": "^1.6.6",
69
- "zod": "^4.1.12"
67
+ "postgraphile": "5.0.0-rc.3",
68
+ "tsafe": "^1.8.12",
69
+ "zod": "^4.1.13"
70
70
  },
71
71
  "devDependencies": {
72
- "@eslint/js": "^9.8.0",
72
+ "@eslint/js": "^9.39.2",
73
73
  "@graphql-codegen/cli": "^6.1.0",
74
74
  "@graphql-codegen/client-preset": "^5.2.1",
75
75
  "@graphql-typed-document-node/core": "^3.2.0",
76
- "@types/express": "^5.0.0",
77
- "@types/node": "^25.0.0",
78
- "@types/pg": "^8.11.5",
79
- "@types/supertest": "^6.0.2",
80
- "eslint": "^9.8.0",
81
- "globals": "^16.0.0",
82
- "pino-pretty": "^13.0.0",
83
- "prettier": "^3.6.2",
84
- "supertest": "^7.0.0",
85
- "tsx": "^4.20.3",
86
- "typescript": "^5.4.5",
87
- "typescript-eslint": "^8.0.1",
76
+ "@types/express": "^5.0.6",
77
+ "@types/node": "^25.0.1",
78
+ "@types/pg": "^8.16.0",
79
+ "@types/supertest": "^6.0.3",
80
+ "eslint": "^9.39.2",
81
+ "globals": "^16.5.0",
82
+ "pino-pretty": "^13.1.3",
83
+ "prettier": "^3.7.4",
84
+ "supertest": "^7.1.4",
85
+ "tsx": "^4.21.0",
86
+ "typescript": "^5.9.3",
87
+ "typescript-eslint": "^8.49.0",
88
88
  "vitest": "^4.0.15"
89
89
  },
90
90
  "peerDependencies": {
91
91
  "graphile-config": "^1.0.0-rc.2"
92
92
  },
93
+ "optionalDependencies": {
94
+ "@moneypot/server": "file:../moneypot-server"
95
+ },
93
96
  "license": "UNLICENSED"
94
97
  }