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.
- package/api/src/crons/index.ts +22 -0
- package/api/src/crons/retry-pending-events.ts +58 -0
- package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
- package/api/src/integrations/app-store/client.ts +369 -0
- package/api/src/integrations/app-store/handlers/index.ts +46 -0
- package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
- package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
- package/api/src/integrations/app-store/notification-routing.ts +18 -0
- package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
- package/api/src/integrations/google-play/client.ts +276 -0
- package/api/src/integrations/google-play/handlers/index.ts +69 -0
- package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
- package/api/src/integrations/google-play/handlers/voided.ts +106 -0
- package/api/src/integrations/google-play/setup.ts +43 -0
- package/api/src/integrations/google-play/verify.ts +251 -0
- package/api/src/integrations/iap-reconcile.ts +415 -0
- package/api/src/libs/audit.ts +38 -8
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/util.ts +13 -0
- package/api/src/queues/event.ts +25 -19
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/entitlements.ts +105 -0
- package/api/src/routes/events.ts +2 -2
- package/api/src/routes/index.ts +12 -2
- package/api/src/routes/integrations/app-store.ts +267 -0
- package/api/src/routes/integrations/google-play.ts +324 -0
- package/api/src/routes/payment-methods.ts +130 -0
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +14 -0
- package/api/src/store/models/entitlement-grant.ts +118 -0
- package/api/src/store/models/entitlement-product.ts +48 -0
- package/api/src/store/models/entitlement.ts +86 -0
- package/api/src/store/models/index.ts +9 -0
- package/api/src/store/models/invoice.ts +20 -0
- package/api/src/store/models/payment-method.ts +62 -1
- package/api/src/store/models/refund.ts +10 -0
- package/api/src/store/models/subscription.ts +14 -0
- package/api/src/store/models/types.ts +32 -0
- package/api/tests/integrations/app-store/client.spec.ts +335 -0
- package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
- package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
- package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
- package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
- package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
- package/api/tests/integrations/google-play/verify.spec.ts +215 -0
- package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
- package/api/tests/libs/entitlement.spec.ts +347 -0
- package/blocklet.yml +1 -1
- package/cloudflare/docs/2026-06-10-bundle-size-analysis.md +288 -0
- package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +23 -1
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/node-fetch.ts +35 -0
- package/cloudflare/shims/queue.ts +28 -2
- package/cloudflare/shims/sequelize-d1/model.ts +19 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/worker.ts +59 -4
- package/cloudflare/wrangler.jsonc +7 -1
- package/cloudflare/wrangler.staging.json +2 -1
- package/package.json +10 -6
- package/scripts/seed-google-play.ts +79 -0
- package/src/components/payment-method/app-store.tsx +103 -0
- package/src/components/payment-method/form.tsx +7 -1
- package/src/components/payment-method/google-play.tsx +85 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +6 -0
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- 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
|
+
});
|
package/cloudflare/worker.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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'] =
|
|
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
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "payment-kit",
|
|
3
|
-
"version": "1.
|
|
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.
|
|
65
|
-
"@blocklet/payment-react": "1.
|
|
66
|
-
"@blocklet/payment-vendor": "1.
|
|
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.
|
|
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": "
|
|
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',
|