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,480 @@
1
+ /* eslint-disable require-await, import/first */
2
+ // Unit tests for ingestVerifiedAppStorePurchase.
3
+ // Models / audit / client are fully mocked — DB is not touched.
4
+
5
+ process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
6
+
7
+ import { ingestVerifiedAppStorePurchase } from '../../../src/integrations/app-store/handlers/subscription';
8
+ import type { AppStoreClient, AppStoreTransactionPayload } from '../../../src/integrations/app-store/client';
9
+ import type { PaymentMethod } from '../../../src/store/models';
10
+
11
+ const mockSubscriptionFindOne: jest.Mock<any, any[]> = jest.fn();
12
+ const mockSubscriptionCount: jest.Mock<any, any[]> = jest.fn();
13
+ const mockSubscriptionCreate: jest.Mock<any, any[]> = jest.fn();
14
+ const mockSubscriptionItemCreate: jest.Mock<any, any[]> = jest.fn();
15
+ const mockCustomerFindOrCreate: jest.Mock<any, any[]> = jest.fn();
16
+ const mockCustomerFindByPk: jest.Mock<any, any[]> = jest.fn();
17
+ const mockPriceFindOne: jest.Mock<any, any[]> = jest.fn();
18
+ const mockGetInvoicePrefix = jest.fn(() => 'TEST');
19
+ const mockCreateEvent: jest.Mock<Promise<undefined>, any[]> = jest.fn(async () => undefined);
20
+
21
+ jest.mock('../../../src/store/models', () => ({
22
+ Subscription: {
23
+ findOne: (...args: any[]) => mockSubscriptionFindOne(...args),
24
+ count: (...args: any[]) => mockSubscriptionCount(...args),
25
+ create: (...args: any[]) => mockSubscriptionCreate(...args),
26
+ },
27
+ Customer: {
28
+ findOrCreate: (...args: any[]) => mockCustomerFindOrCreate(...args),
29
+ findByPk: (...args: any[]) => mockCustomerFindByPk(...args),
30
+ getInvoicePrefix: () => mockGetInvoicePrefix(),
31
+ },
32
+ SubscriptionItem: { create: (...args: any[]) => mockSubscriptionItemCreate(...args) },
33
+ Price: { findOne: (...args: any[]) => mockPriceFindOne(...args) },
34
+ PaymentMethod: {},
35
+ }));
36
+
37
+ jest.mock('../../../src/libs/audit', () => ({
38
+ createEvent: (...args: any[]) => mockCreateEvent(...args),
39
+ }));
40
+
41
+ const makeClient = (payload: Partial<AppStoreTransactionPayload>): AppStoreClient => {
42
+ const buildPayload = () =>
43
+ ({
44
+ transactionId: 'txn_1',
45
+ originalTransactionId: 'orig_1',
46
+ productId: 'pro_monthly',
47
+ bundleId: 'com.example.app',
48
+ environment: 'Sandbox',
49
+ expiresDate: Date.now() + 30 * 24 * 60 * 60 * 1000, // 30 days out
50
+ purchaseDate: Date.now() - 60_000,
51
+ appAccountToken: 'uuid-1',
52
+ ...payload,
53
+ }) as AppStoreTransactionPayload;
54
+ return {
55
+ bundleId: 'com.example.app',
56
+ environment: 'sandbox',
57
+ verifyJwsTransaction: jest.fn(async () => buildPayload()),
58
+ verifyLegacyReceipt: jest.fn(async () => buildPayload()),
59
+ } as unknown as AppStoreClient;
60
+ };
61
+
62
+ const makeMethod = (overrides: Record<string, any> = {}): PaymentMethod =>
63
+ ({
64
+ id: 'pm_app_store_1',
65
+ livemode: false,
66
+ default_currency_id: 'cur_1',
67
+ ...overrides,
68
+ }) as unknown as PaymentMethod;
69
+
70
+ beforeEach(() => {
71
+ mockSubscriptionFindOne.mockReset();
72
+ mockSubscriptionCount.mockReset().mockResolvedValue(0);
73
+ mockSubscriptionCreate.mockReset();
74
+ mockSubscriptionItemCreate.mockReset();
75
+ mockCustomerFindOrCreate.mockReset();
76
+ // Default: any existing sub is owned by the same DID the tests verify under
77
+ // (did:abc) → ownership guard passes. Mismatch tests override this.
78
+ mockCustomerFindByPk.mockReset().mockResolvedValue({ id: 'cus_1', did: 'did:abc' });
79
+ mockPriceFindOne.mockReset().mockResolvedValue(null);
80
+ mockCreateEvent.mockReset().mockImplementation(async () => undefined);
81
+ });
82
+
83
+ describe('ingestVerifiedAppStorePurchase', () => {
84
+ it('is idempotent — returns existing subscription if originalTransactionId already mapped', async () => {
85
+ const existing = { id: 'sub_existing', status: 'active', customer_id: 'cus_1', update: jest.fn() };
86
+ mockSubscriptionFindOne.mockResolvedValue(existing);
87
+ const client = makeClient({});
88
+
89
+ const result = await ingestVerifiedAppStorePurchase({
90
+ customerDid: 'did:abc',
91
+ paymentMethod: makeMethod(),
92
+ client,
93
+ signedTransaction: 'jws-string',
94
+ });
95
+
96
+ expect(result.subscription).toBe(existing);
97
+ expect(result.isFirstSubscribe).toBe(false);
98
+ expect(mockSubscriptionCreate).not.toHaveBeenCalled();
99
+ // Same owner → no re-attach write on the subscription.
100
+ expect(existing.update).not.toHaveBeenCalled();
101
+ });
102
+
103
+ it('refuses verify when the Apple subscription belongs to a different DID (no migration)', async () => {
104
+ // The Apple subscription (same originalTransactionId) is owned by another DID.
105
+ // A different DID verifies it → refuse with an ownership-mismatch error;
106
+ // the subscription is NOT migrated and NOT mutated. Mirrors OpenAI/Claude.
107
+ const update = jest.fn();
108
+ const existing = {
109
+ id: 'sub_existing',
110
+ status: 'active',
111
+ customer_id: 'cus_OLD',
112
+ payment_details: { app_store: { original_transaction_id: 'orig_1', product_id: 'pro_monthly' } },
113
+ update,
114
+ };
115
+ mockSubscriptionFindOne.mockResolvedValue(existing);
116
+ mockCustomerFindByPk.mockResolvedValue({ id: 'cus_OLD', did: 'did:original' });
117
+
118
+ await expect(
119
+ ingestVerifiedAppStorePurchase({
120
+ customerDid: 'did:new',
121
+ paymentMethod: makeMethod(),
122
+ client: makeClient({}),
123
+ signedTransaction: 'jws-string',
124
+ })
125
+ ).rejects.toThrow(/different account/i);
126
+
127
+ expect(update).not.toHaveBeenCalled();
128
+ expect(mockSubscriptionCreate).not.toHaveBeenCalled();
129
+ });
130
+
131
+ it('reactivates a lapsed (canceled) subscription when a fresh valid transaction arrives', async () => {
132
+ // Sandbox re-subscribe / renewal via client verify reuses the same
133
+ // originalTransactionId. The stored sub had expired→canceled, but the new
134
+ // transaction is valid → it must be revived to active, not returned stale.
135
+ const update = jest.fn(async () => undefined);
136
+ const existing = {
137
+ id: 'sub_existing',
138
+ status: 'canceled',
139
+ customer_id: 'cus_1',
140
+ payment_details: { app_store: { original_transaction_id: 'orig_1', product_id: 'pro_monthly' } },
141
+ update,
142
+ };
143
+ mockSubscriptionFindOne.mockResolvedValue(existing);
144
+ const future = Date.now() + 30 * 24 * 60 * 60 * 1000;
145
+ const client = makeClient({ expiresDate: future, transactionId: 'txn_2' });
146
+
147
+ const result = await ingestVerifiedAppStorePurchase({
148
+ customerDid: 'did:abc',
149
+ paymentMethod: makeMethod(),
150
+ client,
151
+ signedTransaction: 'jws-string',
152
+ });
153
+
154
+ expect(result.subscription).toBe(existing);
155
+ expect(result.isFirstSubscribe).toBe(false);
156
+ expect(update).toHaveBeenCalledWith(
157
+ expect.objectContaining({
158
+ status: 'active',
159
+ current_period_end: Math.floor(future / 1000),
160
+ cancel_at_period_end: false,
161
+ payment_details: expect.objectContaining({
162
+ app_store: expect.objectContaining({ transaction_id: 'txn_2', expires_at: Math.floor(future / 1000) }),
163
+ }),
164
+ })
165
+ );
166
+ expect(mockSubscriptionCreate).not.toHaveBeenCalled();
167
+ expect(mockCreateEvent).toHaveBeenCalledWith(
168
+ 'Subscription',
169
+ 'customer.subscription.started',
170
+ existing
171
+ );
172
+ });
173
+
174
+ it('reactivation self-heals empty metadata.product_id from the SKU→Price mapping', async () => {
175
+ // The Price↔SKU mapping was added after the original purchase, so the
176
+ // stored metadata.product_id is empty. Reactivation should resolve via
177
+ // Price.metadata and backfill product_id + price_id snapshots so the
178
+ // admin can show the product name and the specific plan.
179
+ const update = jest.fn(async () => undefined);
180
+ const existing = {
181
+ id: 'sub_existing',
182
+ status: 'canceled',
183
+ customer_id: 'cus_1',
184
+ metadata: {},
185
+ payment_details: { app_store: { original_transaction_id: 'orig_1', product_id: 'pro_monthly' } },
186
+ update,
187
+ };
188
+ mockSubscriptionFindOne.mockResolvedValue(existing);
189
+ mockPriceFindOne.mockResolvedValue({ id: 'price_X', product_id: 'prod_X' });
190
+ const future = Date.now() + 30 * 24 * 60 * 60 * 1000;
191
+ const client = makeClient({ expiresDate: future });
192
+
193
+ await ingestVerifiedAppStorePurchase({
194
+ customerDid: 'did:abc',
195
+ paymentMethod: makeMethod(),
196
+ client,
197
+ signedTransaction: 'jws-string',
198
+ });
199
+
200
+ expect(update).toHaveBeenCalledWith(
201
+ expect.objectContaining({
202
+ status: 'active',
203
+ metadata: expect.objectContaining({ product_id: 'prod_X', price_id: 'price_X' }),
204
+ })
205
+ );
206
+ });
207
+
208
+ it('creates a SubscriptionItem pointing at the exact resolved Price (preserves monthly/yearly distinction)', async () => {
209
+ mockSubscriptionFindOne.mockResolvedValue(null);
210
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
211
+ mockPriceFindOne.mockResolvedValue({ id: 'price_X', product_id: 'prod_X' });
212
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new', livemode: false });
213
+
214
+ await ingestVerifiedAppStorePurchase({
215
+ customerDid: 'did:abc',
216
+ paymentMethod: makeMethod(),
217
+ client: makeClient({}),
218
+ signedTransaction: 'jws-string',
219
+ });
220
+
221
+ expect(mockSubscriptionItemCreate).toHaveBeenCalledWith(
222
+ expect.objectContaining({
223
+ subscription_id: 'sub_new',
224
+ price_id: 'price_X',
225
+ quantity: 1,
226
+ })
227
+ );
228
+ });
229
+
230
+ it('skips SubscriptionItem when no Price has the SKU bound', async () => {
231
+ mockSubscriptionFindOne.mockResolvedValue(null);
232
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
233
+ mockPriceFindOne.mockResolvedValue(null); // admin hasn't wired the binding
234
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new', livemode: false });
235
+
236
+ await ingestVerifiedAppStorePurchase({
237
+ customerDid: 'did:abc',
238
+ paymentMethod: makeMethod(),
239
+ client: makeClient({}),
240
+ signedTransaction: 'jws-string',
241
+ });
242
+
243
+ expect(mockSubscriptionItemCreate).not.toHaveBeenCalled();
244
+ });
245
+
246
+ it('multi-tenant: Price lookup filters by (sku, bundle_id) so two apps with the same SKU string do not collide', async () => {
247
+ mockSubscriptionFindOne.mockResolvedValue(null);
248
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
249
+ mockPriceFindOne.mockResolvedValue({ id: 'price_X', product_id: 'prod_X' });
250
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new', livemode: false });
251
+
252
+ await ingestVerifiedAppStorePurchase({
253
+ customerDid: 'did:abc',
254
+ paymentMethod: makeMethod(),
255
+ client: makeClient({ productId: 'pro_monthly', bundleId: 'com.appA' }),
256
+ signedTransaction: 'jws-string',
257
+ });
258
+
259
+ // The new tenant-scoped lookup must include BOTH the SKU and the
260
+ // bundle_id from the JWS — without bundle_id, App B's Price with the
261
+ // same SKU would silently match.
262
+ expect(mockPriceFindOne).toHaveBeenCalledWith(
263
+ expect.objectContaining({
264
+ where: expect.objectContaining({
265
+ 'metadata.app_store_product_id': 'pro_monthly',
266
+ 'metadata.bundle_id': 'com.appA',
267
+ }),
268
+ })
269
+ );
270
+ // And the created subscription should persist the bundle_id in
271
+ // payment_details so downstream entitlement queries can disambiguate.
272
+ expect(mockSubscriptionCreate).toHaveBeenCalledWith(
273
+ expect.objectContaining({
274
+ payment_details: expect.objectContaining({
275
+ app_store: expect.objectContaining({ product_id: 'pro_monthly', bundle_id: 'com.appA' }),
276
+ }),
277
+ })
278
+ );
279
+ });
280
+
281
+ it('does NOT reactivate a canceled subscription when the new transaction is also expired', async () => {
282
+ const update = jest.fn();
283
+ const existing = { id: 'sub_existing', status: 'canceled', customer_id: 'cus_1', payment_details: { app_store: {} }, update };
284
+ mockSubscriptionFindOne.mockResolvedValue(existing);
285
+ const client = makeClient({ expiresDate: Date.now() - 1000 });
286
+
287
+ const result = await ingestVerifiedAppStorePurchase({
288
+ customerDid: 'did:abc',
289
+ paymentMethod: makeMethod(),
290
+ client,
291
+ signedTransaction: 'jws-string',
292
+ });
293
+
294
+ expect(result.subscription).toBe(existing);
295
+ expect(update).not.toHaveBeenCalled();
296
+ expect(mockSubscriptionCreate).not.toHaveBeenCalled();
297
+ });
298
+
299
+ it('refuses expired purchases', async () => {
300
+ mockSubscriptionFindOne.mockResolvedValue(null);
301
+ const client = makeClient({ expiresDate: Date.now() - 1000 });
302
+
303
+ await expect(
304
+ ingestVerifiedAppStorePurchase({
305
+ customerDid: 'did:abc',
306
+ paymentMethod: makeMethod(),
307
+ client,
308
+ signedTransaction: 'jws-string',
309
+ })
310
+ ).rejects.toThrow(/already expired/);
311
+ expect(mockSubscriptionCreate).not.toHaveBeenCalled();
312
+ });
313
+
314
+ it('refuses purchases with no expiresDate', async () => {
315
+ mockSubscriptionFindOne.mockResolvedValue(null);
316
+ const client = makeClient({ expiresDate: undefined });
317
+
318
+ await expect(
319
+ ingestVerifiedAppStorePurchase({
320
+ customerDid: 'did:abc',
321
+ paymentMethod: makeMethod(),
322
+ client,
323
+ signedTransaction: 'jws-string',
324
+ })
325
+ ).rejects.toThrow(/already expired/);
326
+ });
327
+
328
+ it('creates Subscription + Customer mapping on first valid purchase', async () => {
329
+ mockSubscriptionFindOne.mockResolvedValue(null);
330
+ const customerUpdate = jest.fn(async () => undefined);
331
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', app_store_uuid: undefined, update: customerUpdate }]);
332
+ mockSubscriptionCreate.mockImplementation(async (data) => ({ id: 'sub_new', ...data }));
333
+ const client = makeClient({});
334
+
335
+ const result = await ingestVerifiedAppStorePurchase({
336
+ customerDid: 'did:abc',
337
+ paymentMethod: makeMethod(),
338
+ client,
339
+ signedTransaction: 'jws-string',
340
+ });
341
+
342
+ expect(result.isFirstSubscribe).toBe(true);
343
+ expect(customerUpdate).toHaveBeenCalledWith({ app_store_uuid: 'uuid-1' });
344
+ expect(mockSubscriptionCreate).toHaveBeenCalledWith(
345
+ expect.objectContaining({
346
+ channel: 'app_store',
347
+ environment: 'sandbox',
348
+ status: 'active',
349
+ customer_id: 'cus_1',
350
+ payment_details: expect.objectContaining({
351
+ app_store: expect.objectContaining({
352
+ original_transaction_id: 'orig_1',
353
+ transaction_id: 'txn_1',
354
+ product_id: 'pro_monthly',
355
+ }),
356
+ }),
357
+ })
358
+ );
359
+ expect(mockCreateEvent).toHaveBeenCalledWith(
360
+ 'Subscription',
361
+ 'customer.subscription.started',
362
+ expect.objectContaining({ id: 'sub_new' })
363
+ );
364
+ });
365
+
366
+ it('skips Customer.update when appAccountToken already matches stored uuid', async () => {
367
+ mockSubscriptionFindOne.mockResolvedValue(null);
368
+ const customerUpdate = jest.fn();
369
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', app_store_uuid: 'uuid-1', update: customerUpdate }]);
370
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
371
+
372
+ await ingestVerifiedAppStorePurchase({
373
+ customerDid: 'did:abc',
374
+ paymentMethod: makeMethod(),
375
+ client: makeClient({}),
376
+ signedTransaction: 'jws-string',
377
+ });
378
+
379
+ expect(customerUpdate).not.toHaveBeenCalled();
380
+ });
381
+
382
+ it('isFirstSubscribe=false when customer already has app_store subs', async () => {
383
+ mockSubscriptionFindOne.mockResolvedValue(null);
384
+ mockSubscriptionCount.mockResolvedValue(2); // already has 2 prior subs
385
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
386
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
387
+
388
+ const result = await ingestVerifiedAppStorePurchase({
389
+ customerDid: 'did:abc',
390
+ paymentMethod: makeMethod(),
391
+ client: makeClient({}),
392
+ signedTransaction: 'jws-string',
393
+ });
394
+
395
+ expect(result.isFirstSubscribe).toBe(false);
396
+ });
397
+
398
+ it('routes to verifyJwsTransaction when signedTransaction is provided', async () => {
399
+ mockSubscriptionFindOne.mockResolvedValue(null);
400
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
401
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
402
+ const client = makeClient({});
403
+
404
+ await ingestVerifiedAppStorePurchase({
405
+ customerDid: 'did:abc',
406
+ paymentMethod: makeMethod(),
407
+ client,
408
+ signedTransaction: 'jws-string',
409
+ });
410
+
411
+ expect(client.verifyJwsTransaction).toHaveBeenCalledWith('jws-string');
412
+ expect(client.verifyLegacyReceipt).not.toHaveBeenCalled();
413
+ });
414
+
415
+ it('routes to verifyLegacyReceipt when only receipt is provided', async () => {
416
+ mockSubscriptionFindOne.mockResolvedValue(null);
417
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
418
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
419
+ const client = makeClient({ appAccountToken: undefined });
420
+
421
+ await ingestVerifiedAppStorePurchase({
422
+ customerDid: 'did:abc',
423
+ paymentMethod: makeMethod(),
424
+ client,
425
+ receipt: 'base64-receipt-blob',
426
+ expectedProductIds: ['pro_monthly'],
427
+ });
428
+
429
+ expect(client.verifyLegacyReceipt).toHaveBeenCalledWith('base64-receipt-blob', {
430
+ expectedProductIds: ['pro_monthly'],
431
+ });
432
+ expect(client.verifyJwsTransaction).not.toHaveBeenCalled();
433
+ });
434
+
435
+ it('prefers signedTransaction when both are provided (aistro pattern)', async () => {
436
+ mockSubscriptionFindOne.mockResolvedValue(null);
437
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
438
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
439
+ const client = makeClient({});
440
+
441
+ await ingestVerifiedAppStorePurchase({
442
+ customerDid: 'did:abc',
443
+ paymentMethod: makeMethod(),
444
+ client,
445
+ signedTransaction: 'jws-string',
446
+ receipt: 'should-be-ignored',
447
+ });
448
+
449
+ expect(client.verifyJwsTransaction).toHaveBeenCalled();
450
+ expect(client.verifyLegacyReceipt).not.toHaveBeenCalled();
451
+ });
452
+
453
+ it('throws when neither signedTransaction nor receipt provided', async () => {
454
+ const client = makeClient({});
455
+ await expect(
456
+ ingestVerifiedAppStorePurchase({
457
+ customerDid: 'did:abc',
458
+ paymentMethod: makeMethod(),
459
+ client,
460
+ })
461
+ ).rejects.toThrow(/must provide signedTransaction or receipt/);
462
+ });
463
+
464
+ it('maps environment "Production" → "production" in stored Subscription', async () => {
465
+ mockSubscriptionFindOne.mockResolvedValue(null);
466
+ mockCustomerFindOrCreate.mockResolvedValue([{ id: 'cus_1', update: jest.fn() }]);
467
+ mockSubscriptionCreate.mockResolvedValue({ id: 'sub_new' });
468
+
469
+ await ingestVerifiedAppStorePurchase({
470
+ customerDid: 'did:abc',
471
+ paymentMethod: makeMethod({ livemode: true }),
472
+ client: makeClient({ environment: 'Production' }),
473
+ signedTransaction: 'jws-string',
474
+ });
475
+
476
+ expect(mockSubscriptionCreate).toHaveBeenCalledWith(
477
+ expect.objectContaining({ environment: 'production', livemode: true })
478
+ );
479
+ });
480
+ });