payment-kit 1.28.0 → 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.
Files changed (74) hide show
  1. package/api/src/crons/index.ts +22 -0
  2. package/api/src/crons/retry-pending-events.ts +58 -0
  3. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  4. package/api/src/integrations/app-store/client.ts +369 -0
  5. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  6. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  7. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  8. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  9. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  10. package/api/src/integrations/google-play/client.ts +276 -0
  11. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  12. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  13. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  14. package/api/src/integrations/google-play/setup.ts +43 -0
  15. package/api/src/integrations/google-play/verify.ts +251 -0
  16. package/api/src/integrations/iap-reconcile.ts +415 -0
  17. package/api/src/libs/audit.ts +38 -8
  18. package/api/src/libs/entitlement.ts +399 -0
  19. package/api/src/libs/env.ts +2 -0
  20. package/api/src/libs/security.ts +51 -0
  21. package/api/src/libs/subscription.ts +13 -1
  22. package/api/src/libs/util.ts +13 -0
  23. package/api/src/queues/event.ts +25 -19
  24. package/api/src/queues/webhook.ts +12 -2
  25. package/api/src/routes/entitlements.ts +105 -0
  26. package/api/src/routes/events.ts +2 -2
  27. package/api/src/routes/index.ts +12 -2
  28. package/api/src/routes/integrations/app-store.ts +267 -0
  29. package/api/src/routes/integrations/google-play.ts +324 -0
  30. package/api/src/routes/payment-methods.ts +130 -0
  31. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  32. package/api/src/store/models/customer.ts +14 -0
  33. package/api/src/store/models/entitlement-grant.ts +118 -0
  34. package/api/src/store/models/entitlement-product.ts +48 -0
  35. package/api/src/store/models/entitlement.ts +86 -0
  36. package/api/src/store/models/index.ts +9 -0
  37. package/api/src/store/models/invoice.ts +20 -0
  38. package/api/src/store/models/payment-method.ts +62 -1
  39. package/api/src/store/models/refund.ts +10 -0
  40. package/api/src/store/models/subscription.ts +14 -0
  41. package/api/src/store/models/types.ts +32 -0
  42. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  43. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  44. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  45. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  46. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  47. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  48. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  49. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  50. package/api/tests/libs/entitlement.spec.ts +347 -0
  51. package/blocklet.yml +1 -1
  52. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  53. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  54. package/cloudflare/run-build.js +1 -0
  55. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  56. package/cloudflare/shims/queue.ts +28 -2
  57. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  58. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  59. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  60. package/cloudflare/worker.ts +59 -4
  61. package/cloudflare/wrangler.jsonc +7 -1
  62. package/cloudflare/wrangler.staging.json +2 -1
  63. package/package.json +10 -6
  64. package/scripts/seed-google-play.ts +79 -0
  65. package/src/components/payment-method/app-store.tsx +103 -0
  66. package/src/components/payment-method/form.tsx +7 -1
  67. package/src/components/payment-method/google-play.tsx +85 -0
  68. package/src/components/subscription/list.tsx +20 -0
  69. package/src/locales/en.tsx +63 -0
  70. package/src/locales/zh.tsx +63 -0
  71. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  72. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  73. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  74. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -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
+ }
@@ -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
@@ -1,5 +1,6 @@
1
1
  import { auth } from '@blocklet/sdk/lib/middlewares';
2
2
  import { getVerifyData, verify } from '@blocklet/sdk/lib/util/verify-sign';
3
+ import { verifyLoginToken } from '@blocklet/sdk/lib/util/verify-session';
3
4
  import { getWallet } from '@blocklet/sdk/lib/wallet';
4
5
  import type { NextFunction, Request, Response } from 'express';
5
6
  import type { Model } from 'sequelize';
