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
@@ -1,7 +1,6 @@
1
1
  import isEmpty from 'lodash/isEmpty';
2
2
 
3
3
  import { BN } from '@ocap/util';
4
- import pAll from 'p-all';
5
4
  import { ensureStakedForGas } from '../integrations/arcblock/stake';
6
5
  import { transferErc20FromUser } from '../integrations/ethereum/token';
7
6
  import { createEvent } from '../libs/audit';
@@ -47,6 +46,8 @@ import { CHARGE_SUPPORTED_CHAIN_TYPES, EVM_CHAIN_TYPES } from '../libs/constants
47
46
  import { getCheckoutSessionSubscriptionIds, getSubscriptionCreateSetup, SlippageOptions } from '../libs/session';
48
47
  import { syncStripeSubscriptionAfterRecovery } from '../integrations/stripe/handlers/subscription';
49
48
  import { getLock } from '../libs/lock';
49
+ // eslint-disable-next-line import/no-cycle
50
+ import { ensureInvoiceForCheckout } from '../routes/connect/shared';
50
51
 
51
52
  type PaymentJob = {
52
53
  paymentIntentId: string;
@@ -57,7 +58,12 @@ type PaymentJob = {
57
58
  };
58
59
 
59
60
  type DepositVaultJob = {
60
- currencyId: string;
61
+ // Single-currency payload kept for backward compatibility with any in-flight
62
+ // messages pushed by older workers. New pushes set `currencyIds` instead so one
63
+ // cron tick emits a single batched job regardless of how many currencies have
64
+ // vault enabled.
65
+ currencyId?: string;
66
+ currencyIds?: string[];
61
67
  };
62
68
 
63
69
  async function updateQuantitySold(checkoutSession: CheckoutSession) {
@@ -106,7 +112,23 @@ const handleDepositVault = async (paymentCurrencyId: string) => {
106
112
  export const depositVaultQueue = createQueue<DepositVaultJob>({
107
113
  name: 'deposit-vault',
108
114
  onJob: async (job) => {
109
- await handleDepositVault(job.currencyId);
115
+ let ids: string[] = [];
116
+ if (job.currencyIds?.length) {
117
+ ids = job.currencyIds;
118
+ } else if (job.currencyId) {
119
+ ids = [job.currencyId];
120
+ }
121
+ for (const currencyId of ids) {
122
+ try {
123
+ // eslint-disable-next-line no-await-in-loop
124
+ await handleDepositVault(currencyId);
125
+ } catch (error: any) {
126
+ // Failure in one currency must not poison siblings — original design ran each
127
+ // on its own queue message so a 3-retry ceiling applied per-currency. Now we
128
+ // swallow+log to keep that isolation when sharing a single message.
129
+ logger.error('handleDepositVault failed', { currencyId, error: error?.message || error });
130
+ }
131
+ }
110
132
  },
111
133
  options: {
112
134
  concurrency: 1,
@@ -390,7 +412,38 @@ export const handlePaymentSucceed = async (
390
412
  if (!invoice && !slashStake) {
391
413
  const checkoutSession = await CheckoutSession.findOne({ where: { payment_intent_id: paymentIntent.id } });
392
414
  if (checkoutSession) {
393
- await updateCheckoutSessionOnPaymentSuccess(paymentIntent, checkoutSession);
415
+ if (
416
+ (globalThis as any).__CF_ENV__ &&
417
+ checkoutSession.mode === 'payment' &&
418
+ checkoutSession.invoice_creation?.enabled &&
419
+ !checkoutSession.invoice_id
420
+ ) {
421
+ const customer = await Customer.findByPk(checkoutSession.customer_id);
422
+ if (customer) {
423
+ try {
424
+ const result = await ensureInvoiceForCheckout({
425
+ checkoutSession,
426
+ paymentIntent,
427
+ customer,
428
+ props: {
429
+ status: 'paid',
430
+ amount_due: '0',
431
+ amount_paid: checkoutSession.amount_total,
432
+ amount_remaining: '0',
433
+ },
434
+ });
435
+ if (result?.invoice) {
436
+ invoice = result.invoice;
437
+ }
438
+ } catch (err: any) {
439
+ logger.error('Failed to create invoice for checkout session', {
440
+ checkoutSessionId: checkoutSession.id,
441
+ error: err?.message,
442
+ });
443
+ }
444
+ }
445
+ }
446
+ await updateCheckoutSessionOnPaymentSuccess(paymentIntent, checkoutSession, invoice || undefined);
394
447
  }
395
448
  return;
396
449
  }
@@ -407,6 +460,10 @@ export const handlePaymentSucceed = async (
407
460
  status_transitions: { ...invoice.status_transitions, paid_at: dayjs().unix() },
408
461
  });
409
462
  logger.info(`Invoice ${invoice.id} updated on payment done: ${paymentIntent.id}`);
463
+
464
+ // Credit grant processing is triggered by the invoice.paid event listener
465
+ // (via createEvent in the afterUpdate hook). createEvent self-registers in
466
+ // __cfPendingJobs__ so it completes in CF Workers before the request ends.
410
467
  }
411
468
 
412
469
  // Mark quotes as paid after successful payment
@@ -1376,15 +1433,26 @@ export async function startDepositVaultQueue() {
1376
1433
  const paymentCurrencies = (await PaymentCurrency.scope('withVaultConfig').findAll({
1377
1434
  include: [{ model: PaymentMethod, as: 'payment_method' }],
1378
1435
  })) as (PaymentCurrency & { payment_method: PaymentMethod })[];
1379
- await pAll(
1380
- paymentCurrencies.map((x) => {
1381
- if (CHARGE_SUPPORTED_CHAIN_TYPES.includes(x.payment_method.type) && x.vault_config?.enabled === true) {
1382
- depositVaultQueue.push({ id: `deposit-vault-${x.id}`, job: { currencyId: x.id } });
1383
- }
1384
- return async () => {};
1385
- }),
1386
- {
1387
- concurrency: 5,
1388
- }
1389
- );
1436
+
1437
+ const eligibleIds = paymentCurrencies
1438
+ .filter((x) => CHARGE_SUPPORTED_CHAIN_TYPES.includes(x.payment_method.type) && x.vault_config?.enabled === true)
1439
+ .map((x) => x.id);
1440
+
1441
+ if (eligibleIds.length === 0) {
1442
+ return;
1443
+ }
1444
+
1445
+ // Single aggregated push — the consumer walks the id list internally with
1446
+ // per-currency error isolation. Reduces cron-driven Queue writes from N (one
1447
+ // per currency per 5-minute tick → ~288·N/day) to a constant 288/day (or
1448
+ // fewer when the previous batch hasn't cleared).
1449
+ //
1450
+ // Stable jobId preserves the backpressure the original per-currency IDs
1451
+ // provided: D1's unique constraint rejects a second push while the first
1452
+ // batch is still in D1 (in flight / being consumed). Worst case: next cron
1453
+ // skips one tick, events catch up on the following 5-minute cycle.
1454
+ depositVaultQueue.push({
1455
+ id: 'deposit-vault-batch',
1456
+ job: { currencyIds: eligibleIds },
1457
+ });
1390
1458
  }
@@ -141,7 +141,7 @@ export const handleRefund = async (job: RefundJob) => {
141
141
  async function finalizeRefundAfterTransfer(refund: Refund, paymentDetails: any): Promise<void> {
142
142
  try {
143
143
  // Try to burn credit tokens if this refund is for a credit purchase
144
- const totalBurned = await handleOnchainCreditRefund(refund);
144
+ const totalBurned = await handleCreditRefund(refund);
145
145
 
146
146
  // All succeeded, update status
147
147
  await refund.update({
@@ -506,31 +506,22 @@ events.on('refund.created', (refund: Refund) => {
506
506
  });
507
507
 
508
508
  /**
509
- * Handle onchain credit refund - burn user-held tokens for credit grants
509
+ * Handle credit refund - roll back credit grants when a credit purchase is refunded.
510
510
  *
511
- * When a refund is issued for a credit purchase:
512
- * 1. Find credit grants associated with the invoice
513
- * 2. For each grant, check if its currency is an onchain credit
514
- * 3. Calculate how much to burn based on refund amount
515
- * 4. Only burn user-held tokens (remaining_amount in user's wallet)
516
- * 5. If user has consumed some credit, only burn what they have left
517
- * (the consumed portion stays in system wallet as it corresponds to used services)
518
- * 6. Update credit grant status
511
+ * For each grant linked to the refunded invoice:
512
+ * 1. Compute the proportional amount to claw back (shouldBurn = grantTotal × refundAmount / invoiceTotal).
513
+ * 2. Clamp to remaining_amount (already-consumed credit stays in the system wallet).
514
+ * 3. If the grant is on-chain (chain_status === 'mint_completed'), burn user-held tokens first.
515
+ * Off-chain grants skip the burn the refund has already moved fiat/credit back to the user.
516
+ * 4. Update remaining_amount / status / chain_detail identically in both paths so the
517
+ * customer.credit_grant.voided event fires when a grant is fully rolled back.
519
518
  *
520
519
  * @param refund - The refund record
521
- * @param paymentDetails - Payment details to update on refund
522
- * @returns Total amount burned (as string), '0' if nothing to burn
523
- * @throws Error if burn operation fails
520
+ * @returns Total amount clawed back from user (as string), undefined if nothing to process
521
+ * @throws Error if any on-chain burn fails
524
522
  */
525
- /**
526
- * Handle onchain credit refund - burn user-held tokens for credit grants
527
- *
528
- * @param refund - The refund record
529
- * @returns Total amount burned (as string), undefined if nothing to burn
530
- * @throws Error if burn operation fails
531
- */
532
- export async function handleOnchainCreditRefund(refund: Refund): Promise<string | undefined> {
533
- logger.info('Processing onchain credit refund', { refundId: refund.id, invoiceId: refund.invoice_id });
523
+ export async function handleCreditRefund(refund: Refund): Promise<string | undefined> {
524
+ logger.info('Processing credit refund', { refundId: refund.id, invoiceId: refund.invoice_id });
534
525
 
535
526
  // If no invoice_id, no credit grants to burn
536
527
  if (!refund.invoice_id) {
@@ -549,16 +540,14 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
549
540
  throw new Error(`Invoice ${refund.invoice_id} total is zero or negative`);
550
541
  }
551
542
 
552
- // Find ALL credit grants related to this invoice (not filtered by currency_id)
543
+ // Find ALL credit grants related to this invoice (both on-chain and off-chain).
553
544
  const allGrants = await CreditGrant.findAll({
554
545
  where: {
555
546
  customer_id: refund.customer_id,
556
547
  status: ['pending', 'granted'],
557
548
  },
558
549
  });
559
- const creditGrants = allGrants.filter(
560
- (grant) => grant.metadata?.invoice_id === refund.invoice_id && CreditGrant.hasOnchainToken(grant)
561
- );
550
+ const creditGrants = allGrants.filter((grant) => grant.metadata?.invoice_id === refund.invoice_id);
562
551
 
563
552
  if (creditGrants.length === 0) {
564
553
  logger.info('No active credit grants found for refund', { refundId: refund.id, invoiceId: refund.invoice_id });
@@ -583,8 +572,19 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
583
572
  // eslint-disable-next-line no-await-in-loop
584
573
  const grantCurrency = await PaymentCurrency.findByPk(creditGrant.currency_id);
585
574
 
586
- // Verify currency has token config for burn operation
587
- if (!grantCurrency?.token_config) {
575
+ if (!grantCurrency) {
576
+ logger.error('Payment currency not found for credit grant refund', {
577
+ creditGrantId: creditGrant.id,
578
+ currencyId: creditGrant.currency_id,
579
+ });
580
+ // eslint-disable-next-line no-continue
581
+ continue;
582
+ }
583
+
584
+ const isOnchainGrant = CreditGrant.hasOnchainToken(creditGrant);
585
+
586
+ // On-chain path requires token_config to run the burn; off-chain path doesn't touch the chain.
587
+ if (isOnchainGrant && !grantCurrency.token_config) {
588
588
  logger.error('Payment currency token config not found for burn', {
589
589
  creditGrantId: creditGrant.id,
590
590
  currencyId: creditGrant.currency_id,
@@ -608,6 +608,7 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
608
608
  logger.info('Processing credit grant for refund', {
609
609
  creditGrantId: creditGrant.id,
610
610
  currencyId: grantCurrency.id,
611
+ isOnchainGrant,
611
612
  grantTotal: grantTotalAmount.toString(),
612
613
  grantRemaining: grantRemainingAmount.toString(),
613
614
  shouldBurn: shouldBurnFromGrant.toString(),
@@ -617,70 +618,81 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
617
618
 
618
619
  let burnHash: string | null = null;
619
620
  let burnError: string | null = null;
621
+ let clawedBackAmount = new BN(0);
620
622
 
621
- // Only burn if user has remaining tokens
622
623
  if (actualBurnAmount.gt(new BN(0))) {
623
- // Get the latest DID for burn (handles DID migration via on-chain state)
624
- // eslint-disable-next-line no-await-in-loop
625
- const customerState = await getAccountState(grantCurrency, customer.did);
626
-
627
- try {
624
+ if (isOnchainGrant) {
625
+ // Get the latest DID for burn (handles DID migration via on-chain state)
628
626
  // eslint-disable-next-line no-await-in-loop
629
- burnHash = await burnToken({
630
- paymentCurrency: grantCurrency,
631
- amount: fromUnitToToken(actualBurnAmount.toString(), grantCurrency.decimal),
632
- sender: customerState?.address || customer.did,
633
- data: {
634
- reason: 'credit_grant_refund',
627
+ const customerState = await getAccountState(grantCurrency, customer.did);
628
+
629
+ try {
630
+ // eslint-disable-next-line no-await-in-loop
631
+ burnHash = await burnToken({
632
+ paymentCurrency: grantCurrency,
633
+ amount: fromUnitToToken(actualBurnAmount.toString(), grantCurrency.decimal),
634
+ sender: customerState?.address || customer.did,
635
+ data: {
636
+ reason: 'credit_grant_refund',
637
+ creditGrantId: creditGrant.id,
638
+ refundId: refund.id,
639
+ },
640
+ });
641
+
642
+ clawedBackAmount = actualBurnAmount;
643
+ totalBurnedFromUser = totalBurnedFromUser.add(actualBurnAmount);
644
+
645
+ logger.info('Successfully burned user tokens for refund', {
646
+ creditGrantId: creditGrant.id,
647
+ burnHash,
648
+ amount: actualBurnAmount.toString(),
649
+ });
650
+
651
+ // Emit burned event to trigger reconciliation
652
+ events.emit('customer.credit_grant.burned', creditGrant, {
653
+ burnHash,
654
+ burnedAmount: actualBurnAmount.toString(),
655
+ });
656
+ } catch (error: any) {
657
+ logger.error('Failed to burn user tokens for refund', {
635
658
  creditGrantId: creditGrant.id,
636
659
  refundId: refund.id,
637
- },
638
- });
639
-
660
+ error: error.message,
661
+ });
662
+ burnError = error.message;
663
+ lastError = error.message;
664
+ }
665
+ } else {
666
+ // Off-chain grant: refund has already transferred value back to the user; just roll back the ledger.
667
+ clawedBackAmount = actualBurnAmount;
640
668
  totalBurnedFromUser = totalBurnedFromUser.add(actualBurnAmount);
641
-
642
- logger.info('Successfully burned user tokens for refund', {
643
- creditGrantId: creditGrant.id,
644
- burnHash,
645
- amount: actualBurnAmount.toString(),
646
- });
647
-
648
- // Emit burned event to trigger reconciliation
649
- events.emit('customer.credit_grant.burned', creditGrant, {
650
- burnHash,
651
- burnedAmount: actualBurnAmount.toString(),
652
- });
653
- } catch (error: any) {
654
- logger.error('Failed to burn user tokens for refund', {
655
- creditGrantId: creditGrant.id,
656
- refundId: refund.id,
657
- error: error.message,
658
- });
659
- burnError = error.message;
660
- lastError = error.message;
661
669
  }
662
670
  }
663
671
 
664
672
  // Update credit grant using new chain_detail field
665
- const newRemainingAmount = burnHash
666
- ? grantRemainingAmount.sub(actualBurnAmount).toString()
667
- : grantRemainingAmount.toString();
673
+ const newRemainingAmount = grantRemainingAmount.sub(clawedBackAmount).toString();
668
674
  const isFullyVoided = new BN(newRemainingAmount).eq(new BN(0));
669
675
  const chainDetail = creditGrant.chain_detail || {};
670
676
 
677
+ // chain_status only applies to on-chain grants; leave off-chain grants' chain_status untouched.
678
+ let chainStatusUpdate: CreditGrant['chain_status'] | undefined;
679
+ if (isOnchainGrant) {
680
+ if (burnHash) chainStatusUpdate = 'burn_completed';
681
+ else if (burnError) chainStatusUpdate = 'burn_failed';
682
+ }
683
+
671
684
  // eslint-disable-next-line no-await-in-loop
672
685
  await creditGrant.update({
673
686
  status: isFullyVoided ? 'voided' : 'granted',
674
687
  remaining_amount: newRemainingAmount,
675
688
  voided_at: isFullyVoided ? Math.floor(Date.now() / 1000) : undefined,
676
- // eslint-disable-next-line no-nested-ternary
677
- chain_status: burnHash ? 'burn_completed' : burnError ? 'burn_failed' : undefined,
689
+ ...(chainStatusUpdate ? { chain_status: chainStatusUpdate } : {}),
678
690
  chain_detail: {
679
691
  ...chainDetail,
680
692
  refund: {
681
693
  id: refund.id,
682
694
  burn_hash: burnHash || undefined,
683
- burned_amount: burnHash ? actualBurnAmount.toString() : '0',
695
+ burned_amount: clawedBackAmount.toString(),
684
696
  system_retained: systemRetainedAmount.toString(),
685
697
  burn_error: burnError || undefined,
686
698
  },
@@ -690,14 +702,15 @@ export async function handleOnchainCreditRefund(refund: Refund): Promise<string
690
702
 
691
703
  logger.info('Credit grant processed for refund', {
692
704
  creditGrantId: creditGrant.id,
705
+ isOnchainGrant,
693
706
  newStatus: isFullyVoided ? 'voided' : 'granted',
694
707
  newRemainingAmount,
695
- burnedFromUser: burnHash ? actualBurnAmount.toString() : '0',
708
+ clawedBack: clawedBackAmount.toString(),
696
709
  systemRetained: systemRetainedAmount.toString(),
697
710
  });
698
711
  }
699
712
 
700
- logger.info('Completed onchain credit refund processing', {
713
+ logger.info('Completed credit refund processing', {
701
714
  refundId: refund.id,
702
715
  processedCount: creditGrants.length,
703
716
  totalBurnedFromUser: totalBurnedFromUser.toString(),
@@ -4,6 +4,7 @@ import { BN } from '@ocap/util';
4
4
  import { Op } from 'sequelize';
5
5
  import { createEvent } from '../libs/audit';
6
6
  import { ensurePassportRevoked } from '../integrations/blocklet/passport';
7
+ // eslint-disable-next-line import/no-cycle
7
8
  import { batchHandleStripeSubscriptions } from '../integrations/stripe/resource';
8
9
  import { wallet } from '../libs/auth';
9
10
  import { getExchangeRateService } from '../libs/exchange-rate';
@@ -1617,6 +1617,29 @@ router.post('/:id/skip-payment-method', user, ensureCheckoutSessionOpen, async (
1617
1617
  });
1618
1618
 
1619
1619
  // for checkout page
1620
+ // Lightweight status-only endpoint for polling.
1621
+ // Returns only status fields — no includes, no joins, single D1 query.
1622
+ // Used by waitForCheckoutComplete to minimize D1 load during payment processing.
1623
+ router.get('/status/:id', user, async (req, res) => {
1624
+ const doc = await CheckoutSession.findByPk(req.params.id, {
1625
+ attributes: ['id', 'status', 'payment_status', 'payment_intent_id'],
1626
+ });
1627
+ if (!doc) {
1628
+ return res.status(404).json({ error: 'Checkout session not found' });
1629
+ }
1630
+
1631
+ const piStatus = doc.payment_intent_id
1632
+ ? await PaymentIntent.findByPk(doc.payment_intent_id, {
1633
+ attributes: ['status', 'last_payment_error'],
1634
+ }).then((pi) => (pi ? { status: pi.status, last_payment_error: pi.last_payment_error } : null))
1635
+ : null;
1636
+
1637
+ return res.json({
1638
+ checkoutSession: { status: doc.status, payment_status: doc.payment_status },
1639
+ paymentIntent: piStatus,
1640
+ });
1641
+ });
1642
+
1620
1643
  router.get('/retrieve/:id', user, async (req, res) => {
1621
1644
  const doc = await CheckoutSession.findByPk(req.params.id);
1622
1645
 
@@ -1625,9 +1648,10 @@ router.get('/retrieve/:id', user, async (req, res) => {
1625
1648
  return;
1626
1649
  }
1627
1650
 
1628
- let subscriptions: Subscription[] = [];
1651
+ // Check if subscription status needs updating (conditional write — must happen before reads)
1652
+ let subscriptionIds: string[] = [];
1629
1653
  if (['subscription', 'setup'].includes(doc.mode)) {
1630
- const subscriptionIds = getCheckoutSessionSubscriptionIds(doc);
1654
+ subscriptionIds = getCheckoutSessionSubscriptionIds(doc);
1631
1655
  if (
1632
1656
  doc.success_subscription_count &&
1633
1657
  doc.success_subscription_count >= subscriptionIds.length &&
@@ -1638,41 +1662,56 @@ router.get('/retrieve/:id', user, async (req, res) => {
1638
1662
  payment_status: 'paid',
1639
1663
  });
1640
1664
  }
1641
- subscriptions = await Subscription.findAll({
1642
- where: { id: subscriptionIds },
1643
- attributes: ['id', 'description', 'status', 'current_period_start', 'current_period_end', 'latest_invoice_id'],
1644
- });
1645
1665
  }
1646
1666
 
1667
+ // Parallelize: Subscription lookup + Price.expand are independent (save ~85ms D1 RTT)
1647
1668
  const rawLineItems = doc.line_items;
1669
+ const [subscriptions, expandedLineItems] = await Promise.all([
1670
+ subscriptionIds.length > 0
1671
+ ? Subscription.findAll({
1672
+ where: { id: subscriptionIds },
1673
+ attributes: [
1674
+ 'id',
1675
+ 'description',
1676
+ 'status',
1677
+ 'current_period_start',
1678
+ 'current_period_end',
1679
+ 'latest_invoice_id',
1680
+ ],
1681
+ })
1682
+ : ([] as Subscription[]),
1683
+ Price.expand(rawLineItems, { upsell: true }),
1684
+ ]);
1648
1685
  // @ts-ignore
1649
- doc.line_items = await Price.expand(rawLineItems, { upsell: true });
1686
+ doc.line_items = expandedLineItems;
1650
1687
 
1651
1688
  // Recover discount config for open sessions if checkout_session.discounts was unexpectedly cleared.
1652
1689
  if (!doc.discounts?.length && doc.status === 'open' && doc.payment_status !== 'paid') {
1653
1690
  await recoverDiscountConfigFromRecords(doc);
1654
1691
  }
1655
1692
 
1656
- // Enhance line items with coupon information if discounts are applied
1693
+ // Parallelize: coupon info expansion + discount details are independent (save ~85ms D1 RTT)
1694
+ let enhancedDiscounts: any;
1657
1695
  if (doc.discounts?.length) {
1658
- doc.line_items = await expandLineItemsWithCouponInfo(
1659
- doc.line_items as TLineItemExpanded[],
1660
- doc.discounts,
1661
- doc.currency_id
1662
- );
1696
+ const [expandedItems, discountDetails] = await Promise.all([
1697
+ expandLineItemsWithCouponInfo(doc.line_items as TLineItemExpanded[], doc.discounts, doc.currency_id),
1698
+ expandDiscountsWithDetails(doc.discounts),
1699
+ ]);
1700
+ doc.line_items = expandedItems;
1701
+ enhancedDiscounts = discountDetails;
1702
+ } else {
1703
+ enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
1663
1704
  }
1664
1705
 
1665
- // Expand discounts with complete details
1666
- const enhancedDiscounts = await expandDiscountsWithDetails(doc.discounts);
1667
-
1668
1706
  // Handle dynamic pricing: create or reuse quotes
1669
- // Skip quote generation when checkout session is confirmed and should not change:
1670
- // 1. Payment completed (payment_status === 'paid')
1671
- // 2. Subscription created (has subscription_id) - includes free trial scenarios
1672
- // 3. Status is 'complete'
1673
1707
  const isCheckoutConfirmed = doc.payment_status === 'paid' || doc.status === 'complete' || !!doc.subscription_id;
1674
1708
  const forceRefresh = req.query.forceRefresh === '1' || req.query.forceRefresh === 'true';
1675
1709
 
1710
+ // Start PaymentIntent fetch early — runs in parallel with quote processing (save ~85ms D1 RTT)
1711
+ const paymentIntentPromise = doc.payment_intent_id
1712
+ ? PaymentIntent.findByPk(doc.payment_intent_id)
1713
+ : Promise.resolve(null);
1714
+
1676
1715
  let quotes = {};
1677
1716
  let rateUnavailable = false;
1678
1717
  let rateError: string | undefined;
@@ -1740,8 +1779,8 @@ router.get('/retrieve/:id', user, async (req, res) => {
1740
1779
  rateError = error.message || 'Failed to process price quotes';
1741
1780
  }
1742
1781
 
1743
- // check payment intent
1744
- const paymentIntent = doc.payment_intent_id ? await PaymentIntent.findByPk(doc.payment_intent_id) : null;
1782
+ // Await PaymentIntent that was started in parallel with quotes
1783
+ const paymentIntent = await paymentIntentPromise;
1745
1784
  if ((forceRefresh || quotesRefreshed) && !isCheckoutConfirmed) {
1746
1785
  if (doc.metadata?.quote_locked_at) {
1747
1786
  await doc.update({
@@ -1753,19 +1792,26 @@ router.get('/retrieve/:id', user, async (req, res) => {
1753
1792
  }
1754
1793
  }
1755
1794
 
1795
+ // Parallelize independent lookups — saves 2 D1 round-trips (~170ms)
1796
+ const [paymentMethods, paymentLink, customer] = await Promise.all([
1797
+ getPaymentMethods(doc),
1798
+ doc.payment_link_id ? PaymentLink.findByPk(doc.payment_link_id) : null,
1799
+ req.user ? Customer.findOne({ where: { did: req.user.did } }) : null,
1800
+ ]);
1801
+
1756
1802
  res.json({
1757
1803
  checkoutSession: {
1758
1804
  ...doc.toJSON(),
1759
1805
  discounts: enhancedDiscounts,
1760
1806
  subscriptions,
1761
1807
  },
1762
- quotes, // Include quotes information for frontend
1763
- rateUnavailable, // Warning flag for frontend
1764
- rateError, // Error message for frontend
1765
- paymentMethods: await getPaymentMethods(doc),
1766
- paymentLink: doc.payment_link_id ? await PaymentLink.findByPk(doc.payment_link_id) : null,
1808
+ quotes,
1809
+ rateUnavailable,
1810
+ rateError,
1811
+ paymentMethods,
1812
+ paymentLink,
1767
1813
  paymentIntent,
1768
- customer: req.user ? await Customer.findOne({ where: { did: req.user.did } }) : null,
1814
+ customer,
1769
1815
  ...(stopAcceptingOrders ? { stopAcceptingOrders: true } : {}),
1770
1816
  });
1771
1817
  });
@@ -2623,7 +2669,6 @@ async function prefetchQuotesForCheckoutSession(checkoutSession: CheckoutSession
2623
2669
  router.put('/:id/submit', user, ensureCheckoutSessionOpen, async (req, res) => {
2624
2670
  let consumedQuotes: PriceQuote[] = [];
2625
2671
  const checkoutSession = req.doc as CheckoutSession;
2626
-
2627
2672
  try {
2628
2673
  if (!req.user) {
2629
2674
  return res.status(403).json({ code: 'REQUIRE_LOGIN', error: 'Please login to continue' });
@@ -3696,10 +3741,17 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
3696
3741
  }
3697
3742
  }
3698
3743
 
3699
- const paymentCurrency = await PaymentCurrency.findByPk(checkoutSession.currency_id);
3744
+ // Parallel: load currency + customer (independent lookups)
3745
+ const [paymentCurrency, customer] = await Promise.all([
3746
+ PaymentCurrency.findByPk(checkoutSession.currency_id),
3747
+ Customer.findByPkOrDid(req.user.did),
3748
+ ]);
3700
3749
  if (!paymentCurrency) {
3701
3750
  return res.status(400).json({ error: 'Payment currency not found' });
3702
3751
  }
3752
+ if (!customer) {
3753
+ return res.status(400).json({ error: '' });
3754
+ }
3703
3755
 
3704
3756
  const paymentMethod = await PaymentMethod.findByPk(paymentCurrency.payment_method_id);
3705
3757
  if (!paymentMethod) {
@@ -3717,12 +3769,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
3717
3769
  }
3718
3770
  }
3719
3771
 
3720
- const customer = await Customer.findByPkOrDid(req.user.did);
3721
- if (!customer) {
3722
- return res.status(400).json({ error: '' });
3723
- }
3724
-
3725
- // check if customer can make new purchase
3726
3772
  const canMakeNewPurchase = await customer.canMakeNewPurchase(checkoutSession.invoice_id);
3727
3773
  if (!canMakeNewPurchase) {
3728
3774
  return res.status(403).json({
@@ -3890,7 +3936,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
3890
3936
  canFastPay = true;
3891
3937
  }
3892
3938
  } else if (isPayment && paymentIntent && canFastPay) {
3893
- // if we can complete purchase without any wallet interaction
3894
3939
  delegation = await isDelegationSufficientForPayment({
3895
3940
  paymentMethod,
3896
3941
  paymentCurrency,
@@ -3956,12 +4001,6 @@ router.post('/:id/fast-checkout-confirm', user, ensureCheckoutSessionOpen, async
3956
4001
  await quoteService.markQuotesAsPaidByInvoice(paymentIntent.invoice_id);
3957
4002
  }
3958
4003
 
3959
- logger.info('Checkout session submitted successfully', {
3960
- sessionId: req.params.id,
3961
- paymentIntentId: paymentIntent?.id,
3962
- customerId: customer?.id,
3963
- });
3964
-
3965
4004
  return res.json({
3966
4005
  paymentIntent,
3967
4006
  checkoutSession,
@@ -82,6 +82,8 @@ export default {
82
82
  items,
83
83
  requiredStake,
84
84
  slippageConfig: subscription?.slippage_config || undefined,
85
+ // Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
86
+ delegateState: delegation.state,
85
87
  }),
86
88
  });
87
89
  }
@@ -114,6 +114,8 @@ export default {
114
114
  billingThreshold,
115
115
  items,
116
116
  slippageConfig: subscription?.slippage_config || undefined,
117
+ // Reuse DelegateState fetched above — avoids a duplicate chain RPC on CF cold isolates
118
+ delegateState: delegation.state,
117
119
  }),
118
120
  });
119
121
  }