payment-kit 1.28.0 → 1.29.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (76) hide show
  1. package/api/src/crons/index.ts +22 -0
  2. package/api/src/crons/retry-pending-events.ts +58 -0
  3. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  4. package/api/src/integrations/app-store/client.ts +369 -0
  5. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  6. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  7. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  8. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  9. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  10. package/api/src/integrations/google-play/client.ts +276 -0
  11. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  12. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  13. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  14. package/api/src/integrations/google-play/setup.ts +43 -0
  15. package/api/src/integrations/google-play/verify.ts +251 -0
  16. package/api/src/integrations/iap-reconcile.ts +415 -0
  17. package/api/src/libs/audit.ts +38 -8
  18. package/api/src/libs/entitlement.ts +399 -0
  19. package/api/src/libs/env.ts +2 -0
  20. package/api/src/libs/security.ts +51 -0
  21. package/api/src/libs/subscription.ts +13 -1
  22. package/api/src/libs/util.ts +13 -0
  23. package/api/src/queues/event.ts +25 -19
  24. package/api/src/queues/webhook.ts +12 -2
  25. package/api/src/routes/entitlements.ts +105 -0
  26. package/api/src/routes/events.ts +2 -2
  27. package/api/src/routes/index.ts +12 -2
  28. package/api/src/routes/integrations/app-store.ts +267 -0
  29. package/api/src/routes/integrations/google-play.ts +324 -0
  30. package/api/src/routes/payment-methods.ts +130 -0
  31. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  32. package/api/src/store/models/customer.ts +14 -0
  33. package/api/src/store/models/entitlement-grant.ts +118 -0
  34. package/api/src/store/models/entitlement-product.ts +48 -0
  35. package/api/src/store/models/entitlement.ts +86 -0
  36. package/api/src/store/models/index.ts +9 -0
  37. package/api/src/store/models/invoice.ts +20 -0
  38. package/api/src/store/models/payment-method.ts +62 -1
  39. package/api/src/store/models/refund.ts +10 -0
  40. package/api/src/store/models/subscription.ts +14 -0
  41. package/api/src/store/models/types.ts +32 -0
  42. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  43. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  44. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  45. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  46. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  47. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  48. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  49. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  50. package/api/tests/libs/entitlement.spec.ts +347 -0
  51. package/blocklet.yml +1 -1
  52. package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
  53. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  54. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  55. package/cloudflare/run-build.js +23 -1
  56. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  57. package/cloudflare/shims/node-fetch.ts +35 -0
  58. package/cloudflare/shims/queue.ts +28 -2
  59. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  60. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  61. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  62. package/cloudflare/worker.ts +59 -4
  63. package/cloudflare/wrangler.jsonc +7 -1
  64. package/cloudflare/wrangler.staging.json +2 -1
  65. package/package.json +10 -6
  66. package/scripts/seed-google-play.ts +79 -0
  67. package/src/components/payment-method/app-store.tsx +103 -0
  68. package/src/components/payment-method/form.tsx +7 -1
  69. package/src/components/payment-method/google-play.tsx +85 -0
  70. package/src/components/subscription/list.tsx +20 -0
  71. package/src/locales/en.tsx +63 -0
  72. package/src/locales/zh.tsx +63 -0
  73. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  74. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  75. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  76. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -312,6 +312,25 @@ export class Model {
312
312
  return this.findOne({ ...options, where: { ...(options?.where || {}), id } });
313
313
  }
314
314
 
