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,112 @@
1
+ -- Payment Kit: IAP multi-tenant backfill
2
+ -- Backfills metadata.bundle_id / metadata.package_name on existing Prices and
3
+ -- payment_details.app_store.bundle_id / payment_details.google_play.package_name
4
+ -- on existing Subscriptions, so Payment Kit can be wired into multiple iOS /
5
+ -- Android apps without same-SKU collisions across App Store / Play Console
6
+ -- namespaces (each store's SKU space is per-app, not global).
7
+ --
8
+ -- Backend code already filters Price.findOne by (sku, bundle_id) / (sku,
9
+ -- package_name); without this backfill, every pre-existing Price would stop
10
+ -- resolving the moment the new lookup ships.
11
+ --
12
+ -- Safety: tenant value is DERIVED from the configured PaymentMethods (which
13
+ -- store bundle_id / package_name as plain text under settings JSON — only
14
+ -- private keys are encrypted). The migration deliberately refuses to guess in
15
+ -- ambiguous setups:
16
+ --
17
+ -- * For Subscriptions we always use the sub's own
18
+ -- `default_payment_method_id` to resolve the tenant, which is a 1:1 map —
19
+ -- never ambiguous as long as the row points at a real PaymentMethod.
20
+ -- * For Prices we update only when EXACTLY ONE active PaymentMethod of the
21
+ -- matching type + livemode exists with a non-null tenant. Multi-tenant
22
+ -- installations (two iOS apps sharing one Payment Kit, etc.) skip the
23
+ -- Price backfill — admin must set bundle_id / package_name explicitly
24
+ -- because the migration can't safely guess which app a Price belongs to.
25
+ --
26
+ -- Idempotent. The IS NULL guards make re-runs a no-op for already-backfilled
27
+ -- rows, and the subquery filters skip rows that can't be resolved safely.
28
+
29
+ -- 1. Prices with App Store SKU → set bundle_id (only when one active
30
+ -- app_store PaymentMethod for the same livemode unambiguously identifies
31
+ -- the tenant).
32
+ UPDATE prices
33
+ SET metadata = json_set(
34
+ metadata,
35
+ '$.bundle_id',
36
+ (SELECT json_extract(pm.settings, '$.app_store.bundle_id')
37
+ FROM payment_methods pm
38
+ WHERE pm.type = 'app_store'
39
+ AND pm.livemode = prices.livemode
40
+ AND pm.active = 1
41
+ AND json_extract(pm.settings, '$.app_store.bundle_id') IS NOT NULL
42
+ LIMIT 1)
43
+ )
44
+ WHERE json_extract(metadata, '$.app_store_product_id') IS NOT NULL
45
+ AND json_extract(metadata, '$.bundle_id') IS NULL
46
+ AND (
47
+ SELECT COUNT(*) FROM payment_methods pm
48
+ WHERE pm.type = 'app_store'
49
+ AND pm.livemode = prices.livemode
50
+ AND pm.active = 1
51
+ AND json_extract(pm.settings, '$.app_store.bundle_id') IS NOT NULL
52
+ ) = 1;
53
+
54
+ -- 2. Prices with Google Play SKU → set package_name (same single-tenant guard).
55
+ UPDATE prices
56
+ SET metadata = json_set(
57
+ metadata,
58
+ '$.package_name',
59
+ (SELECT json_extract(pm.settings, '$.google_play.package_name')
60
+ FROM payment_methods pm
61
+ WHERE pm.type = 'google_play'
62
+ AND pm.livemode = prices.livemode
63
+ AND pm.active = 1
64
+ AND json_extract(pm.settings, '$.google_play.package_name') IS NOT NULL
65
+ LIMIT 1)
66
+ )
67
+ WHERE json_extract(metadata, '$.google_play_product_id') IS NOT NULL
68
+ AND json_extract(metadata, '$.package_name') IS NULL
69
+ AND (
70
+ SELECT COUNT(*) FROM payment_methods pm
71
+ WHERE pm.type = 'google_play'
72
+ AND pm.livemode = prices.livemode
73
+ AND pm.active = 1
74
+ AND json_extract(pm.settings, '$.google_play.package_name') IS NOT NULL
75
+ ) = 1;
76
+
77
+ -- 3. App Store Subscriptions → set payment_details.app_store.bundle_id from
78
+ -- the sub's own default_payment_method (1:1 — always safe).
79
+ UPDATE subscriptions
80
+ SET payment_details = json_set(
81
+ payment_details,
82
+ '$.app_store.bundle_id',
83
+ (SELECT json_extract(pm.settings, '$.app_store.bundle_id')
84
+ FROM payment_methods pm
85
+ WHERE pm.id = subscriptions.default_payment_method_id)
86
+ )
87
+ WHERE channel = 'app_store'
88
+ AND json_extract(payment_details, '$.app_store.bundle_id') IS NULL
89
+ AND default_payment_method_id IS NOT NULL
90
+ AND (
91
+ SELECT json_extract(pm.settings, '$.app_store.bundle_id')
92
+ FROM payment_methods pm
93
+ WHERE pm.id = subscriptions.default_payment_method_id
94
+ ) IS NOT NULL;
95
+
96
+ -- 4. Google Play Subscriptions → set payment_details.google_play.package_name.
97
+ UPDATE subscriptions
98
+ SET payment_details = json_set(
99
+ payment_details,
100
+ '$.google_play.package_name',
101
+ (SELECT json_extract(pm.settings, '$.google_play.package_name')
102
+ FROM payment_methods pm
103
+ WHERE pm.id = subscriptions.default_payment_method_id)
104
+ )
105
+ WHERE channel = 'google_play'
106
+ AND json_extract(payment_details, '$.google_play.package_name') IS NULL
107
+ AND default_payment_method_id IS NOT NULL
108
+ AND (
109
+ SELECT json_extract(pm.settings, '$.google_play.package_name')
110
+ FROM payment_methods pm
111
+ WHERE pm.id = subscriptions.default_payment_method_id
112
+ ) IS NOT NULL;
@@ -297,6 +297,7 @@ build({
297
297
  "@blocklet/sdk/lib/wallet-handler": s("shims/blocklet-sdk/wallet-handler.ts"),
298
298
  "@blocklet/sdk/lib/security": s("shims/blocklet-sdk/security.ts"),
299
299
  "@blocklet/sdk/lib/util/verify-sign": s("shims/blocklet-sdk/verify-sign.ts"),
300
+ "@blocklet/sdk/lib/util/verify-session": s("shims/blocklet-sdk/verify-session.ts"),
300
301
  "@blocklet/sdk/lib/util/component-api": s("shims/blocklet-sdk/component-api.ts"),
301
302
  "@blocklet/sdk/lib/error-handler": s("shims/noop.ts"),
302
303
  "@blocklet/sdk/lib/did": s("shims/blocklet-sdk/did.ts"),
@@ -0,0 +1,44 @@
1
+ // CF Workers no-op shim for @blocklet/sdk/lib/util/verify-session.
2
+ //
3
+ // In CF Workers, Bearer / cookie validation happens *before* Express routes
4
+ // run — see worker.ts auth middleware at /api/*, which calls
5
+ // `c.env.AUTH_SERVICE.resolveIdentity(...)` and injects x-user-did into the
6
+ // request headers when the token is valid. By the time security.ts'
7
+ // `authenticate` middleware sees the request, the x-user-did branch handles
8
+ // it. The fallback Bearer-validation path that calls verifyLoginToken
9
+ // directly is only needed in the Express dev server, where the tunnel
10
+ // bypasses Blocklet Server's nginx and we can't rely on header injection.
11
+ //
12
+ // So in Workers we return null — security.ts treats null as "couldn't
13
+ // validate locally, fall through" — and the x-user-did set by the worker
14
+ // layer is the source of truth.
15
+
16
+ export type SessionUser = {
17
+ did: string;
18
+ role?: string;
19
+ fullName?: string;
20
+ provider?: string;
21
+ walletOS?: string;
22
+ method?: string;
23
+ org?: string;
24
+ };
25
+
26
+ export async function verifyLoginToken(_opts: { token: string; strictMode?: boolean }): Promise<SessionUser | null> {
27
+ return null;
28
+ }
29
+
30
+ export async function verifyAccessKey(_opts: { token: string; strictMode?: boolean }): Promise<SessionUser | null> {
31
+ return null;
32
+ }
33
+
34
+ export async function verifyComponentCall(_opts: { req: any; strictMode?: boolean }): Promise<SessionUser | null> {
35
+ return null;
36
+ }
37
+
38
+ export async function verifySignedToken(_opts: { token: string; strictMode?: boolean }): Promise<SessionUser | null> {
39
+ return null;
40
+ }
41
+
42
+ export function getSessionSecret(): string {
43
+ return '';
44
+ }
@@ -408,8 +408,34 @@ export default function createQueue<T = any>({ name, onJob, options = defaults }
408
408
  if (_cfQueue) {
409
409
  try {
410
410
  await sendToCFQueue(jobId, job);
411
- } catch (_err: any) {
412
- // CF Queue unavailable job is safe in D1, cron will dispatch
411
+ } catch (err: any) {
412
+ // CF Queue unavailable (429 Too Many Requests, transport down, etc.)
413
+ // Fall through to inline execution: the worker context already has
414
+ // its own CPU budget (HTTP handler: 30s; scheduled handler: 30s),
415
+ // and a tightly-throttled CF Queue + a backed-up cron dispatcher
416
+ // means immediate jobs would otherwise sit in D1 indefinitely.
417
+ // The D1 row persists either way, so a crash here still lets a
418
+ // later cron tick (or a retry) pick it up.
419
+ console.warn(`[queue:${name}] CF Queue send failed (${err?.message || err}); executing inline`);
420
+ try {
421
+ // Pass persist=false so executeJob does NOT delete the D1 row on a
422
+ // terminal failure — that would wipe the only durable backup (esp.
423
+ // for maxRetries:0 queues like webhookQueue) and the cron dispatcher
424
+ // could never retry it. Delete the row ONLY on success here.
425
+ // (PR #1381 re-review P1.)
426
+ const data = await executeJob(jobId, job, false);
427
+ if (persist) {
428
+ try {
429
+ await store.deleteJob(jobId);
430
+ } catch (_e) {
431
+ /* ignore — duplicate delete is harmless */
432
+ }
433
+ }
434
+ emit('finished', data);
435
+ } catch (execErr: any) {
436
+ // D1 row intact → a later cron tick / retry can pick it up.
437
+ emit('failed', { id: jobId, job, error: execErr });
438
+ }
413
439
  }
414
440
  } else {
415
441
  // No CF Queue binding — execute inline (Blocklet Server compatibility)
@@ -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.0",
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.0",
66
+ "@blocklet/payment-react": "1.29.0",
67
+ "@blocklet/payment-vendor": "1.29.0",
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.0",
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": "02334964fbf505ea2fd27081039542d1f9868d57"
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
+ });