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,35 @@
1
+ /**
2
+ * Cross-environment phase timing helper.
3
+ *
4
+ * In CF Workers, writes phase durations to `globalThis.__d1Timing__.phases`
5
+ * which the worker middleware reads and emits as Server-Timing headers.
6
+ *
7
+ * In Node.js (Blocklet Server), this is a no-op.
8
+ */
9
+
10
+ /**
11
+ * Measure the duration of an async operation and record it under a named phase.
12
+ * Usage:
13
+ * const result = await measurePhase('chain', () => checkTokenBalance(...));
14
+ *
15
+ * Multiple calls to the same phase name accumulate.
16
+ */
17
+ export async function measurePhase<T>(name: string, fn: () => Promise<T>): Promise<T> {
18
+ const t0 = typeof performance !== 'undefined' ? performance.now() : Date.now();
19
+ try {
20
+ return await fn();
21
+ } finally {
22
+ const t = (globalThis as any).__d1Timing__;
23
+ if (t && t.phases) {
24
+ const dur = (typeof performance !== 'undefined' ? performance.now() : Date.now()) - t0;
25
+ t.phases[name] = (t.phases[name] || 0) + dur;
26
+ }
27
+ }
28
+ }
29
+
30
+ /** Record a phase duration directly (for non-Promise paths or manual timing). */
31
+ export function recordPhase(name: string, durationMs: number): void {
32
+ const t = (globalThis as any).__d1Timing__;
33
+ if (!t || !t.phases) return;
34
+ t.phases[name] = (t.phases[name] || 0) + durationMs;
35
+ }
@@ -4,7 +4,7 @@ import { buffer } from 'node:stream/consumers';
4
4
  import { getUrl } from '@blocklet/sdk/lib/component';
5
5
  import { env } from '@blocklet/sdk/lib/env';
6
6
  import { getWalletDid } from '@blocklet/sdk/lib/did';
7
- import { toStakeAddress } from '@arcblock/did-util';
7
+ import { toStakeAddress } from '@arcblock/did-util/cbor';
8
8
  import { customAlphabet } from 'nanoid';
9
9
  import type { LiteralUnion } from 'type-fest';
10
10
  import { joinURL, withQuery, withTrailingSlash } from 'ufo';
@@ -13,8 +13,7 @@ import axios from 'axios';
13
13
  import { ethers } from 'ethers';
14
14
  import { fromUnitToToken } from '@ocap/util';
15
15
  import get from 'lodash/get';
16
- import numbro from 'numbro';
17
- import { trimEnd } from 'lodash';
16
+ import trimEnd from 'lodash/trimEnd';
18
17
  import dayjs from './dayjs';
19
18
  import { blocklet, wallet } from './auth';
20
19
  import type { PaymentCurrency, PaymentMethod, Subscription } from '../store/models';
@@ -31,6 +30,19 @@ export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then rem
31
30
 
32
31
  export const STRIPE_API_VERSION = '2023-08-16';
33
32
  export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
33
+ // Pub/Sub OIDC tokens carry the push-subscription endpoint as `aud` claim. When
34
+ // the dev server is reached via a custom domain (e.g. a Cloudflare tunnel), the
35
+ // BLOCKLET_APP_URL-derived default won't match the URL Google signs against.
36
+ // Set GOOGLE_PLAY_WEBHOOK_URL to the externally-visible URL in that case.
37
+ // Lazy-eval (function not constant) because dotenv loads env AFTER this module
38
+ // is imported — a constant captured at module-load would only see BLOCKLET_APP_URL.
39
+ export const googlePlayEndpoint = (): string =>
40
+ process.env.GOOGLE_PLAY_WEBHOOK_URL || getUrl('/api/integrations/google-play/webhook');
41
+
42
+ // Back-compat constant for any caller that captures it at module-load.
43
+ // Prefer googlePlayEndpoint() going forward.
44
+ export const GOOGLE_PLAY_ENDPOINT: string = googlePlayEndpoint();
45
+ export const APP_STORE_ENDPOINT: string = getUrl('/api/integrations/app-store/webhook');
34
46
  export const STRIPE_EVENTS: any[] = [
35
47
  'checkout.session.async_payment_failed',
36
48
  'checkout.session.async_payment_succeeded',
@@ -259,7 +271,11 @@ export async function getBlockletJson(url?: string) {
259
271
  return cached.data;
260
272
  }
261
273
  }
