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,415 @@
1
+ /* eslint-disable no-await-in-loop, no-continue --
2
+ Sequential per-subscription processing is intentional: each iteration
3
+ issues 1-2 D1 writes + an Apple/Google API call, and parallelizing would
4
+ blast D1 with N concurrent transactions and trip per-method rate limits
5
+ on Apple's App Store Server API. The `continue` guards bail early on
6
+ rows that can't be processed (missing tenant, missing original txn,
7
+ etc.) which is cleaner than nested if-else for a long per-row pipeline. */
8
+ // IAP reconcile cron — webhook backup.
9
+ //
10
+ // Webhooks are the fast path for App Store Server Notifications V2 and Google
11
+ // Play RTDN. They occasionally fail to deliver (Apple/Google outages, our 5xx
12
+ // during deploy, Pub/Sub backlog dropping old messages). When that happens the
13
+ // local Subscription row falls out of sync with the real subscription state at
14
+ // Apple/Google, and the user either keeps Pro after a refund or loses Pro at
15
+ // renewal.
16
+ //
17
+ // This module periodically pulls each active `app_store` / `google_play`
18
+ // subscription's authoritative state from the App Store Server API + Google
19
+ // Play Developer API and applies drift to the local row. Cron schedule is
20
+ // every 5 minutes by default; tuneable via `IAP_RECONCILE_CRON_TIME`.
21
+
22
+ import { Op } from 'sequelize';
23
+
24
+ import { createEvent } from '../libs/audit';
25
+ import logger from '../libs/logger';
26
+ import { PaymentMethod, Price, Subscription, SubscriptionItem } from '../store/models';
27
+ import { AppStoreClient, AppStoreTransactionPayload } from './app-store/client';
28
+ import { GooglePlayClient, GooglePlaySubscriptionPurchase } from './google-play/client';
29
+
30
+ /** Don't re-check subs that were updated by a webhook within the last 5 minutes. */
31
+ const RECENT_UPDATE_GUARD_MS = 5 * 60 * 1000;
32
+
33
+ /** Per-channel batch cap so a single cron tick can't stall on Apple/Google rate limits. */
34
+ const DEFAULT_BATCH_SIZE = Number(process.env.IAP_RECONCILE_BATCH_SIZE ?? '100');
35
+
36
+ type ReconcileStats = { checked: number; updated: number; errors: number };
37
+
38
+ const emptyStats = (): ReconcileStats => ({ checked: 0, updated: 0, errors: 0 });
39
+
40
+ /**
41
+ * Single entry point used by the cron registry. Catches per-channel errors so
42
+ * one channel's outage doesn't take down the other.
43
+ */
44
+ export async function runIapReconcile(): Promise<{ app_store: ReconcileStats; google_play: ReconcileStats }> {
45
+ const [appStore, googlePlay] = await Promise.all([
46
+ reconcileAppStore().catch((err) => {
47
+ logger.error('iap-reconcile: app_store channel failed', { error: err?.message });
48
+ return emptyStats();
49
+ }),
50
+ reconcileGooglePlay().catch((err) => {
51
+ logger.error('iap-reconcile: google_play channel failed', { error: err?.message });
52
+ return emptyStats();
53
+ }),
54
+ ]);
55
+ // Self-heal SubscriptionItem rows for any IAP sub whose Product mapping
56
+ // became available AFTER purchase (typical: customer bought before admin
57
+ // bound the SKU to a local Product, so ingest skipped SubscriptionItem
58
+ // creation; once the binding exists, admin UI's product-name column and
59
+ // anything else that walks subscription.items would otherwise stay blank
60
+ // forever).
61
+ await backfillMissingSubscriptionItems().catch((err) => {
62
+ logger.error('iap-reconcile: subscription_items backfill failed', { error: err?.message });
63
+ });
64
+ logger.info('iap-reconcile: pass complete', { app_store: appStore, google_play: googlePlay });
65
+ return { app_store: appStore, google_play: googlePlay };
66
+ }
67
+
68
+ /**
69
+ * Backfill SubscriptionItem rows for IAP Subscriptions that lost out on the
70
+ * happy-path create (Price was unmapped at ingest time, or the create call
71
+ * crashed mid-flow). Idempotent — only inserts when both:
72
+ * - The sub has zero SubscriptionItem rows
73
+ * - We can resolve a Price via channel SKU → Price.metadata mapping
74
+ *
75
+ * Stripe-style schema: SKU binding lives on Price.metadata, not
76
+ * Product.metadata, so we point the SubscriptionItem at the exact Price the
77
+ * customer paid for (not the Product's default_price). That preserves the
78
+ * monthly-vs-yearly distinction in the SubscriptionItem record.
79
+ */
80
+ async function backfillMissingSubscriptionItems(): Promise<void> {
81
+ const candidates = await Subscription.findAll({
82
+ where: { channel: { [Op.in]: ['google_play', 'app_store'] } as any },
83
+ limit: 500,
84
+ });
85
+ // Cache decoded PaymentMethod tenant identifiers (bundle_id / package_name)
86
+ // so we don't re-decrypt settings for every sub. Most installations have a
87
+ // small number of IAP PaymentMethods so this fits in a Map.
88
+ const tenantCache = new Map<string, string | null>();
89
+ const tenantFor = async (sub: Subscription): Promise<string | null> => {
90
+ const pmId = (sub as any).default_payment_method_id as string | undefined;
91
+ if (!pmId) return null;
92
+ if (tenantCache.has(pmId)) return tenantCache.get(pmId)!;
93
+ const pm = await PaymentMethod.findByPk(pmId);
94
+ const settings = pm ? PaymentMethod.decryptSettings(pm.settings) : null;
95
+ const tenant =
96
+ sub.channel === 'google_play'
97
+ ? (settings?.google_play?.package_name ?? null)
98
+ : (settings?.app_store?.bundle_id ?? null);
99
+ tenantCache.set(pmId, tenant);
100
+ return tenant;
101
+ };
102
+ let inserted = 0;
103
+ for (const sub of candidates) {
104
+ const existing = await SubscriptionItem.count({ where: { subscription_id: sub.id } });
105
+ if (existing > 0) continue;
106
+
107
+ const pd: any = (sub as any).payment_details;
108
+ const sku = sub.channel === 'google_play' ? pd?.google_play?.product_id : pd?.app_store?.product_id;
109
+ if (!sku) continue;
110
+
111
+ // Multi-tenant scoping: filter Price lookup by the originating app's
112
+ // bundle_id (iOS) / package_name (Android). Without this, a sub from
113
+ // App A with SKU "pro_monthly" could be backfilled with App B's Price
114
+ // if App B also has a SKU "pro_monthly" — same SKU string lives in
115
+ // independent App Store / Play Console namespaces.
116
+ const tenant = await tenantFor(sub);
117
+ if (!tenant) {
118
+ logger.warn('iap-reconcile: no tenant id (bundleId/packageName) for sub, skipping backfill', {
119
+ subscriptionId: sub.id,
120
+ channel: sub.channel,
121
+ });
122
+ continue;
123
+ }
124
+ const skuKey = sub.channel === 'google_play' ? 'google_play_product_id' : 'app_store_product_id';
125
+ const tenantKey = sub.channel === 'google_play' ? 'package_name' : 'bundle_id';
126
+ const price = await Price.findOne({
127
+ where: { [`metadata.${skuKey}`]: sku, [`metadata.${tenantKey}`]: tenant } as any,
128
+ });
129
+ if (!price) continue;
130
+
131
+ await SubscriptionItem.create({
132
+ subscription_id: sub.id,
133
+ price_id: price.id,
134
+ quantity: 1,
135
+ livemode: sub.livemode,
136
+ metadata: { backfilled_at: Math.floor(Date.now() / 1000), reason: 'reconcile_missing_item' },
137
+ } as any);
138
+ inserted += 1;
139
+ logger.info('iap-reconcile: backfilled SubscriptionItem', {
140
+ subscriptionId: sub.id,
141
+ productId: price.product_id,
142
+ priceId: price.id,
143
+ });
144
+ }
145
+ if (inserted > 0) logger.info(`iap-reconcile: backfilled ${inserted} subscription_items`);
146
+ }
147
+
148
+ // ---------------------------------------------------------------------------
149
+ // App Store
150
+ // ---------------------------------------------------------------------------
151
+
152
+ export async function reconcileAppStore(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
153
+ const stats = emptyStats();
154
+ const methods = await PaymentMethod.findAll({ where: { type: 'app_store' } });
155
+ if (methods.length === 0) return stats;
156
+
157
+ const subs = await Subscription.findAll({
158
+ where: {
159
+ status: { [Op.in]: ['active', 'past_due', 'trialing'] },
160
+ 'payment_details.app_store.original_transaction_id': { [Op.ne]: null } as any,
161
+ updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
162
+ } as any,
163
+ order: [['updated_at', 'ASC']],
164
+ limit: batchSize,
165
+ });
166
+
167
+ for (const sub of subs) {
168
+ stats.checked += 1;
169
+ try {
170
+ const originalTransactionId = sub.payment_details?.app_store?.original_transaction_id as string | undefined;
171
+ if (!originalTransactionId) continue;
172
+
173
+ const method = pickIapMethodForSub(methods, sub);
174
+ if (!method) {
175
+ logger.warn('iap-reconcile: no matching app_store PaymentMethod', { subscriptionId: sub.id });
176
+ continue;
177
+ }
178
+
179
+ // Apple SDK throws when issuer/key creds aren't present. Skip cleanly.
180
+ let client: AppStoreClient;
181
+ try {
182
+ client = method.getAppStoreClient();
183
+ } catch (err: any) {
184
+ logger.warn('iap-reconcile: cannot build app_store client', { subscriptionId: sub.id, error: err?.message });
185
+ continue;
186
+ }
187
+
188
+ const transaction = await client.getSubscriptionStatus(originalTransactionId);
189
+ if (!transaction) {
190
+ logger.warn('iap-reconcile: app_store getSubscriptionStatus returned null', { originalTransactionId });
191
+ continue;
192
+ }
193
+ const drifted = await applyAppStoreTransactionDrift(sub, transaction);
194
+ if (drifted) stats.updated += 1;
195
+ } catch (err: any) {
196
+ stats.errors += 1;
197
+ logger.error('iap-reconcile: app_store sub failed', { subscriptionId: sub.id, error: err?.message });
198
+ }
199
+ }
200
+ return stats;
201
+ }
202
+
203
+ /**
204
+ * Select the IAP PaymentMethod that actually funds this subscription. Match by
205
+ * the bound `default_payment_method_id` — NOT the non-existent `payment_method_id`
206
+ * (the previous bug, which always fell through to methods[0] and queried the
207
+ * wrong app/credentials in multi-app/multi-env deployments — PR #1381 review P1).
208
+ * Only default to the sole method when there is exactly one; never pick an
209
+ * arbitrary method.
210
+ */
211
+ export function pickIapMethodForSub(methods: PaymentMethod[], sub: Subscription): PaymentMethod | undefined {
212
+ const bound = methods.find((m) => m.id === sub.default_payment_method_id);
213
+ if (bound) return bound;
214
+ return methods.length === 1 ? methods[0] : undefined;
215
+ }
216
+
217
+ /**
218
+ * Compare Apple's authoritative `transaction` payload against our local row
219
+ * and apply only the drifts that matter — period_end forward jump (renewed),
220
+ * revocation (refunded), explicit expiry.
221
+ *
222
+ * Returns true if we wrote to the row.
223
+ */
224
+ export async function applyAppStoreTransactionDrift(
225
+ sub: Subscription,
226
+ transaction: AppStoreTransactionPayload
227
+ ): Promise<boolean> {
228
+ const appleExpiresSec = transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined;
229
+ const revoked = Boolean(transaction.revocationDate);
230
+
231
+ if (revoked) {
232
+ if (sub.status === 'canceled') return false;
233
+ await sub.update({
234
+ status: 'canceled',
235
+ canceled_at: Math.floor((transaction.revocationDate ?? Date.now()) / 1000),
236
+ cancelation_details: {
237
+ reason: 'app_store_revoked',
238
+ feedback: `reconcile-cron: detected revocationDate=${transaction.revocationDate}`,
239
+ } as any,
240
+ metadata: {
241
+ ...(sub.metadata || {}),
242
+ app_store_reconciled_at: Math.floor(Date.now() / 1000),
243
+ },
244
+ });
245
+ createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
246
+ return true;
247
+ }
248
+
249
+ // Renewal drift — Apple says we have more time than we record.
250
+ if (appleExpiresSec && appleExpiresSec > sub.current_period_end + 60) {
251
+ await sub.update({
252
+ status: 'active',
253
+ current_period_end: appleExpiresSec,
254
+ payment_details: {
255
+ ...(sub.payment_details || {}),
256
+ app_store: {
257
+ ...(sub.payment_details?.app_store || ({} as any)),
258
+ expires_at: transaction.expiresDate ? Math.floor(transaction.expiresDate / 1000) : undefined,
259
+ },
260
+ },
261
+ metadata: {
262
+ ...(sub.metadata || {}),
263
+ app_store_reconciled_at: Math.floor(Date.now() / 1000),
264
+ },
265
+ });
266
+ createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
267
+ return true;
268
+ }
269
+
270
+ // Past expiry — Apple says we're past period end but we still say active.
271
+ const nowSec = Math.floor(Date.now() / 1000);
272
+ if (appleExpiresSec && appleExpiresSec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
273
+ await sub.update({
274
+ status: 'canceled',
275
+ canceled_at: nowSec,
276
+ cancelation_details: {
277
+ reason: 'app_store_expired',
278
+ feedback: `reconcile-cron: appleExpires=${transaction.expiresDate} < now`,
279
+ } as any,
280
+ metadata: {
281
+ ...(sub.metadata || {}),
282
+ app_store_reconciled_at: nowSec,
283
+ },
284
+ });
285
+ createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
286
+ return true;
287
+ }
288
+
289
+ return false;
290
+ }
291
+
292
+ // ---------------------------------------------------------------------------
293
+ // Google Play
294
+ // ---------------------------------------------------------------------------
295
+
296
+ export async function reconcileGooglePlay(batchSize = DEFAULT_BATCH_SIZE): Promise<ReconcileStats> {
297
+ const stats = emptyStats();
298
+ const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
299
+ if (methods.length === 0) return stats;
300
+
301
+ const subs = await Subscription.findAll({
302
+ where: {
303
+ status: { [Op.in]: ['active', 'past_due', 'trialing'] },
304
+ 'payment_details.google_play.purchase_token': { [Op.ne]: null } as any,
305
+ updated_at: { [Op.lt]: new Date(Date.now() - RECENT_UPDATE_GUARD_MS) },
306
+ } as any,
307
+ order: [['updated_at', 'ASC']],
308
+ limit: batchSize,
309
+ });
310
+
311
+ for (const sub of subs) {
312
+ stats.checked += 1;
313
+ try {
314
+ const purchaseToken = sub.payment_details?.google_play?.purchase_token as string | undefined;
315
+ const productId = sub.payment_details?.google_play?.product_id as string | undefined;
316
+ if (!purchaseToken || !productId) continue;
317
+
318
+ const method = pickIapMethodForSub(methods, sub);
319
+ if (!method) {
320
+ logger.warn('iap-reconcile: no matching google_play PaymentMethod', { subscriptionId: sub.id });
321
+ continue;
322
+ }
323
+ let client: GooglePlayClient;
324
+ try {
325
+ client = method.getGooglePlayClient();
326
+ } catch (err: any) {
327
+ logger.warn('iap-reconcile: cannot build google_play client', { subscriptionId: sub.id, error: err?.message });
328
+ continue;
329
+ }
330
+
331
+ const purchase = await client.getSubscription(productId, purchaseToken);
332
+ const drifted = await applyGooglePlayPurchaseDrift(sub, purchase);
333
+ if (drifted) stats.updated += 1;
334
+ } catch (err: any) {
335
+ stats.errors += 1;
336
+ logger.error('iap-reconcile: google_play sub failed', { subscriptionId: sub.id, error: err?.message });
337
+ }
338
+ }
339
+ return stats;
340
+ }
341
+
342
+ /**
343
+ * Compare Google's authoritative `purchase` payload against our local row
344
+ * and apply only meaningful drifts.
345
+ *
346
+ * Returns true if we wrote to the row.
347
+ */
348
+ export async function applyGooglePlayPurchaseDrift(
349
+ sub: Subscription,
350
+ purchase: GooglePlaySubscriptionPurchase
351
+ ): Promise<boolean> {
352
+ const expirySec = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
353
+ // cancelReason: 0=user, 1=system, 2=replaced, 3=developer
354
+ const revoked =
355
+ typeof purchase.cancelReason === 'number' && purchase.cancelReason >= 0 && purchase.autoRenewing === false;
356
+
357
+ // Renewal drift forward
358
+ if (expirySec && expirySec > sub.current_period_end + 60) {
359
+ await sub.update({
360
+ status: 'active',
361
+ current_period_end: expirySec,
362
+ payment_details: {
363
+ ...(sub.payment_details || {}),
364
+ google_play: {
365
+ ...((sub.payment_details?.google_play || {}) as any),
366
+ expiry_time_millis: purchase.expiryTimeMillis,
367
+ },
368
+ },
369
+ metadata: {
370
+ ...(sub.metadata || {}),
371
+ google_play_reconciled_at: Math.floor(Date.now() / 1000),
372
+ },
373
+ });
374
+ createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
375
+ return true;
376
+ }
377
+
378
+ // Auto-renew off (user canceled but still has paid period) → schedule cancel
379
+ if (revoked && !sub.cancel_at_period_end) {
380
+ await sub.update({
381
+ cancel_at_period_end: true,
382
+ cancelation_details: {
383
+ reason: 'google_play_auto_renew_off',
384
+ feedback: `reconcile-cron: cancelReason=${purchase.cancelReason} autoRenewing=false`,
385
+ } as any,
386
+ metadata: {
387
+ ...(sub.metadata || {}),
388
+ google_play_reconciled_at: Math.floor(Date.now() / 1000),
389
+ },
390
+ });
391
+ createEvent('Subscription', 'customer.subscription.updated', sub).catch(() => {});
392
+ return true;
393
+ }
394
+
395
+ // Past expiry — Google says we're expired but local still active.
396
+ const nowSec = Math.floor(Date.now() / 1000);
397
+ if (expirySec && expirySec < nowSec - 60 && (sub.status === 'active' || sub.status === 'trialing')) {
398
+ await sub.update({
399
+ status: 'canceled',
400
+ canceled_at: nowSec,
401
+ cancelation_details: {
402
+ reason: 'google_play_expired',
403
+ feedback: `reconcile-cron: googleExpires=${purchase.expiryTimeMillis} < now`,
404
+ } as any,
405
+ metadata: {
406
+ ...(sub.metadata || {}),
407
+ google_play_reconciled_at: nowSec,
408
+ },
409
+ });
410
+ createEvent('Subscription', 'customer.subscription.deleted', sub).catch(() => {});
411
+ return true;
412
+ }
413
+
414
+ return false;
415
+ }
@@ -8,6 +8,29 @@ import { context } from './context';
8
8
 
