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.
- 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/migrations/0004_iap_foundation.sql +72 -0
- package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
- package/cloudflare/run-build.js +1 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -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
|
@@ -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;
|
package/cloudflare/run-build.js
CHANGED
|
@@ -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 (
|
|
412
|
-
// CF Queue unavailable
|
|
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
|
+
});
|
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.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.
|
|
65
|
-
"@blocklet/payment-react": "1.
|
|
66
|
-
"@blocklet/payment-vendor": "1.
|
|
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.
|
|
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": "
|
|
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
|
+
});
|