payment-kit 1.27.1 → 1.28.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 (184) 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 +10 -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/integrations/arcblock/nft.ts +6 -2
  7. package/api/src/integrations/arcblock/stake.ts +3 -2
  8. package/api/src/integrations/arcblock/token.ts +4 -4
  9. package/api/src/integrations/blocklet/notification.ts +1 -1
  10. package/api/src/integrations/ethereum/tx.ts +29 -0
  11. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  12. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  13. package/api/src/integrations/stripe/resource.ts +8 -0
  14. package/api/src/libs/audit.ts +32 -16
  15. package/api/src/libs/auth.ts +49 -2
  16. package/api/src/libs/chain-error.ts +31 -0
  17. package/api/src/libs/error.ts +15 -0
  18. package/api/src/libs/event.ts +42 -1
  19. package/api/src/libs/invoice.ts +69 -34
  20. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  21. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  22. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  23. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  24. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  25. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  26. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  27. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  28. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  29. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  30. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  31. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  32. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  33. package/api/src/libs/pagination.ts +14 -9
  34. package/api/src/libs/payment.ts +25 -10
  35. package/api/src/libs/session.ts +1 -1
  36. package/api/src/libs/timing.ts +35 -0
  37. package/api/src/libs/util.ts +16 -15
  38. package/api/src/libs/wallet-migration.ts +72 -53
  39. package/api/src/queues/auto-recharge.ts +1 -1
  40. package/api/src/queues/credit-consume.ts +94 -12
  41. package/api/src/queues/credit-grant.ts +4 -0
  42. package/api/src/queues/event.ts +14 -2
  43. package/api/src/queues/invoice.ts +1 -0
  44. package/api/src/queues/payment.ts +83 -15
  45. package/api/src/queues/refund.ts +84 -71
  46. package/api/src/queues/subscription.ts +1 -0
  47. package/api/src/routes/checkout-sessions.ts +82 -43
  48. package/api/src/routes/connect/change-payment.ts +2 -0
  49. package/api/src/routes/connect/change-plan.ts +2 -0
  50. package/api/src/routes/connect/pay.ts +12 -3
  51. package/api/src/routes/connect/setup.ts +3 -1
  52. package/api/src/routes/connect/shared.ts +52 -39
  53. package/api/src/routes/connect/subscribe.ts +4 -1
  54. package/api/src/routes/credit-grants.ts +25 -17
  55. package/api/src/routes/donations.ts +2 -2
  56. package/api/src/routes/meter-events.ts +16 -6
  57. package/api/src/routes/payment-links.ts +1 -1
  58. package/api/src/routes/payment-methods.ts +1 -1
  59. package/api/src/routes/settings.ts +1 -1
  60. package/api/src/routes/tax-rates.ts +1 -1
  61. package/api/src/store/models/customer.ts +23 -1
  62. package/api/src/store/models/payment-method.ts +4 -0
  63. package/api/src/store/models/price.ts +23 -14
  64. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  65. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  66. package/api/tests/queues/credit-consume.spec.ts +8 -4
  67. package/api/tests/routes/credit-grants.spec.ts +1 -0
  68. package/blocklet.yml +1 -1
  69. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  70. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  71. package/cloudflare/README.md +499 -0
  72. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  73. package/cloudflare/build.ts +151 -0
  74. package/cloudflare/did-connect-auth.ts +527 -0
  75. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  76. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  77. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  78. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  79. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  80. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  81. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  82. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  83. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  84. package/cloudflare/frontend-shims/session.ts +24 -0
  85. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  86. package/cloudflare/index.html +40 -0
  87. package/cloudflare/migrate-to-d1.js +252 -0
  88. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  89. package/cloudflare/migrations/0002_indexes.sql +75 -0
  90. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  91. package/cloudflare/run-build.js +390 -0
  92. package/cloudflare/scripts/test-decrypt.js +102 -0
  93. package/cloudflare/shims/arcblock-ws.ts +20 -0
  94. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  95. package/cloudflare/shims/axios-lite.ts +117 -0
  96. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  97. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  98. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  99. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  100. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  101. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  102. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  103. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  104. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  105. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  106. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  107. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  108. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  109. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  110. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  111. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  112. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  113. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  114. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  115. package/cloudflare/shims/cookie-parser.ts +3 -0
  116. package/cloudflare/shims/cors.ts +21 -0
  117. package/cloudflare/shims/cron.ts +189 -0
  118. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  119. package/cloudflare/shims/did-space-js.ts +17 -0
  120. package/cloudflare/shims/did-space.ts +11 -0
  121. package/cloudflare/shims/error.ts +18 -0
  122. package/cloudflare/shims/express-compat/index.ts +80 -0
  123. package/cloudflare/shims/express-compat/types.ts +41 -0
  124. package/cloudflare/shims/fastq.ts +105 -0
  125. package/cloudflare/shims/lock.ts +115 -0
  126. package/cloudflare/shims/mime-types.ts +56 -0
  127. package/cloudflare/shims/nedb-storage.ts +9 -0
  128. package/cloudflare/shims/node-child-process.ts +9 -0
  129. package/cloudflare/shims/node-fs.ts +20 -0
  130. package/cloudflare/shims/node-http.ts +13 -0
  131. package/cloudflare/shims/node-https.ts +4 -0
  132. package/cloudflare/shims/node-misc.ts +15 -0
  133. package/cloudflare/shims/node-net.ts +8 -0
  134. package/cloudflare/shims/node-os.ts +14 -0
  135. package/cloudflare/shims/node-tty.ts +8 -0
  136. package/cloudflare/shims/node-zlib.ts +17 -0
  137. package/cloudflare/shims/noop.ts +26 -0
  138. package/cloudflare/shims/payment-vendor.ts +14 -0
  139. package/cloudflare/shims/querystring.ts +12 -0
  140. package/cloudflare/shims/queue.ts +585 -0
  141. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  142. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  143. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  144. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  145. package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
  146. package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
  147. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  148. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  149. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  150. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  151. package/cloudflare/shims/stripe-cf.ts +29 -0
  152. package/cloudflare/shims/ws-lite.ts +103 -0
  153. package/cloudflare/shims/xss.ts +3 -0
  154. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  155. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  156. package/cloudflare/vite.config.ts +162 -0
  157. package/cloudflare/worker.ts +1553 -0
  158. package/cloudflare/wrangler.json +63 -0
  159. package/cloudflare/wrangler.jsonc +69 -0
  160. package/cloudflare/wrangler.staging.json +66 -0
  161. package/cloudflare/wrangler.toml +28 -0
  162. package/jest.config.js +4 -12
  163. package/package.json +26 -22
  164. package/src/app.tsx +62 -4
  165. package/src/components/customer/link.tsx +9 -13
  166. package/src/components/customer/notification-preference.tsx +3 -2
  167. package/src/components/filter-toolbar.tsx +4 -0
  168. package/src/components/invoice/list.tsx +9 -1
  169. package/src/components/invoice-pdf/utils.ts +2 -1
  170. package/src/components/layout/admin.tsx +39 -5
  171. package/src/components/layout/user-cf.tsx +77 -0
  172. package/src/components/payment-intent/actions.tsx +23 -3
  173. package/src/components/safe-did-address.tsx +75 -0
  174. package/src/libs/patch-user-card.ts +25 -0
  175. package/src/libs/util.ts +5 -7
  176. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  177. package/src/pages/admin/customers/customers/detail.tsx +2 -2
  178. package/src/pages/admin/customers/customers/index.tsx +2 -2
  179. package/src/pages/admin/overview.tsx +3 -1
  180. package/src/pages/customer/subscription/detail.tsx +4 -4
  181. package/tsconfig.api.json +1 -6
  182. package/tsconfig.json +3 -4
  183. package/tsconfig.types.json +2 -1
  184. package/vite.config.ts +6 -1