9
9
  const API_VERSION = '2023-09-05';
10
10
 
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
+
11
34
  export function createEvent(
12
35
  scope: string,
13
36
  type: LiteralUnion<EventType, string>,
@@ -58,8 +81,15 @@ async function doCreateEvent(scope: string, type: LiteralUnion<EventType, string
58
81
  pending_webhooks: 99,
59
82
  });
60
83
 
61
- events.emit('event.created', { id: event.id });
62
- events.emit(event.type, data.object, options);
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);
63
93
  }
64
94
 
65
95
  export async function createStatusEvent(
@@ -99,8 +129,8 @@ export async function createStatusEvent(
99
129
  pending_webhooks: 99,
100
130
  });
101
131
 
102
- events.emit('event.created', { id: event.id });
103
- events.emit(event.type, data.object);
132
+ await emitAndAwait('event.created', { id: event.id });
133
+ await emitAndAwait(event.type, data.object);
104
134
  }
105
135
 
106
136
  export async function createCustomEvent(
@@ -136,8 +166,8 @@ export async function createCustomEvent(
136
166
  pending_webhooks: 99,
137
167
  });
138
168
 
139
- events.emit('event.created', { id: event.id });
140
- events.emit(event.type, data.object);
169
+ await emitAndAwait('event.created', { id: event.id });
170
+ await emitAndAwait(event.type, data.object);
141
171
  }
142
172
 
143
173
  /**
@@ -172,7 +202,7 @@ export async function createFlexibleEvent(
172
202
  pending_webhooks: 99,
173
203
  });
174
204
 
175
- events.emit('event.created', { id: event.id });
176
- events.emit(type, data);
205
+ await emitAndAwait('event.created', { id: event.id });
206
+ await emitAndAwait(type, data);
177
207
  return event;
178
208
  }