315
+ /**
316
+ * Sequelize-compatible `findOrCreate`: find a row matching `where`, otherwise
317
+ * insert a new one using `defaults` merged over `where`. Returns the
318
+ * Sequelize tuple `[instance, wasCreated]`.
319
+ *
320
+ * D1 doesn't expose row-level locks, so this is racy at the storage layer —
321
+ * two concurrent calls with the same `where` can both miss and both insert.
322
+ * For our IAP / customer flows that's acceptable because primary-key
323
+ * collisions (or unique-index constraints) catch the second writer, and the
324
+ * IAP entry path already de-dupes by purchase token upstream. Mirrors the
325
+ * single-node Sequelize behavior of "not strictly atomic" under SQLite.
326
+ */
327
+ static async findOrCreate(options: { where: any; defaults?: any }): Promise<[any, boolean]> {
328
+ const existing = await this.findOne({ where: options.where });
329
+ if (existing) return [existing, false];
330
+ const created = await this.create({ ...(options.where || {}), ...(options.defaults || {}) });
331
+ return [created, true];
332
+ }
333
+
315
334
  static async findAndCountAll(options?: any): Promise<{ rows: any[]; count: number }> {
316
335
  // Batch SELECT + COUNT into a single D1 round-trip
317
336
  const { sql: selectSql, values: selectValues } = buildSelectSQL(this.tableName, options, (this as any)._attributes);
@@ -213,10 +213,23 @@ export function buildWhereClause(where: any): { sql: string; values: any[] } {
213
213
 
214
214
  return {
215
215
  sql: conditions.length > 0 ? conditions.join(' AND ') : '',
216
- values,
216
+ values: values.map(coerceBindValue),
217
217
  };
218
218
  }
219
219
 
220
+ /**
221
+ * D1 only accepts primitive bind values (string, number, boolean, null,
222
+ * BigInt, ArrayBuffer). Sequelize APIs commonly pass `Date` objects in WHERE
223
+ * clauses (e.g. `{ updated_at: { [Op.lt]: new Date(...) } }`); coerce them to
224
+ * ISO strings to match how dates are stored. Without this iap-reconcile and
225
+ * any other time-window query throws `D1_TYPE_ERROR: Type 'object' not
226
+ * supported for value 'Tue Jun 02 2026 …'`.
227
+ */
228
+ export function coerceBindValue(v: any): any {
229
+ if (v instanceof Date) return v.toISOString();
230
+ return v;
231
+ }
232
+
220
233
  function processWhereEntry(key: string, value: any, conditions: string[], values: any[]): void {
221
234
  const fieldSQL = fieldToSQL(key);
222
235
 
@@ -0,0 +1,87 @@
1
+ // Regression guard for PR #1381 review P1: on Cloudflare the queue shim only
2
+ // writes a DELAYED job to D1 (with will_run_at, later dispatched by the cron)
3
+ // when it is persisted. A delayed job pushed with persist:false is silently
4
+ // dropped — which is exactly how the webhook retry ladder lost deliveries after
5
+ // the first failure. These tests pin that invariant so callers always persist
6
+ // delayed retries.
7
+
8
+ // A single shared store mock so we can assert what the shim wrote.
9
+ const mockStore = {
10
+ addJob: jest.fn().mockResolvedValue(undefined),
11
+ deleteJob: jest.fn().mockResolvedValue(true),
12
+ getJob: jest.fn(),
13
+ getJobs: jest.fn(),
14
+ getScheduledJobs: jest.fn(),
15
+ updateJob: jest.fn(),
16
+ isCancelled: jest.fn().mockResolvedValue(false),
17
+ findJobs: jest.fn(),
18
+ };
19
+
20
+ jest.mock('../../../api/src/store/models/job', () => ({ Job: { findAll: jest.fn() } }));
21
+ jest.mock('../../../api/src/libs/queue/store', () => ({
22
+ __esModule: true,
23
+ default: jest.fn(() => mockStore),
24
+ }));
25
+
26
+ import createQueue, { setCFQueue, flushPendingJobs } from '../../shims/queue';
27
+
28
+ const futureRunAt = () => Math.floor(Date.now() / 1000) + 600; // +10 min → delayed
29
+
30
+ describe('shim queue — delayed job persistence (PR #1381 P1)', () => {
31
+ beforeEach(() => {
32
+ jest.clearAllMocks();
33
+ setCFQueue(null); // no CF Queue binding; delayed path is independent of it anyway
34
+ });
35
+
36
+ it('persists a delayed job to D1 (with will_run_at) when persist=true', async () => {
37
+ const q = createQueue({ name: 'webhook-persist-true', onJob: jest.fn() });
38
+ q.push({ id: 'j1', job: { v: 1 }, runAt: futureRunAt(), persist: true });
39
+ await flushPendingJobs();
40
+
41
+ expect(mockStore.addJob).toHaveBeenCalledTimes(1);
42
+ const attrs = mockStore.addJob.mock.calls[0]![2];
43
+ expect(attrs.will_run_at).toBeGreaterThan(Date.now());
44
+ expect(attrs.delay).toBeGreaterThan(0);
45
+ });
46
+
47
+ it('DROPS a delayed job (no D1 write) when persist=false — the lost-retry bug class', async () => {
48
+ const q = createQueue({ name: 'webhook-persist-false', onJob: jest.fn() });
49
+ q.push({ id: 'j2', job: { v: 2 }, runAt: futureRunAt(), persist: false });
50
+ await flushPendingJobs();
51
+
52
+ expect(mockStore.addJob).not.toHaveBeenCalled();
53
+ });
54
+ });
55
+
56
+ describe('shim queue — inline fallback keeps the D1 backup on failure (PR #1381 re-review P1)', () => {
57
+ beforeEach(() => {
58
+ jest.clearAllMocks();
59
+ });
60
+
61
+ it('does NOT delete the persisted row when CF Queue send fails AND the inline handler fails', async () => {
62
+ setCFQueue({ send: jest.fn().mockRejectedValue(new Error('429 Too Many Requests')) } as any);
63
+ const onJob = jest.fn().mockRejectedValue(new Error('handler boom'));
64
+ const q = createQueue({ name: 'webhook-fallback-fail', onJob, options: { maxRetries: 1 } });
65
+
66
+ q.push({ id: 'wj1', job: { v: 1 }, persist: true }); // immediate
67
+ await flushPendingJobs();
68
+
69
+ expect(mockStore.addJob).toHaveBeenCalledTimes(1); // durable backup written
70
+ expect(onJob).toHaveBeenCalled(); // inline attempt ran
71
+ expect(mockStore.deleteJob).not.toHaveBeenCalled(); // backup retained → cron can retry
72
+ setCFQueue(null);
73
+ });
74
+
75
+ it('deletes the persisted row when the inline fallback SUCCEEDS', async () => {
76
+ setCFQueue({ send: jest.fn().mockRejectedValue(new Error('429')) } as any);
77
+ const onJob = jest.fn().mockResolvedValue(undefined);
78
+ const q = createQueue({ name: 'webhook-fallback-ok', onJob, options: { maxRetries: 1 } });
79
+
80
+ q.push({ id: 'wj2', job: { v: 2 }, persist: true });
81
+ await flushPendingJobs();
82
+
83
+ expect(onJob).toHaveBeenCalled();
84
+ expect(mockStore.deleteJob).toHaveBeenCalledWith('wj2'); // deleted only on success
85
+ setCFQueue(null);
86
+ });
87
+ });
@@ -33,6 +33,13 @@ import '../api/src/queues/refund';
33
33
  import '../api/src/queues/checkout-session';
34
34
  import '../api/src/queues/discount-status';
35
35
  import '../api/src/queues/exchange-rate-health';
36
+ // Event + webhook queues turn createEvent rows into actual HTTP POSTs to
37
+ // registered WebhookEndpoints. Without these, every subscription state change
38
+ // writes an Event row with pending_webhooks=99 that no one ever consumes —
39
+ // downstream apps (aistro etc.) silently never get notified of purchase /
40
+ // renewal / cancel events.
41
+ import '../api/src/queues/event';
42
+ import '../api/src/queues/webhook';
36
43
 
37
44
  // Import security shim — initFromAuthService fetches EK from AUTH_SERVICE
38
45
  import { initFromAuthService } from './shims/blocklet-sdk/security';
@@ -267,6 +274,22 @@ function buildApp(env: Env): Hono<HonoEnv> {
267
274
  if (c.env.APP_NAME) process.env.BLOCKLET_APP_NAME = c.env.APP_NAME;
268
275
  if (c.env.PAYMENT_CHANGE_LOCKED_PRICE) process.env.PAYMENT_CHANGE_LOCKED_PRICE = c.env.PAYMENT_CHANGE_LOCKED_PRICE;
269
276
  if (c.env.SHORT_URL_DOMAIN) process.env.SHORT_URL_DOMAIN = c.env.SHORT_URL_DOMAIN;
277
+ // Audience for the Pub/Sub OIDC JWT that wraps Google Play RTDN webhooks.
278
+ // Without this mirror, libs/util.ts googlePlayEndpoint() falls back to
279
+ // getUrl('/api/integrations/google-play/webhook') — which resolves to
280
+ // the *.workers.dev origin, not the custom domain Pub/Sub actually
281
+ // POSTs to. Every webhook then dies with "audience mismatch".
282
+ if (c.env.GOOGLE_PLAY_WEBHOOK_URL) {
283
+ process.env.GOOGLE_PLAY_WEBHOOK_URL = c.env.GOOGLE_PLAY_WEBHOOK_URL;
284
+ }
285
+ // Pub/Sub sender binding — without mirroring these, the google_play webhook
286
+ // can't enforce the push service account on CF and would fail open (PR #1381 P1).
287
+ if (c.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT) {
288
+ process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT = c.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT;
289
+ }
290
+ if (c.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER) {
291
+ process.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER = c.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER;
292
+ }
270
293
  process.env.BLOCKLET_MODE = 'production';
271
294
  }
272
295
 
@@ -592,6 +615,31 @@ function buildApp(env: Env): Hono<HonoEnv> {
592
615
  return c.json({ handlers: names, ...result });
593
616
  });
594
617
 
618
+ // Debug: pass through Google Play Developer API for an arbitrary subscription
619
+ // purchase token. Returns the *raw* Google response so we can see if Google
620
+ // itself considers the purchase valid / acknowledged / expired.
621
+ app.get('/api/__dev__/google-play-info', async (c) => {
622
+ const token = c.req.query('token');
623
+ const subId = c.req.query('sub') || 'pk_demo_monthly';
624
+ if (!token) return c.json({ error: 'token query param required' }, 400);
625
+ const { PaymentMethod } = await import('../api/src/store/models/payment-method');
626
+ const method = await PaymentMethod.findOne({
627
+ where: { type: 'google_play', active: true, livemode: false } as any,
628
+ });
629
+ if (!method) return c.json({ error: 'no google_play PaymentMethod' }, 503);
630
+ try {
631
+ const client = method.getGooglePlayClient();
632
+ const purchase = await client.getSubscription(subId, token);
633
+ return c.json({ ok: true, sub: subId, purchase });
634
+ } catch (err: any) {
635
+ return c.json({ ok: false, error: err?.message || String(err) }, 500);
636
+ }
637
+ });
638
+
639
+ // Bootstrap google_play PaymentMethod in staging D1 — the admin UI flow
640
+ // for creating PaymentMethods is owner-gated, and staging has no owner yet
641
+ // (Pengfei's blocklet-service role is `member`). Gated by PAYMENT_LIVEMODE
642
+ // === 'false' so this only ever touches testmode data.
595
643
  // === Express-to-Hono Route Adapter ===
596
644
  mountExpressRoutes(app, '/api', expressRoutes);
597
645
 
@@ -1141,7 +1189,10 @@ function createExpressReq(c: any, routeParams: Record<string, string>): any {
1141
1189
  body: null,
1142
1190
  headers,
1143
1191
  user: null,
1144
- livemode: true,
1192
+ // Worker-wide livemode is driven by the PAYMENT_LIVEMODE env var (set on
1193
+ // each deployment). Routes that filter PaymentMethod by livemode rely on
1194
+ // this value matching what we stored when bootstrapping the methods.
1195
+ livemode: c.env?.PAYMENT_LIVEMODE !== 'false',
1145
1196
  baseCurrency: null,
1146
1197
  ip: headers['cf-connecting-ip'] || headers['x-forwarded-for'] || '127.0.0.1',
1147
1198
  get(name: string) {
@@ -1388,18 +1439,22 @@ function mountExpressRoutes(honoApp: Hono<HonoEnv>, prefix: string, expressRoute
1388
1439
  });
1389
1440
  }
1390
1441
 
1391
- // Inject caller identity resolved by AUTH_SERVICE RPC (or mock fallback)
1442
+ // Inject caller identity resolved by AUTH_SERVICE RPC (or mock fallback).
1443
+ // AUTH_SERVICE returns the bare base58 address; Customer/Subscription queries
1444
+ // and entitlement lookups expect the canonical `did:abt:…` form, so we
1445
+ // normalize once here at the boundary instead of in every downstream call site.
1392
1446
  const caller: CallerIdentityDTO | null = c.get('caller');
1393
1447
  if (caller) {
1448
+ const canonicalDid = caller.did?.startsWith('did:abt:') ? caller.did : `did:abt:${caller.did}`;
1394
1449
  req.user = {
1395
- did: caller.did,
1450
+ did: canonicalDid,
1396
1451
  role: caller.role || 'guest',
1397
1452
  provider: caller.authMethod === 'access-key' ? 'access-key' : 'wallet',
1398
1453
  fullName: caller.displayName || '',
1399
1454
  walletOS: '',
1400
1455
  via: 'dashboard',
1401
1456
  };
1402
- req.headers['x-user-did'] = caller.did;
1457
+ req.headers['x-user-did'] = canonicalDid;
1403
1458
  req.headers['x-user-role'] = `blocklet-${caller.role || 'guest'}`;
1404
1459
  req.headers['x-user-provider'] = caller.authMethod || 'wallet';
1405
1460
  req.headers['x-user-fullname'] = encodeURIComponent(caller.displayName || '');
@@ -65,5 +65,11 @@
65
65
  },
66
66
  "triggers": {
67
67
  "crons": ["* * * * *"]
68
- }
68
+ },
69
+ // Batch crons (subscription scans, queue dispatch, retry backstops) all run in
70
+ // one scheduled() invocation each minute; raise the per-invocation CPU ceiling
71
+ // so they aren't killed mid-run ("Exceeded CPU Limit") on Workers Paid. The
72
+ // structural fix (offload to a Consumer Worker / Paid Queue throughput) is
73
+ // tracked in task-44. No effect on Workers Free (CPU limit is fixed there).
74
+ "limits": { "cpu_ms": 300000 }
69
75
  }
