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
package/api/src/libs/audit.ts
CHANGED
|
@@ -8,14 +8,63 @@ import { context } from './context';
|
|
|
8
8
|
|
|
9
9
|
const API_VERSION = '2023-09-05';
|
|
10
10
|
|
|
11
|
-
|
|
11
|
+
/**
|
|
12
|
+
* Invoke every registered listener for `eventName` and await any Promise
|
|
13
|
+
* results. EventEmitter.emit() returns sync — listener async work would
|
|
14
|
+
* otherwise run as detached microtasks and die when the CF Workers handler
|
|
15
|
+
* unwinds. We want listeners (notably queues/event.ts's event.created
|
|
16
|
+
* handler that drives webhook delivery) to complete inside createEvent's
|
|
17
|
+
* Promise so waitUntil covers the whole chain.
|
|
18
|
+
*/
|
|
19
|
+
async function emitAndAwait(eventName: string, ...args: any[]): Promise<void> {
|
|
20
|
+
const listeners = events.rawListeners(eventName);
|
|
21
|
+
for (const listener of listeners) {
|
|
22
|
+
try {
|
|
23
|
+
const result = (listener as any)(...args);
|
|
24
|
+
if (result && typeof result.then === 'function') {
|
|
25
|
+
// eslint-disable-next-line no-await-in-loop -- sequential await keeps listener ordering deterministic for waitUntil chains
|
|
26
|
+
await result;
|
|
27
|
+
}
|
|
28
|
+
} catch (err: any) {
|
|
29
|
+
console.error('[audit emitAndAwait]', eventName, err?.message || err);
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function createEvent(
|
|
35
|
+
scope: string,
|
|
36
|
+
type: LiteralUnion<EventType, string>,
|
|
37
|
+
model: any,
|
|
38
|
+
options: any = {}
|
|
39
|
+
): Promise<void> {
|
|
40
|
+
// Context-aware tracking for CF Workers:
|
|
41
|
+
// - HTTP requests: use ctx.waitUntil (non-blocking, like Blocklet's fire-and-forget)
|
|
42
|
+
// - Queue consumer/Cron: use __cfPendingJobs__ (blocking, listeners must complete)
|
|
43
|
+
// - Blocklet Server: no tracking needed (long-lived process)
|
|
44
|
+
const promise = doCreateEvent(scope, type, model, options);
|
|
45
|
+
const isHttp = (globalThis as any).__cfHttpContext__;
|
|
46
|
+
if (isHttp) {
|
|
47
|
+
const waitUntil = (globalThis as any).__cfWaitUntil__;
|
|
48
|
+
if (typeof waitUntil === 'function') {
|
|
49
|
+
waitUntil(promise.catch((err: any) => console.error('[createEvent]', type, err?.message || err)));
|
|
50
|
+
}
|
|
51
|
+
} else {
|
|
52
|
+
const pending = (globalThis as any).__cfPendingJobs__;
|
|
53
|
+
if (Array.isArray(pending)) {
|
|
54
|
+
pending.push(promise.catch((err: any) => console.error('[createEvent]', type, err?.message || err)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return promise;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
async function doCreateEvent(scope: string, type: LiteralUnion<EventType, string>, model: any, options: any = {}) {
|
|
12
61
|
const data: any = {
|
|
13
62
|
object: model.dataValues,
|
|
14
63
|
};
|
|
15
64
|
if (type.endsWith('updated')) {
|
|
16
65
|
data.previous_attributes = pick(model._previousDataValues, options.fields);
|
|
17
66
|
}
|
|
18
|
-
|
|
67
|
+
|
|
19
68
|
const event = await Event.create({
|
|
20
69
|
type,
|
|
21
70
|
api_version: API_VERSION,
|
|
@@ -24,17 +73,23 @@ export async function createEvent(scope: string, type: LiteralUnion<EventType, s
|
|
|
24
73
|
object_type: scope,
|
|
25
74
|
data,
|
|
26
75
|
request: {
|
|
27
|
-
// FIXME:
|
|
28
76
|
id: '',
|
|
29
77
|
idempotency_key: '',
|
|
30
78
|
requested_by: options.requestedBy || context.getRequestedBy() || 'system',
|
|
31
79
|
},
|
|
32
80
|
metadata: {},
|
|
33
|
-
pending_webhooks: 99,
|
|
81
|
+
pending_webhooks: 99,
|
|
34
82
|
});
|
|
35
83
|
|
|
36
|
-
|
|
37
|
-
|
|
84
|
+
// Synchronously await listener chains so the HTTP handler's waitUntil
|
|
85
|
+
// scope covers the full handleEvent → addWebhookJob → push pipeline.
|
|
86
|
+
// EventEmitter.emit() returns sync and discards listener Promises — on
|
|
87
|
+
// CF Workers that leaves the listener microtask racing against worker
|
|
88
|
+
// termination, and `customer.subscription.started` / `.deleted` events
|
|
89
|
+
// fired at the tail of HTTP handlers were observed to lose their first
|
|
90
|
+
// delivery attempt because of it.
|
|
91
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
92
|
+
await emitAndAwait(event.type, data.object, options);
|
|
38
93
|
}
|
|
39
94
|
|
|
40
95
|
export async function createStatusEvent(
|
|
@@ -57,7 +112,6 @@ export async function createStatusEvent(
|
|
|
57
112
|
return;
|
|
58
113
|
}
|
|
59
114
|
|
|
60
|
-
// console.log('createStatusEvent', scope, prefix, config, data, options);
|
|
61
115
|
const suffix = config[data.object.status];
|
|
62
116
|
const event = await Event.create({
|
|
63
117
|
type: [prefix, suffix].join('.'),
|
|
@@ -67,17 +121,16 @@ export async function createStatusEvent(
|
|
|
67
121
|
object_type: scope,
|
|
68
122
|
data,
|
|
69
123
|
request: {
|
|
70
|
-
// FIXME:
|
|
71
124
|
id: '',
|
|
72
125
|
idempotency_key: '',
|
|
73
126
|
requested_by: options.requestedBy || context.getRequestedBy() || 'system',
|
|
74
127
|
},
|
|
75
128
|
metadata: {},
|
|
76
|
-
pending_webhooks: 99,
|
|
129
|
+
pending_webhooks: 99,
|
|
77
130
|
});
|
|
78
131
|
|
|
79
|
-
|
|
80
|
-
|
|
132
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
133
|
+
await emitAndAwait(event.type, data.object);
|
|
81
134
|
}
|
|
82
135
|
|
|
83
136
|
export async function createCustomEvent(
|
|
@@ -97,7 +150,6 @@ export async function createCustomEvent(
|
|
|
97
150
|
return;
|
|
98
151
|
}
|
|
99
152
|
|
|
100
|
-
// console.log('createCustomEvent', scope, prefix, type, data, options);
|
|
101
153
|
const event = await Event.create({
|
|
102
154
|
type: [prefix, suffix].join('.'),
|
|
103
155
|
api_version: API_VERSION,
|
|
@@ -106,26 +158,20 @@ export async function createCustomEvent(
|
|
|
106
158
|
object_type: scope,
|
|
107
159
|
data,
|
|
108
160
|
request: {
|
|
109
|
-
// FIXME:
|
|
110
161
|
id: '',
|
|
111
162
|
idempotency_key: '',
|
|
112
163
|
requested_by: options.requestedBy || context.getRequestedBy() || 'system',
|
|
113
164
|
},
|
|
114
165
|
metadata: {},
|
|
115
|
-
pending_webhooks: 99,
|
|
166
|
+
pending_webhooks: 99,
|
|
116
167
|
});
|
|
117
168
|
|
|
118
|
-
|
|
119
|
-
|
|
169
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
170
|
+
await emitAndAwait(event.type, data.object);
|
|
120
171
|
}
|
|
121
172
|
|
|
122
173
|
/**
|
|
123
174
|
* 创建自定义事件,无需依赖模型对象
|
|
124
|
-
* @param type 完整的事件类型,格式为 prefix.suffix
|
|
125
|
-
* @param objectType 对象类型
|
|
126
|
-
* @param objectId 对象ID
|
|
127
|
-
* @param data 事件数据
|
|
128
|
-
* @param options 额外选项
|
|
129
175
|
*/
|
|
130
176
|
export async function createFlexibleEvent(
|
|
131
177
|
type: string,
|
|
@@ -153,10 +199,10 @@ export async function createFlexibleEvent(
|
|
|
153
199
|
requested_by: requestedBy || context.getRequestedBy() || 'system',
|
|
154
200
|
},
|
|
155
201
|
metadata,
|
|
156
|
-
pending_webhooks: 99,
|
|
202
|
+
pending_webhooks: 99,
|
|
157
203
|
});
|
|
158
204
|
|
|
159
|
-
|
|
160
|
-
|
|
205
|
+
await emitAndAwait('event.created', { id: event.id });
|
|
206
|
+
await emitAndAwait(type, data);
|
|
161
207
|
return event;
|
|
162
208
|
}
|
package/api/src/libs/auth.ts
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'path';
|
|
|
3
3
|
import AuthStorage from '@arcblock/did-connect-storage-nedb';
|
|
4
4
|
// @ts-ignore
|
|
5
5
|
import { BlockletService } from '@blocklet/sdk/service/auth';
|
|
6
|
-
import { getWallet } from '@blocklet/sdk/lib/wallet';
|
|
6
|
+
import { getWallet, getAccessWallet } from '@blocklet/sdk/lib/wallet';
|
|
7
7
|
import { WalletAuthenticator } from '@blocklet/sdk/lib/wallet-authenticator';
|
|
8
8
|
import { WalletHandlers } from '@blocklet/sdk/lib/wallet-handler';
|
|
9
9
|
import type { Request } from 'express';
|
|
@@ -13,9 +13,56 @@ import type { WalletObject } from '@ocap/wallet';
|
|
|
13
13
|
import env from './env';
|
|
14
14
|
import logger from './logger';
|
|
15
15
|
|
|
16
|
+
// Workaround #2: @blocklet/sdk's notification.getSender() uses
|
|
17
|
+
// `getWallet().address` (BLOCKLET_APP_SK derived) as the sender appDid for
|
|
18
|
+
// relay/EventBus broadcasts. On migrated blocklets that address (e.g. zNKti3…)
|
|
19
|
+
// is a rotating session id that the relay does not recognise, surfacing as:
|
|
20
|
+
// "Sender blocklet does not exist: <addr>"
|
|
21
|
+
// "Failed to broadcast event via relay: payment_intent.succeeded"
|
|
22
|
+
// The relay/EventBus calls in notification.js invoke `(0, exports.getSender)()`
|
|
23
|
+
// at send time, so mutating the module's own `exports.getSender` intercepts
|
|
24
|
+
// every call site. We must use `require()` (not `import default`) — the module
|
|
25
|
+
// sets `exports.default` to a subset object that does NOT include getSender,
|
|
26
|
+
// so the default-import alias cannot reach the live `exports.getSender`.
|
|
27
|
+
//
|
|
28
|
+
// Path must be `@blocklet/sdk/service/notification` (no `lib/`) — in Node it
|
|
29
|
+
// is a thin re-export that resolves via the module cache to the same object
|
|
30
|
+
// as `@blocklet/sdk/lib/service/notification`; in CF Workers the esbuild
|
|
31
|
+
// config aliases it to a no-op shim (patching a no-op is harmless). Using
|
|
32
|
+
// the `lib/` path breaks the CF build because it does not have an alias
|
|
33
|
+
// and falls through to `@blocklet/sdk` → `shims/blocklet-sdk/index.ts/lib/...`.
|
|
34
|
+
// Same root cause as the WalletAuthenticator override below — remove once the
|
|
35
|
+
// upstream sdk is patched.
|
|
36
|
+
// eslint-disable-next-line @typescript-eslint/no-var-requires, global-require, import/no-extraneous-dependencies
|
|
37
|
+
const notificationExports = require('@blocklet/sdk/service/notification');
|
|
38
|
+
|
|
39
|
+
notificationExports.getSender = () => ({
|
|
40
|
+
appDid: process.env.BLOCKLET_APP_ID || getWallet(undefined, '', 'sk').address,
|
|
41
|
+
wallet: getAccessWallet(),
|
|
42
|
+
});
|
|
43
|
+
logger.info('[sdk-patch] notification.getSender overridden', {
|
|
44
|
+
appDid: notificationExports.getSender().appDid,
|
|
45
|
+
expectedAppId: process.env.BLOCKLET_APP_ID,
|
|
46
|
+
});
|
|
47
|
+
|
|
16
48
|
export const wallet: WalletObject = getWallet();
|
|
17
49
|
export const ethWallet: WalletObject = getWallet('ethereum');
|
|
18
|
-
|
|
50
|
+
|
|
51
|
+
// Workaround for migrated blocklets where `BLOCKLET_APP_SK` is a rotating session
|
|
52
|
+
// key whose derived address ≠ appId. Upstream @blocklet/sdk's WalletAuthenticator
|
|
53
|
+
// passes `wallet: getWallet()` to did-connect-js, so outer.agentDid becomes that
|
|
54
|
+
// rotating address. Meanwhile the federated cert (signed by master) sets
|
|
55
|
+
// agentDid = `did:abt:${verifySite.appId}` (permanent), so the wallet-side strict
|
|
56
|
+
// check `cert.agentDid === outer.agentDid` fails with "Agent did does not match
|
|
57
|
+
// with certificate issuer."
|
|
58
|
+
//
|
|
59
|
+
// Force the authenticator to derive its signing wallet from BLOCKLET_APP_PK
|
|
60
|
+
// (permanent app pk → address = appId), aligning with the fix in
|
|
61
|
+
// @blocklet/sdk PR #12810 that already corrected getDelegatee. Remove once the
|
|
62
|
+
// upstream wallet-authenticator.ts is patched accordingly.
|
|
63
|
+
export const authenticator = new WalletAuthenticator({
|
|
64
|
+
wallet: getWallet(undefined, '', 'sk'),
|
|
65
|
+
});
|
|
19
66
|
export const handlers = new WalletHandlers({
|
|
20
67
|
authenticator,
|
|
21
68
|
tokenStorage: new AuthStorage({
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import CustomError from './error';
|
|
2
|
+
|
|
3
|
+
export function parseChainError(err: unknown): CustomError {
|
|
4
|
+
const msg = (err as Error)?.message ?? String(err);
|
|
5
|
+
|
|
6
|
+
const gasMatch = msg.match(
|
|
7
|
+
/Insufficient fund to pay for tx cost from (\w+)[\s\S]*?expected\s+([\d.]+)\s+(\w+)[\s\S]*?got\s+([\d.]+)/
|
|
8
|
+
);
|
|
9
|
+
if (gasMatch) {
|
|
10
|
+
return new CustomError('INSUFFICIENT_GAS', 'Main account lacks gas token for tx cost').withDetails({
|
|
11
|
+
account: gasMatch[1],
|
|
12
|
+
required: gasMatch[2],
|
|
13
|
+
available: gasMatch[4],
|
|
14
|
+
token: gasMatch[3],
|
|
15
|
+
});
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// Chain rejects gas-payer JWT when the signing account has no on-chain state
|
|
19
|
+
// (fresh DID that never broadcast a DeclareTx). The wallet shouldn't attach
|
|
20
|
+
// x-gas-payer-* headers in that case, but surface a clean code/message so the
|
|
21
|
+
// UI doesn't render the raw GraphQL string. See #1356.
|
|
22
|
+
const gasPayerNotOnChain = msg.match(/Gas payer (\w+)[\s\S]*?does not exist on chain/i);
|
|
23
|
+
if (gasPayerNotOnChain) {
|
|
24
|
+
return new CustomError(
|
|
25
|
+
'GAS_PAYER_NOT_ON_CHAIN',
|
|
26
|
+
'Wallet account has no on-chain history yet; please fund or transact once before paying'
|
|
27
|
+
).withDetails({ account: gasPayerNotOnChain[1] });
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return new CustomError('TX_REJECTED', msg).withDetails({ raw: msg });
|
|
31
|
+
}
|
|
@@ -0,0 +1,399 @@
|
|
|
1
|
+
// Cross-channel entitlement check.
|
|
2
|
+
//
|
|
3
|
+
// Goal: given a customer DID and a product_id, return whether the customer
|
|
4
|
+
// currently has the entitlement, regardless of which channel funded it
|
|
5
|
+
// (Stripe / on-chain / Google Play / App Store / one-time credit purchase).
|
|
6
|
+
//
|
|
7
|
+
// MVP scope (A3-MVP):
|
|
8
|
+
// - Subscription-funded entitlements are fully supported across all channels
|
|
9
|
+
// - One-time-purchase credit grants are supported when CreditGrant.metadata
|
|
10
|
+
// carries a `product_id` field (existing flows already do this for
|
|
11
|
+
// credit-grant-type Products)
|
|
12
|
+
// - When multiple subscriptions cover the same product (rare — e.g. user
|
|
13
|
+
// buys on iOS and on web), we pick by status priority (active > trialing
|
|
14
|
+
// > paused > past_due) and tie-break by latest current_period_end
|
|
15
|
+
//
|
|
16
|
+
// Channel inference:
|
|
17
|
+
// - Subscription.channel (A0 column) wins when set
|
|
18
|
+
// - Falls back to PaymentMethod.type — for legacy subscriptions written
|
|
19
|
+
// before A0, this maps stripe/arcblock/ethereum/base correctly
|
|
20
|
+
|
|
21
|
+
import { Op } from 'sequelize';
|
|
22
|
+
|
|
23
|
+
import { CreditGrant, Customer, PaymentMethod, Price, Product, Subscription, SubscriptionItem } from '../store/models';
|
|
24
|
+
|
|
25
|
+
// Subscriptions in `active`/`trialing` should still grant entitlement only
|
|
26
|
+
// while the current period hasn't elapsed. Without this, a row whose
|
|
27
|
+
// EXPIRED webhook never landed (network drop, RTDN race onto a duplicate
|
|
28
|
+
// row, etc.) stays status=active forever and the customer keeps Pro long
|
|
29
|
+
// after the platform has stopped billing them. `past_due`/`paused` are
|
|
30
|
+
// allowed to outrun current_period_end — those statuses are themselves
|
|
31
|
+
// the "should-have-renewed-but-hasn't" markers and the grace window is
|
|
32
|
+
// platform-controlled, not period-bounded.
|
|
33
|
+
const stillInActivePeriod = () => ({
|
|
34
|
+
[Op.or]: [
|
|
35
|
+
{ status: ['past_due', 'paused'] as any },
|
|
36
|
+
{
|
|
37
|
+
status: ['active', 'trialing'] as any,
|
|
38
|
+
current_period_end: { [Op.gt]: Math.floor(Date.now() / 1000) },
|
|
39
|
+
},
|
|
40
|
+
],
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
export type Channel = 'stripe' | 'app_store' | 'google_play' | 'arcblock' | 'ethereum' | 'base' | 'bitcoin' | null;
|
|
44
|
+
|
|
45
|
+
export type EntitlementCheckResult = {
|
|
46
|
+
active: boolean;
|
|
47
|
+
channel: Channel;
|
|
48
|
+
expires_at: number | null;
|
|
49
|
+
subscription_id: string | null;
|
|
50
|
+
source: 'subscription' | 'one_time' | null;
|
|
51
|
+
credit_remaining?: string;
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const SUBSCRIPTION_STATUS_PRIORITY: Record<string, number> = {
|
|
55
|
+
active: 0,
|
|
56
|
+
trialing: 1,
|
|
57
|
+
paused: 2,
|
|
58
|
+
past_due: 3,
|
|
59
|
+
};
|
|
60
|
+
const ACTIVE_STATUSES = new Set(['active', 'trialing']);
|
|
61
|
+
|
|
62
|
+
function inactiveResult(): EntitlementCheckResult {
|
|
63
|
+
return { active: false, channel: null, expires_at: null, subscription_id: null, source: null };
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
async function inferChannelFromSubscription(sub: Subscription): Promise<Channel> {
|
|
67
|
+
const ch = (sub as any).channel as Channel | undefined;
|
|
68
|
+
if (ch) return ch;
|
|
69
|
+
if (!sub.default_payment_method_id) return null;
|
|
70
|
+
const method = await PaymentMethod.findByPk(sub.default_payment_method_id);
|
|
71
|
+
return (method?.type as Channel) ?? null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function inferChannelFromGrant(grant: CreditGrant): Promise<Channel> {
|
|
75
|
+
// Most CreditGrant rows store the originating payment_method_id in metadata.
|
|
76
|
+
const pmId = grant.metadata?.payment_method_id || grant.metadata?.paymentMethodId;
|
|
77
|
+
if (!pmId) return null;
|
|
78
|
+
const method = await PaymentMethod.findByPk(pmId);
|
|
79
|
+
return (method?.type as Channel) ?? null;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function pickBestSubscription(subs: Subscription[]): Subscription | undefined {
|
|
83
|
+
return [...subs].sort((a, b) => {
|
|
84
|
+
const pa = SUBSCRIPTION_STATUS_PRIORITY[a.status as string] ?? 99;
|
|
85
|
+
const pb = SUBSCRIPTION_STATUS_PRIORITY[b.status as string] ?? 99;
|
|
86
|
+
if (pa !== pb) return pa - pb;
|
|
87
|
+
// tie-break: later expiry wins
|
|
88
|
+
return (b.current_period_end ?? 0) - (a.current_period_end ?? 0);
|
|
89
|
+
})[0];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* The channel SKU stored on an IAP subscription (Apple/Google product id).
|
|
94
|
+
* Its presence marks a subscription whose product MUST be resolved live via the
|
|
95
|
+
* Product↔SKU mapping rather than via its (possibly stale) SubscriptionItem.
|
|
96
|
+
*/
|
|
97
|
+
function channelSkuOf(sub: any): string | undefined {
|
|
98
|
+
const pd = sub?.payment_details as
|
|
99
|
+
| { app_store?: { product_id?: string }; google_play?: { product_id?: string } }
|
|
100
|
+
| undefined;
|
|
101
|
+
return pd?.app_store?.product_id ?? pd?.google_play?.product_id;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Resolve customer DID → Customer row; returns null if not found.
|
|
106
|
+
* Exposed so listEntitlements can reuse the same lookup.
|
|
107
|
+
*/
|
|
108
|
+
function resolveCustomer(customer_did: string, livemode: boolean): Promise<Customer | null> {
|
|
109
|
+
return Customer.findOne({ where: { did: customer_did, livemode } });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/**
|
|
113
|
+
* Find all active subscriptions that grant `product_id` to this customer.
|
|
114
|
+
* Walks each subscription's items → price → product to do the match.
|
|
115
|
+
*/
|
|
116
|
+
async function findSubscriptionsCoveringProduct(
|
|
117
|
+
customer: Customer,
|
|
118
|
+
productId: string,
|
|
119
|
+
livemode: boolean
|
|
120
|
+
): Promise<Subscription[]> {
|
|
121
|
+
const subs = await Subscription.findAll({
|
|
122
|
+
where: {
|
|
123
|
+
customer_id: customer.id,
|
|
124
|
+
livemode,
|
|
125
|
+
...stillInActivePeriod(),
|
|
126
|
+
} as any,
|
|
127
|
+
include: [
|
|
128
|
+
{
|
|
129
|
+
model: SubscriptionItem,
|
|
130
|
+
as: 'items',
|
|
131
|
+
include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
|
|
132
|
+
},
|
|
133
|
+
],
|
|
134
|
+
});
|
|
135
|
+
// Resolve the queried Product's channel SKUs from its Prices (Stripe-style:
|
|
136
|
+
// SKU binding lives on Price.metadata, not Product.metadata). One Product
|
|
137
|
+
// can have N Prices (monthly / yearly / promo), each bound to its own
|
|
138
|
+
// App Store SKU + Google Play SKU. Matching live against Prices means
|
|
139
|
+
// entitlement always reflects the bindings as configured NOW — does not
|
|
140
|
+
// depend on a product_id snapshot frozen into the subscription. A SKU
|
|
141
|
+
// rebind grants only the new product, not both (PR #1381 review P2).
|
|
142
|
+
//
|
|
143
|
+
// Multi-tenant scoping: the key includes the tenant (bundle_id /
|
|
144
|
+
// package_name) so a sub from App A doesn't accidentally satisfy a
|
|
145
|
+
// Price configured for App B that happens to share the SKU string —
|
|
146
|
+
// App Store / Play Console SKU namespaces are per-app, so two apps
|
|
147
|
+
// owning the literal string "pro_monthly" is the expected case.
|
|
148
|
+
const prices = await Price.findAll({ where: { product_id: productId, livemode } as any });
|
|
149
|
+
const appKeys = new Set<string>(); // ${bundle_id}:${sku}
|
|
150
|
+
const gpKeys = new Set<string>(); // ${package_name}:${sku}
|
|
151
|
+
for (const p of prices) {
|
|
152
|
+
const m = ((p as any).metadata as any) || {};
|
|
153
|
+
if (m.app_store_product_id && m.bundle_id) appKeys.add(`${m.bundle_id}:${m.app_store_product_id}`);
|
|
154
|
+
if (m.google_play_product_id && m.package_name) {
|
|
155
|
+
gpKeys.add(`${m.package_name}:${m.google_play_product_id}`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
return subs.filter((sub) => {
|
|
160
|
+
const pd = (sub as any).payment_details as
|
|
161
|
+
| {
|
|
162
|
+
app_store?: { product_id?: string; bundle_id?: string };
|
|
163
|
+
google_play?: { product_id?: string; package_name?: string };
|
|
164
|
+
}
|
|
165
|
+
| undefined;
|
|
166
|
+
|
|
167
|
+
// IAP subs that carry a channel SKU: resolve ONLY via the live channel-SKU ↔
|
|
168
|
+
// current Price metadata mapping. Deliberately ignore the (possibly stale)
|
|
169
|
+
// SubscriptionItem snapshot AND the legacy metadata.product_id — otherwise a
|
|
170
|
+
// SKU rebind would grant both the old item's product and the new mapping
|
|
171
|
+
// (PR #1381 review P2). This is the single authoritative rule for IAP, and
|
|
172
|
+
// listEntitlements applies the same mapping for consistency.
|
|
173
|
+
if (channelSkuOf(sub)) {
|
|
174
|
+
const appSku = pd?.app_store?.product_id;
|
|
175
|
+
const appBundle = pd?.app_store?.bundle_id;
|
|
176
|
+
const gpSku = pd?.google_play?.product_id;
|
|
177
|
+
const gpPkg = pd?.google_play?.package_name;
|
|
178
|
+
if (appSku && appBundle && appKeys.has(`${appBundle}:${appSku}`)) return true;
|
|
179
|
+
if (gpSku && gpPkg && gpKeys.has(`${gpPkg}:${gpSku}`)) return true;
|
|
180
|
+
return false;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
// Stripe / legacy (no channel SKU stored): walk items → price → product,
|
|
184
|
+
// then the metadata snapshot.
|
|
185
|
+
const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
|
|
186
|
+
if (items?.some((it) => it.price?.product_id === productId)) return true;
|
|
187
|
+
return (sub.metadata as any)?.product_id === productId;
|
|
188
|
+
});
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
/**
|
|
192
|
+
* Find a CreditGrant tied to this customer + product, if any.
|
|
193
|
+
* Convention: CreditGrant.metadata.product_id matches.
|
|
194
|
+
*/
|
|
195
|
+
async function findGrantCoveringProduct(customer: Customer, productId: string): Promise<CreditGrant | null> {
|
|
196
|
+
const grants = await CreditGrant.findAll({
|
|
197
|
+
where: { customer_id: customer.id, status: 'granted' as any },
|
|
198
|
+
});
|
|
199
|
+
return grants.find((g) => g.metadata?.product_id === productId) ?? null;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
function isGrantActive(grant: CreditGrant): boolean {
|
|
203
|
+
if (grant.expires_at && grant.expires_at * 1000 < Date.now()) return false;
|
|
204
|
+
// remaining_amount is stored as string (BN-friendly). Treat anything ≠ "0" as positive.
|
|
205
|
+
const remaining = grant.remaining_amount ?? '0';
|
|
206
|
+
return remaining !== '0' && remaining !== '0.0';
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* Check whether the customer currently holds the entitlement for `product_id`.
|
|
211
|
+
*/
|
|
212
|
+
export async function checkEntitlement({
|
|
213
|
+
customer_did,
|
|
214
|
+
product_id,
|
|
215
|
+
livemode = true,
|
|
216
|
+
}: {
|
|
217
|
+
customer_did: string;
|
|
218
|
+
product_id: string;
|
|
219
|
+
livemode?: boolean;
|
|
220
|
+
}): Promise<EntitlementCheckResult> {
|
|
221
|
+
const customer = await resolveCustomer(customer_did, livemode);
|
|
222
|
+
if (!customer) return inactiveResult();
|
|
223
|
+
|
|
224
|
+
// 1. Subscription path
|
|
225
|
+
const matching = await findSubscriptionsCoveringProduct(customer, product_id, livemode);
|
|
226
|
+
const best = pickBestSubscription(matching);
|
|
227
|
+
if (best) {
|
|
228
|
+
return {
|
|
229
|
+
active: ACTIVE_STATUSES.has(best.status as string),
|
|
230
|
+
channel: await inferChannelFromSubscription(best),
|
|
231
|
+
expires_at: best.current_period_end ?? null,
|
|
232
|
+
subscription_id: best.id,
|
|
233
|
+
source: 'subscription',
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
// 2. One-time credit-grant path
|
|
238
|
+
const grant = await findGrantCoveringProduct(customer, product_id);
|
|
239
|
+
if (grant) {
|
|
240
|
+
return {
|
|
241
|
+
active: isGrantActive(grant),
|
|
242
|
+
channel: await inferChannelFromGrant(grant),
|
|
243
|
+
expires_at: grant.expires_at ?? null,
|
|
244
|
+
subscription_id: null,
|
|
245
|
+
source: 'one_time',
|
|
246
|
+
credit_remaining: grant.remaining_amount,
|
|
247
|
+
};
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
return inactiveResult();
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export type EntitlementListItem = {
|
|
254
|
+
product_id: string;
|
|
255
|
+
active: boolean;
|
|
256
|
+
channel: Channel;
|
|
257
|
+
expires_at: number | null;
|
|
258
|
+
subscription_id: string | null;
|
|
259
|
+
source: 'subscription' | 'one_time';
|
|
260
|
+
credit_remaining?: string;
|
|
261
|
+
};
|
|
262
|
+
|
|
263
|
+
/**
|
|
264
|
+
* List every entitlement this customer holds (one row per distinct product).
|
|
265
|
+
* Subscription items contribute first; CreditGrants add anything not already
|
|
266
|
+
* covered by a subscription.
|
|
267
|
+
*/
|
|
268
|
+
export async function listEntitlements({
|
|
269
|
+
customer_did,
|
|
270
|
+
livemode = true,
|
|
271
|
+
}: {
|
|
272
|
+
customer_did: string;
|
|
273
|
+
livemode?: boolean;
|
|
274
|
+
}): Promise<EntitlementListItem[]> {
|
|
275
|
+
const customer = await resolveCustomer(customer_did, livemode);
|
|
276
|
+
if (!customer) return [];
|
|
277
|
+
|
|
278
|
+
const subs = await Subscription.findAll({
|
|
279
|
+
where: {
|
|
280
|
+
customer_id: customer.id,
|
|
281
|
+
livemode,
|
|
282
|
+
...stillInActivePeriod(),
|
|
283
|
+
} as any,
|
|
284
|
+
include: [
|
|
285
|
+
{
|
|
286
|
+
model: SubscriptionItem,
|
|
287
|
+
as: 'items',
|
|
288
|
+
include: [{ model: Price, as: 'price', include: [{ model: Product, as: 'product' }] }],
|
|
289
|
+
},
|
|
290
|
+
],
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
// Group by product_id, picking best subscription per product. IAP subs that
|
|
294
|
+
// carry a channel SKU resolve via the live SKU→Product mapping (NOT their
|
|
295
|
+
// possibly-stale SubscriptionItem) so list agrees with checkEntitlement
|
|
296
|
+
// (PR #1381 review P2); everything else groups by items.
|
|
297
|
+
const productToSubs = new Map<string, Subscription[]>();
|
|
298
|
+
const addToGroup = (pid: string, sub: Subscription) => {
|
|
299
|
+
if (!productToSubs.has(pid)) productToSubs.set(pid, []);
|
|
300
|
+
productToSubs.get(pid)!.push(sub);
|
|
301
|
+
};
|
|
302
|
+
|
|
303
|
+
const skuSubs = subs.filter((s) => channelSkuOf(s));
|
|
304
|
+
if (skuSubs.length) {
|
|
305
|
+
// Build (tenant, SKU)→productId from the Price catalogue. Stripe-style
|
|
306
|
+
// schema: Price.metadata.{app_store,google_play}_product_id binds the
|
|
307
|
+
// channel SKU to a specific Price; that Price's product_id is the
|
|
308
|
+
// entitlement key. One Product with N Prices (monthly / yearly / promo)
|
|
309
|
+
// all roll up to the same entitlement — exactly the behaviour
|
|
310
|
+
// `entitlements.check(productId)` expects on the client side.
|
|
311
|
+
//
|
|
312
|
+
// Multi-tenant scoping: same SKU string can live in independent App
|
|
313
|
+
// Store / Play Console namespaces (two iOS or two Android apps wired
|
|
314
|
+
// into one Payment Kit), so the key includes the tenant
|
|
315
|
+
// (bundle_id / package_name). Without this, Map.set would silently
|
|
316
|
+
// overwrite collisions and route all subs to whichever Price was
|
|
317
|
+
// iterated last. Each sub reads its own tenant from payment_details
|
|
318
|
+
// (persisted at create time, backfilled for legacy rows).
|
|
319
|
+
const prices = await Price.findAll({ where: { livemode } as any });
|
|
320
|
+
const appKeyToPid = new Map<string, string>();
|
|
321
|
+
const gpKeyToPid = new Map<string, string>();
|
|
322
|
+
for (const p of prices) {
|
|
323
|
+
const m = ((p as any).metadata as any) || {};
|
|
324
|
+
if (m.app_store_product_id && m.bundle_id) {
|
|
325
|
+
appKeyToPid.set(`${m.bundle_id}:${m.app_store_product_id}`, p.product_id);
|
|
326
|
+
}
|
|
327
|
+
if (m.google_play_product_id && m.package_name) {
|
|
328
|
+
gpKeyToPid.set(`${m.package_name}:${m.google_play_product_id}`, p.product_id);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
for (const sub of skuSubs) {
|
|
332
|
+
const pd = (sub as any).payment_details as
|
|
333
|
+
| {
|
|
334
|
+
app_store?: { product_id?: string; bundle_id?: string };
|
|
335
|
+
google_play?: { product_id?: string; package_name?: string };
|
|
336
|
+
}
|
|
337
|
+
| undefined;
|
|
338
|
+
const appSku = pd?.app_store?.product_id;
|
|
339
|
+
const appBundle = pd?.app_store?.bundle_id;
|
|
340
|
+
const gpSku = pd?.google_play?.product_id;
|
|
341
|
+
const gpPkg = pd?.google_play?.package_name;
|
|
342
|
+
const pid =
|
|
343
|
+
(appSku && appBundle && appKeyToPid.get(`${appBundle}:${appSku}`)) ||
|
|
344
|
+
(gpSku && gpPkg && gpKeyToPid.get(`${gpPkg}:${gpSku}`));
|
|
345
|
+
if (pid) addToGroup(pid, sub);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
for (const sub of subs) {
|
|
350
|
+
// eslint-disable-next-line no-continue -- IAP-with-SKU handled above via live mapping
|
|
351
|
+
if (channelSkuOf(sub)) continue;
|
|
352
|
+
const items = (sub as any).items as Array<{ price?: { product_id?: string } }> | undefined;
|
|
353
|
+
const productIds = new Set(items?.map((it) => it.price?.product_id).filter(Boolean) as string[]);
|
|
354
|
+
for (const pid of productIds) addToGroup(pid, sub);
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
const subscriptionProductIds = new Set<string>();
|
|
358
|
+
// Run channel inference in parallel — each is a Sequelize lookup, no shared state.
|
|
359
|
+
const subscriptionRows = await Promise.all(
|
|
360
|
+
Array.from(productToSubs.entries()).map(async ([productId, candidates]) => {
|
|
361
|
+
const best = pickBestSubscription(candidates)!;
|
|
362
|
+
subscriptionProductIds.add(productId);
|
|
363
|
+
const item: EntitlementListItem = {
|
|
364
|
+
product_id: productId,
|
|
365
|
+
active: ACTIVE_STATUSES.has(best.status as string),
|
|
366
|
+
channel: await inferChannelFromSubscription(best),
|
|
367
|
+
expires_at: best.current_period_end ?? null,
|
|
368
|
+
subscription_id: best.id,
|
|
369
|
+
source: 'subscription',
|
|
370
|
+
};
|
|
371
|
+
return item;
|
|
372
|
+
})
|
|
373
|
+
);
|
|
374
|
+
|
|
375
|
+
// Add CreditGrant-funded entitlements for products not already covered
|
|
376
|
+
const grants = await CreditGrant.findAll({
|
|
377
|
+
where: { customer_id: customer.id, status: 'granted' as any },
|
|
378
|
+
});
|
|
379
|
+
const uncoveredGrants = grants.filter((g) => {
|
|
380
|
+
const pid = g.metadata?.product_id;
|
|
381
|
+
return pid && !subscriptionProductIds.has(pid);
|
|
382
|
+
});
|
|
383
|
+
const grantRows = await Promise.all(
|
|
384
|
+
uncoveredGrants.map(async (grant) => {
|
|
385
|
+
const item: EntitlementListItem = {
|
|
386
|
+
product_id: grant.metadata!.product_id,
|
|
387
|
+
active: isGrantActive(grant),
|
|
388
|
+
channel: await inferChannelFromGrant(grant),
|
|
389
|
+
expires_at: grant.expires_at ?? null,
|
|
390
|
+
subscription_id: null,
|
|
391
|
+
source: 'one_time',
|
|
392
|
+
credit_remaining: grant.remaining_amount,
|
|
393
|
+
};
|
|
394
|
+
return item;
|
|
395
|
+
})
|
|
396
|
+
);
|
|
397
|
+
|
|
398
|
+
return [...subscriptionRows, ...grantRows];
|
|
399
|
+
}
|
package/api/src/libs/env.ts
CHANGED
|
@@ -18,6 +18,8 @@ export const depositVaultCronTime: string = process.env.DEPOSIT_VAULT_CRON_TIME
|
|
|
18
18
|
export const creditConsumptionCronTime: string = process.env.CREDIT_CONSUMPTION_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
19
19
|
export const vendorStatusCheckCronTime: string = process.env.VENDOR_STATUS_CHECK_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
20
20
|
export const vendorReturnScanCronTime: string = process.env.VENDOR_RETURN_SCAN_CRON_TIME || '0 */10 * * * *'; // 默认每 10 min 执行一次
|
|
21
|
+
export const iapReconcileCronTime: string = process.env.IAP_RECONCILE_CRON_TIME || '0 */5 * * * *'; // 默认每 5 min 执行一次:webhook 兜底,拉 App Store / Google Play 订阅最新状态
|
|
22
|
+
export const eventRetryCronTime: string = process.env.EVENT_RETRY_CRON_TIME || '30 */5 * * * *'; // 默认每 5 min 执行一次(错开整点避开 iap-reconcile):扫 pending_webhooks>0 的事件兜底投递
|
|
21
23
|
export const quoteCleanupCronTime: string = process.env.QUOTE_CLEANUP_CRON_TIME || '0 0 2 * * *'; // 默认每天凌晨 2 点执行一次
|
|
22
24
|
export const vendorTimeoutMinutes: number = process.env.VENDOR_TIMEOUT_MINUTES
|
|
23
25
|
? +process.env.VENDOR_TIMEOUT_MINUTES
|