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
@@ -8,14 +8,63 @@ import { context } from './context';
8
8
 
9
9
  const API_VERSION = '2023-09-05';
10
10
 
11
- export async function createEvent(scope: string, type: LiteralUnion<EventType, string>, model: any, options: any = {}) {
11
+ /**
12
+ * Invoke every registered listener for `eventName` and await any Promise
13
+ * results. EventEmitter.emit() returns sync — listener async work would
14
+ * otherwise run as detached microtasks and die when the CF Workers handler
15
+ * unwinds. We want listeners (notably queues/event.ts's event.created
16
+ * handler that drives webhook delivery) to complete inside createEvent's
17
+ * Promise so waitUntil covers the whole chain.
18
+ */
19
+ async function emitAndAwait(eventName: string, ...args: any[]): Promise<void> {
20
+ const listeners = events.rawListeners(eventName);
21
+ for (const listener of listeners) {
22
+ try {
23
+ const result = (listener as any)(...args);
24
+ if (result && typeof result.then === 'function') {
25
+ // eslint-disable-next-line no-await-in-loop -- sequential await keeps listener ordering deterministic for waitUntil chains
26
+ await result;
27
+ }
28
+ } catch (err: any) {
29
+ console.error('[audit emitAndAwait]', eventName, err?.message || err);
30
+ }
31
+ }
32
+ }
33
+
34
+ export function createEvent(
35
+ scope: string,
36
+ type: LiteralUnion<EventType, string>,
37
+ model: any,
38
+ options: any = {}
39
+ ): Promise<void> {
40
+ // Context-aware tracking for CF Workers:
41
+ // - HTTP requests: use ctx.waitUntil (non-blocking, like Blocklet's fire-and-forget)
42
+ // - Queue consumer/Cron: use __cfPendingJobs__ (blocking, listeners must complete)
43
+ // - Blocklet Server: no tracking needed (long-lived process)
44
+ const promise = doCreateEvent(scope, type, model, options);
45
+ const isHttp = (globalThis as any).__cfHttpContext__;
46
+ if (isHttp) {
47
+ const waitUntil = (globalThis as any).__cfWaitUntil__;
48
+ if (typeof waitUntil === 'function') {
49
+ waitUntil(promise.catch((err: any) => console.error('[createEvent]', type, err?.message || err)));
50
+ }
51
+ } else {
52
+ const pending = (globalThis as any).__cfPendingJobs__;
53
+ if (Array.isArray(pending)) {
54
+ pending.push(promise.catch((err: any) => console.error('[createEvent]', type, err?.message || err)));
55
+ }
56
+ }
57
+ return promise;
58
+ }
59
+
60
+ async function doCreateEvent(scope: string, type: LiteralUnion<EventType, string>, model: any, options: any = {}) {
12
61
  const data: any = {
13
62
  object: model.dataValues,
14
63
  };
15
64
  if (type.endsWith('updated')) {
16
65
  data.previous_attributes = pick(model._previousDataValues, options.fields);
17
66
  }
18
- // console.log('createEvent', scope, type, data, options);
67
+
19
68
  const event = await Event.create({
20
69
  type,
21
70
  api_version: API_VERSION,
@@ -24,17 +73,23 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
24
73
  object_type: scope,
25
74
  data,
26
75
  request: {
27
- // FIXME:
28
76
  id: '',
29
77
  idempotency_key: '',
30
78
  requested_by: options.requestedBy || context.getRequestedBy() || 'system',
31
79
  },
32
80
  metadata: {},
33
- pending_webhooks: 99, // force all events goto the event queue
81
+ pending_webhooks: 99,
34
82
  });
35
83
 
36
- events.emit('event.created', { id: event.id });
37
- events.emit(event.type, data.object, options);
84
+ // Synchronously await listener chains so the HTTP handler's waitUntil
85
+ // scope covers the full handleEvent → addWebhookJob → push pipeline.
86
+ // EventEmitter.emit() returns sync and discards listener Promises — on
87
+ // CF Workers that leaves the listener microtask racing against worker
88
+ // termination, and `customer.subscription.started` / `.deleted` events
89
+ // fired at the tail of HTTP handlers were observed to lose their first
90
+ // delivery attempt because of it.
91
+ await emitAndAwait('event.created', { id: event.id });
92
+ await emitAndAwait(event.type, data.object, options);
38
93
  }
39
94
 
40
95
  export async function createStatusEvent(
@@ -57,7 +112,6 @@ export async function createStatusEvent(
57
112
  return;
58
113
  }
59
114
 
60
- // console.log('createStatusEvent', scope, prefix, config, data, options);
61
115
  const suffix = config[data.object.status];
62
116
  const event = await Event.create({
63
117
  type: [prefix, suffix].join('.'),
@@ -67,17 +121,16 @@ export async function createStatusEvent(
67
121
  object_type: scope,
68
122
  data,
69
123
  request: {
70
- // FIXME:
71
124
  id: '',
72
125
  idempotency_key: '',
73
126
  requested_by: options.requestedBy || context.getRequestedBy() || 'system',
74
127
  },
75
128
  metadata: {},
76
- pending_webhooks: 99, // force all events goto the event queue
129
+ pending_webhooks: 99,
77
130
  });
78
131
 
79
- events.emit('event.created', { id: event.id });
80
- events.emit(event.type, data.object);
132
+ await emitAndAwait('event.created', { id: event.id });
133
+ await emitAndAwait(event.type, data.object);
81
134
  }
82
135
 
83
136
  export async function createCustomEvent(
@@ -97,7 +150,6 @@ export async function createCustomEvent(
97
150
  return;
98
151
  }
99
152
 
100
- // console.log('createCustomEvent', scope, prefix, type, data, options);
101
153
  const event = await Event.create({
102
154
  type: [prefix, suffix].join('.'),
103
155
  api_version: API_VERSION,
@@ -106,26 +158,20 @@ export async function createCustomEvent(
106
158
  object_type: scope,
107
159
  data,
108
160
  request: {
109
- // FIXME:
110
161
  id: '',
111
162
  idempotency_key: '',
112
163
  requested_by: options.requestedBy || context.getRequestedBy() || 'system',
113
164
  },
114
165
  metadata: {},
115
- pending_webhooks: 99, // force all events goto the event queue
166
+ pending_webhooks: 99,
116
167
  });
117
168
 
118
- events.emit('event.created', { id: event.id });
119
- events.emit(event.type, data.object);
169
+ await emitAndAwait('event.created', { id: event.id });
170
+ await emitAndAwait(event.type, data.object);
120
171
  }
121
172
 
122
173
  /**
123
174
  * 创建自定义事件,无需依赖模型对象
124
- * @param type 完整的事件类型,格式为 prefix.suffix
125
- * @param objectType 对象类型
126
- * @param objectId 对象ID
127
- * @param data 事件数据
128
- * @param options 额外选项
129
175
  */
130
176
  export async function createFlexibleEvent(
131
177
  type: string,
@@ -153,10 +199,10 @@ export async function createFlexibleEvent(
153
199
  requested_by: requestedBy || context.getRequestedBy() || 'system',
154
200
  },
155
201
  metadata,
156
- pending_webhooks: 99, // force all events goto the event queue
202
+ pending_webhooks: 99,
157
203
  });
158
204
 
159
- events.emit('event.created', { id: event.id });
160
- events.emit(type, data);
205
+ await emitAndAwait('event.created', { id: event.id });
206
+ await emitAndAwait(type, data);
161
207
  return event;
162
208
  }
@@ -3,7 +3,7 @@ import path from 'path';
3
3
  import AuthStorage from '@arcblock/did-connect-storage-nedb';
4
4
  // @ts-ignore
5
5
  import { BlockletService } from '@blocklet/sdk/service/auth';
6
- import { getWallet } from '@blocklet/sdk/lib/wallet';
6
+ import { getWallet, getAccessWallet } from '@blocklet/sdk/lib/wallet';
7
7
  import { WalletAuthenticator } from '@blocklet/sdk/lib/wallet-authenticator';
8
8
  import { WalletHandlers } from '@blocklet/sdk/lib/wallet-handler';
9
9
  import type { Request } from 'express';
@@ -13,9 +13,56 @@ import type { WalletObject } from '@ocap/wallet';
13
13
  import env from './env';
14
14
  import logger from './logger';
15
15
 
16
+ // Workaround #2: @blocklet/sdk's notification.getSender() uses
17
+ // `getWallet().address` (BLOCKLET_APP_SK derived) as the sender appDid for
18
+ // relay/EventBus broadcasts. On migrated blocklets that address (e.g. zNKti3…)
19
+ // is a rotating session id that the relay does not recognise, surfacing as:
20
+ // "Sender blocklet does not exist: <addr>"
21
+ // "Failed to broadcast event via relay: payment_intent.succeeded"
22
+ // The relay/EventBus calls in notification.js invoke `(0, exports.getSender)()`
23
+ // at send time, so mutating the module's own `exports.getSender` intercepts
24
+ // every call site. We must use `require()` (not `import default`) — the module
25
+ // sets `exports.default` to a subset object that does NOT include getSender,
26
+ // so the default-import alias cannot reach the live `exports.getSender`.
27
+ //
28
+ // Path must be `@blocklet/sdk/service/notification` (no `lib/`) — in Node it
29
+ // is a thin re-export that resolves via the module cache to the same object
30
+ // as `@blocklet/sdk/lib/service/notification`; in CF Workers the esbuild
31
+ // config aliases it to a no-op shim (patching a no-op is harmless). Using
32
+ // the `lib/` path breaks the CF build because it does not have an alias
33
+ // and falls through to `@blocklet/sdk` → `shims/blocklet-sdk/index.ts/lib/...`.
34
+ // Same root cause as the WalletAuthenticator override below — remove once the
35
+ // upstream sdk is patched.
36
+ // eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-extraneous-dependencies
37
+ const notificationExports = require('@blocklet/sdk/service/notification');
38
+
39
+ notificationExports.getSender = () => ({
40
+ appDid: process.env.BLOCKLET_APP_ID || getWallet(undefined, '', 'sk').address,
41
+ wallet: getAccessWallet(),
42
+ });
43
+ logger.info('[sdk-patch] notification.getSender overridden', {
44
+ appDid: notificationExports.getSender().appDid,
45
+ expectedAppId: process.env.BLOCKLET_APP_ID,
46
+ });
47
+
16
48
  export const wallet: WalletObject = getWallet();
17
49
  export const ethWallet: WalletObject = getWallet('ethereum');
18
- export const authenticator = new WalletAuthenticator();
50
+
51
+ // Workaround for migrated blocklets where `BLOCKLET_APP_SK` is a rotating session
52
+ // key whose derived address ≠ appId. Upstream @blocklet/sdk's WalletAuthenticator
53
+ // passes `wallet: getWallet()` to did-connect-js, so outer.agentDid becomes that
54
+ // rotating address. Meanwhile the federated cert (signed by master) sets
55
+ // agentDid = `did:abt:${verifySite.appId}` (permanent), so the wallet-side strict
56
+ // check `cert.agentDid === outer.agentDid` fails with "Agent did does not match
57
+ // with certificate issuer."
58
+ //
59
+ // Force the authenticator to derive its signing wallet from BLOCKLET_APP_PK
60
+ // (permanent app pk → address = appId), aligning with the fix in
61
+ // @blocklet/sdk PR #12810 that already corrected getDelegatee. Remove once the
62
+ // upstream wallet-authenticator.ts is patched accordingly.
63
+ export const authenticator = new WalletAuthenticator({
64
+ wallet: getWallet(undefined, '', 'sk'),
65
+ });
19
66
  export const handlers = new WalletHandlers({
20
67
  authenticator,
21
68
  tokenStorage: new AuthStorage({
@@ -0,0 +1,31 @@
1
+ import CustomError from './error';
2
+
3
+ export function parseChainError(err: unknown): CustomError {
4
+ const msg = (err as Error)?.message ?? String(err);
5
+
6
+ const gasMatch = msg.match(
7
+ /Insufficient fund to pay for tx cost from (\w+)[\s\S]*?expected\s+([\d.]+)\s+(\w+)[\s\S]*?got\s+([\d.]+)/
8
+ );
9
+ if (gasMatch) {
10
+ return new CustomError('INSUFFICIENT_GAS', 'Main account lacks gas token for tx cost').withDetails({
11
+ account: gasMatch[1],
12
+ required: gasMatch[2],
13
+ available: gasMatch[4],
14
+ token: gasMatch[3],
15
+ });
16
+ }
17
+
18
+ // Chain rejects gas-payer JWT when the signing account has no on-chain state
19
+ // (fresh DID that never broadcast a DeclareTx). The wallet shouldn't attach
20
+ // x-gas-payer-* headers in that case, but surface a clean code/message so the
21
+ // UI doesn't render the raw GraphQL string. See #1356.
22
+ const gasPayerNotOnChain = msg.match(/Gas payer (\w+)[\s\S]*?does not exist on chain/i);
23
+ if (gasPayerNotOnChain) {
24
+ return new CustomError(
25
+ 'GAS_PAYER_NOT_ON_CHAIN',
26
+ 'Wallet account has no on-chain history yet; please fund or transact once before paying'
27
+ ).withDetails({ account: gasPayerNotOnChain[1] });
28
+ }
29
+
30
+ return new CustomError('TX_REJECTED', msg).withDetails({ raw: msg });
31
+ }
@@ -0,0 +1,399 @@
1
+ // Cross-channel entitlement check.
2
+ //
3
+ // Goal: given a customer DID and a product_id, return whether the customer
4
+ // currently has the entitlement, regardless of which channel funded it
5
+ // (Stripe / on-chain / Google Play / App Store / one-time credit purchase).
6
+ //
7
+ // MVP scope (A3-MVP):
8
+ // - Subscription-funded entitlements are fully supported across all channels
9
+ // - One-time-purchase credit grants are supported when CreditGrant.metadata
10
+ // carries a `product_id` field (existing flows already do this for
11
+ // credit-grant-type Products)
12
+ // - When multiple subscriptions cover the same product (rare — e.g. user
13
+ // buys on iOS and on web), we pick by status priority (active > trialing
14
+ // > paused > past_due) and tie-break by latest current_period_end
15
+ //
16
+ // Channel inference:
17
+ // - Subscription.channel (A0 column) wins when set
18
+ // - Falls back to PaymentMethod.type — for legacy subscriptions written
19
+ // before A0, this maps stripe/arcblock/ethereum/base correctly
20
+
21
+ import { Op } from 'sequelize';
22
+
23
+ import { CreditGrant, Customer, PaymentMethod, Price, Product, Subscription, SubscriptionItem } from '../store/models';
24
+
25
+ // Subscriptions in `active`/`trialing` should still grant entitlement only
26
+ // while the current period hasn't elapsed. Without this, a row whose
27
+ // EXPIRED webhook never landed (network drop, RTDN race onto a duplicate
28
+ // row, etc.) stays status=active forever and the customer keeps Pro long
29
+ // after the platform has stopped billing them. `past_due`/`paused` are
30
+ // allowed to outrun current_period_end — those statuses are themselves
31
+ // the "should-have-renewed-but-hasn't" markers and the grace window is
32
+ // platform-controlled, not period-bounded.
33
+ const stillInActivePeriod = () => ({
34
+ [Op.or]: [
35
+ { status: ['past_due', 'paused'] as any },
36
+ {
37
+ status: ['active', 'trialing'] as any,
38
+ current_period_end: { [Op.gt]: Math.floor(Date.now() / 1000) },
39
+ },
40
+ ],
41
+ });
42
+
43
+ export type Channel = 'stripe' | 'app_store' | 'google_play' | 'arcblock' | 'ethereum' | 'base' | 'bitcoin' | null;
44
+
45
+ export type EntitlementCheckResult = {
46
+ active: boolean;
47
+ channel: Channel;
48
+ expires_at: number | null;
49
+ subscription_id: string | null;
50
+ source: 'subscription' | 'one_time' | null;
51
+ credit_remaining?: string;
52
+ };
53
+
54
+ const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
55
+ active: 0,
56
+ trialing: 1,
57
+ paused: 2,
58
+ past_due: 3,
59
+ };
60
+ const ACTIVE_STATUSES = new Set(['active', 'trialing']);
61
+
62
+ function inactiveResult(): EntitlementCheckResult {
63
+ return { active: false, channel: null, expires_at: null, subscription_id: null, source: null };
64
+ }
65
+
66
+ async function inferChannelFromSubscription(sub: Subscription): Promise<Channel> {
67
+ const ch = (sub as any).channel as Channel | undefined;
68
+ if (ch) return ch;
69
+ if (!sub.default_payment_method_id) return null;
70
+ const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
71
+ return (method?.type as Channel) ?? null;
72
+ }
73
+
74
+ async function inferChannelFromGrant(grant: CreditGrant): Promise<Channel> {
75
+ // Most CreditGrant rows store the originating payment_method_id in metadata.
76
+ const pmId = grant.metadata?.payment_method_id || grant.metadata?.paymentMethodId;
77
+ if (!pmId) return null;
78
+ const method = await PaymentMethod.findByPk(pmId);
79
+ return (method?.type as Channel) ?? null;
80
+ }
81
+
82
+ function pickBestSubscription(subs: Subscription[]): Subscription | undefined {
83
+ return [...subs].sort((a, b) => {
84
+ const pa = SUBSCRIPTION_STATUS_PRIORITY[a.status as string] ?? 99;
85
+ const pb = SUBSCRIPTION_STATUS_PRIORITY[b.status as string] ?? 99;
86
+ if (pa !== pb) return pa - pb;
87
+ // tie-break: later expiry wins
88
+ return (b.current_period_end ?? 0) - (a.current_period_end ?? 0);
89
+ })[0];
90
+ }
91
+
92
+ /**
93
+ * The channel SKU stored on an IAP subscription (Apple/Google product id).
94
+ * Its presence marks a subscription whose product MUST be resolved live via the
95
+ * Product↔SKU mapping rather than via its (possibly stale) SubscriptionItem.
96
+ */
97
+ function channelSkuOf(sub: any): string | undefined {
98
+ const pd = sub?.payment_details as
99
+ | { app_store?: { product_id?: string }; google_play?: { product_id?: string } }
100
+ | undefined;
101
+ return pd?.app_store?.product_id ?? pd?.google_play?.product_id;
102
+ }
103
+
104
+ /**
105
+ * Resolve customer DID → Customer row; returns null if not found.
106
+ * Exposed so listEntitlements can reuse the same lookup.
107
+ */
108
+ function resolveCustomer(customer_did: string, livemode: boolean): Promise<Customer | null> {
109
+ return Customer.findOne({ where: { did: customer_did, livemode } });
110
+ }
111
+
112
+ /**
113
+ * Find all active subscriptions that grant `product_id` to this customer.
114
+ * Walks each subscription's items → price → product to do the match.
115
+ */
116
+ async function findSubscriptionsCoveringProduct(
117
+ customer: Customer,
118
+ productId: string,
119
+ livemode: boolean
120
+ ): Promise<Subscription[]> {
121
+ const subs = await Subscription.findAll({
122
+ where: {
123
+ customer_id: customer.id,
124
+ livemode,
125
+ ...stillInActivePeriod(),
126
+ } as any,
127
+ include: [
128
+ {
129
+ model: SubscriptionItem,
130
+ as: 'items',
131
+ include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
132
+ },
133
+ ],
134
+ });
135
+ // Resolve the queried Product's channel SKUs from its Prices (Stripe-style:
136
+ // SKU binding lives on Price.metadata, not Product.metadata). One Product
137
+ // can have N Prices (monthly / yearly / promo), each bound to its own
138
+ // App Store SKU + Google Play SKU. Matching live against Prices means
139
+ // entitlement always reflects the bindings as configured NOW — does not
140
+ // depend on a product_id snapshot frozen into the subscription. A SKU
141
+ // rebind grants only the new product, not both (PR #1381 review P2).
142
+ //
143
+ // Multi-tenant scoping: the key includes the tenant (bundle_id /
144
+ // package_name) so a sub from App A doesn't accidentally satisfy a
145
+ // Price configured for App B that happens to share the SKU string —
146
+ // App Store / Play Console SKU namespaces are per-app, so two apps
147
+ // owning the literal string "pro_monthly" is the expected case.
148
+ const prices = await Price.findAll({ where: { product_id: productId, livemode } as any });
149
+ const appKeys = new Set<string>(); // ${bundle_id}:${sku}
150
+ const gpKeys = new Set<string>(); // ${package_name}:${sku}
151
+ for (const p of prices) {
152
+ const m = ((p as any).metadata as any) || {};
153
+ if (m.app_store_product_id && m.bundle_id) appKeys.add(`${m.bundle_id}:${m.app_store_product_id}`);
154
+ if (m.google_play_product_id && m.package_name) {
155
+ gpKeys.add(`${m.package_name}:${m.google_play_product_id}`);
156
+ }
157
+ }
158
+
159
+ return subs.filter((sub) => {
160
+ const pd = (sub as any).payment_details as
161
+ | {
162
+ app_store?: { product_id?: string; bundle_id?: string };
163
+ google_play?: { product_id?: string; package_name?: string };
164
+ }
165
+ | undefined;
166
+
167
+ // IAP subs that carry a channel SKU: resolve ONLY via the live channel-SKU ↔
168
+ // current Price metadata mapping. Deliberately ignore the (possibly stale)
169
+ // SubscriptionItem snapshot AND the legacy metadata.product_id — otherwise a
170
+ // SKU rebind would grant both the old item's product and the new mapping
171
+ // (PR #1381 review P2). This is the single authoritative rule for IAP, and
172
+ // listEntitlements applies the same mapping for consistency.
173
+ if (channelSkuOf(sub)) {
174
+ const appSku = pd?.app_store?.product_id;
175
+ const appBundle = pd?.app_store?.bundle_id;
176
+ const gpSku = pd?.google_play?.product_id;
177
+ const gpPkg = pd?.google_play?.package_name;
178
+ if (appSku && appBundle && appKeys.has(`${appBundle}:${appSku}`)) return true;
179
+ if (gpSku && gpPkg && gpKeys.has(`${gpPkg}:${gpSku}`)) return true;
180
+ return false;
181
+ }
182
+
183
+ // Stripe / legacy (no channel SKU stored): walk items → price → product,
184
+ // then the metadata snapshot.
185
+ const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
186
+ if (items?.some((it) => it.price?.product_id === productId)) return true;
187
+ return (sub.metadata as any)?.product_id === productId;
188
+ });
189
+ }
190
+
191
+ /**
192
+ * Find a CreditGrant tied to this customer + product, if any.
193
+ * Convention: CreditGrant.metadata.product_id matches.
194
+ */
195
+ async function findGrantCoveringProduct(customer: Customer, productId: string): Promise<CreditGrant | null> {
196
+ const grants = await CreditGrant.findAll({
197
+ where: { customer_id: customer.id, status: 'granted' as any },
198
+ });
199
+ return grants.find((g) => g.metadata?.product_id === productId) ?? null;
200
+ }
201
+
202
+ function isGrantActive(grant: CreditGrant): boolean {
203
+ if (grant.expires_at && grant.expires_at * 1000 < Date.now()) return false;
204
+ // remaining_amount is stored as string (BN-friendly). Treat anything ≠ "0" as positive.
205
+ const remaining = grant.remaining_amount ?? '0';
206
+ return remaining !== '0' && remaining !== '0.0';
207
+ }
208
+
209
+ /**
210
+ * Check whether the customer currently holds the entitlement for `product_id`.
211
+ */
212
+ export async function checkEntitlement({
213
+ customer_did,
214
+ product_id,
215
+ livemode = true,
216
+ }: {
217
+ customer_did: string;
218
+ product_id: string;
219
+ livemode?: boolean;
220
+ }): Promise<EntitlementCheckResult> {
221
+ const customer = await resolveCustomer(customer_did, livemode);
222
+ if (!customer) return inactiveResult();
223
+
224
+ // 1. Subscription path
225
+ const matching = await findSubscriptionsCoveringProduct(customer, product_id, livemode);
226
+ const best = pickBestSubscription(matching);
227
+ if (best) {
228
+ return {
229
+ active: ACTIVE_STATUSES.has(best.status as string),
230
+ channel: await inferChannelFromSubscription(best),
231
+ expires_at: best.current_period_end ?? null,
232
+ subscription_id: best.id,
233
+ source: 'subscription',
234
+ };
235
+ }
236
+
237
+ // 2. One-time credit-grant path
238
+ const grant = await findGrantCoveringProduct(customer, product_id);
239
+ if (grant) {
240
+ return {
241
+ active: isGrantActive(grant),
242
+ channel: await inferChannelFromGrant(grant),
243
+ expires_at: grant.expires_at ?? null,
244
+ subscription_id: null,
245
+ source: 'one_time',
246
+ credit_remaining: grant.remaining_amount,
247
+ };
248
+ }
249
+
250
+ return inactiveResult();
251
+ }
252
+
253
+ export type EntitlementListItem = {
254
+ product_id: string;
255
+ active: boolean;
256
+ channel: Channel;
257
+ expires_at: number | null;
258
+ subscription_id: string | null;
259
+ source: 'subscription' | 'one_time';
260
+ credit_remaining?: string;
261
+ };
262
+
263
+ /**
264
+ * List every entitlement this customer holds (one row per distinct product).
265
+ * Subscription items contribute first; CreditGrants add anything not already
266
+ * covered by a subscription.
267
+ */
268
+ export async function listEntitlements({
269
+ customer_did,
270
+ livemode = true,
271
+ }: {
272
+ customer_did: string;
273
+ livemode?: boolean;
274
+ }): Promise<EntitlementListItem[]> {
275
+ const customer = await resolveCustomer(customer_did, livemode);
276
+ if (!customer) return [];
277
+
278
+ const subs = await Subscription.findAll({
279
+ where: {
280
+ customer_id: customer.id,
281
+ livemode,
282
+ ...stillInActivePeriod(),
283
+ } as any,
284
+ include: [
285
+ {
286
+ model: SubscriptionItem,
287
+ as: 'items',
288
+ include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
289
+ },
290
+ ],
291
+ });
292
+
293
+ // Group by product_id, picking best subscription per product. IAP subs that
294
+ // carry a channel SKU resolve via the live SKU→Product mapping (NOT their
295
+ // possibly-stale SubscriptionItem) so list agrees with checkEntitlement
296
+ // (PR #1381 review P2); everything else groups by items.
297
+ const productToSubs = new Map<string, Subscription[]>();
298
+ const addToGroup = (pid: string, sub: Subscription) => {
299
+ if (!productToSubs.has(pid)) productToSubs.set(pid, []);
300
+ productToSubs.get(pid)!.push(sub);
301
+ };
302
+
303
+ const skuSubs = subs.filter((s) => channelSkuOf(s));
304
+ if (skuSubs.length) {
305
+ // Build (tenant, SKU)→productId from the Price catalogue. Stripe-style
306
+ // schema: Price.metadata.{app_store,google_play}_product_id binds the
307
+ // channel SKU to a specific Price; that Price's product_id is the
308
+ // entitlement key. One Product with N Prices (monthly / yearly / promo)
309
+ // all roll up to the same entitlement — exactly the behaviour
310
+ // `entitlements.check(productId)` expects on the client side.
311
+ //
312
+ // Multi-tenant scoping: same SKU string can live in independent App
313
+ // Store / Play Console namespaces (two iOS or two Android apps wired
314
+ // into one Payment Kit), so the key includes the tenant
315
+ // (bundle_id / package_name). Without this, Map.set would silently
316
+ // overwrite collisions and route all subs to whichever Price was
317
+ // iterated last. Each sub reads its own tenant from payment_details
318
+ // (persisted at create time, backfilled for legacy rows).
319
+ const prices = await Price.findAll({ where: { livemode } as any });
320
+ const appKeyToPid = new Map<string, string>();
321
+ const gpKeyToPid = new Map<string, string>();
322
+ for (const p of prices) {
323
+ const m = ((p as any).metadata as any) || {};
324
+ if (m.app_store_product_id && m.bundle_id) {
325
+ appKeyToPid.set(`${m.bundle_id}:${m.app_store_product_id}`, p.product_id);
326
+ }
327
+ if (m.google_play_product_id && m.package_name) {
328
+ gpKeyToPid.set(`${m.package_name}:${m.google_play_product_id}`, p.product_id);
329
+ }
330
+ }
331
+ for (const sub of skuSubs) {
332
+ const pd = (sub as any).payment_details as
333
+ | {
334
+ app_store?: { product_id?: string; bundle_id?: string };
335
+ google_play?: { product_id?: string; package_name?: string };
336
+ }
337
+ | undefined;
338
+ const appSku = pd?.app_store?.product_id;
339
+ const appBundle = pd?.app_store?.bundle_id;
340
+ const gpSku = pd?.google_play?.product_id;
341
+ const gpPkg = pd?.google_play?.package_name;
342
+ const pid =
343
+ (appSku && appBundle && appKeyToPid.get(`${appBundle}:${appSku}`)) ||
344
+ (gpSku && gpPkg && gpKeyToPid.get(`${gpPkg}:${gpSku}`));
345
+ if (pid) addToGroup(pid, sub);
346
+ }
347
+ }
348
+
349
+ for (const sub of subs) {
350
+ // eslint-disable-next-line no-continue -- IAP-with-SKU handled above via live mapping
351
+ if (channelSkuOf(sub)) continue;
352
+ const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
353
+ const productIds = new Set(items?.map((it) => it.price?.product_id).filter(Boolean) as string[]);
354
+ for (const pid of productIds) addToGroup(pid, sub);
355
+ }
356
+
357
+ const subscriptionProductIds = new Set<string>();
358
+ // Run channel inference in parallel — each is a Sequelize lookup, no shared state.
359
+ const subscriptionRows = await Promise.all(
360
+ Array.from(productToSubs.entries()).map(async ([productId, candidates]) => {
361
+ const best = pickBestSubscription(candidates)!;
362
+ subscriptionProductIds.add(productId);
363
+ const item: EntitlementListItem = {
364
+ product_id: productId,
365
+ active: ACTIVE_STATUSES.has(best.status as string),
366
+ channel: await inferChannelFromSubscription(best),
367
+ expires_at: best.current_period_end ?? null,
368
+ subscription_id: best.id,
369
+ source: 'subscription',
370
+ };
371
+ return item;
372
+ })
373
+ );
374
+
375
+ // Add CreditGrant-funded entitlements for products not already covered
376
+ const grants = await CreditGrant.findAll({
377
+ where: { customer_id: customer.id, status: 'granted' as any },
378
+ });
379
+ const uncoveredGrants = grants.filter((g) => {
380
+ const pid = g.metadata?.product_id;
381
+ return pid && !subscriptionProductIds.has(pid);
382
+ });
383
+ const grantRows = await Promise.all(
384
+ uncoveredGrants.map(async (grant) => {
385
+ const item: EntitlementListItem = {
386
+ product_id: grant.metadata!.product_id,
387
+ active: isGrantActive(grant),
388
+ channel: await inferChannelFromGrant(grant),
389
+ expires_at: grant.expires_at ?? null,
390
+ subscription_id: null,
391
+ source: 'one_time',
392
+ credit_remaining: grant.remaining_amount,
393
+ };
394
+ return item;
395
+ })
396
+ );
397
+
398
+ return [...subscriptionRows, ...grantRows];
399
+ }
@@ -18,6 +18,8 @@ export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME
18
18
  export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
19
19
  export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
20
20
  export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
21
+ export const iapReconcileCronTime: string = process.env.IAP_RECONCILE_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次:webhook 兜底,拉 App Store / Google Play 订阅最新状态
22
+ export const eventRetryCronTime: string = process.env.EVENT_RETRY_CRON_TIME || '30 */5 * * * *'; // 默认每 5 min 执行一次(错开整点避开 iap-reconcile):扫 pending_webhooks>0 的事件兜底投递
21
23
  export const quoteCleanupCronTime: string = process.env.QUOTE_CLEANUP_CRON_TIME || '0 0 2 * * *'; // 默认每天凌晨 2 点执行一次
22
24
  export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
23
25
  ? +process.env.VENDOR_TIMEOUT_MINUTES