@@ -140,17 +140,22 @@ function calculateFetchStrategy<T>(
140
140
  return { fetchLimit: Math.max(sourceCount, 1000), fetchOffset: 0 };
141
141
  }
142
142
 
143
- // For database sources with multiple sources, use conservative strategy
143
+ // For database sources with multiple sources, use demand-driven strategy
144
144
  if (sourceMeta?.type === 'database') {
145
145
  if (sources.length > 1) {
146
- // For multi-source scenarios, we need more data to ensure correct merging
147
- // Especially for later pages, estimation can be inaccurate
148
- const bufferMultiplier = Math.max(3, Math.ceil(page * 1.5)); // More aggressive buffer for later pages
149
- const minDataRatio = page <= 2 ? 0.6 : 0.8; // Get more data for later pages
150
- const fetchLimit = Math.min(
151
- sourceCount,
152
- Math.max(pageSize * bufferMultiplier, Math.ceil(sourceCount * minDataRatio))
153
- );
146
+ // Multi-source merge fetches from offset=0 and merge-sorts in memory.
147
+ // We need (offset + pageSize) items after merging. Since we don't know
148
+ // how items interleave between sources, fetch (offset + pageSize) from
149
+ // each source with a buffer for merge uncertainty.
150
+ //
151
+ // Previous approach used sourceCount * 0.6~0.8 which over-fetched
152
+ // massively for early pages (e.g. page 1 fetched 2000+ rows) and still
153
+ // under-fetched for late pages. This demand-driven approach fetches
154
+ // proportionally to the page position — fast for early pages, more data
155
+ // only when needed for later pages.
156
+ const needed = offset + pageSize;
157
+ const buffer = Math.max(pageSize * 3, 50); // generous buffer for interleave uncertainty
158
+ const fetchLimit = Math.min(sourceCount, needed + buffer);
154
159
  return { fetchLimit, fetchOffset: 0 };
155
160
  }
156
161
  // Single database source can use precise offset
@@ -387,20 +387,20 @@ export async function isDelegationSufficientForPayment(args: {
387
387
  delegator,
388
388
  source,
389
389
  });
390
- return { sufficient: false, reason: 'NO_DELEGATION' };
390
+ return { sufficient: false, reason: 'NO_DELEGATION', state };
391
391
  }
