payment-kit 1.27.2 → 1.29.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 (241) hide show
  1. package/__blocklet__.js +37 -0
  2. package/api/ocap-1.30-subpath-shims.d.ts +35 -0
  3. package/api/src/crons/index.ts +32 -0
  4. package/api/src/crons/metering-subscription-detection.ts +12 -14
  5. package/api/src/crons/overdue-detection.ts +51 -74
  6. package/api/src/crons/retry-pending-events.ts +58 -0
  7. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  8. package/api/src/integrations/app-store/client.ts +369 -0
  9. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  10. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  11. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  12. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  13. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  14. package/api/src/integrations/arcblock/nft.ts +6 -2
  15. package/api/src/integrations/arcblock/stake.ts +3 -2
  16. package/api/src/integrations/arcblock/token.ts +4 -4
  17. package/api/src/integrations/blocklet/notification.ts +1 -1
  18. package/api/src/integrations/ethereum/tx.ts +29 -0
  19. package/api/src/integrations/google-play/client.ts +276 -0
  20. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  21. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  22. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  23. package/api/src/integrations/google-play/setup.ts +43 -0
  24. package/api/src/integrations/google-play/verify.ts +251 -0
  25. package/api/src/integrations/iap-reconcile.ts +415 -0
  26. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  27. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  28. package/api/src/integrations/stripe/resource.ts +8 -0
  29. package/api/src/libs/audit.ts +70 -24
  30. package/api/src/libs/auth.ts +49 -2
  31. package/api/src/libs/chain-error.ts +31 -0
  32. package/api/src/libs/entitlement.ts +399 -0
  33. package/api/src/libs/env.ts +2 -0
  34. package/api/src/libs/error.ts +15 -0
  35. package/api/src/libs/event.ts +42 -1
  36. package/api/src/libs/invoice.ts +69 -34
  37. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  38. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  39. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  40. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  41. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  42. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  43. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  44. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  45. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  46. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  47. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  48. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  49. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  50. package/api/src/libs/pagination.ts +14 -9
  51. package/api/src/libs/payment.ts +25 -10
  52. package/api/src/libs/security.ts +51 -0
  53. package/api/src/libs/session.ts +1 -1
  54. package/api/src/libs/subscription.ts +13 -1
  55. package/api/src/libs/timing.ts +35 -0
  56. package/api/src/libs/util.ts +29 -15
  57. package/api/src/libs/wallet-migration.ts +72 -53
  58. package/api/src/queues/auto-recharge.ts +1 -1
  59. package/api/src/queues/credit-consume.ts +94 -12
  60. package/api/src/queues/credit-grant.ts +4 -0
  61. package/api/src/queues/event.ts +39 -21
  62. package/api/src/queues/invoice.ts +1 -0
  63. package/api/src/queues/payment.ts +83 -15
  64. package/api/src/queues/refund.ts +84 -71
  65. package/api/src/queues/subscription.ts +1 -0
  66. package/api/src/queues/webhook.ts +12 -2
  67. package/api/src/routes/checkout-sessions.ts +82 -43
  68. package/api/src/routes/connect/change-payment.ts +2 -0
  69. package/api/src/routes/connect/change-plan.ts +2 -0
  70. package/api/src/routes/connect/pay.ts +12 -3
  71. package/api/src/routes/connect/setup.ts +3 -1
  72. package/api/src/routes/connect/shared.ts +52 -39
  73. package/api/src/routes/connect/subscribe.ts +4 -1
  74. package/api/src/routes/credit-grants.ts +25 -17
  75. package/api/src/routes/donations.ts +2 -2
  76. package/api/src/routes/entitlements.ts +105 -0
  77. package/api/src/routes/events.ts +2 -2
  78. package/api/src/routes/index.ts +12 -2
  79. package/api/src/routes/integrations/app-store.ts +267 -0
  80. package/api/src/routes/integrations/google-play.ts +324 -0
  81. package/api/src/routes/meter-events.ts +16 -6
  82. package/api/src/routes/payment-links.ts +1 -1
  83. package/api/src/routes/payment-methods.ts +131 -1
  84. package/api/src/routes/settings.ts +1 -1
  85. package/api/src/routes/tax-rates.ts +1 -1
  86. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  87. package/api/src/store/models/customer.ts +37 -1
  88. package/api/src/store/models/entitlement-grant.ts +118 -0
  89. package/api/src/store/models/entitlement-product.ts +48 -0
  90. package/api/src/store/models/entitlement.ts +86 -0
  91. package/api/src/store/models/index.ts +9 -0
  92. package/api/src/store/models/invoice.ts +20 -0
  93. package/api/src/store/models/payment-method.ts +66 -1
  94. package/api/src/store/models/price.ts +23 -14
  95. package/api/src/store/models/refund.ts +10 -0
  96. package/api/src/store/models/subscription.ts +14 -0
  97. package/api/src/store/models/types.ts +32 -0
  98. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  99. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  100. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  101. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  102. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  103. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  104. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  105. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  106. package/api/tests/libs/entitlement.spec.ts +347 -0
  107. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  108. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  109. package/api/tests/queues/credit-consume.spec.ts +8 -4
  110. package/api/tests/routes/credit-grants.spec.ts +1 -0
  111. package/blocklet.yml +1 -1
  112. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  113. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  114. package/cloudflare/README.md +499 -0
  115. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  116. package/cloudflare/build.ts +151 -0
  117. package/cloudflare/did-connect-auth.ts +527 -0
  118. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  119. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  120. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  121. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  122. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  123. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  124. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  125. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  126. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  127. package/cloudflare/frontend-shims/session.ts +24 -0
  128. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  129. package/cloudflare/index.html +40 -0
  130. package/cloudflare/migrate-to-d1.js +252 -0
  131. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  132. package/cloudflare/migrations/0002_indexes.sql +75 -0
  133. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  134. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  135. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  136. package/cloudflare/run-build.js +391 -0
  137. package/cloudflare/scripts/test-decrypt.js +102 -0
  138. package/cloudflare/shims/arcblock-ws.ts +20 -0
  139. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  140. package/cloudflare/shims/axios-lite.ts +117 -0
  141. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  142. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  143. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  144. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  145. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  146. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  147. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  148. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  149. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  150. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  151. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  152. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  153. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  154. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  155. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  156. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  157. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  158. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  159. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  160. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  161. package/cloudflare/shims/cookie-parser.ts +3 -0
  162. package/cloudflare/shims/cors.ts +21 -0
  163. package/cloudflare/shims/cron.ts +189 -0
  164. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  165. package/cloudflare/shims/did-space-js.ts +17 -0
  166. package/cloudflare/shims/did-space.ts +11 -0
  167. package/cloudflare/shims/error.ts +18 -0
  168. package/cloudflare/shims/express-compat/index.ts +80 -0
  169. package/cloudflare/shims/express-compat/types.ts +41 -0
  170. package/cloudflare/shims/fastq.ts +105 -0
  171. package/cloudflare/shims/lock.ts +115 -0
  172. package/cloudflare/shims/mime-types.ts +56 -0
  173. package/cloudflare/shims/nedb-storage.ts +9 -0
  174. package/cloudflare/shims/node-child-process.ts +9 -0
  175. package/cloudflare/shims/node-fs.ts +20 -0
  176. package/cloudflare/shims/node-http.ts +13 -0
  177. package/cloudflare/shims/node-https.ts +4 -0
  178. package/cloudflare/shims/node-misc.ts +15 -0
  179. package/cloudflare/shims/node-net.ts +8 -0
  180. package/cloudflare/shims/node-os.ts +14 -0
  181. package/cloudflare/shims/node-tty.ts +8 -0
  182. package/cloudflare/shims/node-zlib.ts +17 -0
  183. package/cloudflare/shims/noop.ts +26 -0
  184. package/cloudflare/shims/payment-vendor.ts +14 -0
  185. package/cloudflare/shims/querystring.ts +12 -0
  186. package/cloudflare/shims/queue.ts +611 -0
  187. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  188. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  189. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  190. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  191. package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
  192. package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
  193. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  194. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  195. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  196. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  197. package/cloudflare/shims/stripe-cf.ts +29 -0
  198. package/cloudflare/shims/ws-lite.ts +103 -0
  199. package/cloudflare/shims/xss.ts +3 -0
  200. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  201. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  202. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  203. package/cloudflare/vite.config.ts +162 -0
  204. package/cloudflare/worker.ts +1608 -0
  205. package/cloudflare/wrangler.json +63 -0
  206. package/cloudflare/wrangler.jsonc +75 -0
  207. package/cloudflare/wrangler.staging.json +67 -0
  208. package/cloudflare/wrangler.toml +28 -0
  209. package/jest.config.js +4 -12
  210. package/package.json +30 -22
  211. package/scripts/seed-google-play.ts +79 -0
  212. package/src/app.tsx +62 -4
  213. package/src/components/customer/link.tsx +9 -13
  214. package/src/components/customer/notification-preference.tsx +3 -2
  215. package/src/components/filter-toolbar.tsx +4 -0
  216. package/src/components/invoice/list.tsx +9 -1
  217. package/src/components/invoice-pdf/utils.ts +2 -1
  218. package/src/components/layout/admin.tsx +39 -5
  219. package/src/components/layout/user-cf.tsx +77 -0
  220. package/src/components/payment-intent/actions.tsx +23 -3
  221. package/src/components/payment-method/app-store.tsx +103 -0
  222. package/src/components/payment-method/form.tsx +7 -1
  223. package/src/components/payment-method/google-play.tsx +85 -0
  224. package/src/components/safe-did-address.tsx +75 -0
  225. package/src/components/subscription/list.tsx +20 -0
  226. package/src/libs/patch-user-card.ts +25 -0
  227. package/src/libs/util.ts +5 -7
  228. package/src/locales/en.tsx +63 -0
  229. package/src/locales/zh.tsx +63 -0
  230. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  231. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  232. package/src/pages/admin/customers/customers/detail.tsx +8 -2
  233. package/src/pages/admin/customers/customers/index.tsx +2 -2
  234. package/src/pages/admin/overview.tsx +3 -1
  235. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  236. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
  237. package/src/pages/customer/subscription/detail.tsx +4 -4
  238. package/tsconfig.api.json +1 -6
  239. package/tsconfig.json +3 -4
  240. package/tsconfig.types.json +2 -1
  241. package/vite.config.ts +6 -1
