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,335 @@
1
+ // Unit tests for AppStoreClient.
2
+ // All synthetic JWS fixtures here can't pass real Apple signature verification,
3
+ // so we set APP_STORE_SKIP_SIGNATURE_VERIFY=true to exercise the decode/validate
4
+ // surface. A dedicated test below confirms real-mode rejects synthetic JWSes.
5
+
6
+ /* eslint-disable import/first */
7
+ process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
8
+
9
+ import { AppStoreClient, AppStoreSettings } from '../../../src/integrations/app-store/client';
10
+
11
+ const mockConfigApple = jest.fn();
12
+ const mockValidateAppleReceipt = jest.fn();
13
+ jest.mock('node-apple-receipt-verify', () => ({
14
+ config: (...args: any[]) => mockConfigApple(...args),
15
+ validate: (...args: any[]) => mockValidateAppleReceipt(...args),
16
+ }));
17
+
18
+ beforeEach(() => {
19
+ mockConfigApple.mockReset();
20
+ mockValidateAppleReceipt.mockReset();
21
+ });
22
+
23
+ const baseSettings: AppStoreSettings = {
24
+ bundle_id: 'com.example.app',
25
+ environment: 'sandbox',
26
+ };
27
+
28
+ const makeJws = (payload: object, header: object = { alg: 'ES256' }): string => {
29
+ const h = Buffer.from(JSON.stringify(header)).toString('base64url');
30
+ const p = Buffer.from(JSON.stringify(payload)).toString('base64url');
31
+ return `${h}.${p}.signature-placeholder`;
32
+ };
33
+
34
+ describe('AppStoreClient.fromSettings', () => {
35
+ it('rejects missing bundle_id', () => {
36
+ expect(() => AppStoreClient.fromSettings({ ...baseSettings, bundle_id: '' })).toThrow(/bundle_id/);
37
+ });
38
+
39
+ it('rejects bad environment', () => {
40
+ expect(() => AppStoreClient.fromSettings({ ...baseSettings, environment: 'staging' as any })).toThrow(
41
+ /environment/
42
+ );
43
+ });
44
+
45
+ it('accepts minimal valid settings (no Server API creds)', () => {
46
+ const c = AppStoreClient.fromSettings(baseSettings);
47
+ expect(c.bundleId).toBe('com.example.app');
48
+ expect(c.environment).toBe('sandbox');
49
+ });
50
+ });
51
+
52
+ describe('AppStoreClient.verifyJwsTransaction (mock-phase decode)', () => {
53
+ let client: AppStoreClient;
54
+ beforeEach(() => {
55
+ client = AppStoreClient.fromSettings(baseSettings);
56
+ });
57
+
58
+ it('decodes a well-formed JWS payload', async () => {
59
+ const jws = makeJws({
60
+ transactionId: 'txn_1',
61
+ originalTransactionId: 'orig_1',
62
+ productId: 'pro_monthly',
63
+ expiresDate: 1800000000000,
64
+ appAccountToken: 'uuid-from-client',
65
+ environment: 'Sandbox',
66
+ bundleId: 'com.example.app',
67
+ });
68
+ const decoded = await client.verifyJwsTransaction(jws);
69
+ expect(decoded.transactionId).toBe('txn_1');
70
+ expect(decoded.originalTransactionId).toBe('orig_1');
71
+ expect(decoded.productId).toBe('pro_monthly');
72
+ expect(decoded.appAccountToken).toBe('uuid-from-client');
73
+ expect(decoded.environment).toBe('Sandbox');
74
+ });
75
+
76
+ it('rejects malformed JWS (not 3 segments)', async () => {
77
+ await expect(client.verifyJwsTransaction('not-a-jws')).rejects.toThrow(/format invalid/);
78
+ await expect(client.verifyJwsTransaction('a.b')).rejects.toThrow(/format invalid/);
79
+ });
80
+
81
+ it('rejects JWS whose payload is not valid JSON', async () => {
82
+ const h = Buffer.from(JSON.stringify({ alg: 'ES256' })).toString('base64url');
83
+ const broken = Buffer.from('this is not json').toString('base64url');
84
+ await expect(client.verifyJwsTransaction(`${h}.${broken}.sig`)).rejects.toThrow(/not valid JSON/);
85
+ });
86
+
87
+ it('rejects JWS whose bundleId disagrees with configured bundle_id', async () => {
88
+ const jws = makeJws({
89
+ transactionId: 'txn_1',
90
+ originalTransactionId: 'orig_1',
91
+ productId: 'pro_monthly',
92
+ environment: 'Sandbox',
93
+ bundleId: 'com.evil.app',
94
+ });
95
+ await expect(client.verifyJwsTransaction(jws)).rejects.toThrow(/bundleId mismatch/);
96
+ });
97
+
98
+ it('passes when JWS payload omits bundleId (older StoreKit 2 payloads)', async () => {
99
+ const jws = makeJws({
100
+ transactionId: 'txn_1',
101
+ originalTransactionId: 'orig_1',
102
+ productId: 'pro_monthly',
103
+ environment: 'Sandbox',
104
+ });
105
+ const decoded = await client.verifyJwsTransaction(jws);
106
+ expect(decoded.transactionId).toBe('txn_1');
107
+ });
108
+ });
109
+
110
+ describe('AppStoreClient write methods', () => {
111
+ it('refundSubscription is gated unless APP_STORE_WRITE_ENABLED', async () => {
112
+ const c = AppStoreClient.fromSettings(baseSettings);
113
+ await expect(c.refundSubscription('orig_1')).rejects.toThrow(/APP_STORE_WRITE_ENABLED/);
114
+ });
115
+
116
+ it('cancelSubscription is gated unless APP_STORE_WRITE_ENABLED', async () => {
117
+ const c = AppStoreClient.fromSettings(baseSettings);
118
+ await expect(c.cancelSubscription('orig_1')).rejects.toThrow(/APP_STORE_WRITE_ENABLED/);
119
+ });
120
+
121
+ it('getSubscriptionStatus errors clearly when Server API creds are not configured', async () => {
122
+ const c = AppStoreClient.fromSettings(baseSettings);
123
+ await expect(c.getSubscriptionStatus('orig_1')).rejects.toThrow(/issuer_id\/key_id\/private_key_pem/);
124
+ });
125
+ });
126
+
127
+ // `getSubscriptionStatus` against a mocked Apple SDK — we don't hit the real API.
128
+ const mockGetAllSubscriptionStatuses = jest.fn();
129
+ jest.mock('../../../src/integrations/app-store/signed-data-verifier', () => {
130
+ const actual = jest.requireActual('../../../src/integrations/app-store/signed-data-verifier');
131
+ return {
132
+ ...actual,
133
+ getAllSubscriptionStatuses: (...args: any[]) => mockGetAllSubscriptionStatuses(...args),
134
+ };
135
+ });
136
+
137
+ describe('AppStoreClient.getSubscriptionStatus (Server API)', () => {
138
+ beforeEach(() => {
139
+ mockGetAllSubscriptionStatuses.mockReset();
140
+ });
141
+
142
+ const credSettings: AppStoreSettings = {
143
+ bundle_id: 'com.example.app',
144
+ environment: 'sandbox',
145
+ issuer_id: 'iss_1',
146
+ key_id: 'KEY_ID_1',
147
+ private_key_pem: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
148
+ };
149
+
150
+ it('returns decoded transaction payload when Apple returns matching txn', async () => {
151
+ const inner = (() => {
152
+ const h = Buffer.from(JSON.stringify({ alg: 'ES256' })).toString('base64url');
153
+ const p = Buffer.from(
154
+ JSON.stringify({
155
+ transactionId: 'txn_latest',
156
+ originalTransactionId: 'orig_X',
157
+ productId: 'sub_06',
158
+ bundleId: 'com.example.app',
159
+ environment: 'Sandbox',
160
+ expiresDate: 1900000000000,
161
+ })
162
+ ).toString('base64url');
163
+ return `${h}.${p}.sig`;
164
+ })();
165
+ mockGetAllSubscriptionStatuses.mockResolvedValue({
166
+ data: [{ lastTransactions: [{ originalTransactionId: 'orig_X', signedTransactionInfo: inner }] }],
167
+ });
168
+ const c = AppStoreClient.fromSettings(credSettings);
169
+ const result = await c.getSubscriptionStatus('orig_X');
170
+ expect(result?.transactionId).toBe('txn_latest');
171
+ expect(mockGetAllSubscriptionStatuses).toHaveBeenCalledWith(
172
+ 'orig_X',
173
+ expect.objectContaining({ bundleId: 'com.example.app' })
174
+ );
175
+ });
176
+
177
+ it('returns null when Apple returns no matching transaction', async () => {
178
+ mockGetAllSubscriptionStatuses.mockResolvedValue({ data: [] });
179
+ const c = AppStoreClient.fromSettings(credSettings);
180
+ expect(await c.getSubscriptionStatus('orig_unknown')).toBeNull();
181
+ });
182
+ });
183
+
184
+ describe('AppStoreClient.verifyNotificationPayload (mock-phase decode)', () => {
185
+ let client: AppStoreClient;
186
+ beforeEach(() => {
187
+ client = AppStoreClient.fromSettings(baseSettings);
188
+ });
189
+
190
+ it('decodes a well-formed signedPayload', async () => {
191
+ const jws = makeJws({
192
+ notificationType: 'DID_RENEW',
193
+ subtype: 'BILLING_RECOVERY',
194
+ notificationUUID: 'uuid-notif-1',
195
+ version: '2.0',
196
+ signedDate: 1700000000000,
197
+ data: {
198
+ bundleId: 'com.example.app',
199
+ environment: 'Sandbox',
200
+ signedTransactionInfo: 'inner-jws',
201
+ status: 1,
202
+ },
203
+ });
204
+ const decoded = await client.verifyNotificationPayload(jws);
205
+ expect(decoded.notificationType).toBe('DID_RENEW');
206
+ expect(decoded.subtype).toBe('BILLING_RECOVERY');
207
+ expect(decoded.data.environment).toBe('Sandbox');
208
+ expect(decoded.data.signedTransactionInfo).toBe('inner-jws');
209
+ });
210
+
211
+ it('rejects malformed JWS (not 3 segments)', async () => {
212
+ await expect(client.verifyNotificationPayload('a.b')).rejects.toThrow(/JWS format invalid/);
213
+ });
214
+
215
+ it('rejects when notification bundleId disagrees with configured one', async () => {
216
+ const jws = makeJws({
217
+ notificationType: 'DID_RENEW',
218
+ notificationUUID: 'uuid-1',
219
+ version: '2.0',
220
+ signedDate: 1700000000000,
221
+ data: { bundleId: 'com.evil.app', environment: 'Sandbox' },
222
+ });
223
+ await expect(client.verifyNotificationPayload(jws)).rejects.toThrow(/bundleId mismatch/);
224
+ });
225
+ });
226
+
227
+ describe('AppStoreClient.verifyLegacyReceipt', () => {
228
+ const withSecret: AppStoreSettings = { ...baseSettings, shared_secret: 'sk_test_secret' };
229
+
230
+ it('refuses when shared_secret is not configured', async () => {
231
+ const c = AppStoreClient.fromSettings(baseSettings);
232
+ await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/shared_secret is required/);
233
+ });
234
+
235
+ it('configures node-apple-receipt-verify with provided secret + both envs', async () => {
236
+ mockValidateAppleReceipt.mockResolvedValue([
237
+ {
238
+ bundleId: 'com.example.app',
239
+ productId: 'sub_06',
240
+ transactionId: 'txn_1',
241
+ originalTransactionId: 'orig_1',
242
+ purchaseDate: 1700000000000,
243
+ expirationDate: 1800000000000,
244
+ },
245
+ ]);
246
+ const c = AppStoreClient.fromSettings(withSecret);
247
+ await c.verifyLegacyReceipt('base64-receipt');
248
+ expect(mockConfigApple).toHaveBeenCalledWith(
249
+ expect.objectContaining({
250
+ secret: 'sk_test_secret',
251
+ environment: ['production', 'sandbox'],
252
+ })
253
+ );
254
+ });
255
+
256
+ it('normalizes one-item receipt to AppStoreTransactionPayload', async () => {
257
+ mockValidateAppleReceipt.mockResolvedValue([
258
+ {
259
+ bundleId: 'com.example.app',
260
+ productId: 'sub_06',
261
+ transactionId: 'txn_1',
262
+ originalTransactionId: 'orig_1',
263
+ purchaseDate: 1700000000000,
264
+ expirationDate: 1800000000000,
265
+ },
266
+ ]);
267
+ const c = AppStoreClient.fromSettings(withSecret);
268
+ const result = await c.verifyLegacyReceipt('base64-receipt');
269
+ expect(result).toEqual({
270
+ transactionId: 'txn_1',
271
+ originalTransactionId: 'orig_1',
272
+ productId: 'sub_06',
273
+ purchaseDate: 1700000000000,
274
+ expiresDate: 1800000000000,
275
+ appAccountToken: undefined,
276
+ environment: 'Sandbox',
277
+ bundleId: 'com.example.app',
278
+ webOrderLineItemId: undefined,
279
+ });
280
+ });
281
+
282
+ it('picks the item with the latest expirationDate when receipt contains multiple', async () => {
283
+ mockValidateAppleReceipt.mockResolvedValue([
284
+ { productId: 'sub_06', transactionId: 'old', expirationDate: 1500000000000 },
285
+ { productId: 'sub_06', transactionId: 'new', expirationDate: 1800000000000 },
286
+ { productId: 'sub_06', transactionId: 'mid', expirationDate: 1600000000000 },
287
+ ]);
288
+ const c = AppStoreClient.fromSettings(withSecret);
289
+ const result = await c.verifyLegacyReceipt('base64-receipt');
290
+ expect(result.transactionId).toBe('new');
291
+ });
292
+
293
+ it('filters by expectedProductIds when provided', async () => {
294
+ mockValidateAppleReceipt.mockResolvedValue([
295
+ { productId: 'other_sku', transactionId: 'wrong', expirationDate: 1900000000000 },
296
+ { productId: 'sub_06', transactionId: 'right', expirationDate: 1800000000000 },
297
+ ]);
298
+ const c = AppStoreClient.fromSettings(withSecret);
299
+ const result = await c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] });
300
+ expect(result.transactionId).toBe('right');
301
+ });
302
+
303
+ it('throws when no item matches expectedProductIds', async () => {
304
+ mockValidateAppleReceipt.mockResolvedValue([
305
+ { productId: 'unknown_sku', transactionId: 'x', expirationDate: 1800000000000 },
306
+ ]);
307
+ const c = AppStoreClient.fromSettings(withSecret);
308
+ await expect(c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] })).rejects.toThrow(
309
+ /no matching purchases/
310
+ );
311
+ });
312
+
313
+ it('rejects when receipt bundleId disagrees with configured bundle_id', async () => {
314
+ mockValidateAppleReceipt.mockResolvedValue([
315
+ {
316
+ bundleId: 'com.evil.app',
317
+ productId: 'sub_06',
318
+ transactionId: 'txn_1',
319
+ originalTransactionId: 'orig_1',
320
+ expirationDate: 1800000000000,
321
+ },
322
+ ]);
323
+ const c = AppStoreClient.fromSettings(withSecret);
324
+ await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/bundleId mismatch/);
325
+ });
326
+
327
+ it('falls back to transactionId when originalTransactionId is absent', async () => {
328
+ mockValidateAppleReceipt.mockResolvedValue([
329
+ { productId: 'sub_06', transactionId: 'txn_only', expirationDate: 1800000000000 },
330
+ ]);
331
+ const c = AppStoreClient.fromSettings(withSecret);
332
+ const result = await c.verifyLegacyReceipt('base64-receipt');
333
+ expect(result.originalTransactionId).toBe('txn_only');
334
+ });
335
+ });