@@ -62,5 +62,6 @@
62
62
  },
63
63
  "triggers": {
64
64
  "crons": ["* * * * *"]
65
- }
65
+ },
66
+ "limits": { "cpu_ms": 300000 }
66
67
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.28.0",
3
+ "version": "1.29.1",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "prelint": "npm run types",
@@ -47,6 +47,7 @@
47
47
  },
48
48
  "dependencies": {
49
49
  "@abtnode/cron": "^1.17.12",
50
+ "@apple/app-store-server-library": "^3.1.0",
50
51
  "@arcblock/did": "^1.30.9",
51
52
  "@arcblock/did-connect-js": "4.0.0",
52
53
  "@arcblock/did-connect-react": "^3.5.2",
@@ -61,9 +62,9 @@
61
62
  "@blocklet/error": "^0.3.5",
62
63
  "@blocklet/js-sdk": "^1.17.12",
63
64
  "@blocklet/logger": "^1.17.12",
64
- "@blocklet/payment-broker-client": "1.28.0",
65
- "@blocklet/payment-react": "1.28.0",
66
- "@blocklet/payment-vendor": "1.28.0",
65
+ "@blocklet/payment-broker-client": "1.29.1",
66
+ "@blocklet/payment-react": "1.29.1",
67
+ "@blocklet/payment-vendor": "1.29.1",
67
68
  "@blocklet/sdk": "^1.17.12",
68
69
  "@blocklet/ui-react": "^3.5.2",
69
70
  "@blocklet/uploader": "^0.3.20",
@@ -98,6 +99,7 @@
98
99
  "fastq": "^1.19.1",
99
100
  "flat": "^5.0.2",
100
101
  "google-libphonenumber": "^3.2.42",
102
+ "google-play-billing-validator": "^2.1.3",
101
103
  "html2canvas": "^1.4.1",
102
104
  "iframe-resizer-react": "^1.1.1",
103
105
  "joi": "17.12.2",
@@ -108,6 +110,7 @@
108
110
  "morgan": "^1.10.0",
109
111
  "mui-daterange-picker": "^1.0.5",
110
112
  "nanoid": "^3.3.11",
113
+ "node-apple-receipt-verify": "^1.15.0",
111
114
  "numbro": "^2.5.0",
112
115
  "p-all": "3.0.0",
113
116
  "p-wait-for": "^3.2.0",
@@ -137,13 +140,14 @@
137
140
  "devDependencies": {
138
141
  "@abtnode/types": "^1.17.12",
139
142
  "@arcblock/eslint-config-ts": "^0.3.3",
140
- "@blocklet/payment-types": "1.28.0",
143
+ "@blocklet/payment-types": "1.29.1",
141
144
  "@types/cookie-parser": "^1.4.9",
142
145
  "@types/cors": "^2.8.19",
143
146
  "@types/debug": "^4.1.12",
144
147
  "@types/dotenv-flow": "^3.3.3",
145
148
  "@types/express": "^4.17.23",
146
149
  "@types/node": "^18.19.112",
150
+ "@types/node-apple-receipt-verify": "^1.7.5",
147
151
  "@types/react": "^18.3.23",
148
152
  "@types/react-dom": "^18.3.7",
149
153
  "@vitejs/plugin-react": "^4.6.0",
@@ -184,5 +188,5 @@
184
188
  "parser": "typescript"
185
189
  }
186
190
  },
