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
@@ -1,6 +1,7 @@
1
1
  import Cron from '@abtnode/cron';
2
2
 
3
3
  import { checkStakeRevokeTx } from '../integrations/arcblock/stake';
4
+ import { runIapReconcile } from '../integrations/iap-reconcile';
4
5
  import {
5
6
  batchHandleStripeInvoices,
6
7
  batchHandleStripePayments,
@@ -9,7 +10,9 @@ import {
9
10
  import {
10
11
  creditConsumptionCronTime,
11
12
  depositVaultCronTime,
13
+ eventRetryCronTime,
12
14
  expiredSessionCleanupCronTime,
15
+ iapReconcileCronTime,
13
16
  notificationCronTime,
14
17
  overdueDetectionCronTime,
15
18
  paymentStatCronTime,
@@ -30,6 +33,7 @@ import { startVendorStatusCheckSchedule } from '../queues/vendors/status-check';
30
33
  import { CheckoutSession } from '../store/models';
31
34
  import { createOverdueDetection } from './overdue-detection';
32
35
  import { createPaymentStat } from './payment-stat';
36
+ import { retryPendingEvents } from './retry-pending-events';
33
37
  import { SubscriptionTrialWillEndSchedule } from './subscription-trial-will-end';
34
38
  import { SubscriptionWillCanceledSchedule } from './subscription-will-canceled';
35
39
  import { SubscriptionWillRenewSchedule } from './subscription-will-renew';
@@ -128,6 +132,24 @@ function init() {
128
132
  fn: checkStakeRevokeTx,
129
133
  options: { runOnInit: false },
130
134
  },
135
+ {
136
+ // Backup for App Store / Google Play webhooks — pulls authoritative
137
+ // subscription state and patches local drift (refunds/renewals the
138
+ // webhook missed). See blocklets/core/api/src/integrations/iap-reconcile.ts.
139
+ name: 'iap.reconcile',
140
+ time: iapReconcileCronTime,
141
+ fn: () => runIapReconcile(),
142
+ options: { runOnInit: false },
143
+ },
144
+ {
145
+ // Backstop for the fire-and-forget event → webhook path. Rescans events
146
+ // with pending_webhooks>0 older than 60s and re-invokes handleEvent.
147
+ // See blocklets/core/api/src/crons/retry-pending-events.ts.
148
+ name: 'event.retry',
149
+ time: eventRetryCronTime,
150
+ fn: () => retryPendingEvents(),
151
+ options: { runOnInit: false },
152
+ },
131
153
  {
132
154
  name: 'payment.stat',
133
155
  time: paymentStatCronTime,
@@ -0,0 +1,58 @@
1
+ // Backstop for the event → webhook fire-and-forget path.
2
+ //
3
+ // createEvent emits 'event.created' (sync) → an async listener calls
4
+ // handleEvent → schedules HTTP webhook delivery. On CF Workers the listener's
5
+ // microtask can lose the worker before its waitUntil registers if it fires
6
+ // from the last line of an HTTP handler (e.g. ingestVerifiedGooglePlayPurchase
7
+ // ends with `createEvent('subscription.started', ...).catch(...)` right before
8
+ // returning — the response flushes and runtime may stop draining new
9
+ // waitUntils). Result: events row exists with pending_webhooks>0 but no
10
+ // webhook_attempt is ever written, so downstream apps silently miss the
11
+ // notification.
12
+ //
13
+ // This cron rescans for pending events older than the realtime grace window
14
+ // and re-invokes handleEvent. Stripe et al ship the same belt-and-suspenders.
15
+
16
+ import { Op } from 'sequelize';
17
+
18
+ import logger from '../libs/logger';
19
+ import { eventQueue } from '../queues/event';
20
+ import { Event } from '../store/models/event';
21
+
22
+ const REALTIME_GRACE_SECONDS = 60;
23
+ // Each cron tick only enqueues, does NOT inline await handleEvent. CF Workers
24
+ // scheduled handler has a tight CPU budget (~30s) — inline handling N events
25
+ // blew past it after ~7 iterations and was cut off mid-batch, so the tail
26
+ // never got processed. Pushing to eventQueue lets the consumer (which has a
27
+ // looser per-message budget) work through them asynchronously.
28
+ //
29
+ // BATCH_LIMIT must stay small: even push-only, the scheduled handler awaits
30
+ // flushPendingJobs() at the end which await's each push's enqueue promise
31
+ // (D1 addJob + CF Queue send). 50 was too many — pushes never finished
32
+ // sending, jobs table never got rows, handleEvent never ran. 5 leaves
33
+ // generous headroom and we still drain the backlog over a few minutes.
34
+ const BATCH_LIMIT = 5;
35
+
36
+ export async function retryPendingEvents(): Promise<void> {
37
+ const threshold = new Date(Date.now() - REALTIME_GRACE_SECONDS * 1000);
38
+ const docs = await Event.findAll({
39
+ where: {
40
+ pending_webhooks: { [Op.gt]: 0 },
41
+ created_at: { [Op.lt]: threshold },
42
+ },
43
+ attributes: ['id'],
44
+ order: [['created_at', 'ASC']],
45
+ limit: BATCH_LIMIT,
46
+ });
47
+
48
+ if (docs.length === 0) return;
49
+ logger.info(`event.retry: enqueuing ${docs.length} pending events older than ${REALTIME_GRACE_SECONDS}s`);
50
+
51
+ for (const doc of docs) {
52
+ try {
53
+ eventQueue.push({ id: doc.id, job: { eventId: doc.id }, persist: false });
54
+ } catch (err: any) {
55
+ logger.error('event.retry enqueue failed', { eventId: doc.id, error: err?.message });
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,26 @@
1
+ // Apple Root Certificates for App Store JWS verification.
2
+ //
3
+ // Vendored as base64 constants so `tsc` compiles cleanly without copying
4
+ // non-ts assets into dist/. Source files (DER-encoded `.cer`) live under
5
+ // `./certs/` and were downloaded from https://www.apple.com/certificateauthority/
6
+ //
7
+ // Apple's IAP JWS signing chain currently terminates at one of these roots.
8
+ // Apple rotates roots roughly every 25 years — refresh when they do.
9
+ // - Apple Inc. Root Certificate (RSA 2048, valid 2006-04 → 2035-02)
10
+ // - Apple Root CA - G2 (RSA 4096, valid 2014-04 → 2039-04)
11
+ // - Apple Root CA - G3 (ECC P-384, valid 2014-04 → 2039-04)
12
+
13
+ const APPLE_INC_ROOT_B64 =
14
+ 'MIIEuzCCA6OgAwIBAgIBAjANBgkqhkiG9w0BAQUFADBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwHhcNMDYwNDI1MjE0MDM2WhcNMzUwMjA5MjE0MDM2WjBiMQswCQYDVQQGEwJVUzETMBEGA1UEChMKQXBwbGUgSW5jLjEmMCQGA1UECxMdQXBwbGUgQ2VydGlmaWNhdGlvbiBBdXRob3JpdHkxFjAUBgNVBAMTDUFwcGxlIFJvb3QgQ0EwggEiMA0GCSqGSIb3DQEBAQUAA4IBDwAwggEKAoIBAQDkkakJH5HbHkdQ6wXtXnmELes2oldMVeyLGYne+Uts9QerIjAC6Bg++FAJ039BqJj50cpmnCRrEdCju+QbKsMflZ56DKRHi1vUFjczy8QPTc4UadHJGXL1XQ7Vf1+b8iUDulWPTV0N8WQ1IxVLFVkds5T39pyez1C6wVhQZ48ItCD3y6wsIG9wtj8BMIy3Q88PnT3zK0koGsj+zrW5DtleHNbLPbU6rfQPDgCSC7EhFi501TwN22IWq6NxkkdTVcGvL0Gz+PvjcM3mo0xFfh9Ma1CWQYnEdGILEINBhzOKgbEwWOxaBDKMaLOPHd5lc/9nXmW8Sdh2nzMUZaF3lMktAgMBAAGjggF6MIIBdjAOBgNVHQ8BAf8EBAMCAQYwDwYDVR0TAQH/BAUwAwEB/zAdBgNVHQ4EFgQUK9BpR5R2Cf70a40uQKb3R01/CF4wHwYDVR0jBBgwFoAUK9BpR5R2Cf70a40uQKb3R01/CF4wggERBgNVHSAEggEIMIIBBDCCAQAGCSqGSIb3Y2QFATCB8jAqBggrBgEFBQcCARYeaHR0cHM6Ly93d3cuYXBwbGUuY29tL2FwcGxlY2EvMIHDBggrBgEFBQcCAjCBthqBs1JlbGlhbmNlIG9uIHRoaXMgY2VydGlmaWNhdGUgYnkgYW55IHBhcnR5IGFzc3VtZXMgYWNjZXB0YW5jZSBvZiB0aGUgdGhlbiBhcHBsaWNhYmxlIHN0YW5kYXJkIHRlcm1zIGFuZCBjb25kaXRpb25zIG9mIHVzZSwgY2VydGlmaWNhdGUgcG9saWN5IGFuZCBjZXJ0aWZpY2F0aW9uIHByYWN0aWNlIHN0YXRlbWVudHMuMA0GCSqGSIb3DQEBBQUAA4IBAQBcNplMLXi37Yyb3PN3m/J20ncwT8EfhYOFG5k9RzfyqZtAjizUsZAS2L70c5vu0mQPy3lPNNiiPvl4/2vIB+x9OYOLUyDTOMSxv5pPCmv/K/xZpwUJfBdAVhEedNO3iyM7R6PVbyTi69G3cN8PReEnyvFteO3ntRcXqNx+IjXKJdXZD9Zr1KIkIxH3oayPc4FgxhtbCS+SsvhESPBgOJ4V9T0mZyCKM2r3DYLP3uujL/lTaltkwGMzd/c6ByxW69oPIQ7aunMZT7XZNn/Bh1XZp5m5MkL72NVxnn6hUrcbvZNCJBIqxw8dtk2cXmPIS4AXUKqK1drk/NAJBzewdXUh';
15
+
16
+ const APPLE_ROOT_CA_G2_B64 =
17
+ 'MIIFkjCCA3qgAwIBAgIIAeDltYNno+AwDQYJKoZIhvcNAQEMBQAwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEcyMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxMDA5WhcNMzkwNDMwMTgxMDA5WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzIxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzCCAiIwDQYJKoZIhvcNAQEBBQADggIPADCCAgoCggIBANgREkhI2imKScUcx+xuM23+TfvgHN6sXuI2pyT5f1BrTM65MFQn5bPW7SXmMLYFN14UIhHF6Kob0vuy0gmVOKTvKkmMXT5xZgM4+xb1hYjkWpIMBDLyyED7Ul+f9sDx47pFoFDVEovy3d6RhiPw9bZyLgHaC/YuOQhfGaFjQQscp5TBhsRTL3b2CtcM0YM/GlMZ81fVJ3/8E7j4ko380yhDPLVoACVdJ2LT3VXdRCCQgzWTxb+4Gftr49wIQuavbfqeQMpOhYV4SbHXw8EwOTKrfl+q04tvny0aIWhwZ7Oj8ZhBbZF8+NfbqOdfIRqMM78xdLe40fTgIvS/cjTf94FNcX1RoeKz8NMoFnNvzcytN31O661A4T+B/fc9Cj6i8b0xlilZ3MIZgIxbdMYs0xBTJh0UT8TUgWY8h2czJxQI6bR3hDRSj4n4aJgXv8O7qhOTH11UL6jHfPsNFL4VPSQ08prcdUFmIrQB1guvkJ4M6mL4m1k8COKWNORj3rw31OsMiANDC1CvoDTdUE0V+1ok2Az6DGOeHwOx4e7hqkP0ZmUoNwIx7wHHHtHMn23KVDpA287PT0aLSmWaasZobNfMmRtHsHLDd4/E92GcdB/O/WuhwpyUgquUoue9G7q5cDmVF8Up8zlYNPXEpMZ7YLlmQ1A/bmH8DvmGqmAMQ0uVAgMBAAGjQjBAMB0GA1UdDgQWBBTEmRNsGAPCe8CjoA1/coB6HHcmjTAPBgNVHRMBAf8EBTADAQH/MA4GA1UdDwEB/wQEAwIBBjANBgkqhkiG9w0BAQwFAAOCAgEAUabz4vS4PZO/Lc4Pu1vhVRROTtHlznldgX/+tvCHM/jvlOV+3Gp5pxy+8JS3ptEwnMgNCnWefZKVfhidfsJxaXwU6s+DDuQUQp50DhDNqxq6EWGBeNjxtUVAeKuowM77fWM3aPbn+6/Gw0vsHzYmE1SGlHKy6gLti23kDKaQwFd1z4xCfVzmMX3zybKSaUYOiPjjLUKyOKimGY3xn83uamW8GrAlvacp/fQ+onVJv57byfenHmOZ4VxG/5IFjPoeIPmGlFYl5bRXOJ3riGQUIUkhOb9iZqmxospvPyFgxYnURTbImHy99v6ZSYA7LNKmp4gDBDEZt7Y6YUX6yfIjyGNzv1aJMbDZfGKnexWoiIqrOEDCzBL/FePwN983csvMmOa/orz6JopxVtfnJBtIRD6e/J/JzBrsQzwBvDR4yGn1xuZW7AYJNpDrFEobXsmII9oDMJELuDY++ee1KG++P+w8j2Ud5cAeh6Squpj9kuNsJnfdBrRkBof0Tta6SqoWqPQFZ2aWuuJVecMsXUmPgEkrihLHdoBR37q9ZV0+N0djMenl9MU/S60EinpxLK8JQzcPqOMyT/RFtm2XNuyE9QoB6he7hY1Ck3DDUOUUi78/w0EP3SIEIwiKum1xRKtzCTrJ+VKACd+66eYWyi4uTLLT3OUEVLLUNIAytbwPF+E=';
18
+
19
+ const APPLE_ROOT_CA_G3_B64 =
20
+ 'MIICQzCCAcmgAwIBAgIILcX8iNLFS5UwCgYIKoZIzj0EAwMwZzEbMBkGA1UEAwwSQXBwbGUgUm9vdCBDQSAtIEczMSYwJAYDVQQLDB1BcHBsZSBDZXJ0aWZpY2F0aW9uIEF1dGhvcml0eTETMBEGA1UECgwKQXBwbGUgSW5jLjELMAkGA1UEBhMCVVMwHhcNMTQwNDMwMTgxOTA2WhcNMzkwNDMwMTgxOTA2WjBnMRswGQYDVQQDDBJBcHBsZSBSb290IENBIC0gRzMxJjAkBgNVBAsMHUFwcGxlIENlcnRpZmljYXRpb24gQXV0aG9yaXR5MRMwEQYDVQQKDApBcHBsZSBJbmMuMQswCQYDVQQGEwJVUzB2MBAGByqGSM49AgEGBSuBBAAiA2IABJjpLz1AcqTtkyJygRMc3RCV8cWjTnHcFBbZDuWmBSp3ZHtfTjjTuxxEtX/1H7YyYl3J6YRbTzBPEVoA/VhYDKX1DyxNB0cTddqXl5dvMVztK517IDvYuVTZXpmkOlEKMaNCMEAwHQYDVR0OBBYEFLuw3qFYM4iapIqZ3r6966/ayySrMA8GA1UdEwEB/wQFMAMBAf8wDgYDVR0PAQH/BAQDAgEGMAoGCCqGSM49BAMDA2gAMGUCMQCD6cHEFl4aXTQY2e3v9GwOAEZLuN+yRhHFD/3meoyhpmvOwgPUnPWTxnS4at+qIxUCMG1mihDK1A3UT82NQz60imOlM27jbdoXt2QfyFMm+YhidDkLF1vLUagM6BgD56KyKA==';
21
+
22
+ export const APPLE_ROOT_CERTS: Buffer[] = [
23
+ Buffer.from(APPLE_INC_ROOT_B64, 'base64'),
24
+ Buffer.from(APPLE_ROOT_CA_G2_B64, 'base64'),
25
+ Buffer.from(APPLE_ROOT_CA_G3_B64, 'base64'),
26
+ ];
@@ -0,0 +1,369 @@
1
+ // App Store integration client wrapper.
2
+ //
3
+ // Primary verify path is **client-side initiated** (mirrors A2 google_play):
4
+ // the mobile client posts a `jws_signed_transaction` (StoreKit 2) right after a
5
+ // purchase finishes. Apple's JWS carries a full self-attesting payload signed
6
+ // by Apple — no shared secret, no server callback to Apple needed for the
7
+ // happy path. We only hit App Store Server API when we need fresher state
8
+ // (e.g. SUBSCRIPTION_STATUS at renewal time).
9
+ //
10
+ // A1 mock phase: `verifyJwsTransaction` decodes the JWT payload **without**
11
+ // signature verification, so we can wire end-to-end with test fixtures. Real
12
+ // signature verification (Apple Root CA → leaf cert → RS256) is gated behind
13
+ // the same TODO marker as `google-play/verify.ts`.
14
+ //
15
+ // Write methods (cancel / refund) intentionally throw unless the
16
+ // APP_STORE_WRITE_ENABLED env flag is set — same safety rail as google_play.
17
+ //
18
+ // Tests replace this class via jest mocks.
19
+
20
+ import { config as configApple, validate as validateAppleReceipt } from 'node-apple-receipt-verify';
21
+
22
+ import logger from '../../libs/logger';
23
+ import {
24
+ AppStoreApiCredentials,
25
+ getAllSubscriptionStatuses,
26
+ verifySignedNotification,
27
+ verifySignedTransaction,
28
+ } from './signed-data-verifier';
29
+
30
+ /** App Store Server Notification V2 `notificationType` — Apple-defined string enum. */
31
+ export type AppStoreNotificationType =
32
+ | 'SUBSCRIBED'
33
+ | 'DID_RENEW'
34
+ | 'EXPIRED'
35
+ | 'DID_FAIL_TO_RENEW'
36
+ | 'GRACE_PERIOD_EXPIRED'
37
+ | 'DID_CHANGE_RENEWAL_STATUS'
38
+ | 'DID_CHANGE_RENEWAL_PREF'
39
+ | 'OFFER_REDEEMED'
40
+ | 'PRICE_INCREASE'
41
+ | 'REFUND'
42
+ | 'REFUND_DECLINED'
43
+ | 'REFUND_REVERSED'
44
+ | 'REVOKE'
45
+ | 'CONSUMPTION_REQUEST'
46
+ | 'RENEWAL_EXTENDED'
47
+ | 'RENEWAL_EXTENSION'
48
+ | 'TEST';
49
+
50
+ /** Decoded payload of the outer `signedPayload` JWS in an App Store Server Notification V2. */
51
+ export type AppStoreNotificationPayload = {
52
+ notificationType: AppStoreNotificationType | string;
53
+ /** Optional subtype (e.g. INITIAL_BUY / RESUBSCRIBE / VOLUNTARY / AUTO_RENEW_DISABLED / GRACE_PERIOD). */
54
+ subtype?: string;
55
+ /** UUID Apple generates per notification — use this for idempotency. */
56
+ notificationUUID: string;
57
+ version: string;
58
+ signedDate: number;
59
+ data: {
60
+ appAppleId?: number;
61
+ bundleId: string;
62
+ bundleVersion?: string;
63
+ environment: 'Production' | 'Sandbox';
64
+ /** Inner JWS carrying the transaction details (decode with verifyJwsTransaction). */
65
+ signedTransactionInfo?: string;
66
+ /** Inner JWS carrying renewal info — auto-renew state, expiration intent, etc. */
67
+ signedRenewalInfo?: string;
68
+ /** Subscription status: 1=active, 2=expired, 3=billing-retry, 4=grace, 5=revoked. */
69
+ status?: 1 | 2 | 3 | 4 | 5;
70
+ };
71
+ };
72
+
73
+ /** Decoded payload of a StoreKit 2 `signedTransactionInfo` JWS. */
74
+ export type AppStoreTransactionPayload = {
75
+ /** Apple's internal id for this specific charge */
76
+ transactionId: string;
77
+ /** Stable id across renewals (= subscription "thread") */
78
+ originalTransactionId: string;
79
+ /** Apple SKU (the Product Id configured in App Store Connect) */
80
+ productId: string;
81
+ /** RFC3339 millis (string in some SDK versions, number in others — normalize to number) */
82
+ purchaseDate?: number;
83
+ /** Subscription-only: expiry of this purchase */
84
+ expiresDate?: number;
85
+ /** UUID set by client via `Product.purchase(options:)` — our equivalent of obfuscatedExternalAccountId */
86
+ appAccountToken?: string;
87
+ /** "Production" | "Sandbox" */
88
+ environment: 'Production' | 'Sandbox';
89
+ /** When Apple signed this JWS */
90
+ signedDate?: number;
91
+ /** App bundle id, must match our configured one */
92
+ bundleId: string;
93
+ /** "AUTO_RENEWABLE" | "NON_RENEWABLE" | "CONSUMABLE" | "NON_CONSUMABLE" */
94
+ type?: string;
95
+ /** webOrderLineItemId — Apple-side line item id per renewal */
96
+ webOrderLineItemId?: string;
97
+ /** Set when Apple revokes the transaction (refund / family-sharing removal). Unix ms. */
98
+ revocationDate?: number;
99
+ /** Code why the transaction was revoked: 0=unknown, 1=app-issue. */
100
+ revocationReason?: number;
101
+ };
102
+
103
+ export type AppStoreSettings = {
104
+ bundle_id: string;
105
+ /** Constrained at runtime in `fromSettings`; widened here to align with PaymentMethodSettings (LiteralUnion). */
106
+ environment: string;
107
+ /** App-Specific Shared Secret — only needed for StoreKit 1 legacy receipts. */
108
+ shared_secret?: string;
109
+ /** App Store Server API credentials (only needed when calling Apple back for state refresh, NOT for JWS verify). */
110
+ issuer_id?: string;
111
+ key_id?: string;
112
+ private_key_pem?: string;
113
+ };
114
+
115
+ const WRITE_ENABLED = process.env.APP_STORE_WRITE_ENABLED === 'true';
116
+
117
+ export class AppStoreClient {
118
+ declare readonly bundleId: string;
119
+
120
+ declare readonly environment: 'production' | 'sandbox';
121
+
122
+ private readonly sharedSecret?: string;
123
+
124
+ private readonly serverApiCredentials?: {
125
+ issuerId: string;
126
+ keyId: string;
127
+ privateKeyPem: string;
128
+ };
129
+
130
+ private constructor(settings: AppStoreSettings, environment: 'production' | 'sandbox') {
131
+ this.bundleId = settings.bundle_id;
132
+ this.environment = environment;
133
+ this.sharedSecret = settings.shared_secret;
134
+ if (settings.issuer_id && settings.key_id && settings.private_key_pem) {
135
+ this.serverApiCredentials = {
136
+ issuerId: settings.issuer_id,
137
+ keyId: settings.key_id,
138
+ privateKeyPem: settings.private_key_pem,
139
+ };
140
+ }
141
+ }
142
+
143
+ public static fromSettings(settings: AppStoreSettings): AppStoreClient {
144
+ if (!settings.bundle_id) {
145
+ throw new Error('AppStoreClient: bundle_id is required');
146
+ }
147
+ if (settings.environment !== 'production' && settings.environment !== 'sandbox') {
148
+ throw new Error(`AppStoreClient: environment must be production|sandbox, got ${settings.environment}`);
149
+ }
150
+ return new AppStoreClient(settings, settings.environment);
151
+ }
152
+
153
+ /**
154
+ * Verify + decode a StoreKit 2 signedTransactionInfo JWS.
155
+ *
156
+ * Delegates to Apple's official SignedDataVerifier:
157
+ * 1. Splits JWS, pulls leaf cert from header.x5c[0]
158
+ * 2. Verifies x5c chain against bundled Apple Root CAs
159
+ * 3. ES256 signature check on the payload
160
+ *
161
+ * Apple's verifyAndDecodeTransaction does NOT enforce bundleId — we layer
162
+ * that check here. Set `APP_STORE_SKIP_SIGNATURE_VERIFY=true` to bypass
163
+ * (decode only) for unit tests and emergency sandbox debugging.
164
+ */
165
+ public async verifyJwsTransaction(jws: string): Promise<AppStoreTransactionPayload> {
166
+ const decoded = await verifySignedTransaction(jws, this.bundleId, this.environment);
167
+
168
+ if (decoded.bundleId && decoded.bundleId !== this.bundleId) {
169
+ throw new Error(`AppStoreClient: bundleId mismatch — JWS says ${decoded.bundleId}, configured ${this.bundleId}`);
170
+ }
171
+ if (!decoded.transactionId || !decoded.productId) {
172
+ throw new Error('AppStoreClient: JWS payload missing transactionId or productId');
173
+ }
174
+
175
+ logger.debug('App Store JWS verified + decoded', {
176
+ transactionId: decoded.transactionId,
177
+ productId: decoded.productId,
178
+ environment: decoded.environment,
179
+ });
180
+
181
+ return {
182
+ transactionId: decoded.transactionId,
183
+ originalTransactionId: decoded.originalTransactionId ?? decoded.transactionId,
184
+ productId: decoded.productId,
185
+ purchaseDate: decoded.purchaseDate,
186
+ expiresDate: decoded.expiresDate,
187
+ appAccountToken: decoded.appAccountToken,
188
+ environment: (decoded.environment as 'Production' | 'Sandbox') ?? 'Sandbox',
189
+ signedDate: decoded.signedDate,
190
+ bundleId: decoded.bundleId ?? this.bundleId,
191
+ type: decoded.type,
192
+ webOrderLineItemId: decoded.webOrderLineItemId,
193
+ revocationDate: (decoded as any).revocationDate,
194
+ revocationReason: (decoded as any).revocationReason,
195
+ };
196
+ }
197
+
198
+ /**
199
+ * Verify + decode the outer signedPayload from an App Store Server
200
+ * Notification V2. Apple's SignedDataVerifier handles the cert chain +
201
+ * ES256 signature; we layer bundleId enforcement on top because the SDK
202
+ * trusts the configured bundleId but doesn't reject mismatching payloads.
203
+ */
204
+ public async verifyNotificationPayload(signedPayload: string): Promise<AppStoreNotificationPayload> {
205
+ const decoded = await verifySignedNotification(signedPayload, this.bundleId, this.environment);
206
+
207
+ if (decoded.data?.bundleId && decoded.data.bundleId !== this.bundleId) {
208
+ throw new Error(
209
+ `AppStoreClient: notification bundleId mismatch — JWS says ${decoded.data.bundleId}, configured ${this.bundleId}`
210
+ );
211
+ }
212
+ if (!decoded.notificationType || !decoded.notificationUUID) {
213
+ throw new Error('AppStoreClient: notification payload missing notificationType or notificationUUID');
214
+ }
215
+
216
+ logger.debug('App Store notification verified + decoded', {
217
+ notificationType: decoded.notificationType,
218
+ subtype: decoded.subtype,
219
+ notificationUUID: decoded.notificationUUID,
220
+ });
221
+
222
+ return {
223
+ notificationType: decoded.notificationType as string,
224
+ subtype: decoded.subtype as string | undefined,
225
+ notificationUUID: decoded.notificationUUID,
226
+ version: decoded.version ?? '2.0',
227
+ signedDate: decoded.signedDate ?? Date.now(),
228
+ data: {
229
+ appAppleId: decoded.data?.appAppleId,
230
+ bundleId: decoded.data?.bundleId ?? this.bundleId,
231
+ bundleVersion: decoded.data?.bundleVersion,
232
+ environment: (decoded.data?.environment as 'Production' | 'Sandbox') ?? 'Sandbox',
233
+ signedTransactionInfo: decoded.data?.signedTransactionInfo,
234
+ signedRenewalInfo: decoded.data?.signedRenewalInfo,
235
+ status: decoded.data?.status as 1 | 2 | 3 | 4 | 5 | undefined,
236
+ },
237
+ };
238
+ }
239
+
240
+ /**
241
+ * Verify a StoreKit 1 (legacy) base64 receipt via Apple's verifyReceipt endpoint.
242
+ *
243
+ * Calls `node-apple-receipt-verify` which POSTs to
244
+ * https://buy.itunes.apple.com/verifyReceipt (production)
245
+ * https://sandbox.itunes.apple.com/verifyReceipt (sandbox, auto fallback)
246
+ *
247
+ * Returns a payload shaped like a StoreKit 2 transaction so downstream code
248
+ * (ingestVerifiedAppStorePurchase) can stay agnostic. Note: legacy receipts
249
+ * do NOT carry an appAccountToken — there's no client-set UUID for reverse
250
+ * lookup. Callers should rely on the customer's session identity instead.
251
+ *
252
+ * @param expectedProductIds — only items matching one of these productIds are kept.
253
+ * If unset, all items are kept (receipt may contain multiple SKUs).
254
+ */
255
+ public async verifyLegacyReceipt(
256
+ receipt: string,
257
+ options: { expectedProductIds?: string[] } = {}
258
+ ): Promise<AppStoreTransactionPayload> {
259
+ if (!this.sharedSecret) {
260
+ throw new Error('AppStoreClient: shared_secret is required for legacy receipt verification');
261
+ }
262
+
263
+ // `node-apple-receipt-verify` uses a module-level config. We re-configure on
264
+ // every call so that multiple PaymentMethods (different secrets) don't
265
+ // shadow each other.
266
+ configApple({
267
+ secret: this.sharedSecret,
268
+ verbose: false,
269
+ environment: ['production', 'sandbox'],
270
+ });
271
+
272
+ const items = (await validateAppleReceipt({ receipt })) as Array<any>;
273
+ const filtered = options.expectedProductIds
274
+ ? items.filter((i) => options.expectedProductIds!.includes(i.productId))
275
+ : items;
276
+ if (!filtered.length) {
277
+ throw new Error('AppStoreClient: verifyReceipt returned no matching purchases');
278
+ }
279
+
280
+ // Pick the item with the latest expirationDate (subscription) or first one (non-subscription).
281
+ const latest = filtered.reduce((acc, cur) => {
282
+ const accExp = Number(acc.expirationDate ?? 0);
283
+ const curExp = Number(cur.expirationDate ?? 0);
284
+ return curExp > accExp ? cur : acc;
285
+ });
286
+
287
+ if (latest.bundleId && latest.bundleId !== this.bundleId) {
288
+ throw new Error(
289
+ `AppStoreClient: receipt bundleId mismatch — got ${latest.bundleId}, configured ${this.bundleId}`
290
+ );
291
+ }
292
+
293
+ logger.debug('App Store legacy receipt verified', {
294
+ productId: latest.productId,
295
+ transactionId: latest.transactionId,
296
+ expirationDate: latest.expirationDate,
297
+ });
298
+
299
+ return {
300
+ transactionId: String(latest.transactionId),
301
+ originalTransactionId: String(latest.originalTransactionId ?? latest.transactionId),
302
+ productId: latest.productId,
303
+ purchaseDate: latest.purchaseDate ? Number(latest.purchaseDate) : undefined,
304
+ expiresDate: latest.expirationDate ? Number(latest.expirationDate) : undefined,
305
+ // legacy receipts carry no client-set UUID
306
+ appAccountToken: undefined,
307
+ environment: this.environment === 'production' ? 'Production' : 'Sandbox',
308
+ bundleId: latest.bundleId ?? this.bundleId,
309
+ webOrderLineItemId: latest.webOrderLineItemId,
310
+ };
311
+ }
312
+
313
+ /**
314
+ * App Store Server API — pull latest subscription status by originalTransactionId.
315
+ *
316
+ * Returns the **decoded** latest `signedTransactionInfo` payload from
317
+ * Apple's StatusResponse (re-verified via SignedDataVerifier). Apple
318
+ * returns one transaction per `subscriptionGroupIdentifier`; we always
319
+ * pick the entry whose `originalTransactionId` matches the input.
320
+ *
321
+ * Requires issuer_id / key_id / private_key_pem in PaymentMethod settings.
322
+ */
323
+ public async getSubscriptionStatus(originalTransactionId: string): Promise<AppStoreTransactionPayload | null> {
324
+ if (!this.serverApiCredentials) {
325
+ throw new Error(
326
+ `AppStoreClient: getSubscriptionStatus(${originalTransactionId}) requires issuer_id/key_id/private_key_pem in settings`
327
+ );
328
+ }
329
+ const creds: AppStoreApiCredentials = {
330
+ issuerId: this.serverApiCredentials.issuerId,
331
+ keyId: this.serverApiCredentials.keyId,
332
+ privateKeyPem: this.serverApiCredentials.privateKeyPem,
333
+ bundleId: this.bundleId,
334
+ environment: this.environment,
335
+ };
336
+ const response = await getAllSubscriptionStatuses(originalTransactionId, creds);
337
+
338
+ // Apple groups by subscriptionGroupIdentifier — flatten + find the matching original txn.
339
+ const allTransactions = (response.data ?? []).flatMap((group) => group.lastTransactions ?? []);
340
+ const matched = allTransactions.find((t) => t.originalTransactionId === originalTransactionId);
341
+ if (!matched?.signedTransactionInfo) {
342
+ logger.warn('app_store getSubscriptionStatus: no matching transaction found', { originalTransactionId });
343
+ return null;
344
+ }
345
+ return this.verifyJwsTransaction(matched.signedTransactionInfo);
346
+ }
347
+
348
+ /** Refund — Apple actually doesn't let third parties refund subscriptions; this is here for symmetry with GooglePlayClient. */
349
+ // eslint-disable-next-line class-methods-use-this, require-await
350
+ public async refundSubscription(originalTransactionId: string): Promise<void> {
351
+ if (!WRITE_ENABLED) {
352
+ throw new Error(
353
+ `refundSubscription(${originalTransactionId}) is gated behind APP_STORE_WRITE_ENABLED env. Note: Apple Server API has no third-party refund endpoint — refunds must be requested by the end user via reportProblem.apple.com or by Apple support.`
354
+ );
355
+ }
356
+ throw new Error('refundSubscription not implementable — Apple does not expose a server-side refund API');
357
+ }
358
+
359
+ /** Cancel — same caveat as refund: Apple expects the user to cancel via App Store > Subscriptions. */
360
+ // eslint-disable-next-line class-methods-use-this, require-await
361
+ public async cancelSubscription(originalTransactionId: string): Promise<void> {
362
+ if (!WRITE_ENABLED) {
363
+ throw new Error(
364
+ `cancelSubscription(${originalTransactionId}) is gated behind APP_STORE_WRITE_ENABLED env. Apple does not expose a server-initiated cancel — the user must cancel from their device.`
365
+ );
366
+ }
367
+ throw new Error('cancelSubscription not implementable — Apple does not expose a server-side cancel API');
368
+ }
369
+ }
@@ -0,0 +1,46 @@
1
+ // Top-level dispatch for an App Store Server Notification V2.
2
+ //
3
+ // Apple S2S envelope (already decoded by routes/integrations/app-store.ts):
4
+ // {
5
+ // notificationType: "DID_RENEW" | "EXPIRED" | ... ,
6
+ // subtype?: "INITIAL_BUY" | "VOLUNTARY" | ...,
7
+ // notificationUUID: "<uuid>",
8
+ // version: "2.0",
9
+ // signedDate: <unix ms>,
10
+ // data: {
11
+ // bundleId, environment,
12
+ // signedTransactionInfo, signedRenewalInfo,
13
+ // status (1=active, 2=expired, 3=billing-retry, 4=grace, 5=revoked)
14
+ // }
15
+ // }
16
+
17
+ import logger from '../../../libs/logger';
18
+ import { AppStoreClient, AppStoreNotificationPayload } from '../client';
19
+ import { handleAppStoreSubscriptionEvent } from './subscription';
20
+
21
+ export default async function handleAppStoreNotification(
22
+ notification: AppStoreNotificationPayload,
23
+ client: AppStoreClient
24
+ ): Promise<void> {
25
+ if (notification.notificationType === 'TEST') {
26
+ logger.info('app_store TEST notification received', {
27
+ notificationUUID: notification.notificationUUID,
28
+ bundleId: notification.data.bundleId,
29
+ });
30
+ return;
31
+ }
32
+
33
+ // Every actionable notification carries the inner transaction JWS — decode it
34
+ // here so the state machine can be tested with a pre-decoded payload.
35
+ if (!notification.data.signedTransactionInfo) {
36
+ logger.warn('app_store notification has no signedTransactionInfo — nothing to dispatch', {
37
+ notificationType: notification.notificationType,
38
+ notificationUUID: notification.notificationUUID,
39
+ });
40
+ return;
41
+ }
42
+
43
+ const transaction = await client.verifyJwsTransaction(notification.data.signedTransactionInfo);
44
+
45
+ await handleAppStoreSubscriptionEvent({ notification, transaction });
46
+ }