payment-kit 1.27.2 → 1.29.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +32 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- 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/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -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 +70 -24
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -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/security.ts +51 -0
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +29 -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 +39 -21
- 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/queues/webhook.ts +12 -2
- 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/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- 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 +131 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +37 -1
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +66 -1
- package/api/src/store/models/price.ts +23 -14
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- 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/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +391 -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-session.ts +44 -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 +611 -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 +1176 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +306 -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-delayed-persist.spec.ts +87 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1608 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +75 -0
- package/cloudflare/wrangler.staging.json +67 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +30 -22
- package/scripts/seed-google-play.ts +79 -0
- 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/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/safe-did-address.tsx +75 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +8 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -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
|
@@ -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';
|
|
@@ -31,6 +30,19 @@ export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then rem
|
|
|
31
30
|
|
|
32
31
|
export const STRIPE_API_VERSION = '2023-08-16';
|
|
33
32
|
export const STRIPE_ENDPOINT: string = getUrl('/api/integrations/stripe/webhook');
|
|
33
|
+
// Pub/Sub OIDC tokens carry the push-subscription endpoint as `aud` claim. When
|
|
34
|
+
// the dev server is reached via a custom domain (e.g. a Cloudflare tunnel), the
|
|
35
|
+
// BLOCKLET_APP_URL-derived default won't match the URL Google signs against.
|
|
36
|
+
// Set GOOGLE_PLAY_WEBHOOK_URL to the externally-visible URL in that case.
|
|
37
|
+
// Lazy-eval (function not constant) because dotenv loads env AFTER this module
|
|
38
|
+
// is imported — a constant captured at module-load would only see BLOCKLET_APP_URL.
|
|
39
|
+
export const googlePlayEndpoint = (): string =>
|
|
40
|
+
process.env.GOOGLE_PLAY_WEBHOOK_URL || getUrl('/api/integrations/google-play/webhook');
|
|
41
|
+
|
|
42
|
+
// Back-compat constant for any caller that captures it at module-load.
|
|
43
|
+
// Prefer googlePlayEndpoint() going forward.
|
|
44
|
+
export const GOOGLE_PLAY_ENDPOINT: string = googlePlayEndpoint();
|
|
45
|
+
export const APP_STORE_ENDPOINT: string = getUrl('/api/integrations/app-store/webhook');
|
|
34
46
|
export const STRIPE_EVENTS: any[] = [
|
|
35
47
|
'checkout.session.async_payment_failed',
|
|
36
48
|
'checkout.session.async_payment_succeeded',
|
|
@@ -259,7 +271,11 @@ export async function getBlockletJson(url?: string) {
|
|
|
259
271
|
return cached.data;
|
|
260
272
|
}
|
|
261
273
|
}
|
|
262
|
-
const
|
|
274
|
+
const baseUrl = url || process.env.BLOCKLET_APP_URL;
|
|
275
|
+
if (!baseUrl) {
|
|
276
|
+
return null;
|
|
277
|
+
}
|
|
278
|
+
const scriptUrl = new URL('__blocklet__.js?type=json', withTrailingSlash(baseUrl));
|
|
263
279
|
try {
|
|
264
280
|
const { data: blockletMeta } = await api.get(scriptUrl.href);
|
|
265
281
|
cachedBlockletJsonResult.set(blockletKey, { data: blockletMeta, expiry: now + CACHE_TTL });
|
|
@@ -686,17 +702,15 @@ export function formatNumber(
|
|
|
686
702
|
if (!n || n === '0') {
|
|
687
703
|
return '0';
|
|
688
704
|
}
|
|
689
|
-
const
|
|
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;
|
|
705
|
+
const value = typeof n === 'string' ? parseFloat(n) : n;
|
|
706
|
+
if (Number.isNaN(value)) return '0';
|
|
707
|
+
const fixed = precision || precision === 0 ? value.toFixed(precision) : String(value);
|
|
708
|
+
const [intPart = '0', decPart] = fixed.split('.');
|
|
709
|
+
const left = thousandSeparated ? intPart.replace(/\B(?=(\d{3})+(?!\d))/g, ',') : intPart;
|
|
710
|
+
if (!decPart) return left;
|
|
711
|
+
if (!trim) return `${left}.${decPart}`;
|
|
712
|
+
const trimmed = trimEnd(decPart, '0');
|
|
713
|
+
return trimmed ? `${left}.${trimmed}` : left;
|
|
700
714
|
}
|
|
701
715
|
|
|
702
716
|
const CURRENCY_SYMBOLS: Record<string, string> = {
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
* This module provides fallback query mechanisms using migratedFrom list.
|
|
9
9
|
*/
|
|
10
10
|
|
|
11
|
-
import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util';
|
|
11
|
+
import { toDelegateAddress, toStakeAddress } from '@arcblock/did-util/cbor';
|
|
12
12
|
import type OcapClient from '@ocap/client';
|
|
13
13
|
|
|
14
14
|
import { wallet } from './auth';
|
|
@@ -16,23 +16,26 @@ import logger from './logger';
|
|
|
16
16
|
import { Subscription } from '../store/models';
|
|
17
17
|
|
|
18
18
|
// Cache for migratedFrom list (keyed by wallet.address + chain host)
|
|
19
|
+
// Empty results are cached too (most common case) with TTL to allow refresh if app migrates
|
|
20
|
+
const MIGRATED_FROM_CACHE_TTL_MS = 5 * 60 * 1000; // 5 minutes
|
|
19
21
|
let cachedMigratedFrom: string[] | null = null;
|
|
20
22
|
let cachedWalletAddress: string | null = null;
|
|
21
23
|
let cachedChainHost: string | null = null;
|
|
24
|
+
let cachedMigratedFromAt = 0;
|
|
22
25
|
|
|
23
26
|
/**
|
|
24
27
|
* Get the migratedFrom list for the current app wallet (with caching)
|
|
25
|
-
* The cache is invalidated when wallet.address or chain host changes
|
|
28
|
+
* The cache is invalidated when wallet.address or chain host changes, or after TTL expires
|
|
26
29
|
*/
|
|
27
30
|
export async function getMigratedFromList(client: OcapClient): Promise<string[]> {
|
|
28
31
|
// @ts-ignore - OcapClient has host property
|
|
29
32
|
const chainHost = client?.config?.httpEndpoint || 'unknown';
|
|
30
|
-
// If wallet address and chain host haven't changed, use cached value
|
|
33
|
+
// If wallet address and chain host haven't changed, and cache is fresh, use cached value
|
|
31
34
|
if (
|
|
32
35
|
wallet.address === cachedWalletAddress &&
|
|
33
36
|
chainHost === cachedChainHost &&
|
|
34
|
-
cachedMigratedFrom &&
|
|
35
|
-
|
|
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
|
@@ -34,29 +34,35 @@ export const handleEvent = async (job: EventJob) => {
|
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
},
|
|
37
|
+
// Decide which endpoints still need a first attempt. The previous logic
|
|
38
|
+
// counted only SUCCESS attempts (2xx), so any permanent-failure endpoint
|
|
39
|
+
// (e.g. wrong URL → 404) would forever match attemptCount===0 and get
|
|
40
|
+
// re-pushed by the retry cron every minute, producing thousands of bogus
|
|
41
|
+
// attempts. We now count ANY attempt: webhookQueue has its own retry
|
|
42
|
+
// ladder (MAX_RETRY_COUNT=20) that owns recovery for transient failures,
|
|
43
|
+
// so re-pushing from this handler after the first attempt is double-work.
|
|
44
|
+
let stillPending = 0;
|
|
45
|
+
for (const webhook of eventWebhooks) {
|
|
46
|
+
// eslint-disable-next-line no-await-in-loop -- sequential per-webhook scheduling keeps D1 writes ordered
|
|
47
|
+
const anyAttempt = await WebhookAttempt.count({
|
|
48
|
+
where: { event_id: event.id, webhook_endpoint_id: webhook.id },
|
|
50
49
|
});
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
if (attemptCount === 0) {
|
|
50
|
+
if (anyAttempt === 0) {
|
|
51
|
+
stillPending += 1;
|
|
54
52
|
logger.info(`Scheduling attempt for event ${event.id} and webhook ${webhook.id}`, job);
|
|
55
|
-
|
|
53
|
+
// persist=true: write a D1 jobs row so CF Queue transport failures
|
|
54
|
+
// (momentary unavailability, consumer skip) don't silently lose the
|
|
55
|
+
// delivery — cron picks orphaned rows back up.
|
|
56
|
+
// eslint-disable-next-line no-await-in-loop -- same reason as above
|
|
57
|
+
await addWebhookJob(event.id, webhook.id, { persist: true });
|
|
56
58
|
}
|
|
57
|
-
}
|
|
59
|
+
}
|
|
58
60
|
|
|
59
|
-
|
|
61
|
+
// pending_webhooks reflects "endpoints that have not yet been attempted at
|
|
62
|
+
// all" — once all matched endpoints have any attempt row, the cron's
|
|
63
|
+
// pending>0 scan stops finding this event and it falls out of rotation.
|
|
64
|
+
await event.update({ pending_webhooks: stillPending });
|
|
65
|
+
logger.info(`Finished handling event ${job.eventId} (stillPending=${stillPending})`);
|
|
60
66
|
};
|
|
61
67
|
|
|
62
68
|
export const eventQueue = createQueue<EventJob>({
|
|
@@ -93,6 +99,18 @@ eventQueue.on('failed', ({ id, job, error }) => {
|
|
|
93
99
|
logger.error('event job failed', { id, job, error });
|
|
94
100
|
});
|
|
95
101
|
|
|
96
|
-
events.on('event.created', (event) => {
|
|
97
|
-
|
|
102
|
+
events.on('event.created', async (event) => {
|
|
103
|
+
if ((globalThis as any).__CF_ENV__) {
|
|
104
|
+
// CF Workers: execute inline to save 2 CF Queue ops per event.
|
|
105
|
+
// eventQueue only dispatches webhooks — lightweight DB lookup + webhookQueue.push.
|
|
106
|
+
// Webhook delivery still goes through webhookQueue with full retry guarantees.
|
|
107
|
+
try {
|
|
108
|
+
await handleEvent({ eventId: event.id });
|
|
109
|
+
} catch (err: any) {
|
|
110
|
+
logger.error('event inline handler failed', { eventId: event.id, error: err?.message });
|
|
111
|
+
}
|
|
112
|
+
} else {
|
|
113
|
+
// Blocklet Server: use queue as before
|
|
114
|
+
eventQueue.push({ id: event.id, job: { eventId: event.id }, persist: false });
|
|
115
|
+
}
|
|
98
116
|
});
|
|
@@ -10,6 +10,7 @@ import { CheckoutSession } from '../store/models/checkout-session';
|
|
|
10
10
|
import { PaymentIntent } from '../store/models/payment-intent';
|
|
11
11
|
import { Subscription } from '../store/models/subscription';
|
|
12
12
|
import { PriceQuote } from '../store/models/price-quote';
|
|
13
|
+
// eslint-disable-next-line import/no-cycle
|
|
13
14
|
import { paymentQueue } from './payment';
|
|
14
15
|
import { getQuoteService } from '../libs/quote-service';
|
|
15
16
|
|