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,635 @@
1
+ // App Store — subscription verification & ingest.
2
+ //
3
+ // A1 mock phase: only the client-initiated verify path is implemented (mirror
4
+ // of `integrations/google-play/handlers/subscription.ts`). Apple's S2S
5
+ // notifications (App Store Server Notifications V2) will land alongside A2's
6
+ // Pub/Sub webhook in a later commit.
7
+ //
8
+ // Idempotency: the stable key across renewals is `originalTransactionId` — Apple
9
+ // keeps it the same for every renewal of the same subscription, while
10
+ // `transactionId` changes per charge. We store both in payment_details for
11
+ // audit, but uniqueness/de-dup keys off originalTransactionId.
12
+
13
+ import { createEvent } from '../../../libs/audit';
14
+ import logger from '../../../libs/logger';
15
+ import { Customer, PaymentMethod, Price, Subscription, SubscriptionItem } from '../../../store/models';
16
+ import {
17
+ AppStoreClient,
18
+ AppStoreNotificationPayload,
19
+ AppStoreNotificationType,
20
+ AppStoreTransactionPayload,
21
+ } from '../client';
22
+
23
+ /**
24
+ * Thrown when a verify/restore presents an Apple subscription that already
25
+ * belongs to a different customer (DID). Apple subscriptions are NOT transferred
26
+ * between accounts — the client should prompt the user to sign in with the
27
+ * owning account. Mirrors OpenAI/Claude ("this subscription is associated with
28
+ * another account").
29
+ */
30
+ export class AppStoreOwnershipMismatchError extends Error {
31
+ code = 'subscription_owned_by_another_account';
32
+
33
+ constructor(message: string) {
34
+ super(message);
35
+ this.name = 'AppStoreOwnershipMismatchError';
36
+ }
37
+ }
38
+
39
+ /**
40
+ * Look up an existing local Subscription by Apple's originalTransactionId,
41
+ * stored at `payment_details.app_store.original_transaction_id`. JSON path
42
+ * lookup is dialect-specific; matches the existing google_play pattern.
43
+ */
44
+ function findSubscriptionByOriginalTransactionId(originalTransactionId: string): Promise<Subscription | null> {
45
+ return Subscription.findOne({
46
+ where: {
47
+ 'payment_details.app_store.original_transaction_id': originalTransactionId,
48
+ } as any,
49
+ });
50
+ }
51
+
52
+ /**
53
+ * Client-initiated verify path (StoreKit 2 JWS).
54
+ *
55
+ * Mobile client posts the `jws_signed_transaction` right after
56
+ * `Product.purchase(...)` succeeds. We decode + verify the JWS (mock phase:
57
+ * decode only — see client.ts), then either return the existing Subscription
58
+ * or create a new one. Idempotent — safe to call on client-side retries.
59
+ */
60
+ export async function ingestVerifiedAppStorePurchase({
61
+ customerDid,
62
+ paymentMethod,
63
+ client,
64
+ signedTransaction,
65
+ receipt,
66
+ expectedProductIds,
67
+ }: {
68
+ customerDid: string;
69
+ paymentMethod: PaymentMethod;
70
+ client: AppStoreClient;
71
+ /** StoreKit 2 JWS string (preferred when present) */
72
+ signedTransaction?: string;
73
+ /** StoreKit 1 legacy base64 receipt (fallback) */
74
+ receipt?: string;
75
+ /** Optional productId allowlist for legacy receipt filtering. */
76
+ expectedProductIds?: string[];
77
+ }): Promise<{
78
+ subscription: Subscription;
79
+ isFirstSubscribe: boolean;
80
+ transaction: AppStoreTransactionPayload;
81
+ }> {
82
+ if (!signedTransaction && !receipt) {
83
+ throw new Error('app_store verify: must provide signedTransaction or receipt');
84
+ }
85
+
86
+ // 1. Verify via the right path. JWS wins if both are present (aistro pattern).
87
+ const transaction = signedTransaction
88
+ ? await client.verifyJwsTransaction(signedTransaction)
89
+ : await client.verifyLegacyReceipt(receipt!, { expectedProductIds });
90
+
91
+ // 2. Idempotency: if Subscription already exists for this originalTransactionId.
92
+ const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
93
+ if (existing) {
94
+ // Ownership guard (A+): an Apple subscription (one originalTransactionId /
95
+ // Apple account) is bound to the first DID that claimed it. If a DIFFERENT
96
+ // DID verifies/restores it, do NOT migrate — refuse so the client can prompt
97
+ // the user to sign in with the owning account. Subscriptions are not
98
+ // transferred between accounts. Mirrors OpenAI/Claude.
99
+ const owner = await Customer.findByPk(existing.customer_id);
100
+ if (owner && owner.did !== customerDid) {
101
+ logger.warn('app_store verify: subscription owned by a different DID — refusing', {
102
+ subscriptionId: existing.id,
103
+ ownerDid: owner.did,
104
+ requestDid: customerDid,
105
+ });
106
+ throw new AppStoreOwnershipMismatchError(
107
+ 'This App Store subscription is linked to a different account. Please sign in with that account to use it.'
108
+ );
109
+ }
110
+
111
+ const newExpiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
112
+ const newTxnValid = !!newExpiresAt && newExpiresAt * 1000 > Date.now();
113
+ const lapsed = ['canceled', 'incomplete_expired', 'past_due'].includes(existing.status as string);
114
+ // Sandbox re-subscribe / renewal arriving via client verify reuses the SAME
115
+ // originalTransactionId. If the stored sub had lapsed (expired→canceled) but
116
+ // the new transaction is valid, reactivate it instead of returning the stale
117
+ // canceled row — otherwise a re-purchase records nothing and the user stays
118
+ // "not subscribed". Active subs are returned untouched (the S2S webhook owns
119
+ // renewal bookkeeping for the active path).
120
+ if (lapsed && newTxnValid) {
121
+ const updatePatch: any = {
122
+ status: 'active',
123
+ current_period_end: newExpiresAt,
124
+ ended_at: null,
125
+ cancel_at_period_end: false,
126
+ payment_details: {
127
+ ...(existing.payment_details || {}),
128
+ app_store: {
129
+ ...(existing.payment_details?.app_store || {}),
130
+ transaction_id: transaction.transactionId,
131
+ expires_at: newExpiresAt,
132
+ },
133
+ },
134
+ };
135
+ // Self-heal: if the Product↔SKU mapping was added after the original
136
+ // purchase, metadata.product_id was frozen empty. Resolve it now so the
137
+ // admin can show the product name (entitlement already resolves live; this
138
+ // only fixes the stored display snapshot).
139
+ if (!(existing.metadata as any)?.product_id) {
140
+ // Multi-tenant scoping: filter by (sku, bundle_id) — two different
141
+ // iOS apps can have the same SKU string in their own App Store
142
+ // Connect namespaces; bundle_id from the JWS is the tenant key.
143
+ const mappedPrice = await Price.findOne({
144
+ where: {
145
+ 'metadata.app_store_product_id': transaction.productId,
146
+ 'metadata.bundle_id': transaction.bundleId,
147
+ } as any,
148
+ });
149
+ if (mappedPrice) {
150
+ updatePatch.metadata = {
151
+ ...(existing.metadata || {}),
152
+ app_store_product_id: transaction.productId,
153
+ product_id: mappedPrice.product_id,
154
+ price_id: mappedPrice.id,
155
+ };
156
+ }
157
+ }
158
+ await existing.update(updatePatch);
159
+ createEvent('Subscription', 'customer.subscription.started', existing).catch(console.error);
160
+ logger.info('app_store verify: reactivated lapsed subscription from fresh transaction', {
161
+ subscriptionId: existing.id,
162
+ originalTransactionId: transaction.originalTransactionId,
163
+ newExpiresAt,
164
+ });
165
+ return { subscription: existing, isFirstSubscribe: false, transaction };
166
+ }
167
+
168
+ // SKU drift sync — when the StoreKit-side product_id differs from
169
+ // what we have stored (Apple crossgrade / downgrade landed in
170
+ // Sandbox immediately; or the production DID_RENEW webhook hadn't
171
+ // landed yet and the client is forcing a re-verify), update our
172
+ // snapshot + repoint the SubscriptionItem at the new Price so
173
+ // entitlement.check reflects the new tier without waiting for the
174
+ // webhook. Without this, `purchase.restore()` after a tier change
175
+ // is a no-op — exactly the bug surfaced during real-device verify.
176
+ const storedAppStoreSku = (existing as any).payment_details?.app_store?.product_id;
177
+ if (storedAppStoreSku && storedAppStoreSku !== transaction.productId) {
178
+ const newPrice = await Price.findOne({
179
+ where: {
180
+ 'metadata.app_store_product_id': transaction.productId,
181
+ 'metadata.bundle_id': transaction.bundleId,
182
+ } as any,
183
+ });
184
+ const driftPatch: any = {
185
+ payment_details: {
186
+ ...(existing.payment_details || {}),
187
+ app_store: {
188
+ ...(existing.payment_details?.app_store || {}),
189
+ product_id: transaction.productId,
190
+ transaction_id: transaction.transactionId,
191
+ },
192
+ },
193
+ metadata: {
194
+ ...(existing.metadata || {}),
195
+ app_store_product_id: transaction.productId,
196
+ product_id: newPrice?.product_id,
197
+ price_id: newPrice?.id,
198
+ },
199
+ };
200
+ if (newExpiresAt) driftPatch.current_period_end = newExpiresAt;
201
+ await existing.update(driftPatch);
202
+
203
+ // Repoint SubscriptionItem(s) at the new Price so admin views and
204
+ // any items→price→product walks see the right tier. Idempotent —
205
+ // hitting the same Price twice is a no-op update.
206
+ if (newPrice) {
207
+ const items = await SubscriptionItem.findAll({ where: { subscription_id: existing.id } as any });
208
+ for (const item of items) {
209
+ if ((item as any).price_id !== newPrice.id) {
210
+ // eslint-disable-next-line no-await-in-loop -- sequential per-item updates to avoid concurrent writes on the same sub
211
+ await item.update({ price_id: newPrice.id });
212
+ }
213
+ }
214
+ }
215
+ logger.info('app_store verify: SKU drift detected, synced to current Apple-side product', {
216
+ subscriptionId: existing.id,
217
+ oldSku: storedAppStoreSku,
218
+ newSku: transaction.productId,
219
+ newProductId: newPrice?.product_id,
220
+ newPriceId: newPrice?.id,
221
+ });
222
+ return { subscription: existing, isFirstSubscribe: false, transaction };
223
+ }
224
+
225
+ logger.info('app_store verify: existing subscription, returning current state', {
226
+ subscriptionId: existing.id,
227
+ originalTransactionId: transaction.originalTransactionId,
228
+ });
229
+ return { subscription: existing, isFirstSubscribe: false, transaction };
230
+ }
231
+
232
+ // 3. Refuse expired subscriptions.
233
+ const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
234
+ if (!expiresAt || expiresAt * 1000 < Date.now()) {
235
+ throw new Error('app_store purchase already expired');
236
+ }
237
+
238
+ // 4. findOrCreate Customer + persist appAccountToken so future S2S notifications can reverse-lookup.
239
+ const [customer] = await Customer.findOrCreate({
240
+ where: { did: customerDid },
241
+ defaults: {
242
+ did: customerDid,
243
+ livemode: !!paymentMethod.livemode,
244
+ delinquent: false,
245
+ invoice_prefix: Customer.getInvoicePrefix(),
246
+ } as any,
247
+ });
248
+ if (transaction.appAccountToken && customer.app_store_uuid !== transaction.appAccountToken) {
249
+ await customer.update({ app_store_uuid: transaction.appAccountToken });
250
+ }
251
+
252
+ // 5. Map Apple productId → local Price (Stripe-style: SKU binding lives on
253
+ // Price.metadata, not Product.metadata, so one Product can have N Prices
254
+ // with N SKUs — monthly / yearly / promo all map under the same tier).
255
+ // Multi-tenant scoping: filter by (sku, bundle_id) — two iOS apps can
256
+ // have the same SKU string in their independent App Store Connect
257
+ // namespaces; bundle_id from the JWS is the tenant discriminator.
258
+ // Log but don't block if the mapping is missing; admin can wire it later
259
+ // and the entitlement layer resolves live (same policy as google_play).
260
+ const price = await Price.findOne({
261
+ where: {
262
+ 'metadata.app_store_product_id': transaction.productId,
263
+ 'metadata.bundle_id': transaction.bundleId,
264
+ } as any,
265
+ });
266
+ if (!price) {
267
+ logger.warn('app_store verify: no local Price mapped to (productId, bundleId)', {
268
+ productId: transaction.productId,
269
+ bundleId: transaction.bundleId,
270
+ });
271
+ }
272
+
273
+ // 6. Is this the customer's first paid app_store subscription?
274
+ const isFirstSubscribe =
275
+ (await Subscription.count({
276
+ where: { customer_id: customer.id, channel: 'app_store' } as any,
277
+ })) === 0;
278
+
279
+ // 7. Create the Subscription.
280
+ const now = Math.floor(Date.now() / 1000);
281
+ const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
282
+ const environment = transaction.environment === 'Production' ? 'production' : 'sandbox';
283
+ const subscription = await Subscription.create({
284
+ livemode: !!paymentMethod.livemode,
285
+ customer_id: customer.id,
286
+ currency_id: paymentMethod.default_currency_id ?? '',
287
+ default_payment_method_id: paymentMethod.id,
288
+ status: 'active',
289
+ channel: 'app_store',
290
+ environment,
291
+ current_period_start: purchaseStartedAt,
292
+ current_period_end: expiresAt,
293
+ cancel_at_period_end: false,
294
+ billing_cycle_anchor: now,
295
+ collection_method: 'charge_automatically',
296
+ start_date: now,
297
+ metadata: {
298
+ app_store_product_id: transaction.productId,
299
+ product_id: price?.product_id,
300
+ price_id: price?.id,
301
+ },
302
+ payment_details: {
303
+ app_store: {
304
+ original_transaction_id: transaction.originalTransactionId,
305
+ transaction_id: transaction.transactionId,
306
+ product_id: transaction.productId,
307
+ // Multi-tenant: persist the verified bundle_id so cross-channel
308
+ // entitlement lookups can disambiguate same-SKU collisions across
309
+ // different iOS apps wired into the same Payment Kit instance.
310
+ bundle_id: transaction.bundleId,
311
+ web_order_line_item_id: transaction.webOrderLineItemId,
312
+ environment: transaction.environment,
313
+ expires_at: expiresAt,
314
+ },
315
+ },
316
+ pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
317
+ } as any);
318
+
319
+ // 8. Create a SubscriptionItem pointing at the exact Price the customer
320
+ // paid for (monthly vs yearly vs promo distinction is preserved here,
321
+ // not collapsed to the Product's default_price). Without the item, the
322
+ // admin product column + any items→price→product walk (entitlement,
323
+ // invoice/notification templates) render empty for App Store subs.
324
+ if (price) {
325
+ await SubscriptionItem.create({
326
+ subscription_id: subscription.id,
327
+ price_id: price.id,
328
+ quantity: 1,
329
+ livemode: subscription.livemode,
330
+ metadata: {},
331
+ } as any);
332
+ }
333
+
334
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
335
+ logger.info('app_store verify: subscription created', {
336
+ subscriptionId: subscription.id,
337
+ customerId: customer.id,
338
+ productId: price?.product_id,
339
+ priceId: price?.id,
340
+ originalTransactionId: transaction.originalTransactionId,
341
+ });
342
+
343
+ return { subscription, isFirstSubscribe, transaction };
344
+ }
345
+
346
+ /**
347
+ * Top-level dispatch for an App Store Server Notification V2 (already
348
+ * decoded by routes/integrations/app-store.ts). Mirrors google_play's
349
+ * handleGooglePlaySubscriptionEvent shape: map Apple `notificationType` to
350
+ * the local Subscription state machine and emit `customer.subscription.*`
351
+ * events so the downstream entitlement flow stays channel-agnostic.
352
+ *
353
+ * @param notification — outer signedPayload decoded
354
+ * @param transaction — inner signedTransactionInfo decoded by caller
355
+ * (we don't decode here because the caller already has
356
+ * the AppStoreClient and may want to apply additional
357
+ * validation before forwarding to the state machine).
358
+ */
359
+ export async function handleAppStoreSubscriptionEvent({
360
+ notification,
361
+ transaction,
362
+ }: {
363
+ notification: AppStoreNotificationPayload;
364
+ transaction: AppStoreTransactionPayload;
365
+ }): Promise<void> {
366
+ const { notificationType, subtype, notificationUUID } = notification;
367
+ logger.info('received app_store notification', {
368
+ notificationType,
369
+ subtype,
370
+ notificationUUID,
371
+ originalTransactionId: transaction.originalTransactionId,
372
+ });
373
+
374
+ // SUBSCRIBED / OFFER_REDEEMED create the initial subscription; the others
375
+ // require an existing local Subscription to operate on.
376
+ if (notificationType === 'SUBSCRIBED' || notificationType === 'OFFER_REDEEMED') {
377
+ await handleAppStoreSubscribed({ transaction, environment: notification.data.environment });
378
+ return;
379
+ }
380
+
381
+ const subscription = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
382
+ if (!subscription) {
383
+ logger.warn('local subscription not found for app_store notification', {
384
+ notificationType,
385
+ originalTransactionId: transaction.originalTransactionId,
386
+ });
387
+ return;
388
+ }
389
+
390
+ switch (notificationType as AppStoreNotificationType) {
391
+ case 'DID_RENEW':
392
+ await handleAppStoreRenewed(subscription, transaction);
393
+ break;
394
+
395
+ case 'EXPIRED':
396
+ case 'GRACE_PERIOD_EXPIRED':
397
+ await markAppStoreExpired(subscription);
398
+ break;
399
+
400
+ case 'DID_FAIL_TO_RENEW':
401
+ await markAppStorePastDue(subscription);
402
+ break;
403
+
404
+ case 'DID_CHANGE_RENEWAL_STATUS':
405
+ if (subtype === 'AUTO_RENEW_DISABLED') {
406
+ await scheduleAppStoreCancelAtPeriodEnd(subscription);
407
+ } else if (subtype === 'AUTO_RENEW_ENABLED') {
408
+ await unscheduleAppStoreCancel(subscription);
409
+ }
410
+ break;
411
+
412
+ case 'REVOKE':
413
+ case 'REFUND':
414
+ await handleAppStoreRevoked(subscription, notificationType, subtype);
415
+ break;
416
+
417
+ case 'REFUND_REVERSED':
418
+ // Apple reversed a previous refund. If we marked canceled because of the
419
+ // refund, we *should* reactivate — but only the human ops team can be
420
+ // sure right now. Log loudly and record metadata; reactivation is manual.
421
+ await subscription.update({
422
+ metadata: {
423
+ ...(subscription.metadata || {}),
424
+ app_store_last_notification_type: notificationType,
425
+ app_store_refund_reversed_at: Math.floor(Date.now() / 1000),
426
+ },
427
+ });
428
+ logger.warn('app_store REFUND_REVERSED received — manual review recommended', {
429
+ subscriptionId: subscription.id,
430
+ });
431
+ break;
432
+
433
+ case 'PRICE_INCREASE':
434
+ case 'DID_CHANGE_RENEWAL_PREF':
435
+ case 'RENEWAL_EXTENDED':
436
+ case 'RENEWAL_EXTENSION':
437
+ case 'REFUND_DECLINED':
438
+ case 'CONSUMPTION_REQUEST':
439
+ // Informational only — record latest notification type but no state transition.
440
+ await subscription.update({
441
+ metadata: {
442
+ ...(subscription.metadata || {}),
443
+ app_store_last_notification_type: notificationType,
444
+ app_store_last_notification_subtype: subtype,
445
+ },
446
+ });
447
+ break;
448
+
449
+ default:
450
+ logger.warn('unhandled app_store notificationType', { notificationType, subtype });
451
+ }
452
+ }
453
+
454
+ async function handleAppStoreSubscribed({
455
+ transaction,
456
+ }: {
457
+ transaction: AppStoreTransactionPayload;
458
+ environment: 'Production' | 'Sandbox';
459
+ }): Promise<void> {
460
+ // Idempotency: if the Subscription already exists (e.g. client beat the
461
+ // webhook with /verify), there's nothing to do.
462
+ const existing = await findSubscriptionByOriginalTransactionId(transaction.originalTransactionId);
463
+ if (existing) {
464
+ logger.info('app_store SUBSCRIBED — local subscription already exists, no-op', {
465
+ subscriptionId: existing.id,
466
+ originalTransactionId: transaction.originalTransactionId,
467
+ });
468
+ return;
469
+ }
470
+
471
+ // Try to map back to a Customer via appAccountToken (Apple's equivalent of
472
+ // obfuscatedExternalAccountId). Webhook-first purchases without a prior
473
+ // client verify happen on family-share, restore-purchase, and TestFlight
474
+ // paths — log loudly so ops can chase the orphan.
475
+ if (!transaction.appAccountToken) {
476
+ logger.warn('app_store SUBSCRIBED with no appAccountToken — orphan purchase, cannot map to customer', {
477
+ originalTransactionId: transaction.originalTransactionId,
478
+ productId: transaction.productId,
479
+ });
480
+ return;
481
+ }
482
+ const customer = await Customer.findOne({ where: { app_store_uuid: transaction.appAccountToken } });
483
+ if (!customer) {
484
+ logger.warn('app_store SUBSCRIBED — no Customer matches appAccountToken', {
485
+ appAccountToken: transaction.appAccountToken,
486
+ originalTransactionId: transaction.originalTransactionId,
487
+ });
488
+ return;
489
+ }
490
+
491
+ // Find the app_store PaymentMethod for this livemode (assume one per livemode,
492
+ // mirrors the route-level constraint).
493
+ const paymentMethod = await PaymentMethod.findOne({
494
+ where: { type: 'app_store', active: true, livemode: customer.livemode },
495
+ });
496
+ if (!paymentMethod) {
497
+ logger.error('app_store SUBSCRIBED — no active PaymentMethod, skipping Subscription create');
498
+ return;
499
+ }
500
+
501
+ // Multi-tenant: scope the SKU lookup to this app's bundle_id so a
502
+ // collision (two apps with same SKU string) routes to the right Price.
503
+ const price = await Price.findOne({
504
+ where: {
505
+ 'metadata.app_store_product_id': transaction.productId,
506
+ 'metadata.bundle_id': transaction.bundleId,
507
+ } as any,
508
+ });
509
+
510
+ const now = Math.floor(Date.now() / 1000);
511
+ const expiresAt = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : 0;
512
+ const purchaseStartedAt = transaction.purchaseDate ? Math.floor(Number(transaction.purchaseDate) / 1000) : now;
513
+ const subscription = await Subscription.create({
514
+ livemode: customer.livemode,
515
+ customer_id: customer.id,
516
+ currency_id: paymentMethod.default_currency_id ?? '',
517
+ default_payment_method_id: paymentMethod.id,
518
+ status: 'active',
519
+ channel: 'app_store',
520
+ environment: transaction.environment === 'Production' ? 'production' : 'sandbox',
521
+ current_period_start: purchaseStartedAt,
522
+ current_period_end: expiresAt || now,
523
+ cancel_at_period_end: false,
524
+ billing_cycle_anchor: now,
525
+ collection_method: 'charge_automatically',
526
+ start_date: now,
527
+ metadata: {
528
+ app_store_product_id: transaction.productId,
529
+ product_id: price?.product_id,
530
+ price_id: price?.id,
531
+ },
532
+ payment_details: {
533
+ app_store: {
534
+ original_transaction_id: transaction.originalTransactionId,
535
+ transaction_id: transaction.transactionId,
536
+ product_id: transaction.productId,
537
+ // Multi-tenant: persist the verified bundle_id (same rationale as
538
+ // the client-verify path above).
539
+ bundle_id: transaction.bundleId,
540
+ web_order_line_item_id: transaction.webOrderLineItemId,
541
+ environment: transaction.environment,
542
+ expires_at: expiresAt,
543
+ },
544
+ },
545
+ pending_invoice_item_interval: { interval: 'month', interval_count: 1 } as any,
546
+ } as any);
547
+
548
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
549
+ logger.info('app_store SUBSCRIBED — subscription created from webhook', {
550
+ subscriptionId: subscription.id,
551
+ customerId: customer.id,
552
+ productId: price?.product_id,
553
+ priceId: price?.id,
554
+ });
555
+ }
556
+
557
+ async function handleAppStoreRenewed(
558
+ subscription: Subscription,
559
+ transaction: AppStoreTransactionPayload
560
+ ): Promise<void> {
561
+ const newExpiry = transaction.expiresDate ? Math.floor(Number(transaction.expiresDate) / 1000) : undefined;
562
+ await subscription.update({
563
+ status: 'active',
564
+ current_period_end: newExpiry ?? subscription.current_period_end,
565
+ payment_details: {
566
+ ...(subscription.payment_details || {}),
567
+ app_store: {
568
+ ...(subscription.payment_details?.app_store || {
569
+ original_transaction_id: transaction.originalTransactionId,
570
+ product_id: transaction.productId,
571
+ }),
572
+ transaction_id: transaction.transactionId,
573
+ expires_at: newExpiry,
574
+ },
575
+ },
576
+ });
577
+ createEvent('Subscription', 'customer.subscription.started', subscription).catch(console.error);
578
+ }
579
+
580
+ async function markAppStoreExpired(subscription: Subscription): Promise<void> {
581
+ if (['canceled', 'incomplete_expired'].includes(subscription.status as string)) return;
582
+ await subscription.update({ status: 'canceled', ended_at: Math.floor(Date.now() / 1000) });
583
+ createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
584
+ }
585
+
586
+ async function markAppStorePastDue(subscription: Subscription): Promise<void> {
587
+ if (subscription.status === 'past_due') return;
588
+ await subscription.update({ status: 'past_due' });
589
+ }
590
+
591
+ async function scheduleAppStoreCancelAtPeriodEnd(subscription: Subscription): Promise<void> {
592
+ if (subscription.cancel_at_period_end) return;
593
+ await subscription.update({ cancel_at_period_end: true });
594
+ }
595
+
596
+ async function unscheduleAppStoreCancel(subscription: Subscription): Promise<void> {
597
+ if (!subscription.cancel_at_period_end) return;
598
+ await subscription.update({ cancel_at_period_end: false });
599
+ }
600
+
601
+ async function handleAppStoreRevoked(
602
+ subscription: Subscription,
603
+ notificationType: string,
604
+ subtype: string | undefined
605
+ ): Promise<void> {
606
+ const isRefund = notificationType === 'REFUND';
607
+ const now = Math.floor(Date.now() / 1000);
608
+ await subscription.update({
609
+ status: 'canceled',
610
+ ended_at: now,
611
+ cancelation_details: {
612
+ ...(subscription.cancelation_details ?? { comment: '', feedback: 'other' }),
613
+ reason: 'cancellation_requested',
614
+ },
615
+ metadata: {
616
+ ...(subscription.metadata || {}),
617
+ app_store_last_notification_type: notificationType,
618
+ app_store_last_notification_subtype: subtype,
619
+ ...(isRefund ? { refunded: true, refunded_at: now } : {}),
620
+ },
621
+ });
622
+
623
+ // IAP refunds are mirrored as subscription state only — the canonical refund
624
+ // record lives at Apple. We deliberately do NOT create a local Refund ledger
625
+ // row; user-facing refund/receipt history is in the App Store.
626
+ // See docs/superpowers/specs/2026-06-04-iap-records-organization-design.md.
627
+ logger.info('app_store REVOKE/REFUND — status mirrored to canceled', {
628
+ subscriptionId: subscription.id,
629
+ notificationType,
630
+ subtype,
631
+ refunded: isRefund,
632
+ });
633
+
634
+ createEvent('Subscription', 'customer.subscription.deleted', subscription).catch(console.error);
635
+ }
@@ -0,0 +1,17 @@
1
+ // Minimal ambient declarations for `node-apple-receipt-verify`.
2
+ // The library ships no @types package; we only use config() + validate(),
3
+ // and accept that validate() returns `unknown` items we narrow ourselves.
4
+
5
+ declare module 'node-apple-receipt-verify' {
6
+ export interface AppleReceiptConfig {
7
+ secret?: string;
8
+ verbose?: boolean;
9
+ environment?: Array<'production' | 'sandbox'>;
10
+ /** ignore expired items, default false */
11
+ ignoreExpired?: boolean;
12
+ /** extended fields, etc. */
13
+ [k: string]: any;
14
+ }
15
+ export function config(options: AppleReceiptConfig): void;
16
+ export function validate(options: { receipt: string }): Promise<unknown[]>;
17
+ }
@@ -0,0 +1,18 @@
1
+ // Pure helper (no DB/SDK imports) for App Store Server Notification routing.
2
+
3
+ /**
4
+ * Decode an ASSN signedPayload's JWS body WITHOUT verifying its signature — used
5
+ * ONLY to read bundleId/environment so the webhook can select the matching
6
+ * PaymentMethod before doing the trusted verification with THAT method's client.
7
+ * Never trust these values for anything else.
8
+ */
9
+ export function peekNotificationRouting(signedPayload: string): { bundleId?: string; environment?: string } | null {
10
+ const seg = signedPayload.split('.')[1];
11
+ if (!seg) return null;
12
+ try {
13
+ const payload = JSON.parse(Buffer.from(seg, 'base64url').toString('utf8'));
14
+ return { bundleId: payload?.data?.bundleId, environment: payload?.data?.environment };
15
+ } catch {
16
+ return null;
17
+ }
18
+ }