392
392
 
393
393
  // have transfer permissions?
394
394
  const grant = (state as DelegateState).ops.find((x: any) => x.key === OCAP_PAYMENT_TX_TYPE)?.value;
395
395
  if (!grant) {
396
- return { sufficient: false, reason: 'NO_TRANSFER_PERMISSION' };
396
+ return { sufficient: false, reason: 'NO_TRANSFER_PERMISSION', state };
397
397
  }
398
398
 
399
399
  // check token limits
400
400
  if (grant.limit && Array.isArray(grant.limit.tokens) && grant.limit.tokens.length > 0) {
401
401
  const tokenLimit = grant.limit.tokens.find((x: any) => x.address === tokenAddress);
402
402
  if (!tokenLimit) {
403
- return { sufficient: false, reason: 'NO_TOKEN_PERMISSION' };
403
+ return { sufficient: false, reason: 'NO_TOKEN_PERMISSION', state };
404
404
  }
405
405
 
406
406
  // FIXME: @wangshijun check other conditions in the token limit: txCount, totalAllowance, validUntil, rateLimit
@@ -411,7 +411,7 @@ export async function isDelegationSufficientForPayment(args: {
411
411
  const allWalletAddresses = [wallet.address, ...migratedFrom];
412
412
  const hasValidTo = allWalletAddresses.some((addr) => tokenLimit.to.includes(addr));
413
413
  if (!hasValidTo) {
414
- return { sufficient: false, reason: 'NO_TRANSFER_TO' };
414
+ return { sufficient: false, reason: 'NO_TRANSFER_TO', state };
415
415
  }
416
416
  }
417
417
 
@@ -426,12 +426,14 @@ export async function isDelegationSufficientForPayment(args: {
426
426
  return {
427
427
  sufficient: false,
428
428
  reason: 'NO_ENOUGH_ALLOWANCE',
429
+ state,
429
430
  };
430
431
  }
431
432
  } else if (totalAmount.gt(allowance)) {
432
433
  return {
433
434
  sufficient: false,
434
435
  reason: 'NO_ENOUGH_ALLOWANCE',
436
+ state,
435
437
  };
436
438
  }
437
439
  }
@@ -441,7 +443,7 @@ export async function isDelegationSufficientForPayment(args: {
441
443
  const { tokens } = await client.getAccountTokens({ address: delegator, token: tokenAddress });
442
444
  const [token] = tokens;
443
445
  if (!token) {
444
- return { sufficient: false, reason: 'NO_TOKEN' };
446
+ return { sufficient: false, reason: 'NO_TOKEN', state };
445
447
  }
446
448
 
447
449
  if (new BN(token.balance).lt(totalAmount)) {
@@ -449,6 +451,7 @@ export async function isDelegationSufficientForPayment(args: {
449
451
  sufficient: false,
450
452
  reason: 'NO_ENOUGH_TOKEN',
451
453
  token,
454
+ state,
452
455
  requestedAmount: totalAmount.toString(),
453
456
  };
454
457
  }
@@ -602,7 +605,11 @@ export async function getTokenLimitsForDelegation(
602
605
  paymentMethod: PaymentMethod,
603
606
  paymentCurrency: PaymentCurrency,
604
607
  address: string,
605
- amount: string
608
+ amount: string,
609
+ // Optional: if caller already fetched DelegateState (e.g. isDelegationSufficientForPayment),
610
+ // pass it here to skip a redundant chain RPC. Caller MUST use state from the same request
611
+ // to avoid stale-balance issues (see note on delegationCache above).
612
+ delegateState?: DelegateState | null
606
613
  ): Promise<TokenLimit[]> {
607
614
  const hasMetered = items.some((x) => x.price.recurring?.usage_type === 'metered');
608
615
  const allowance = hasMetered ? '0' : amount;
@@ -618,8 +625,16 @@ export async function getTokenLimitsForDelegation(
618
625
  };
619
626
 
620
627
  if (paymentMethod.type === 'arcblock') {
621
- const client = paymentMethod.getOcapClient();
622
- const { state } = await client.getDelegateState({ address });
628
+ let state: DelegateState | null | undefined = delegateState;
629
+ if (state === undefined) {
630
+ const client = paymentMethod.getOcapClient();
631
+ // CF Workers: warm up chain context to avoid setTimeout(resolve,0) hang on cold client.
632
+ await client.getContext();
633
+ ({ state } = await client.getDelegateState({ address }));
634
+ logger.info('getTokenLimitsForDelegation: fetched DelegateState from chain', { address });
635
+ } else {
636
+ logger.info('getTokenLimitsForDelegation: reused DelegateState from caller', { address });
637
+ }
623
638
 
624
639
  // If we never delegated before
625
640
  if (!state) {
@@ -630,10 +645,10 @@ export async function getTokenLimitsForDelegation(
630
645
  return [entry];
631
646
  }
632
647
 
633
- const op = state.ops.find((x) => x.key === OCAP_PAYMENT_TX_TYPE);
648
+ const op = state.ops.find((x: any) => x.key === OCAP_PAYMENT_TX_TYPE);
634
649
  if (op && Array.isArray(op.value.limit?.tokens) && op.value.limit.tokens.length > 0) {
635
650
  const tokenLimits = cloneDeep(op.value.limit.tokens);
636
- const index = op.value.limit.tokens.findIndex((x) => x.address === paymentCurrency.contract);
651
+ const index = op.value.limit.tokens.findIndex((x: any) => x.address === paymentCurrency.contract);
637
652
  // we are updating an existing token limit
638
653
  if (index > -1) {
639
654
  const limit = op.value.limit.tokens[index] as TokenLimit;
@@ -5,7 +5,7 @@ import { BN, fromTokenToUnit, fromUnitToToken } from '@ocap/util';
5
5
  import cloneDeep from 'lodash/cloneDeep';
6
6
  import isEqual from 'lodash/isEqual';
7
7
  import pAll from 'p-all';
8
- import { omit } from 'lodash';
8
+ import omit from 'lodash/omit';
9
9
  import dayjs from './dayjs';
10
10
  import { validCoupon } from './discount/coupon';
11
11
  import { getPriceUintAmountByCurrency, getPriceCurrencyOptions } from './price';
@@ -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';
@@ -259,7 +258,11 @@ export async function getBlockletJson(url?: string) {
259
258
  return cached.data;
260
259
  }
261
260
  }
262
- const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(url || process.env.BLOCKLET_APP_URL));
261
+ const baseUrl = url || process.env.BLOCKLET_APP_URL;
262
+ if (!baseUrl) {
263
+ return null;
264
+ }
265
+ const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(baseUrl));
263
266
  try {
264
267
  const { data: blockletMeta } = await api.get(scriptUrl.href);
265
268
  cachedBlockletJsonResult.set(blockletKey, { data: blockletMeta, expiry: now + CACHE_TTL });
@@ -686,17 +689,15 @@ export function formatNumber(
686
689
  if (!n || n === '0') {
687
690
  return '0';
688
691
  }
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;
692
+ const value = typeof n === 'string' ? parseFloat(n) : n;
693
+ if (Number.isNaN(value)) return '0';
694
+ const fixed = precision || precision === 0 ? value.toFixed(precision) : String(value);
695
+ const [intPart = '0', decPart] = fixed.split('.');
696
+ const left = thousandSeparated ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart;
697
+ if (!decPart) return left;
698
+ if (!trim) return `${left}.${decPart}`;
699
+ const trimmed = trimEnd(decPart, '0');
700
+ return trimmed ? `${left}.${trimmed}` : left;
700
701
  }
701
702
 
702
703
  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);
@@ -93,6 +93,18 @@ eventQueue.on('failed', ({ id, job, error }) => {
93
93
  logger.error('event job failed', { id, job, error });
94
94
  });
95
95
 
96
- events.on('event.created', (event) => {
97
- eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
96
+ events.on('event.created', async (event) => {
97
+ if ((globalThis as any).__CF_ENV__) {
98
+ // CF Workers: execute inline to save 2 CF Queue ops per event.
99
+ // eventQueue only dispatches webhooks — lightweight DB lookup + webhookQueue.push.
100
+ // Webhook delivery still goes through webhookQueue with full retry guarantees.
101
+ try {
102
+ await handleEvent({ eventId: event.id });
103
+ } catch (err: any) {
104
+ logger.error('event inline handler failed', { eventId: event.id, error: err?.message });
105
+ }
106
+ } else {
107
+ // Blocklet Server: use queue as before
108
+ eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
109
+ }
98
110
  });
@@ -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