payment-kit 1.28.0 → 1.29.1

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 (76) 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/docs/2026-06-10-bundle-size-analysis.md +288 -0
  53. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  54. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  55. package/cloudflare/run-build.js +23 -1
  56. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  57. package/cloudflare/shims/node-fetch.ts +35 -0
  58. package/cloudflare/shims/queue.ts +28 -2
  59. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  60. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  61. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  62. package/cloudflare/worker.ts +59 -4
  63. package/cloudflare/wrangler.jsonc +7 -1
  64. package/cloudflare/wrangler.staging.json +2 -1
  65. package/package.json +10 -6
  66. package/scripts/seed-google-play.ts +79 -0
  67. package/src/components/payment-method/app-store.tsx +103 -0
  68. package/src/components/payment-method/form.tsx +7 -1
  69. package/src/components/payment-method/google-play.tsx +85 -0
  70. package/src/components/subscription/list.tsx +20 -0
  71. package/src/locales/en.tsx +63 -0
  72. package/src/locales/zh.tsx +63 -0
  73. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  74. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  75. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  76. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -0,0 +1,565 @@
1
+ // Google Play Real-Time Developer Notification — subscription state machine.
2
+ //
3
+ // Maps each notificationType to the local Subscription state, calls
4
+ // `client.acknowledgeSubscription` for new purchases (3-day deadline), and
5
+ // emits the `customer.subscription.*` events so EntitlementGrant gets derived
6
+ // downstream — exactly the same events the Stripe handler emits.
7
+
8
+ import { Op } from 'sequelize';
9
+
10
+ import { createEvent } from '../../../libs/audit';
11
+ import logger from '../../../libs/logger';
12
+ import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
13
+ import { GooglePlayClient, GooglePlaySubscriptionPurchase } from '../client';
14
+
15
+ export const GooglePlayNotificationType = {
16
+ SUBSCRIPTION_RECOVERED: 1,
17
+ SUBSCRIPTION_RENEWED: 2,
18
+ SUBSCRIPTION_CANCELED: 3,
19
+ SUBSCRIPTION_PURCHASED: 4,
20
+ SUBSCRIPTION_ON_HOLD: 5,
21
+ SUBSCRIPTION_IN_GRACE_PERIOD: 6,
22
+ SUBSCRIPTION_RESTARTED: 7,
23
+ SUBSCRIPTION_PRICE_CHANGE_CONFIRMED: 8,
24
+ SUBSCRIPTION_DEFERRED: 9,
25
+ SUBSCRIPTION_PAUSED: 10,
26
+ SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED: 11,
27
+ SUBSCRIPTION_REVOKED: 12,
28
+ SUBSCRIPTION_EXPIRED: 13,
29
+ } as const;
30
+
31
+ export type GooglePlaySubscriptionNotification = {
32
+ version: string;
33
+ notificationType: number;
34
+ purchaseToken: string;
35
+ subscriptionId: string;
36
+ };
37
+
38
+ /**
39
+ * Look up an existing local Subscription by Google Play purchase token,
40
+ * stored in `payment_details.google_play.purchase_token`.
41
+ *
42
+ * Prefers non-canceled rows so webhooks always target the live winner even if
43
+ * dedup has marked race-loser duplicates canceled with the same purchaseToken.
44
+ * Falls back to any row (oldest first) when nothing active remains — useful for
45
+ * post-expiry webhooks that should still find the row to log against.
46
+ */
47
+ async function findSubscriptionByPurchaseToken(purchaseToken: string): Promise<Subscription | null> {
48
+ const active = await Subscription.findOne({
49
+ where: {
50
+ 'payment_details.google_play.purchase_token': purchaseToken,
51
+ status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
52
+ } as any,
53
+ order: [['created_at', 'ASC']],
54
+ });
55
+ if (active) return active;
56
+ return Subscription.findOne({
57
+ where: {
58
+ 'payment_details.google_play.purchase_token': purchaseToken,
59
+ } as any,
60
+ order: [['created_at', 'ASC']],
61
+ });
62
+ }
63
+
64
+ /**
65
+ * Reconcile race-created duplicates for the same purchaseToken. Keeps the
66
+ * earliest-created active row, marks the rest canceled with a dedup marker so
67
+ * audit can trace why. Safe to call concurrently — converges to the same
68
+ * winner regardless of caller order.
69
+ */
70
+ async function reconcilePurchaseTokenDuplicates(purchaseToken: string): Promise<Subscription> {
71
+ const live = await Subscription.findAll({
72
+ where: {
73
+ 'payment_details.google_play.purchase_token': purchaseToken,
74
+ status: { [Op.notIn]: ['canceled', 'incomplete_expired'] },
75
+ } as any,
76
+ order: [['created_at', 'ASC']],
77
+ });
78
+ if (live.length === 0) {
79
+ throw new Error(`reconcile: no live subscription found for purchaseToken ${purchaseToken}`);
80
+ }
81
+ // Non-null: guarded by the `live.length === 0` throw above. TS doesn't narrow
82
+ // array index access, so assert explicitly.
83
+ const winner = live[0]!;
84
+ if (live.length === 1) return winner;
85
+ const now = Math.floor(Date.now() / 1000);
86
+ for (const dup of live.slice(1)) {
87
+ // eslint-disable-next-line no-await-in-loop -- sequential cancellation to avoid concurrent writes on the same sub set
88
+ await dup.update({
89
+ status: 'canceled',
90
+ canceled_at: now,
91
+ metadata: {
92
+ ...(dup.metadata || {}),
93
+ dedup_replaced_by: winner.id,
94
+ dedup_reason: 'race_duplicate_on_verify',
95
+ },
96
+ });
97
+ logger.warn('google_play verify: race-duplicate Subscription marked canceled', {
98
+ duplicateId: dup.id,
99
+ winnerId: winner.id,
100
+ purchaseToken,
101
+ });
102
+ }
103
+ return winner;
104
+ }
105
+
106
+ /**
107
+ * Look up the Customer that owns this purchase, via the
108
+ * `obfuscatedExternalAccountId` set by the mobile SDK before purchase.
109
+ */
110
+ function findCustomerByObfuscatedAccountId(uuid: string): Promise<Customer | null> {
111
+ return Customer.findOne({ where: { google_play_uuid: uuid } });
112
+ }
113
+
114
+ export type HandleGooglePlayNotificationDeps = {
115
+ packageName: string;
116
+ client: GooglePlayClient;
117
+ notification: GooglePlaySubscriptionNotification;
118
+ };
119
+
120
+ /**
121
+ * Top-level dispatch for a subscriptionNotification payload.
122
+ */
123
+ export async function handleGooglePlaySubscriptionEvent({
124
+ packageName,
125
+ client,
126
+ notification,
127
+ }: HandleGooglePlayNotificationDeps): Promise<void> {
128
+ const { notificationType, purchaseToken, subscriptionId } = notification;
129
+
130
+ logger.info('received google_play subscription notification', {
131
+ packageName,
132
+ notificationType,
133
+ subscriptionId,
134
+ });
135
+
136
+ if (notificationType === GooglePlayNotificationType.SUBSCRIPTION_PURCHASED) {
137
+ await handlePurchased({ client, subscriptionId, purchaseToken });
138
+ return;
139
+ }
140
+
141
+ const subscription = await findSubscriptionByPurchaseToken(purchaseToken);
142
+ if (!subscription) {
143
+ logger.warn('local subscription not found for google_play notification', {
144
+ notificationType,
145
+ purchaseToken,
146
+ subscriptionId,
147
+ });
148
+ return;
149
+ }
150
+
151
+ switch (notificationType) {
152
+ case GooglePlayNotificationType.SUBSCRIPTION_RENEWED:
153
+ case GooglePlayNotificationType.SUBSCRIPTION_DEFERRED:
154
+ await handleRenewedOrDeferred({ client, subscription, subscriptionId, purchaseToken });
155
+ break;
156
+
157
+ case GooglePlayNotificationType.SUBSCRIPTION_RECOVERED:
158
+ case GooglePlayNotificationType.SUBSCRIPTION_RESTARTED:
159
+ await handleResumed(subscription);
160
+ break;
161
+
162
+ case GooglePlayNotificationType.SUBSCRIPTION_ON_HOLD:
163
+ case GooglePlayNotificationType.SUBSCRIPTION_IN_GRACE_PERIOD:
164
+ await markPastDue(subscription);
165
+ break;
166
+
167
+ case GooglePlayNotificationType.SUBSCRIPTION_PAUSED:
168
+ await markPaused(subscription);
169
+ break;
170
+
171
+ case GooglePlayNotificationType.SUBSCRIPTION_CANCELED:
172
+ await scheduleCancelAtPeriodEnd(subscription);
173
+ break;
174
+
175
+ case GooglePlayNotificationType.SUBSCRIPTION_EXPIRED:
176
+ await markExpired(subscription);
177
+ break;
178
+
179
+ case GooglePlayNotificationType.SUBSCRIPTION_REVOKED:
180
+ await handleRevoked(subscription);
181
+ break;
182
+
183
+ case GooglePlayNotificationType.SUBSCRIPTION_PRICE_CHANGE_CONFIRMED:
184
+ case GooglePlayNotificationType.SUBSCRIPTION_PAUSE_SCHEDULE_CHANGED:
185
+ // Informational only — store latest payload but no state transition
186
+ await subscription.update({
187
+ metadata: {
188
+ ...(subscription.metadata || {}),
189
+ google_play_last_notification_type: notificationType,
190
+ },
191
+ });
192
+ break;
193
+
194
+ default:
195
+ logger.warn('unhandled google_play notificationType', { notificationType });
196
+ }
197
+ }
198
+
199
+ async function handlePurchased({
200
+ client,
201
+ subscriptionId,
202
+ purchaseToken,
203
+ }: {
204
+ client: GooglePlayClient;
205
+ subscriptionId: string;
206
+ purchaseToken: string;
207
+ }): Promise<void> {
208
+ // Critical: must call acknowledge within 3 days of SUBSCRIPTION_PURCHASED,
209
+ // otherwise Google auto-refunds. Do it before any other work.
210
+ try {
211
+ await client.acknowledgeSubscription(subscriptionId, purchaseToken);
212
+ logger.info('acknowledged google_play subscription', { subscriptionId, purchaseToken });
213
+ } catch (err) {
214
+ logger.error('failed to acknowledge google_play subscription', { err, subscriptionId, purchaseToken });
215
+ throw err;
216
+ }
217
+
218
+ const purchase = await client.getSubscription(subscriptionId, purchaseToken);
219
+ const customer = await resolveCustomer(purchase, purchaseToken);
220
+ if (!customer) {
221
+ logger.warn('orphan google_play purchase — no customer match; subscription not created', {
222
+ subscriptionId,
223
+ purchaseToken,
224
+ });
225
+ return;
226
+ }
227
+
228
+ // A2 mock phase: Subscription model wiring (Product/Price/SubscriptionItem
229
+ // mapping from Google productId → local price) lands with the resource sync
230
+ // commit. Here we only ensure ack happens and record the orphan-safety event.
231
+ logger.info('google_play purchase resolved to customer (Subscription creation deferred to resource sync)', {
232
+ customerId: customer.id,
233
+ subscriptionId,
234
+ purchaseToken,
235
+ });
236
+ }
237
+
238
+ function resolveCustomer(purchase: GooglePlaySubscriptionPurchase, purchaseToken: string): Promise<Customer | null> {
239
+ const uuid = purchase.obfuscatedExternalAccountId;
240
+ if (!uuid) {
241
+ logger.warn('google_play purchase has no obfuscatedExternalAccountId', { purchaseToken });
242
+ return Promise.resolve(null);
243
+ }
244
+ return findCustomerByObfuscatedAccountId(uuid);
245
+ }
246
+
247
+ async function handleRenewedOrDeferred({
248
+ client,
249
+ subscription,
250
+ subscriptionId,
251
+ purchaseToken,
252
+ }: {
253
+ client: GooglePlayClient;
254
+ subscription: Subscription;
255
+ subscriptionId: string;
256
+ purchaseToken: string;
257
+ }): Promise<void> {
258
+ const purchase = await client.getSubscription(subscriptionId, purchaseToken);
259
+ const newExpiry = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : undefined;
260
+ await subscription.update({
261
+ status: 'active',
262
+ current_period_end: newExpiry ?? subscription.current_period_end,
263
+ payment_details: {
264
+ ...(subscription.payment_details || {}),
265
+ google_play: {
266
+ ...(subscription.payment_details?.google_play || { purchase_token: purchaseToken, product_id: subscriptionId }),
267
+ expiry_time_millis: purchase.expiryTimeMillis,
268
+ },
269
+ },
270
+ });
271
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
272
+ }
273
+
274
+ async function handleResumed(subscription: Subscription): Promise<void> {
275
+ await subscription.update({ status: 'active' });
276
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
277
+ }
278
+
279
+ async function markPastDue(subscription: Subscription): Promise<void> {
280
+ if (subscription.status === 'past_due') return;
281
+ await subscription.update({ status: 'past_due' });
282
+ }
283
+
284
+ async function markPaused(subscription: Subscription): Promise<void> {
285
+ if (subscription.status === 'paused') return;
286
+ await subscription.update({ status: 'paused' });
287
+ }
288
+
289
+ async function scheduleCancelAtPeriodEnd(subscription: Subscription): Promise<void> {
290
+ await subscription.update({ cancel_at_period_end: true });
291
+ }
292
+
293
+ async function markExpired(subscription: Subscription): Promise<void> {
294
+ if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
295
+ await subscription.update({ status: 'canceled', canceled_at: Math.floor(Date.now() / 1000) });
296
+ createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
297
+ }
298
+
299
+ /**
300
+ * Client-initiated verify path (aistro-style).
301
+ *
302
+ * Mobile client posts the purchaseToken right after StoreKit / BillingClient
303
+ * finishes a purchase. We verify against Google, ACK, and either find the
304
+ * existing Subscription or create a new one. Idempotent — safe to call on
305
+ * client-side retries.
306
+ */
307
+ export async function ingestVerifiedGooglePlayPurchase({
308
+ customerDid,
309
+ paymentMethod,
310
+ client,
311
+ purchaseToken,
312
+ subscriptionId,
313
+ }: {
314
+ customerDid: string;
315
+ paymentMethod: PaymentMethod;
316
+ client: GooglePlayClient;
317
+ purchaseToken: string;
318
+ subscriptionId: string;
319
+ }): Promise<{
320
+ subscription: Subscription;
321
+ isFirstSubscribe: boolean;
322
+ purchase: GooglePlaySubscriptionPurchase;
323
+ }> {
324
+ // 1. Verify with Google (this is the only path to confirm authenticity).
325
+ const purchase = await client.getSubscription(subscriptionId, purchaseToken);
326
+
327
+ // 2. Idempotency: if Subscription already exists for this token, return it.
328
+ const existing = await findSubscriptionByPurchaseToken(purchaseToken);
329
+ if (existing) {
330
+ // Best-effort: ACK if Google says not yet acknowledged.
331
+ if (purchase.acknowledgementState === 0) {
332
+ try {
333
+ await client.acknowledgeSubscription(subscriptionId, purchaseToken);
334
+ } catch (err) {
335
+ logger.error('google_play verify: ACK failed on existing subscription', { err });
336
+ }
337
+ }
338
+
339
+ // SKU drift sync — mirror of the app_store path. When the user
340
+ // crossgrades / changes plan inside the same purchase token (Google
341
+ // Play subscription product with multiple base plans), the
342
+ // subscriptionId Google returns differs from what we stored. Update
343
+ // payment_details + repoint SubscriptionItem at the new Price so
344
+ // entitlement.check reflects the new tier without waiting for the
345
+ // RTDN webhook.
346
+ const storedGoogleSku = (existing as any).payment_details?.google_play?.product_id;
347
+ if (storedGoogleSku && storedGoogleSku !== subscriptionId) {
348
+ // Multi-tenant scoping: filter by (sku, package_name) — two Android
349
+ // apps can have the same SKU string in their independent Play
350
+ // Console namespaces; package_name from the client is the tenant key.
351
+ const newPrice = await Price.findOne({
352
+ where: {
353
+ 'metadata.google_play_product_id': subscriptionId,
354
+ 'metadata.package_name': client.packageName,
355
+ } as any,
356
+ });
357
+ const newExpiresAt = purchase.expiryTimeMillis
358
+ ? Math.floor(Number(purchase.expiryTimeMillis) / 1000)
359
+ : (existing.current_period_end ?? undefined);
360
+ const driftPatch: any = {
361
+ payment_details: {
362
+ ...(existing.payment_details || {}),
363
+ google_play: {
364
+ ...(existing.payment_details?.google_play || {}),
365
+ product_id: subscriptionId,
366
+ },
367
+ },
368
+ metadata: {
369
+ ...(existing.metadata || {}),
370
+ google_play_product_id: subscriptionId,
371
+ product_id: newPrice?.product_id,
372
+ price_id: newPrice?.id,
373
+ },
374
+ };
375
+ if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
376
+ await existing.update(driftPatch);
377
+ if (newPrice) {
378
+ const items = await SubscriptionItem.findAll({ where: { subscription_id: existing.id } as any });
379
+ for (const item of items) {
380
+ if ((item as any).price_id !== newPrice.id) {
381
+ // eslint-disable-next-line no-await-in-loop -- sequential per-item updates to avoid concurrent writes on the same sub
382
+ await item.update({ price_id: newPrice.id });
383
+ }
384
+ }
385
+ }
386
+ logger.info('google_play verify: SKU drift detected, synced to current Google-side product', {
387
+ subscriptionId: existing.id,
388
+ oldSku: storedGoogleSku,
389
+ newSku: subscriptionId,
390
+ newProductId: newPrice?.product_id,
391
+ newPriceId: newPrice?.id,
392
+ });
393
+ return { subscription: existing, isFirstSubscribe: false, purchase };
394
+ }
395
+
396
+ logger.info('google_play verify: existing subscription, returning current state', {
397
+ subscriptionId: existing.id,
398
+ purchaseToken,
399
+ });
400
+ return { subscription: existing, isFirstSubscribe: false, purchase };
401
+ }
402
+
403
+ // 3. Refuse expired / unpurchased states.
404
+ if (purchase.paymentState !== undefined && purchase.paymentState !== 1) {
405
+ throw new Error(`google_play purchase paymentState=${purchase.paymentState} not active`);
406
+ }
407
+ const expiresAt = purchase.expiryTimeMillis ? Math.floor(Number(purchase.expiryTimeMillis) / 1000) : 0;
408
+ if (!expiresAt || expiresAt * 1000 < Date.now()) {
409
+ throw new Error('google_play purchase already expired');
410
+ }
411
+
412
+ // 4. findOrCreate Customer + persist UUID mapping (D-004) so future webhooks reverse-lookup works.
413
+ const [customer] = await Customer.findOrCreate({
414
+ where: { did: customerDid },
415
+ defaults: {
416
+ did: customerDid,
417
+ livemode: !!paymentMethod.livemode,
418
+ delinquent: false,
419
+ invoice_prefix: Customer.getInvoicePrefix(),
420
+ } as any,
421
+ });
422
+ if (purchase.obfuscatedExternalAccountId && customer.google_play_uuid !== purchase.obfuscatedExternalAccountId) {
423
+ await customer.update({ google_play_uuid: purchase.obfuscatedExternalAccountId });
424
+ }
425
+
426
+ // 5. Map Google subscriptionId → local Price (Stripe-style: SKU binding lives
427
+ // on Price.metadata, not Product.metadata, so one Product can have N
428
+ // Prices with N SKUs — monthly / yearly / promo all under the same tier).
429
+ // Multi-tenant scoping: filter by (sku, package_name) — two Android
430
+ // apps can have the same SKU string in their independent Play Console
431
+ // namespaces; package_name from the client is the tenant discriminator.
432
+ // If missing, we still create the Subscription so we don't lose data, but
433
+ // log loudly so ops can fix the binding.
434
+ const price = await Price.findOne({
435
+ where: {
436
+ 'metadata.google_play_product_id': subscriptionId,
437
+ 'metadata.package_name': client.packageName,
438
+ } as any,
439
+ });
440
+ if (!price) {
441
+ logger.warn('google_play verify: no local Price mapped to (subscriptionId, packageName)', {
442
+ subscriptionId,
443
+ packageName: client.packageName,
444
+ });
445
+ }
446
+
447
+ // 6. Is this the customer's first paid subscription on Google Play?
448
+ const isFirstSubscribe =
449
+ (await Subscription.count({
450
+ where: { customer_id: customer.id, channel: 'google_play' } as any,
451
+ })) === 0;
452
+
453
+ // 7. ACK before creating local rows — if ACK fails we want to know early.
454
+ if (purchase.acknowledgementState === 0) {
455
+ await client.acknowledgeSubscription(subscriptionId, purchaseToken);
456
+ }
457
+
458
+ // 8. Create the Subscription.
459
+ const now = Math.floor(Date.now() / 1000);
460
+ const subscription = await Subscription.create({
461
+ livemode: !!paymentMethod.livemode,
462
+ customer_id: customer.id,
463
+ currency_id: paymentMethod.default_currency_id ?? '',
464
+ default_payment_method_id: paymentMethod.id,
465
+ status: 'active',
466
+ channel: 'google_play',
467
+ environment: 'production',
468
+ current_period_start: purchase.startTimeMillis ? Math.floor(Number(purchase.startTimeMillis) / 1000) : now,
469
+ current_period_end: expiresAt,
470
+ cancel_at_period_end: false,
471
+ billing_cycle_anchor: now,
472
+ collection_method: 'charge_automatically',
473
+ start_date: now,
474
+ metadata: {
475
+ google_play_product_id: subscriptionId,
476
+ product_id: price?.product_id,
477
+ price_id: price?.id,
478
+ },
479
+ payment_details: {
480
+ google_play: {
481
+ purchase_token: purchaseToken,
482
+ product_id: subscriptionId,
483
+ // Multi-tenant: persist the verified package_name so cross-channel
484
+ // entitlement lookups can disambiguate same-SKU collisions across
485
+ // different Android apps wired into the same Payment Kit instance.
486
+ package_name: client.packageName,
487
+ order_id: purchase.orderId,
488
+ expiry_time_millis: purchase.expiryTimeMillis,
489
+ environment: 'production',
490
+ },
491
+ },
492
+ pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
493
+ } as any);
494
+
495
+ // 9. Create a SubscriptionItem pointing at the exact Price the customer
496
+ // paid for. Stripe-style: monthly vs yearly vs promo distinction is
497
+ // preserved here, not collapsed to the Product's default_price. Without
498
+ // the item, downstream lookups that walk Subscription → items → price →
499
+ // product (entitlement queries, invoice/notification templates that call
500
+ // getMainProductName, etc.) all render empty for IAP-channel subs.
501
+ if (price) {
502
+ await SubscriptionItem.create({
503
+ subscription_id: subscription.id,
504
+ price_id: price.id,
505
+ quantity: 1,
506
+ livemode: subscription.livemode,
507
+ metadata: {},
508
+ } as any);
509
+ }
510
+
511
+ // 10. Race-loser reconciliation. Concurrent /verify calls for the same
512
+ // purchaseToken (Google's BillingClient fires onPurchasesUpdated multiple
513
+ // times in some scenarios — network blips, BillingClient reconnect, retry
514
+ // after orphan ack) all pass the step-2 idempotency check before any of
515
+ // them commit a row. We can't acquire a row-level lock on D1, so the
516
+ // cheapest correct fix is post-create reconciliation: each writer scans
517
+ // for siblings, the earliest-created one wins, the rest mark themselves
518
+ // canceled so subsequent webhooks target only the winner.
519
+ const winner = await reconcilePurchaseTokenDuplicates(purchaseToken);
520
+ if (winner.id !== subscription.id) {
521
+ logger.info('google_play verify: lost create race, returning earliest sibling as winner', {
522
+ myId: subscription.id,
523
+ winnerId: winner.id,
524
+ purchaseToken,
525
+ });
526
+ return { subscription: winner, isFirstSubscribe: false, purchase };
527
+ }
528
+
529
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
530
+ logger.info('google_play verify: subscription created', {
531
+ subscriptionId: subscription.id,
532
+ customerId: customer.id,
533
+ productId: price?.product_id,
534
+ priceId: price?.id,
535
+ });
536
+
537
+ return { subscription, isFirstSubscribe, purchase };
538
+ }
539
+
540
+ async function handleRevoked(subscription: Subscription): Promise<void> {
541
+ const now = Math.floor(Date.now() / 1000);
542
+ await subscription.update({
543
+ status: 'canceled',
544
+ ended_at: now,
545
+ cancelation_details: {
546
+ ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
547
+ reason: 'cancellation_requested',
548
+ },
549
+ metadata: {
550
+ ...(subscription.metadata || {}),
551
+ refunded: true,
552
+ refunded_at: now,
553
+ },
554
+ });
555
+
556
+ // IAP refunds are mirrored as subscription state only — the canonical refund
557
+ // record lives at Google Play. We deliberately do NOT create a local Refund
558
+ // ledger row; user-facing refund/receipt history is in the Play Store.
559
+ // See docs/superpowers/specs/2026-06-04-iap-records-organization-design.md.
560
+ logger.info('google_play SUBSCRIPTION_REVOKED — status mirrored to canceled (refunded)', {
561
+ subscriptionId: subscription.id,
562
+ });
563
+
564
+ createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
565
+ }
@@ -0,0 +1,106 @@
1
+ // Voided Purchase Notification — fires when a purchase is refunded or chargeback'd.
2
+ // Distinct from `subscriptionNotification` (which carries SUBSCRIPTION_REVOKED only
3
+ // when access is also revoked alongside the refund). VoidedPurchase fires on the
4
+ // underlying order regardless of whether access is revoked.
5
+ //
6
+ // Payload (per Google docs):
7
+ // {
8
+ // purchaseToken: string,
9
+ // orderId: string,
10
+ // productType: 1 = ONE_TIME, 2 = SUBSCRIPTION,
11
+ // refundType: 1 = FULL, 2 = QUANTITY_BASED
12
+ // }
13
+
14
+ import { createEvent } from '../../../libs/audit';
15
+ import logger from '../../../libs/logger';
16
+ import { Subscription } from '../../../store/models';
17
+
18
+ export const GooglePlayVoidedProductType = {
19
+ ONE_TIME: 1,
20
+ SUBSCRIPTION: 2,
21
+ } as const;
22
+
23
+ export const GooglePlayVoidedRefundType = {
24
+ FULL: 1,
25
+ QUANTITY_BASED: 2,
26
+ } as const;
27
+
28
+ export type GooglePlayVoidedPurchaseNotification = {
29
+ purchaseToken: string;
30
+ orderId: string;
31
+ productType: number;
32
+ refundType: number;
33
+ };
34
+
35
+ export async function handleGooglePlayVoidedPurchase({
36
+ packageName,
37
+ notification,
38
+ }: {
39
+ packageName: string;
40
+ notification: GooglePlayVoidedPurchaseNotification;
41
+ }): Promise<void> {
42
+ const { purchaseToken, orderId, productType, refundType } = notification;
43
+
44
+ logger.info('received google_play voided-purchase notification', {
45
+ packageName,
46
+ orderId,
47
+ productType,
48
+ refundType,
49
+ });
50
+
51
+ if (productType !== GooglePlayVoidedProductType.SUBSCRIPTION) {
52
+ // One-time product voids are out of scope here (the one-time IAP flow isn't
53
+ // wired yet). Log and skip so Pub/Sub still ACKs.
54
+ logger.info('google_play voided-purchase: ignoring non-subscription productType', {
55
+ productType,
56
+ orderId,
57
+ });
58
+ return;
59
+ }
60
+
61
+ const subscription = await Subscription.findOne({
62
+ where: {
63
+ 'payment_details.google_play.purchase_token': purchaseToken,
64
+ } as any,
65
+ });
66
+
67
+ if (!subscription) {
68
+ logger.warn('google_play voided-purchase: no local subscription matches purchaseToken', {
69
+ purchaseToken,
70
+ orderId,
71
+ });
72
+ return;
73
+ }
74
+
75
+ // Terminate access. Idempotent — already-canceled subs are a no-op.
76
+ // We mirror handleRevoked's pattern from subscription.ts (which currently does
77
+ // the same minimal update + emits the same event; a future iteration should
78
+ // also create a Refund row, but that path needs payment_intent_id wiring for
79
+ // IAP channels which isn't done yet — see TODO below).
80
+ if (subscription.status !== 'canceled' && subscription.status !== 'incomplete_expired') {
81
+ await subscription.update({
82
+ status: 'canceled',
83
+ ended_at: Math.floor(Date.now() / 1000),
84
+ cancelation_details: {
85
+ ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
86
+ reason: 'cancellation_requested',
87
+ },
88
+ metadata: {
89
+ ...(subscription.metadata || {}),
90
+ google_play_voided_order_id: orderId,
91
+ google_play_voided_refund_type: refundType,
92
+ },
93
+ });
94
+ createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
95
+ }
96
+
97
+ // TODO: create a Refund row for audit. Blocked on payment_intent_id schema —
98
+ // it's NOT NULL but we don't synthesize Stripe-style payment intents for IAP
99
+ // channel purchases. Either relax the column, or maintain a parallel
100
+ // 'platform_voided' record on Subscription.metadata only.
101
+ logger.info('google_play voided-purchase: subscription terminated (Refund row TBD)', {
102
+ subscriptionId: subscription.id,
103
+ orderId,
104
+ refundType,
105
+ });
106
+ }