@@ -0,0 +1,103 @@
1
+ // Lightweight ws shim for CF Workers — uses native WebSocket
2
+ // Avoids bundling the full 128KB ws package (pulled by ethers)
3
+
4
+ class WebSocketShim {
5
+ static CONNECTING = 0;
6
+ static OPEN = 1;
7
+ static CLOSING = 2;
8
+ static CLOSED = 3;
9
+
10
+ CONNECTING = 0;
11
+ OPEN = 1;
12
+ CLOSING = 2;
13
+ CLOSED = 3;
14
+
15
+ _ws: WebSocket | null = null;
16
+ readyState = 0;
17
+
18
+ onopen: ((ev: any) => void) | null = null;
19
+ onclose: ((ev: any) => void) | null = null;
20
+ onmessage: ((ev: any) => void) | null = null;
21
+ onerror: ((ev: any) => void) | null = null;
22
+
23
+ constructor(url: string, protocols?: string | string[]) {
24
+ try {
25
+ this._ws = new WebSocket(url, protocols);
26
+ this._ws.addEventListener('open', (ev) => {
27
+ this.readyState = 1;
28
+ this.onopen?.(ev);
29
+ });
30
+ this._ws.addEventListener('close', (ev) => {
31
+ this.readyState = 3;
32
+ this.onclose?.(ev);
33
+ });
34
+ this._ws.addEventListener('message', (ev) => {
35
+ this.onmessage?.(ev);
36
+ });
37
+ this._ws.addEventListener('error', (ev) => {
38
+ this.onerror?.(ev);
39
+ });
40
+ } catch (e) {
41
+ this.readyState = 3;
42
+ }
43
+ }
44
+
45
+ send(data: any) {
46
+ this._ws?.send(data);
47
+ }
48
+
49
+ close(code?: number, reason?: string) {
50
+ this.readyState = 2;
51
+ this._ws?.close(code, reason);
52
+ }
53
+
54
+ addEventListener(type: string, listener: any) {
55
+ this._ws?.addEventListener(type, listener);
56
+ }
57
+
58
+ removeEventListener(type: string, listener: any) {
59
+ this._ws?.removeEventListener(type, listener);
60
+ }
61
+
62
+ on(event: string, listener: any) {
63
+ if (event === 'open') this.onopen = listener;
64
+ else if (event === 'close') this.onclose = listener;
65
+ else if (event === 'message') this.onmessage = listener;
66
+ else if (event === 'error') this.onerror = listener;
67
+ return this;
68
+ }
69
+
70
+ off(_event: string, _listener: any) {
71
+ return this;
72
+ }
73
+
74
+ once(event: string, listener: any) {
75
+ const wrapped = (...args: any[]) => {
76
+ this.off(event, wrapped);
77
+ listener(...args);
78
+ };
79
+ return this.on(event, wrapped);
80
+ }
81
+
82
+ terminate() {
83
+ this.close();
84
+ }
85
+
86
+ ping() {}
87
+ pong() {}
88
+ }
89
+
90
+ // WebSocket.Server stub — not used in CF Workers
91
+ class ServerStub {
92
+ constructor(_opts?: any) {}
93
+ on() { return this; }
94
+ close() {}
95
+ address() { return { port: 0 }; }
96
+ }
97
+
98
+ (WebSocketShim as any).Server = ServerStub;
99
+ (WebSocketShim as any).WebSocket = WebSocketShim;
100
+ (WebSocketShim as any).WebSocketServer = ServerStub;
101
+ (WebSocketShim as any).createWebSocketStream = () => { throw new Error('Not supported in CF Workers'); };
102
+
103
+ export = WebSocketShim;
@@ -0,0 +1,3 @@
1
+ export function xss(_opts?: any) {
2
+ return (_req: any, _res: any, next: any) => next();
3
+ }
@@ -0,0 +1,210 @@
1
+ import cronModule, { cronInstance, __test__ } from '../../shims/cron';
2
+
3
+ const { matchesCron, shouldRunInWindow } = __test__;
4
+
5
+ // Anchor date: 2026-04-17T10:00:00Z (a Friday, minute=0, hour=10)
6
+ const at = (hh: number, mm: number, dayOffset = 0) =>
7
+ new Date(Date.UTC(2026, 3, 17 + dayOffset, hh, mm, 0));
8
+
9
+ describe('matchesCron (reference correctness, not modified by this fix)', () => {
10
+ it.each([
11
+ ['0 * * * * *', 10, 0, true],
12
+ ['0 5 * * * *', 10, 5, true],
13
+ ['0 5 * * * *', 10, 4, false],
14
+ ['0 */5 * * * *', 10, 0, true],
15
+ ['0 */5 * * * *', 10, 5, true],
16
+ ['0 */5 * * * *', 10, 4, false],
17
+ ['0 */5 * * * *', 10, 7, false],
18
+ ['0 */10 * * * *', 10, 0, true],
19
+ ['0 */10 * * * *', 10, 5, false],
20
+ ['0 */10 * * * *', 10, 10, true],
21
+ ['0 */30 * * * *', 10, 0, true],
22
+ ['0 */30 * * * *', 10, 15, false],
23
+ ['0 */30 * * * *', 10, 30, true],
24
+ ['0 5 */6 * * *', 6, 5, true],
25
+ ['0 5 */6 * * *', 6, 6, false],
26
+ ['0 5 */6 * * *', 7, 5, false],
27
+ ['0 5 */6 * * *', 12, 5, true],
28
+ ['0 0 0 * * *', 0, 0, true],
29
+ ['0 0 0 * * *', 0, 1, false],
30
+ ['0 0 10 * * *', 10, 0, true],
31
+ ['0 0 10 * * *', 10, 1, false],
32
+ ])('matchesCron(%s) at %d:%d -> %s', (expr, h, m, expected) => {
33
+ expect(matchesCron(expr, at(h, m))).toBe(expected);
34
+ });
35
+ });
36
+
37
+ // These tests define the POST-FIX behavior: 1-minute window, not 5-minute.
38
+ // Before fix: shouldRunInWindow("0 */5 * * * *", h:m) is true for any m in [0..59].
39
+ // After fix: shouldRunInWindow is true only at the exact matching minute.
40
+ describe('shouldRunInWindow — 1 minute window (post-fix behavior)', () => {
41
+ describe('every-5-minute cron "0 */5 * * * *"', () => {
42
+ it('matches exactly at minute 0, 5, 10, ..., 55 within an hour (12 times)', () => {
43
+ const matches: number[] = [];
44
+ for (let m = 0; m < 60; m += 1) {
45
+ if (shouldRunInWindow('0 */5 * * * *', at(10, m))) matches.push(m);
46
+ }
47
+ expect(matches).toEqual([0, 5, 10, 15, 20, 25, 30, 35, 40, 45, 50, 55]);
48
+ expect(matches).toHaveLength(12);
49
+ });
50
+
51
+ it('does NOT match at minutes 1-4, 6-9, 11-14, ... (the bug we are fixing)', () => {
52
+ for (const m of [1, 2, 3, 4, 6, 7, 8, 9, 11, 12, 13, 14]) {
53
+ expect(shouldRunInWindow('0 */5 * * * *', at(10, m))).toBe(false);
54
+ }
55
+ });
56
+ });
57
+
58
+ describe('every-10-minute cron "0 */10 * * * *"', () => {
59
+ it('matches exactly 6 times per hour (minute 0, 10, 20, 30, 40, 50)', () => {
60
+ const matches: number[] = [];
61
+ for (let m = 0; m < 60; m += 1) {
62
+ if (shouldRunInWindow('0 */10 * * * *', at(10, m))) matches.push(m);
63
+ }
64
+ expect(matches).toEqual([0, 10, 20, 30, 40, 50]);
65
+ });
66
+ });
67
+
68
+ describe('every-30-minute cron "0 */30 * * * *"', () => {
69
+ it('matches exactly 2 times per hour', () => {
70
+ const matches: number[] = [];
71
+ for (let m = 0; m < 60; m += 1) {
72
+ if (shouldRunInWindow('0 */30 * * * *', at(10, m))) matches.push(m);
73
+ }
74
+ expect(matches).toEqual([0, 30]);
75
+ });
76
+ });
77
+
78
+ describe('every-minute cron "* * * * *"', () => {
79
+ it('matches every minute', () => {
80
+ for (let m = 0; m < 60; m += 1) {
81
+ expect(shouldRunInWindow('* * * * *', at(10, m))).toBe(true);
82
+ }
83
+ });
84
+ });
85
+
86
+ describe('single-minute cron "0 5 */6 * * *" (every 6 hours at minute 5)', () => {
87
+ it('matches exactly 4 times in a 24-hour window (hours 0, 6, 12, 18)', () => {
88
+ const matches: string[] = [];
89
+ for (let h = 0; h < 24; h += 1) {
90
+ for (let m = 0; m < 60; m += 1) {
91
+ if (shouldRunInWindow('0 5 */6 * * *', at(h, m))) matches.push(`${h}:${m}`);
92
+ }
93
+ }
94
+ expect(matches).toEqual(['0:5', '6:5', '12:5', '18:5']);
95
+ });
96
+ });
97
+
98
+ describe('daily cron "0 1 0 * * *" (00:01 every day)', () => {
99
+ it('matches exactly once per day', () => {
100
+ const matches: string[] = [];
101
+ for (let h = 0; h < 24; h += 1) {
102
+ for (let m = 0; m < 60; m += 1) {
103
+ if (shouldRunInWindow('0 1 0 * * *', at(h, m))) matches.push(`${h}:${m}`);
104
+ }
105
+ }
106
+ expect(matches).toEqual(['0:1']);
107
+ });
108
+ });
109
+
110
+ describe('5-field cron "*/5 * * * *" (no seconds column)', () => {
111
+ it('also supported — matches 12 times per hour', () => {
112
+ const matches: number[] = [];
113
+ for (let m = 0; m < 60; m += 1) {
114
+ if (shouldRunInWindow('*/5 * * * *', at(10, m))) matches.push(m);
115
+ }
116
+ expect(matches).toHaveLength(12);
117
+ });
118
+ });
119
+ });
120
+
121
+ // Invariant check: non-5-aligned cron expressions must still be reachable when
122
+ // shim uses 1-minute window (post-fix). This guards against the review finding
123
+ // that if CF trigger is "*/5" while shim uses 1-min matching, jobs at minute 1
124
+ // (e.g. expiredSessionCleanupCronTime="0 1 * * * *") would never fire.
125
+ describe('non-5-aligned cron expressions (review P1-1 regression guard)', () => {
126
+ it.each([
127
+ ['0 1 * * * *', 'expiredSessionCleanupCronTime (minute 1 every hour)', [1]],
128
+ ['0 1 0 * * *', 'paymentStatCronTime (00:01 daily)', [1]],
129
+ ['0 7 * * * *', 'hypothetical minute 7', [7]],
130
+ ['0 13 * * * *', 'hypothetical minute 13', [13]],
131
+ ])('matches %s (%s) at the expected minute', (expr, _label, expectedMinutes) => {
132
+ const matches: number[] = [];
133
+ for (let m = 0; m < 60; m += 1) {
134
+ // Check at hour 0 so the hour field "0" in '0 1 0 * * *' also aligns.
135
+ if (shouldRunInWindow(expr, at(0, m))) matches.push(m);
136
+ }
137
+ expect(matches).toEqual(expectedMinutes);
138
+ });
139
+ });
140
+
141
+ describe('regression guard — bug condition from 2026-04-17 incident', () => {
142
+ it('each minute in an hour produces the EXPECTED number of matches for payment-kit cron expressions', () => {
143
+ const expressions: Record<string, number> = {
144
+ '0 */5 * * * *': 12, // depositVault, revokeStake
145
+ '0 */10 * * * *': 6, // creditConsume, vendorStatusCheck, vendorReturnScan
146
+ '0 */20 * * * *': 3, // stripePayment
147
+ '0 */30 * * * *': 2, // subscription, stripeInvoice
148
+ };
149
+
150
+ for (const [expr, expected] of Object.entries(expressions)) {
151
+ let count = 0;
152
+ for (let m = 0; m < 60; m += 1) {
153
+ if (shouldRunInWindow(expr, at(10, m))) count += 1;
154
+ }
155
+ expect({ expr, count }).toEqual({ expr, count: expected });
156
+ }
157
+ });
158
+ });
159
+
160
+ // runAll(date) integration test — verifies the shim uses the passed date
161
+ // (scheduledTime from CF) rather than wall-clock at execution time. This
162
+ // addresses PR #1341 review P2-1: a scheduled event intended for 00:01 but
163
+ // delivered late at 00:02 must still match "0 1 * * * *" jobs.
164
+ describe('cronInstance.runAll(date) — scheduledTime integration (review P2-1)', () => {
165
+ afterEach(() => {
166
+ // cronModule.init({...}) mutates the singleton registry; reset between cases
167
+ cronModule.init({ jobs: [] });
168
+ });
169
+
170
+ it('passes the provided date to shouldRunInWindow, not wall clock', async () => {
171
+ const calls: string[] = [];
172
+ cronModule.init({
173
+ jobs: [
174
+ {
175
+ name: 'test.minute-1',
176
+ time: '0 1 * * * *',
177
+ fn: async () => { calls.push('minute-1'); },
178
+ },
179
+ {
180
+ name: 'test.minute-2',
181
+ time: '0 2 * * * *',
182
+ fn: async () => { calls.push('minute-2'); },
183
+ },
184
+ ],
185
+ });
186
+
187
+ // Scheduled intended at 00:01 but delivered late so wall-clock is in minute 2.
188
+ // Caller (worker.ts) should pass the intended time (new Date(event.scheduledTime)).
189
+ const intendedMinute1 = new Date(Date.UTC(2026, 3, 17, 0, 1, 0));
190
+ await cronInstance.runAll(intendedMinute1);
191
+
192
+ expect(calls).toEqual(['minute-1']);
193
+ });
194
+
195
+ it('defaults to new Date() when no arg is passed', async () => {
196
+ // Smoke check that the default-arg path still works (e.g. from manual dev endpoints).
197
+ let ran = false;
198
+ cronModule.init({
199
+ jobs: [
200
+ {
201
+ name: 'test.always',
202
+ time: '* * * * *',
203
+ fn: async () => { ran = true; },
204
+ },
205
+ ],
206
+ });
207
+ await cronInstance.runAll();
208
+ expect(ran).toBe(true);
209
+ });
210
+ });
@@ -0,0 +1,87 @@
1
+ // Regression guard for PR #1381 review P1: on Cloudflare the queue shim only
2
+ // writes a DELAYED job to D1 (with will_run_at, later dispatched by the cron)
3
+ // when it is persisted. A delayed job pushed with persist:false is silently
4
+ // dropped — which is exactly how the webhook retry ladder lost deliveries after
5
+ // the first failure. These tests pin that invariant so callers always persist
6
+ // delayed retries.
7
+
8
+ // A single shared store mock so we can assert what the shim wrote.
9
+ const mockStore = {
10
+ addJob: jest.fn().mockResolvedValue(undefined),
11
+ deleteJob: jest.fn().mockResolvedValue(true),
12
+ getJob: jest.fn(),
13
+ getJobs: jest.fn(),
14
+ getScheduledJobs: jest.fn(),
15
+ updateJob: jest.fn(),
16
+ isCancelled: jest.fn().mockResolvedValue(false),
17
+ findJobs: jest.fn(),
18
+ };
19
+
20
+ jest.mock('../../../api/src/store/models/job', () => ({ Job: { findAll: jest.fn() } }));
21
+ jest.mock('../../../api/src/libs/queue/store', () => ({
22
+ __esModule: true,
23
+ default: jest.fn(() => mockStore),
24
+ }));
25
+
26
+ import createQueue, { setCFQueue, flushPendingJobs } from '../../shims/queue';
27
+
28
+ const futureRunAt = () => Math.floor(Date.now() / 1000) + 600; // +10 min → delayed
29
+
30
+ describe('shim queue — delayed job persistence (PR #1381 P1)', () => {
31
+ beforeEach(() => {
32
+ jest.clearAllMocks();
33
+ setCFQueue(null); // no CF Queue binding; delayed path is independent of it anyway
34
+ });
35
+
36
+ it('persists a delayed job to D1 (with will_run_at) when persist=true', async () => {
37
+ const q = createQueue({ name: 'webhook-persist-true', onJob: jest.fn() });
38
+ q.push({ id: 'j1', job: { v: 1 }, runAt: futureRunAt(), persist: true });
39
+ await flushPendingJobs();
40
+
41
+ expect(mockStore.addJob).toHaveBeenCalledTimes(1);
42
+ const attrs = mockStore.addJob.mock.calls[0]![2];
43
+ expect(attrs.will_run_at).toBeGreaterThan(Date.now());
44
+ expect(attrs.delay).toBeGreaterThan(0);
45
+ });
46
+
47
+ it('DROPS a delayed job (no D1 write) when persist=false — the lost-retry bug class', async () => {
48
+ const q = createQueue({ name: 'webhook-persist-false', onJob: jest.fn() });
49
+ q.push({ id: 'j2', job: { v: 2 }, runAt: futureRunAt(), persist: false });
50
+ await flushPendingJobs();
51
+
52
+ expect(mockStore.addJob).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe('shim queue — inline fallback keeps the D1 backup on failure (PR #1381 re-review P1)', () => {
57
+ beforeEach(() => {
58
+ jest.clearAllMocks();
59
+ });
60
+
61
+ it('does NOT delete the persisted row when CF Queue send fails AND the inline handler fails', async () => {
62
+ setCFQueue({ send: jest.fn().mockRejectedValue(new Error('429 Too Many Requests')) } as any);
63
+ const onJob = jest.fn().mockRejectedValue(new Error('handler boom'));
64
+ const q = createQueue({ name: 'webhook-fallback-fail', onJob, options: { maxRetries: 1 } });
65
+
66
+ q.push({ id: 'wj1', job: { v: 1 }, persist: true }); // immediate
67
+ await flushPendingJobs();
68
+
69
+ expect(mockStore.addJob).toHaveBeenCalledTimes(1); // durable backup written
70
+ expect(onJob).toHaveBeenCalled(); // inline attempt ran
71
+ expect(mockStore.deleteJob).not.toHaveBeenCalled(); // backup retained → cron can retry
72
+ setCFQueue(null);
73
+ });
74
+
75
+ it('deletes the persisted row when the inline fallback SUCCEEDS', async () => {
76
+ setCFQueue({ send: jest.fn().mockRejectedValue(new Error('429')) } as any);
77
+ const onJob = jest.fn().mockResolvedValue(undefined);
78
+ const q = createQueue({ name: 'webhook-fallback-ok', onJob, options: { maxRetries: 1 } });
79
+
80
+ q.push({ id: 'wj2', job: { v: 2 }, persist: true });
81
+ await flushPendingJobs();
82
+
83
+ expect(onJob).toHaveBeenCalled();
84
+ expect(mockStore.deleteJob).toHaveBeenCalledWith('wj2'); // deleted only on success
85
+ setCFQueue(null);
86
+ });
87
+ });
@@ -0,0 +1,186 @@
1
+ // Tests for shims/queue.ts:runAllScheduledJobs aggregated D1 scan.
2
+ //
3
+ // Verifies the Plan 8 refactor: 16 × 2 per-queue queries collapse into a
4
+ // single Job.findAll across all registered queues. Behavior invariants
5
+ // preserved: due-delayed + stuck-immediate semantics, 2-minute created_at
6
+ // filter for immediate jobs, per-queue dispatch order by created_at.
7
+
8
+ // Mock the Job model before importing the shim — import order matters
9
+ // because shims/queue.ts imports Job at module top.
10
+ jest.mock('../../../api/src/store/models/job', () => ({
11
+ Job: {
12
+ findAll: jest.fn(),
13
+ },
14
+ }));
15
+
16
+ // Mock createQueueStore so registering a handler doesn't hit D1.
17
+ jest.mock('../../../api/src/libs/queue/store', () => ({
18
+ __esModule: true,
19
+ default: jest.fn(() => ({
20
+ // Only the surface actually touched by dispatchJob / handler registration:
21
+ deleteJob: jest.fn().mockResolvedValue(true),
22
+ getJob: jest.fn(),
23
+ getJobs: jest.fn(),
24
+ getScheduledJobs: jest.fn(),
25
+ addJob: jest.fn(),
26
+ updateJob: jest.fn(),
27
+ isCancelled: jest.fn().mockResolvedValue(false),
28
+ findJobs: jest.fn(),
29
+ })),
30
+ }));
31
+
32
+ import { Op } from 'sequelize';
33
+ import { Job } from '../../../api/src/store/models/job';
34
+ import {
35
+ runAllScheduledJobs,
36
+ setCFQueue,
37
+ getAllHandlerNames,
38
+ __test__,
39
+ } from '../../shims/queue';
40
+
41
+ const mockedFindAll = Job.findAll as unknown as jest.Mock;
42
+
43
+ describe('runAllScheduledJobs — aggregated D1 scan', () => {
44
+ beforeEach(() => {
45
+ jest.clearAllMocks();
46
+ __test__?.resetHandlers?.();
47
+ setCFQueue(null);
48
+ });
49
+
50
+ function registerQueue(name: string, executeJob = jest.fn().mockResolvedValue(undefined)) {
51
+ __test__!.registerForTest!(name, { executeJob });
52
+ return executeJob;
53
+ }
54
+
55
+ it('returns early with no query when no queues are registered', async () => {
56
+ const result = await runAllScheduledJobs();
57
+ expect(mockedFindAll).not.toHaveBeenCalled();
58
+ expect(result).toEqual({ dispatched: 0, failed: 0, queues: [] });
59
+ });
60
+
61
+ it('issues exactly ONE findAll across all registered queues (no per-queue loop)', async () => {
62
+ for (let i = 0; i < 16; i += 1) registerQueue(`q${i}`);
63
+ mockedFindAll.mockResolvedValue([]);
64
+
65
+ await runAllScheduledJobs();
66
+
67
+ expect(mockedFindAll).toHaveBeenCalledTimes(1);
68
+ const arg = mockedFindAll.mock.calls[0][0];
69
+ expect(arg.where.queue).toEqual({ [Op.in]: expect.arrayContaining(['q0', 'q15']) });
70
+ expect(arg.where.cancelled).toBe(false);
71
+ expect(arg.where[Op.or]).toHaveLength(2);
72
+ expect(arg.order).toEqual([['created_at', 'ASC']]);
73
+ });
74
+
75
+ it('groups rows by queue and dispatches via the registered handler', async () => {
76
+ const execA = registerQueue('qa');
77
+ const execB = registerQueue('qb');
78
+
79
+ mockedFindAll.mockResolvedValue([
80
+ { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
81
+ { id: 'b1', queue: 'qb', job: { v: 2 }, created_at: new Date() },
82
+ { id: 'a2', queue: 'qa', job: { v: 3 }, created_at: new Date() },
83
+ ]);
84
+
85
+ const result = await runAllScheduledJobs();
86
+
87
+ expect(execA).toHaveBeenCalledTimes(2);
88
+ expect(execB).toHaveBeenCalledTimes(1);
89
+ expect(result.dispatched).toBe(3);
90
+ expect(result.failed).toBe(0);
91
+ expect(result.queues.sort()).toEqual(['qa', 'qb']);
92
+ });
93
+
94
+ it('filters out rows missing id or job payload', async () => {
95
+ const exec = registerQueue('qa');
96
+
97
+ mockedFindAll.mockResolvedValue([
98
+ { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
99
+ { id: '', queue: 'qa', job: { v: 2 }, created_at: new Date() },
100
+ { id: 'a3', queue: 'qa', job: null, created_at: new Date() },
101
+ ]);
102
+
103
+ const result = await runAllScheduledJobs();
104
+
105
+ expect(exec).toHaveBeenCalledTimes(1);
106
+ expect(result.dispatched).toBe(1);
107
+ });
108
+
109
+ it('continues dispatching siblings when one job throws', async () => {
110
+ const exec = jest
111
+ .fn()
112
+ .mockRejectedValueOnce(new Error('boom'))
113
+ .mockResolvedValue(undefined);
114
+ registerQueue('qa', exec);
115
+
116
+ mockedFindAll.mockResolvedValue([
117
+ { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
118
+ { id: 'a2', queue: 'qa', job: { v: 2 }, created_at: new Date() },
119
+ ]);
120
+
121
+ const result = await runAllScheduledJobs();
122
+
123
+ expect(exec).toHaveBeenCalledTimes(2);
124
+ expect(result.dispatched).toBe(1);
125
+ expect(result.failed).toBe(1);
126
+ });
127
+
128
+ it('returns empty result without throwing when D1 scan fails', async () => {
129
+ registerQueue('qa');
130
+ mockedFindAll.mockRejectedValue(new Error('D1 timeout'));
131
+
132
+ const result = await runAllScheduledJobs();
133
+
134
+ expect(result).toEqual({ dispatched: 0, failed: 0, queues: [] });
135
+ });
136
+
137
+ it('ignores rows for queues that are no longer registered', async () => {
138
+ const exec = registerQueue('qa');
139
+ mockedFindAll.mockResolvedValue([
140
+ { id: 'a1', queue: 'qa', job: { v: 1 }, created_at: new Date() },
141
+ { id: 'z1', queue: 'qzombie', job: { v: 2 }, created_at: new Date() },
142
+ ]);
143
+
144
+ const result = await runAllScheduledJobs();
145
+
146
+ expect(exec).toHaveBeenCalledTimes(1);
147
+ expect(result.dispatched).toBe(1);
148
+ expect(result.queues).toEqual(['qa']);
149
+ });
150
+
151
+ it('where clause covers due-delayed OR stuck-immediate jobs with 2-min cutoff', async () => {
152
+ registerQueue('qa');
153
+ mockedFindAll.mockResolvedValue([]);
154
+
155
+ const before = Date.now();
156
+ await runAllScheduledJobs();
157
+ const after = Date.now();
158
+
159
+ const arg = mockedFindAll.mock.calls[0][0];
160
+ const orClauses = arg.where[Op.or];
161
+ expect(orClauses).toHaveLength(2);
162
+
163
+ const delayedClause = orClauses.find((c: any) => c.delay && c.will_run_at);
164
+ expect(delayedClause).toBeDefined();
165
+ expect(delayedClause.delay).toEqual({ [Op.not]: -1 });
166
+ const dueCutoff = delayedClause.will_run_at[Op.lte];
167
+ expect(dueCutoff).toBeGreaterThanOrEqual(before);
168
+ expect(dueCutoff).toBeLessThanOrEqual(after);
169
+
170
+ const immediateClause = orClauses.find((c: any) => c.delay === -1);
171
+ expect(immediateClause).toBeDefined();
172
+ // D1 rejects Date objects at the bind layer, so the shim serializes to ISO string.
173
+ const cutoffValue: string = immediateClause.created_at[Op.lt];
174
+ expect(typeof cutoffValue).toBe('string');
175
+ const cutoffMs = new Date(cutoffValue).getTime();
176
+ // 2 minutes back from "now", allowing for small wall-clock drift.
177
+ expect(before - cutoffMs).toBeGreaterThanOrEqual(2 * 60 * 1000 - 50);
178
+ expect(before - cutoffMs).toBeLessThanOrEqual(2 * 60 * 1000 + 50);
179
+ });
180
+
181
+ it('registers handlers that show up in getAllHandlerNames', () => {
182
+ registerQueue('q1');
183
+ registerQueue('q2');
184
+ expect(getAllHandlerNames().sort()).toEqual(['q1', 'q2']);
185
+ });
186
+ });