@@ -39,6 +40,56 @@ export function authenticate<T extends Model>({
39
40
  ensureLogin,
40
41
  }: PermissionSpec<T>) {
41
42
  return async (req: Request, res: Response, next: NextFunction) => {
43
+ // Dev-only bypass: requires both NODE_ENV=development AND the explicit
44
+ // opt-in env ENABLE_DEV_FAKE_AUTH=1, plus the x-dev-fake-did header on
45
+ // the request. Lets mobile demo clients that don't go through DID Connect
46
+ // (e.g. the local-tunnel backend which bypasses Blocklet Server) still
47
+ // exercise real handlers. Production never sets ENABLE_DEV_FAKE_AUTH, so
48
+ // this branch can't trigger there; dev defaults off, so we don't
49
+ // accidentally regress to fake auth after wiring the real flow.
50
+ if (process.env.NODE_ENV === 'development' && process.env.ENABLE_DEV_FAKE_AUTH === '1') {
51
+ const devDid = req.get('x-dev-fake-did');
52
+ if (devDid) {
53
+ req.user = {
54
+ did: devDid,
55
+ role: 'owner', // satisfies routes that require owner/admin
56
+ provider: 'dev',
57
+ fullName: 'dev-fake-user',
58
+ walletOS: '',
59
+ via: 'dev',
60
+ };
61
+ return next();
62
+ }
63
+ }
64
+
65
+ // Authenticate by Authorization: Bearer <login-token>. The token is a JWT
66
+ // signed with this blocklet's session secret (see @blocklet/sdk session
67
+ // middleware). When clients hit us through a tunnel that bypasses Blocklet
68
+ // Server (so x-user-did is NOT injected), we need to verify the token
69
+ // ourselves. verifyLoginToken does local JWT signature verification, no
70
+ // HTTP callback. On success we forward into the existing x-user-did branch
71
+ // by populating req.headers so the role/mine/record cascade below applies
72
+ // unchanged.
73
+ const authHeader = req.get('authorization');
74
+ if (authHeader && /^Bearer\s+/i.test(authHeader) && !req.headers['x-user-did']) {
75
+ const token = authHeader.replace(/^Bearer\s+/i, '').trim();
76
+ if (token) {
77
+ const session = await verifyLoginToken({ token, strictMode: false }).catch(() => null);
78
+ if (session?.did) {
79
+ // Some BS versions put a bare base58 address in the JWT, others
80
+ // the canonical `did:abt:…` form. Normalize so downstream code
81
+ // (entitlement self-check, mine: lookups by req.user.did) sees the
82
+ // same shape clients send in `customer_did` query params.
83
+ const canonicalDid = session.did.startsWith('did:abt:') ? session.did : `did:abt:${session.did}`;
84
+ req.headers['x-user-did'] = canonicalDid;
85
+ req.headers['x-user-role'] = `blocklet-${session.role || 'user'}`;
86
+ req.headers['x-user-provider'] = session.provider || 'wallet';
87
+ req.headers['x-user-fullname'] = encodeURIComponent(session.fullName || '');
88
+ req.headers['x-user-wallet-os'] = session.walletOS || '';
89
+ }
90
+ }
91
+ }
92
+
42
93
  // authenticate by component call
43
94
  const sig = req.get('x-component-sig');
44
95
  if (component && sig) {
@@ -1357,8 +1357,20 @@ export async function isSubscriptionOverdraftProtectionEnabled(subscription: Sub
1357
1357
  throw new Error(`PaymentMethod not found in ${subscription.id}`);
1358
1358
  }
1359
1359
 
1360
+ // Overdraft protection is only meaningful for on-chain (arcblock) payment
1361
+ // methods where users stake tokens. IAP / Stripe channels have no notion of
1362
+ // staking; return disabled-default silently rather than throwing into the
1363
+ // outer catch (which would spam the error log on every google_play /
1364
+ // app_store / stripe subscription event).
1360
1365
  if (paymentMethod.type !== 'arcblock') {
1361
- throw new Error(`PaymentMethod type not supported in ${subscription.id}`);
1366
+ return {
1367
+ enabled: false,
1368
+ remaining: '0',
1369
+ used: '0',
1370
+ shouldPay: '0',
1371
+ unused: '0',
1372
+ revokedStake: '0',
1373
+ };
1362
1374
  }
1363
1375
  if (!subscription.overdraft_protection) {
1364
1376
  return {
@@ -30,6 +30,19 @@ export const CHECKOUT_SESSION_TTL = 6 * 60 * 60; // expires in 6 hours, then rem
30
30
 
31
31
  export const STRIPE_API_VERSION = '2023-08-16';
32
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');
33
46
  export const STRIPE_EVENTS: any[] = [
34
47
  'checkout.session.async_payment_failed',
35
48
  'checkout.session.async_payment_succeeded',
@@ -34,29 +34,35 @@ export const handleEvent = async (job: EventJob) => {
34
34
  return;
35
35
  }
36
36
 
37
- await event.update({ pending_webhooks: eventWebhooks.length });
38
- logger.info(`Updated event ${event.id} with ${eventWebhooks.length} pending webhooks`);
39
-
40
- eventWebhooks.forEach(async (webhook) => {
41
- const attemptCount = await WebhookAttempt.count({
42
- where: {
43
- event_id: event.id,
44
- webhook_endpoint_id: webhook.id,
45
- response_status: {
46
- [Op.gte]: 200,
47
- [Op.lt]: 300,
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
- // we should only push webhook if it's not successfully attempted before
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
- await addWebhookJob(event.id, webhook.id, { persist: false });
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
- logger.info(`Finished handling event ${job.eventId}`);
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>({
@@ -134,7 +134,12 @@ export const handleWebhook = async (job: WebhookJob) => {
134
134
  process.nextTick(() => {
135
135
  addWebhookJob(event.id, webhook.id, {
136
136
  runAt: getNextRetry(retryCount, event.created_at),
137
- persist: false,
137
+ // persist=true: on Cloudflare the queue shim only writes DELAYED jobs to
138
+ // D1 (with will_run_at, dispatched by the cron) when persisted, and does
139
+ // NOT send them to CF Queue. With persist:false the retry was silently
140
+ // dropped, so after the first failure the whole retry ladder vanished
141
+ // and the delivery was lost (PR #1381 review P1).
142
+ persist: true,
138
143
  });
139
144
  });
140
145
  } else {
@@ -208,7 +213,12 @@ export async function addWebhookJob(
208
213
  replace?: boolean;
209
214
  } = {}
210
215
  ) {
211
- const { runAt, persist = false, replace = true } = options;
216
+ // Default persist=true so CF Workers' Queue transport is backed by a D1
217
+ // jobs row — if CF Queue is momentarily unavailable or the consumer
218
+ // doesn't pick the message up, the row stays for the next cron dispatcher
219
+ // to retry. With persist=false the message can simply vanish (we lost
220
+ // ~13 minutes of webhook deliveries on staging confirming this).
221
+ const { runAt, persist = true, replace = true } = options;
212
222
  const jobId = getWebhookJobId(eventId, webhookId);
213
223
  const exist = await webhookQueue.get(jobId);
214
224