payment-kit 1.27.2 → 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.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +10 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/integrations/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +32 -16
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +16 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +14 -2
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -2
- package/api/src/routes/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +1 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/models/customer.ts +23 -1
- package/api/src/store/models/payment-method.ts +4 -0
- package/api/src/store/models/price.ts +23 -14
- package/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
- package/cloudflare/run-build.js +390 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +585 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1157 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +293 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1553 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +69 -0
- package/cloudflare/wrangler.staging.json +66 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +26 -22
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- package/src/components/safe-did-address.tsx +75 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/customers/customers/detail.tsx +2 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- 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
|
|
143
|
+
// For database sources with multiple sources, use demand-driven strategy
|
|
144
144
|
if (sourceMeta?.type === 'database') {
|
|
145
145
|
if (sources.length > 1) {
|
|
146
|
-
//
|
|
147
|
-
//
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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
|
package/api/src/libs/payment.ts
CHANGED
|
@@ -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
|
-
|
|
622
|
-
|
|
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;
|
package/api/src/libs/session.ts
CHANGED
|
@@ -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
|
|
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
|
+
}
|
package/api/src/libs/util.ts
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
};
|
|
694
|
-
|
|
695
|
-
if (!trim) {
|
|
696
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
153
|
-
|
|
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
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
appDid: oldAppDid,
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
-
|
|
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
|
|
|
@@ -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 >=
|
|
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:
|
|
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 >=
|
|
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
|
-
//
|
|
1364
|
-
|
|
1365
|
-
|
|
1366
|
-
|
|
1367
|
-
|
|
1368
|
-
|
|
1369
|
-
|
|
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);
|
package/api/src/queues/event.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|