262
- const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(url || process.env.BLOCKLET_APP_URL));
274
+ const baseUrl = url || process.env.BLOCKLET_APP_URL;
275
+ if (!baseUrl) {
276
+ return null;
277
+ }
278
+ const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(baseUrl));
263
279
  try {
264
280
  const { data: blockletMeta } = await api.get(scriptUrl.href);
265
281
  cachedBlockletJsonResult.set(blockletKey, { data: blockletMeta, expiry: now + CACHE_TTL });
@@ -686,17 +702,15 @@ export function formatNumber(
686
702
  if (!n || n === '0') {
687
703
  return '0';
688
704
  }
689
- const num = numbro(n);
690
- const options = {
691
- thousandSeparated,
692
- ...((precision || precision === 0) && { mantissa: precision }),
693
- };
694
- const result = num.format(options);
695
- if (!trim) {
696
- return result;
697
- }
698
- const [left, right] = result.split('.');
699
- return right ? [left, trimEnd(right, '0')].filter(Boolean).join('.') : left;
705
+ const value = typeof n === 'string' ? parseFloat(n) : n;
706
+ if (Number.isNaN(value)) return '0';
707
+ const fixed = precision || precision === 0 ? value.toFixed(precision) : String(value);
708
+ const [intPart = '0', decPart] = fixed.split('.');
709
+ const left = thousandSeparated ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart;
710
+ if (!decPart) return left;
711
+ if (!trim) return `${left}.${decPart}`;
712
+ const trimmed = trimEnd(decPart, '0');
713
+ return trimmed ? `${left}.${trimmed}` : left;
700
714
  }
701
715
 
702
716
  const CURRENCY_SYMBOLS: Record<string, string> = {
@@ -8,7 +8,7 @@
8
8
  * This module provides fallback query mechanisms using migratedFrom list.
9
9
  */
10
10
 
11
- import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util';
11
+ import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util/cbor';
12
12
  import type OcapClient from '@ocap/client';
13
13
 
14
14
  import { wallet } from './auth';
@@ -16,23 +16,26 @@ import logger from './logger';
16
16
  import { Subscription } from '../store/models';
17
17
 
18
18
  // Cache for migratedFrom list (keyed by wallet.address + chain host)
19
+ // Empty results are cached too (most common case) with TTL to allow refresh if app migrates
20
+ const MIGRATED_FROM_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
19
21
  let cachedMigratedFrom: string[] | null = null;
20
22
  let cachedWalletAddress: string | null = null;
21
23
  let cachedChainHost: string | null = null;
24
+ let cachedMigratedFromAt = 0;
22
25
 
23
26
  /**
24
27
  * Get the migratedFrom list for the current app wallet (with caching)
25
- * The cache is invalidated when wallet.address or chain host changes
28
+ * The cache is invalidated when wallet.address or chain host changes, or after TTL expires
26
29
  */
27
30
  export async function getMigratedFromList(client: OcapClient): Promise<string[]> {
28
31
  // @ts-ignore - OcapClient has host property
29
32
  const chainHost = client?.config?.httpEndpoint || 'unknown';
30
- // If wallet address and chain host haven't changed, use cached value
33
+ // If wallet address and chain host haven't changed, and cache is fresh, use cached value
31
34
  if (
32
35
  wallet.address === cachedWalletAddress &&
33
36
  chainHost === cachedChainHost &&
34
- cachedMigratedFrom &&
35
- cachedMigratedFrom.length > 0
37
+ cachedMigratedFrom !== null &&
38
+ Date.now() - cachedMigratedFromAt < MIGRATED_FROM_CACHE_TTL_MS
36
39
  ) {
37
40
  return cachedMigratedFrom;
38
41
  }
@@ -43,6 +46,7 @@ export async function getMigratedFromList(client: OcapClient): Promise<string[]>
43
46
  cachedMigratedFrom = state?.migratedFrom || [];
44
47
  cachedWalletAddress = wallet.address;
45
48
  cachedChainHost = chainHost;
49
+ cachedMigratedFromAt = Date.now();
46
50
 
47
51
  if (cachedMigratedFrom.length > 0) {
48
52
  logger.info('wallet-migration: loaded migratedFrom list', {
@@ -126,31 +130,34 @@ export async function getDelegationAddressWithFallback({
126
130
  // Continue to fallback instead of returning early
127
131
  }
128
132
 
129
- // 2. Try current wallet.address
133
+ // 2 & 3. Query current wallet delegation and migratedFrom list in parallel
130
134
  const currentAddress = toDelegateAddress(delegator, wallet.address);
131
135
  let currentStateResult: { hasState: boolean; opsCount: number; error?: string } = {
132
136
  hasState: false,
133
137
  opsCount: 0,
134
138
  };
135
- try {
136
- const { state: currentState } = await client.getDelegateState({ address: currentAddress });
137
- currentStateResult = {
138
- hasState: !!currentState,
139
- opsCount: currentState?.ops?.length || 0,
140
- };
141
- if (currentState?.ops?.length > 0) {
142
- return { address: currentAddress, needsBackfill: true, source: 'current' };
143
- }
144
- } catch (err) {
145
- currentStateResult.error = String(err);
146
- logger.warn('wallet-migration: failed to query current delegation state', {
147
- address: currentAddress,
148
- error: err,
149
- });
150
- }
151
139
 
152
- // 3. Fallback to migratedFrom addresses
153
- const migratedFrom = await getMigratedFromList(client);
140
+ const [currentDelegateResult, migratedFrom] = await Promise.all([
141
+ client.getDelegateState({ address: currentAddress }).catch((err: any) => {
142
+ currentStateResult.error = String(err);
143
+ logger.warn('wallet-migration: failed to query current delegation state', {
144
+ address: currentAddress,
145
+ error: err,
146
+ });
147
+ return { state: null };
148
+ }),
149
+ getMigratedFromList(client),
150
+ ]);
151
+
152
+ const currentState = currentDelegateResult.state;
153
+ currentStateResult = {
154
+ hasState: !!currentState,
155
+ opsCount: currentState?.ops?.length || 0,
156
+ error: currentStateResult.error, // preserve error from catch
157
+ };
158
+ if ((currentState?.ops?.length ?? 0) > 0) {
159
+ return { address: currentAddress, needsBackfill: true, source: 'current' };
160
+ }
154
161
  const migratedResults: Array<{
155
162
  appDid: string;
156
163
  address: string;
@@ -158,38 +165,50 @@ export async function getDelegationAddressWithFallback({
158
165
  opsCount: number;
159
166
  error?: string;
160
167
  }> = [];
161
- for (const oldAppDid of migratedFrom) {
162
- const oldAddress = toDelegateAddress(delegator, oldAppDid);
163
- try {
164
- // eslint-disable-next-line no-await-in-loop
165
- const { state: oldState } = await client.getDelegateState({ address: oldAddress });
166
- migratedResults.push({
167
- appDid: oldAppDid,
168
- address: oldAddress,
169
- hasState: !!oldState,
170
- opsCount: oldState?.ops?.length || 0,
171
- });
172
- if (oldState?.ops?.length > 0) {
173
- logger.info('wallet-migration: found delegation in migratedFrom', {
174
- delegator,
168
+
169
+ if (migratedFrom.length > 0) {
170
+ const results = await Promise.allSettled(
171
+ migratedFrom.map(async (oldAppDid) => {
172
+ const oldAddress = toDelegateAddress(delegator, oldAppDid);
173
+ const { state: oldState } = await client.getDelegateState({ address: oldAddress });
174
+ return { appDid: oldAppDid, address: oldAddress, state: oldState };
175
+ })
176
+ );
177
+
178
+ for (let i = 0; i < results.length; i++) {
179
+ const result = results[i]!;
180
+ const oldAppDid = migratedFrom[i]!;
181
+ if (result.status === 'fulfilled') {
182
+ const { address, state: oldState } = result.value;
183
+ migratedResults.push({
184
+ appDid: oldAppDid,
185
+ address,
186
+ hasState: !!oldState,
187
+ opsCount: oldState?.ops?.length || 0,
188
+ });
189
+ if ((oldState?.ops?.length ?? 0) > 0) {
190
+ logger.info('wallet-migration: found delegation in migratedFrom', {
191
+ delegator,
192
+ oldAppDid,
193
+ oldAddress: address,
194
+ });
195
+ return { address, needsBackfill: true, source: 'migrated' };
196
+ }
197
+ } else {
198
+ const oldAddress = toDelegateAddress(delegator, oldAppDid);
199
+ migratedResults.push({
200
+ appDid: oldAppDid,
201
+ address: oldAddress,
202
+ hasState: false,
203
+ opsCount: 0,
204
+ error: String(result.reason),
205
+ });
206
+ logger.warn('wallet-migration: failed to query migrated delegation state', {
207
+ address: oldAddress,
175
208
  oldAppDid,
176
- oldAddress,
209
+ error: result.reason,
177
210
  });
178
- return { address: oldAddress, needsBackfill: true, source: 'migrated' };
179
211
  }
180
- } catch (err) {
181
- migratedResults.push({
182
- appDid: oldAppDid,
183
- address: oldAddress,
184
- hasState: false,
185
- opsCount: 0,
186
- error: String(err),
187
- });
188
- logger.warn('wallet-migration: failed to query migrated delegation state', {
189
- address: oldAddress,
190
- oldAppDid,
191
- error: err,
192
- });
193
212
  }
194
213
  }
195
214
 
@@ -707,7 +707,7 @@ export async function checkAndTriggerAutoRecharge(
707
707
 
708
708
  await autoRechargeQueue.push({
709
709
  job: jobData,
710
- id: `auto-recharge-${customer.id}-${currencyId}}`,
710
+ id: `auto-recharge-${customer.id}-${currencyId}`,
711
711
  persist: true,
712
712
  });
713
713
 
@@ -16,6 +16,12 @@ import { handlePastDueSubscriptionRecovery } from './payment';
16
16
  import { checkAndTriggerAutoRecharge } from './auto-recharge';
17
17
  import { addTokenTransferJob } from './token-transfer';
18
18
 
19
+ // CF Workers: lower retry limit to conserve Queue ops (10K/day free plan).
20
+ // In Blocklet Server (no Queue ops limit), use the full MAX_RETRY_COUNT.
21
+ // After max retries, mark as requires_action — retryFailedEventsForCustomer()
22
+ // picks them up when credit is granted.
23
+ const CREDIT_MAX_RETRY = (globalThis as any).__CF_ENV__ ? 5 : MAX_RETRY_COUNT;
24
+
19
25
  type CreditConsumptionJob = {
20
26
  meterEventId: string;
21
27
  };
@@ -25,6 +31,27 @@ type BatchCreditConsumptionJob = {
25
31
  batchKey?: string;
26
32
  };
27
33
 
34
+ /**
35
+ * Returns true if `error` is one of the failure modes where retrying is
36
+ * guaranteed to fail again — the underlying condition does not change with
37
+ * time. These should short-circuit straight to `markAsRequiresAction` so the
38
+ * event leaves the retry chain immediately, rather than producing N more
39
+ * scheduled queue messages on the indefinite exponential-backoff schedule.
40
+ *
41
+ * History: by 2026-04-25 a single staging customer had accumulated ~7,000
42
+ * `requires_capture` events stuck looping on these two messages, draining
43
+ * the daily Queue cap before any real business traffic could land. Once
44
+ * cleared, this guard prevents the same accumulation from re-forming.
45
+ */
46
+ function isNonRetryableCreditError(error: any): boolean {
47
+ const message = typeof error?.message === 'string' ? error.message : '';
48
+ if (!message) return false;
49
+ // Customer has no credit left — only resolved by an external top-up, never
50
+ // by retry. retryFailedEventsForCustomer() picks them up on credit grant.
51
+ if (message.startsWith('Insufficient credit balance')) return true;
52
+ return false;
53
+ }
54
+
28
55
  type CreditConsumptionContext = {
29
56
  meterEvent: MeterEvent;
30
57
  meter: TMeterExpanded;
@@ -667,13 +694,14 @@ export async function handleCreditConsumption(job: CreditConsumptionJob) {
667
694
  const meterEvent = await MeterEvent.findByPk(meterEventId);
668
695
  if (meterEvent && !['completed', 'canceled', 'requires_action'].includes(meterEvent.status)) {
669
696
  const attemptCount = meterEvent.attempt_count + 1;
697
+ const nonRetryable = isNonRetryableCreditError(error);
670
698
 
671
- if (attemptCount >= MAX_RETRY_COUNT) {
699
+ if (attemptCount >= CREDIT_MAX_RETRY || nonRetryable) {
672
700
  await meterEvent.markAsRequiresAction(error.message);
673
701
  logger.warn('MeterEvent marked as requires_action', {
674
702
  meterEventId,
675
703
  attemptCount,
676
- reason: attemptCount >= MAX_RETRY_COUNT ? 'max_retries_exceeded' : 'non_retryable_error',
704
+ reason: nonRetryable ? 'non_retryable_error' : 'max_retries_exceeded',
677
705
  });
678
706
  } else {
679
707
  const nextAttemptTime = getNextRetry(attemptCount);
@@ -1136,7 +1164,7 @@ async function handleBatchCreditConsumptionInner(meterEventIds: string[], batchS
1136
1164
  failedEvents.map(({ event, error: failError }) => async () => {
1137
1165
  try {
1138
1166
  const attemptCount = event.attempt_count + 1;
1139
- if (attemptCount >= MAX_RETRY_COUNT) {
1167
+ if (attemptCount >= CREDIT_MAX_RETRY || isNonRetryableCreditError(failError)) {
1140
1168
  await event.markAsRequiresAction(failError.message);
1141
1169
  } else {
1142
1170
  const nextAttemptTime = getNextRetry(attemptCount);
@@ -1360,16 +1388,70 @@ export async function startCreditConsumeQueue(): Promise<void> {
1360
1388
  });
1361
1389
  }
1362
1390
 
1363
- // eslint-disable-next-line no-await-in-loop
1364
- const results = await Promise.allSettled(
1365
- batchEvents.map(async (event) => {
1366
- const jobId = `meter-event-${event.id}`;
1367
- const existingJob = await creditQueue.get(jobId);
1368
- if (!existingJob) {
1369
- addCreditConsumptionJob(event.id, true);
1391
+ // Group pending events by (customerId, eventName, subscriptionId) and issue ONE
1392
+ // batched creditQueue push per group. Previously every event produced its own
1393
+ // queue message, so a backlog (e.g., after CF Queue throttling resets at UTC
1394
+ // midnight) burned N queue writes where N could reach hundreds on an active
1395
+ // tenant. A single recovery run catching 50 pending events from the same
1396
+ // customer now drops from 50 writes to 1. Singletons (one event per group, or
1397
+ // events lacking customer_id) keep the legacy per-event path so failure
1398
+ // isolation is unchanged.
1399
+ const groups = new Map<
1400
+ string,
1401
+ { customerId: string; eventName: string; subscriptionId?: string; eventIds: string[] }
1402
+ >();
1403
+ const standalone: string[] = [];
1404
+
1405
+ for (const event of batchEvents) {
1406
+ const customerId = event.payload?.customer_id;
1407
+ if (!customerId) {
1408
+ standalone.push(event.id);
1409
+ } else {
1410
+ const subscriptionId = event.payload?.subscription_id;
1411
+ const key = getBatchKey(customerId, event.event_name, subscriptionId);
1412
+ const group = groups.get(key);
1413
+ if (group) {
1414
+ group.eventIds.push(event.id);
1415
+ } else {
1416
+ groups.set(key, {
1417
+ customerId,
1418
+ eventName: event.event_name,
1419
+ subscriptionId,
1420
+ eventIds: [event.id],
1421
+ });
1370
1422
  }
1371
- })
1372
- );
1423
+ }
1424
+ }
1425
+
1426
+ const dispatches: Promise<any>[] = [];
1427
+ for (const group of groups.values()) {
1428
+ if (group.eventIds.length === 1) {
1429
+ // Single event — use the original per-event path so replace/dedupe/status
1430
+ // checks stay identical to pre-change behavior.
1431
+ dispatches.push(addCreditConsumptionJob(group.eventIds[0]!, true));
1432
+ } else {
1433
+ // Multi-event group: one batched push replaces N single pushes.
1434
+ // Stable jobId (no Date.now) — if a previous recovery batch for this
1435
+ // key is still in flight, D1's unique constraint rejects the second
1436
+ // push silently, preventing duplicate queue writes every cron cycle.
1437
+ // Worst case: events arriving while a batch is in flight wait one cron
1438
+ // interval (10 min) for the next recovery push.
1439
+ const jobId = `batch-credit-recovery-${getBatchKey(group.customerId, group.eventName, group.subscriptionId)}`;
1440
+ creditQueue.push({
1441
+ id: jobId,
1442
+ job: {
1443
+ meterEventIds: group.eventIds,
1444
+ batchKey: getBatchKey(group.customerId, group.eventName, group.subscriptionId),
1445
+ } as any,
1446
+ });
1447
+ }
1448
+ }
1449
+ for (const eventId of standalone) {
1450
+ dispatches.push(addCreditConsumptionJob(eventId, true));
1451
+ }
1452
+
1453
+ // eslint-disable-next-line no-await-in-loop
1454
+ const results = await Promise.allSettled(dispatches);
1373
1455
 
1374
1456
  totalFailed += results.filter((r) => r.status === 'rejected').length;
1375
1457
  totalProcessed += batchEvents.length;
@@ -796,6 +796,10 @@ async function handleInvoiceCredit(invoiceId: string) {
796
796
  creditGrantId: creditGrant.id,
797
797
  });
798
798
 
799
+ // Credit grant activation is handled by the customer.credit_grant.created event
800
+ // listener via createEvent() in the afterCreate hook. createEvent() self-registers
801
+ // in __cfPendingJobs__ so it completes in CF Workers before the request ends.
802
+
799
803
  return creditGrant;
800
804
  });
801
805
  await Promise.all(createPromises);
@@ -34,29 +34,35 @@ export const handleEvent = async (job: EventJob) => {
34
34
  return;
35
35
  }
36
36
 
37
- await event.update({ pending_webhooks: eventWebhooks.length });
38
- logger.info(`Updated event ${event.id} with ${eventWebhooks.length} pending webhooks`);
39
-
40
- eventWebhooks.forEach(async (webhook) => {
41
- const attemptCount = await WebhookAttempt.count({
42
- where: {
43
- event_id: event.id,
44
- webhook_endpoint_id: webhook.id,
45
- response_status: {
46
- [Op.gte]: 200,
47
- [Op.lt]: 300,
48
- },
49
- },
37
+ // Decide which endpoints still need a first attempt. The previous logic
38
+ // counted only SUCCESS attempts (2xx), so any permanent-failure endpoint
39
+ // (e.g. wrong URL → 404) would forever match attemptCount===0 and get
40
+ // re-pushed by the retry cron every minute, producing thousands of bogus
41
+ // attempts. We now count ANY attempt: webhookQueue has its own retry
42
+ // ladder (MAX_RETRY_COUNT=20) that owns recovery for transient failures,
43
+ // so re-pushing from this handler after the first attempt is double-work.
44
+ let stillPending = 0;
45
+ for (const webhook of eventWebhooks) {
46
+ // eslint-disable-next-line no-await-in-loop -- sequential per-webhook scheduling keeps D1 writes ordered
47
+ const anyAttempt = await WebhookAttempt.count({
48
+ where: { event_id: event.id, webhook_endpoint_id: webhook.id },
50
49
  });
51
-
52
- // we should only push webhook if it's not successfully attempted before
53
- if (attemptCount === 0) {
50
+ if (anyAttempt === 0) {
51
+ stillPending += 1;
54
52
  logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
55
- await addWebhookJob(event.id, webhook.id, { persist: false });
53
+ // persist=true: write a D1 jobs row so CF Queue transport failures
54
+ // (momentary unavailability, consumer skip) don't silently lose the
55
+ // delivery — cron picks orphaned rows back up.
56
+ // eslint-disable-next-line no-await-in-loop -- same reason as above
57
+ await addWebhookJob(event.id, webhook.id, { persist: true });
56
58
  }
57
- });
59
+ }
58
60
 
59
- logger.info(`Finished handling event ${job.eventId}`);
61
+ // pending_webhooks reflects "endpoints that have not yet been attempted at
62
+ // all" — once all matched endpoints have any attempt row, the cron's
63
+ // pending>0 scan stops finding this event and it falls out of rotation.
64
+ await event.update({ pending_webhooks: stillPending });
65
+ logger.info(`Finished handling event ${job.eventId} (stillPending=${stillPending})`);
60
66
  };
61
67
 
62
68
  export const eventQueue = createQueue<EventJob>({
@@ -93,6 +99,18 @@ eventQueue.on('failed', ({ id, job, error }) => {
93
99
  logger.error('event job failed', { id, job, error });
94
100
  });
95
101
 
96
- events.on('event.created', (event) => {
97
- eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
102
+ events.on('event.created', async (event) => {
103
+ if ((globalThis as any).__CF_ENV__) {
104
+ // CF Workers: execute inline to save 2 CF Queue ops per event.
105
+ // eventQueue only dispatches webhooks — lightweight DB lookup + webhookQueue.push.
106
+ // Webhook delivery still goes through webhookQueue with full retry guarantees.
107
+ try {
108
+ await handleEvent({ eventId: event.id });
109
+ } catch (err: any) {
110
+ logger.error('event inline handler failed', { eventId: event.id, error: err?.message });
111
+ }
112
+ } else {
113
+ // Blocklet Server: use queue as before
114
+ eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
115
+ }
98
116
  });
@@ -10,6 +10,7 @@ import { CheckoutSession } from '../store/models/checkout-session';
10
10
  import { PaymentIntent } from '../store/models/payment-intent';
11
11
  import { Subscription } from '../store/models/subscription';
12
12
  import { PriceQuote } from '../store/models/price-quote';
13
+ // eslint-disable-next-line import/no-cycle
13
14
  import { paymentQueue } from './payment';
14
15
  import { getQuoteService } from '../libs/quote-service';
15
16