187
- "gitHead": "1486b54f913b83fb42323a89cce7503814d0685a"
191
+ "gitHead": "e66e469df2a1ed80e15b17fd8511c2ce01a0a54e"
188
192
  }
@@ -0,0 +1,79 @@
1
+ #!/usr/bin/env tsx
2
+ /* eslint-disable no-console */
3
+ //
4
+ // Seed a `google_play` PaymentMethod from a service account JSON file.
5
+ //
6
+ // Usage:
7
+ // GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH=/path/to/sa.json \
8
+ // GOOGLE_PLAY_PACKAGE_NAME=io.arcblock.aistro \
9
+ // tsx scripts/seed-google-play.ts
10
+ //
11
+ // Optional:
12
+ // PAYMENT_METHOD_ID existing id to update; otherwise a new one is created
13
+ // PAYMENT_METHOD_LIVEMODE "true" (default) | "false"
14
+ //
15
+ // The JSON contents are stored encrypted via PaymentMethod.encryptSettings.
16
+
17
+ import 'dotenv-flow/config';
18
+ import { readFileSync } from 'fs';
19
+
20
+ import { PaymentMethod } from '../api/src/store/models';
21
+ import { sequelize } from '../api/src/store/sequelize';
22
+ import migrate from '../api/src/store/migrate';
23
+ import { initialize } from '../api/src/store/models';
24
+
25
+ async function main(): Promise<void> {
26
+ const jsonPath = process.env.GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH;
27
+ const packageName = process.env.GOOGLE_PLAY_PACKAGE_NAME;
28
+ if (!jsonPath || !packageName) {
29
+ console.error('Missing GOOGLE_PLAY_SERVICE_ACCOUNT_JSON_PATH or GOOGLE_PLAY_PACKAGE_NAME');
30
+ process.exit(1);
31
+ }
32
+ const livemode = process.env.PAYMENT_METHOD_LIVEMODE !== 'false';
33
+ const existingId = process.env.PAYMENT_METHOD_ID;
34
+
35
+ const raw = readFileSync(jsonPath, 'utf8');
36
+ // Validate JSON shape early
37
+ const parsed = JSON.parse(raw);
38
+ if (!parsed.client_email || !parsed.private_key) {
39
+ throw new Error('service account JSON missing client_email or private_key');
40
+ }
41
+
42
+ initialize(sequelize);
43
+ await migrate();
44
+
45
+ const settings = PaymentMethod.encryptSettings({
46
+ google_play: {
47
+ package_name: packageName,
48
+ service_account_json: raw,
49
+ pubsub_topic_name: '',
50
+ },
51
+ });
52
+
53
+ if (existingId) {
54
+ const method = await PaymentMethod.findByPk(existingId);
55
+ if (!method) throw new Error(`PaymentMethod ${existingId} not found`);
56
+ await method.update({ settings, active: true, livemode });
57
+ console.log('updated PaymentMethod', method.id);
58
+ } else {
59
+ const method = await PaymentMethod.create({
60
+ type: 'google_play',
61
+ name: `Google Play (${packageName})`,
62
+ description: 'In-App Billing via Google Play Console',
63
+ logo: '',
64
+ active: true,
65
+ livemode,
66
+ confirmation: { type: 'callback' },
67
+ settings,
68
+ features: { recurring: true, refund: true, dispute: false },
69
+ } as any);
70
+ console.log('created PaymentMethod', method.id, `client_email=${parsed.client_email}`);
71
+ }
72
+
73
+ await sequelize.close();
74
+ }
75
+
76
+ main().catch((err) => {
77
+ console.error(err);
78
+ process.exit(1);
79
+ });
@@ -0,0 +1,103 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { FormInput } from '@blocklet/payment-react';
3
+ import { Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
4
+ import { Controller, useFormContext } from 'react-hook-form';
5
+
6
+ export default function AppStoreMethodForm({ checkDisabled }: { checkDisabled: (key: string) => boolean }) {
7
+ const { t } = useLocaleContext();
8
+ const { control } = useFormContext();
9
+
10
+ return (
11
+ <>
12
+ <FormInput
13
+ name="name"
14
+ type="text"
15
+ rules={{ required: true }}
16
+ label={t('admin.paymentMethod.name.label')}
17
+ placeholder={t('admin.paymentMethod.name.tip')}
18
+ disabled={checkDisabled('name')}
19
+ inputProps={{ maxLength: 32 }}
20
+ />
21
+ <FormInput
22
+ name="description"
23
+ type="text"
24
+ rules={{ required: true }}
25
+ label={t('admin.paymentMethod.description.label')}
26
+ placeholder={t('admin.paymentMethod.description.tip')}
27
+ inputProps={{ maxLength: 255 }}
28
+ />
29
+ <FormInput
30
+ name="settings.app_store.bundle_id"
31
+ type="text"
32
+ rules={{ required: true }}
33
+ label={t('admin.paymentMethod.app_store.bundle_id.label')}
34
+ placeholder={t('admin.paymentMethod.app_store.bundle_id.tip')}
35
+ disabled={checkDisabled('settings.app_store.bundle_id')}
36
+ />
37
+
38
+ <Stack spacing={0.5}>
39
+ <Typography variant="body2">{t('admin.paymentMethod.app_store.environment.label')}</Typography>
40
+ <Controller
41
+ name="settings.app_store.environment"
42
+ control={control}
43
+ rules={{ required: true }}
44
+ render={({ field }) => (
45
+ <ToggleButtonGroup
46
+ {...field}
47
+ exclusive
48
+ disabled={checkDisabled('settings.app_store.environment')}
49
+ onChange={(_, value: string | null) => {
50
+ if (value !== null) field.onChange(value);
51
+ }}>
52
+ <ToggleButton value="production">
53
+ {t('admin.paymentMethod.app_store.environment.production')}
54
+ </ToggleButton>
55
+ <ToggleButton value="sandbox">{t('admin.paymentMethod.app_store.environment.sandbox')}</ToggleButton>
56
+ </ToggleButtonGroup>
57
+ )}
58
+ />
59
+ <Typography variant="caption" color="text.secondary">
60
+ {t('admin.paymentMethod.app_store.environment.tip')}
61
+ </Typography>
62
+ </Stack>
63
+
64
+ <FormInput
65
+ name="settings.app_store.shared_secret"
66
+ type="password"
67
+ label={t('admin.paymentMethod.app_store.shared_secret.label')}
68
+ placeholder={t('admin.paymentMethod.app_store.shared_secret.tip')}
69
+ disabled={checkDisabled('settings.app_store.shared_secret')}
70
+ />
71
+
72
+ <Typography variant="subtitle2" sx={{ mt: 2 }}>
73
+ {t('admin.paymentMethod.app_store.serverApi.heading')}
74
+ </Typography>
75
+ <Typography variant="caption" color="text.secondary" sx={{ mt: -1, display: 'block' }}>
76
+ {t('admin.paymentMethod.app_store.serverApi.tip')}
77
+ </Typography>
78
+ <FormInput
79
+ name="settings.app_store.issuer_id"
80
+ type="text"
81
+ label={t('admin.paymentMethod.app_store.issuer_id.label')}
82
+ placeholder={t('admin.paymentMethod.app_store.issuer_id.tip')}
83
+ disabled={checkDisabled('settings.app_store.issuer_id')}
84
+ />
85
+ <FormInput
86
+ name="settings.app_store.key_id"
87
+ type="text"
88
+ label={t('admin.paymentMethod.app_store.key_id.label')}
89
+ placeholder={t('admin.paymentMethod.app_store.key_id.tip')}
90
+ disabled={checkDisabled('settings.app_store.key_id')}
91
+ />
92
+ <FormInput
93
+ name="settings.app_store.private_key_pem"
94
+ type="text"
95
+ multiline
96
+ rows={5}
97
+ label={t('admin.paymentMethod.app_store.private_key_pem.label')}
98
+ placeholder={t('admin.paymentMethod.app_store.private_key_pem.tip')}
99
+ disabled={checkDisabled('settings.app_store.private_key_pem')}
100
+ />
101
+ </>
102
+ );
103
+ }
@@ -3,11 +3,13 @@ import { Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/materia
3
3
  import { styled } from '@mui/system';
4
4
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
5
5
 
6
+ import AppStoreMethodForm from './app-store';
6
7
  import ArcBlockMethodForm from './arcblock';
8
+ import BaseMethodForm from './base';
7
9
  import BitcoinMethodForm from './bitcoin';
8
10
  import EthereumMethodForm from './ethereum';
11
+ import GooglePlayMethodForm from './google-play';
9
12
  import StripeMethodForm from './stripe';
10
- import BaseMethodForm from './base';
11
13
 
12
14
  export default function PaymentMethodForm({
13
15
  action = 'create',
@@ -47,6 +49,8 @@ export default function PaymentMethodForm({
47
49
  <ToggleButton value="stripe">Stripe</ToggleButton>
48
50
  <ToggleButton value="ethereum">Ethereum</ToggleButton>
49
51
  <ToggleButton value="base">Base</ToggleButton>
52
+ <ToggleButton value="google_play">Google Play</ToggleButton>
53
+ <ToggleButton value="app_store">App Store</ToggleButton>
50
54
  <ToggleButton value="bitcoin" disabled>
51
55
  Bitcoin
52
56
  </ToggleButton>
@@ -60,6 +64,8 @@ export default function PaymentMethodForm({
60
64
  {type === 'arcblock' && <ArcBlockMethodForm checkDisabled={checkDisabled} />}
61
65
  {type === 'ethereum' && <EthereumMethodForm checkDisabled={checkDisabled} />}
62
66
  {type === 'base' && <BaseMethodForm checkDisabled={checkDisabled} />}
67
+ {type === 'google_play' && <GooglePlayMethodForm checkDisabled={checkDisabled} />}
68
+ {type === 'app_store' && <AppStoreMethodForm checkDisabled={checkDisabled} />}
63
69
  {type === 'bitcoin' && <BitcoinMethodForm checkDisabled={checkDisabled} />}
64
70
  </Root>
65
71
  );
@@ -0,0 +1,85 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { FormInput } from '@blocklet/payment-react';
3
+ import { useFormContext, useWatch } from 'react-hook-form';
4
+ import { Alert, Typography } from '@mui/material';
5
+
6
+ export default function GooglePlayMethodForm({ checkDisabled }: { checkDisabled: (key: string) => boolean }) {
7
+ const { t } = useLocaleContext();
8
+ const { control } = useFormContext();
9
+ const serviceAccountJson = useWatch({ control, name: 'settings.google_play.service_account_json' }) as
10
+ | string
11
+ | undefined;
12
+
13
+ // Quick client-side JSON sanity check — the server re-validates and decrypts.
14
+ let parseError: string | null = null;
15
+ let clientEmail: string | null = null;
16
+ if (serviceAccountJson) {
17
+ try {
18
+ const parsed = JSON.parse(serviceAccountJson);
19
+ if (!parsed.client_email || !parsed.private_key) {
20
+ parseError = t('admin.paymentMethod.google_play.service_account_json.missingFields');
21
+ } else {
22
+ clientEmail = parsed.client_email;
23
+ }
24
+ } catch {
25
+ parseError = t('admin.paymentMethod.google_play.service_account_json.invalidJson');
26
+ }
27
+ }
28
+
29
+ return (
30
+ <>
31
+ <FormInput
32
+ name="name"
33
+ type="text"
34
+ rules={{ required: true }}
35
+ label={t('admin.paymentMethod.name.label')}
36
+ placeholder={t('admin.paymentMethod.name.tip')}
37
+ disabled={checkDisabled('name')}
38
+ inputProps={{ maxLength: 32 }}
39
+ />
40
+ <FormInput
41
+ name="description"
42
+ type="text"
43
+ rules={{ required: true }}
44
+ label={t('admin.paymentMethod.description.label')}
45
+ placeholder={t('admin.paymentMethod.description.tip')}
46
+ inputProps={{ maxLength: 255 }}
47
+ />
48
+ <FormInput
49
+ name="settings.google_play.package_name"
50
+ type="text"
51
+ rules={{ required: true }}
52
+ label={t('admin.paymentMethod.google_play.package_name.label')}
53
+ placeholder={t('admin.paymentMethod.google_play.package_name.tip')}
54
+ disabled={checkDisabled('settings.google_play.package_name')}
55
+ />
56
+ <FormInput
57
+ name="settings.google_play.service_account_json"
58
+ type="text"
59
+ multiline
60
+ rows={6}
61
+ rules={{ required: true }}
62
+ label={t('admin.paymentMethod.google_play.service_account_json.label')}
63
+ placeholder={t('admin.paymentMethod.google_play.service_account_json.tip')}
64
+ disabled={checkDisabled('settings.google_play.service_account_json')}
65
+ />
66
+ {parseError && (
67
+ <Alert severity="error" sx={{ mt: -1 }}>
68
+ {parseError}
69
+ </Alert>
70
+ )}
71
+ {clientEmail && (
72
+ <Typography variant="caption" color="text.secondary" sx={{ mt: -1, display: 'block' }}>
73
+ {t('admin.paymentMethod.google_play.service_account_json.detectedClient')}: {clientEmail}
74
+ </Typography>
75
+ )}
76
+ <FormInput
77
+ name="settings.google_play.pubsub_topic_name"
78
+ type="text"
79
+ label={t('admin.paymentMethod.google_play.pubsub_topic_name.label')}
80
+ placeholder={t('admin.paymentMethod.google_play.pubsub_topic_name.tip')}
81
+ disabled={checkDisabled('settings.google_play.pubsub_topic_name')}
82
+ />
83
+ </>
84
+ );
85
+ }
@@ -152,6 +152,26 @@ export default function SubscriptionList({
152
152
  },
153
153
  },
154
154
  },
155
+ {
156
+ label: t('admin.subscription.channel'),
157
+ name: 'channel',
158
+ options: {
159
+ filter: true,
160
+ customBodyRenderLite: (_: string, index: number) => {
161
+ const item = data.list[index] as TSubscriptionExpanded;
162
+ const channel = (item as any).channel || (item as any).paymentMethod?.type;
163
+ if (!channel) return null;
164
+ let color: 'primary' | 'secondary' | 'default' = 'default';
165
+ if (channel === 'google_play' || channel === 'app_store') color = 'primary';
166
+ else if (channel === 'stripe') color = 'secondary';
167
+ return (
168
+ <Link to={`/admin/billing/${item.id}`}>
169
+ <Status label={channel} color={color as any} />
170
+ </Link>
171
+ );
172
+ },
173
+ },
174
+ },
155
175
  {
156
176
  label: t('common.createdAt'),
157
177
  name: 'created_at',