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
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Cross-channel entitlement query API.
|
|
2
|
+
//
|
|
3
|
+
// Endpoints:
|
|
4
|
+
// GET /api/entitlements/check ?customer_did=&product_id=[&livemode=]
|
|
5
|
+
// GET /api/entitlements/list ?customer_did=[&livemode=]
|
|
6
|
+
//
|
|
7
|
+
// Auth model:
|
|
8
|
+
// - Component-to-component calls (other blocklets via @blocklet/payment-client):
|
|
9
|
+
// trusted via the component signature; `roles: ['owner','admin']` is the gate.
|
|
10
|
+
// - Logged-in end users (mobile demo, web SPA): `mine: true` lets them in iff
|
|
11
|
+
// their DID matches the query's customer_did — enforced in the handler.
|
|
12
|
+
|
|
13
|
+
import { Router } from 'express';
|
|
14
|
+
import Joi from 'joi';
|
|
15
|
+
|
|
16
|
+
import { checkEntitlement, listEntitlements } from '../libs/entitlement';
|
|
17
|
+
import logger from '../libs/logger';
|
|
18
|
+
import { authenticate } from '../libs/security';
|
|
19
|
+
import { PaymentMethod } from '../store/models';
|
|
20
|
+
|
|
21
|
+
const router = Router();
|
|
22
|
+
// component+owner/admin for cross-blocklet calls; ensureLogin for end users —
|
|
23
|
+
// handler then enforces that non-admin users can only query their own DID.
|
|
24
|
+
const auth = authenticate<PaymentMethod>({ component: true, roles: ['owner', 'admin'], ensureLogin: true });
|
|
25
|
+
|
|
26
|
+
function isAdminUser(role?: string): boolean {
|
|
27
|
+
return role === 'owner' || role === 'admin';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// Strip the `did:abt:` prefix if present so callers can compare DIDs across
|
|
31
|
+
// the two shapes that show up in the codebase (bare base58 vs canonical).
|
|
32
|
+
// The CF Workers AUTH_SERVICE returns bare addresses; clients send canonical.
|
|
33
|
+
function canonicalDid(did: string | undefined | null): string {
|
|
34
|
+
if (!did) return '';
|
|
35
|
+
return did.startsWith('did:abt:') ? did.slice('did:abt:'.length) : did;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function isSelf(req: any, customerDid: string): boolean {
|
|
39
|
+
const a = canonicalDid(req.user?.did);
|
|
40
|
+
const b = canonicalDid(customerDid);
|
|
41
|
+
return !!a && a === b;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const checkQuerySchema = Joi.object<{
|
|
45
|
+
customer_did: string;
|
|
46
|
+
product_id: string;
|
|
47
|
+
livemode?: string | boolean;
|
|
48
|
+
}>({
|
|
49
|
+
customer_did: Joi.string().required(),
|
|
50
|
+
product_id: Joi.string().required(),
|
|
51
|
+
livemode: Joi.alternatives(Joi.boolean(), Joi.string().valid('true', 'false')),
|
|
52
|
+
});
|
|
53
|
+
|
|
54
|
+
const listQuerySchema = Joi.object<{
|
|
55
|
+
customer_did: string;
|
|
56
|
+
livemode?: string | boolean;
|
|
57
|
+
}>({
|
|
58
|
+
customer_did: Joi.string().required(),
|
|
59
|
+
livemode: Joi.alternatives(Joi.boolean(), Joi.string().valid('true', 'false')),
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
function parseLivemode(value: string | boolean | undefined, fallback: boolean): boolean {
|
|
63
|
+
if (typeof value === 'boolean') return value;
|
|
64
|
+
if (value === 'true') return true;
|
|
65
|
+
if (value === 'false') return false;
|
|
66
|
+
return fallback;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
router.get('/check', auth, async (req, res) => {
|
|
70
|
+
try {
|
|
71
|
+
const input = await checkQuerySchema.validateAsync(req.query, { stripUnknown: true });
|
|
72
|
+
if (!isAdminUser((req as any).user?.role) && !isSelf(req, input.customer_did)) {
|
|
73
|
+
res.status(403).json({ error: 'Cannot query entitlements for other customers' });
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
const livemode = parseLivemode(input.livemode, !!req.livemode);
|
|
77
|
+
const result = await checkEntitlement({
|
|
78
|
+
customer_did: input.customer_did,
|
|
79
|
+
product_id: input.product_id,
|
|
80
|
+
livemode,
|
|
81
|
+
});
|
|
82
|
+
res.json(result);
|
|
83
|
+
} catch (err: any) {
|
|
84
|
+
logger.error('entitlements/check failed', { error: err?.message, stack: err?.stack });
|
|
85
|
+
res.status(400).json({ error: err?.message ?? 'check failed' });
|
|
86
|
+
}
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
router.get('/list', auth, async (req, res) => {
|
|
90
|
+
try {
|
|
91
|
+
const input = await listQuerySchema.validateAsync(req.query, { stripUnknown: true });
|
|
92
|
+
if (!isAdminUser((req as any).user?.role) && !isSelf(req, input.customer_did)) {
|
|
93
|
+
res.status(403).json({ error: 'Cannot list entitlements for other customers' });
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
const livemode = parseLivemode(input.livemode, !!req.livemode);
|
|
97
|
+
const list = await listEntitlements({ customer_did: input.customer_did, livemode });
|
|
98
|
+
res.json({ list });
|
|
99
|
+
} catch (err: any) {
|
|
100
|
+
logger.error('entitlements/list failed', { error: err?.message, stack: err?.stack });
|
|
101
|
+
res.status(400).json({ error: err?.message ?? 'list failed' });
|
|
102
|
+
}
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export default router;
|
package/api/src/routes/events.ts
CHANGED
|
@@ -186,7 +186,7 @@ router.get('/retry-webhooks', auth, async (req, res) => {
|
|
|
186
186
|
// eslint-disable-next-line no-restricted-syntax
|
|
187
187
|
for (const webhook of eventWebhooks) {
|
|
188
188
|
// eslint-disable-next-line no-await-in-loop
|
|
189
|
-
const added = await addWebhookJob(event.id, webhook.id, { persist:
|
|
189
|
+
const added = await addWebhookJob(event.id, webhook.id, { persist: true });
|
|
190
190
|
if (added) {
|
|
191
191
|
scheduled += 1;
|
|
192
192
|
}
|
|
@@ -255,7 +255,7 @@ router.post('/:id/retry-webhooks', auth, async (req, res) => {
|
|
|
255
255
|
// eslint-disable-next-line no-restricted-syntax
|
|
256
256
|
for (const webhook of eventWebhooks) {
|
|
257
257
|
// eslint-disable-next-line no-await-in-loop
|
|
258
|
-
const added = await addWebhookJob(event.id, webhook.id, { persist:
|
|
258
|
+
const added = await addWebhookJob(event.id, webhook.id, { persist: true });
|
|
259
259
|
if (added) {
|
|
260
260
|
scheduled += 1;
|
|
261
261
|
logger.info('Manually scheduled webhook retry', { eventId: event.id, webhookId: webhook.id });
|
package/api/src/routes/index.ts
CHANGED
|
@@ -10,6 +10,9 @@ import creditTransactions from './credit-transactions';
|
|
|
10
10
|
import customers from './customers';
|
|
11
11
|
import donations from './donations';
|
|
12
12
|
import events from './events';
|
|
13
|
+
import entitlements from './entitlements';
|
|
14
|
+
import appStore from './integrations/app-store';
|
|
15
|
+
import googlePlay from './integrations/google-play';
|
|
13
16
|
import stripe from './integrations/stripe';
|
|
14
17
|
import invoices from './invoices';
|
|
15
18
|
import meterEvents from './meter-events';
|
|
@@ -49,8 +52,12 @@ router.use((req, _, next) => {
|
|
|
49
52
|
} catch {
|
|
50
53
|
req.livemode = true;
|
|
51
54
|
}
|
|
52
|
-
} else {
|
|
53
|
-
req.livemode
|
|
55
|
+
} else if (typeof req.livemode !== 'boolean') {
|
|
56
|
+
// CF Workers' createExpressReq pre-populates req.livemode from the worker's
|
|
57
|
+
// PAYMENT_LIVEMODE env var; honor that when set. Express dev has no
|
|
58
|
+
// upstream, so we fall back to the env var ourselves — defaulting to
|
|
59
|
+
// livemode=true unless explicitly disabled via PAYMENT_LIVEMODE=false.
|
|
60
|
+
req.livemode = process.env.PAYMENT_LIVEMODE !== 'false';
|
|
54
61
|
}
|
|
55
62
|
|
|
56
63
|
next();
|
|
@@ -101,6 +108,9 @@ router.use('/donations', loadBaseCurrency, donations);
|
|
|
101
108
|
router.use('/events', events);
|
|
102
109
|
router.use('/invoices', invoices);
|
|
103
110
|
router.use('/integrations/stripe', stripe);
|
|
111
|
+
router.use('/integrations/google-play', googlePlay);
|
|
112
|
+
router.use('/integrations/app-store', appStore);
|
|
113
|
+
router.use('/entitlements', entitlements);
|
|
104
114
|
router.use('/meter-events', meterEvents);
|
|
105
115
|
router.use('/meters', meters);
|
|
106
116
|
router.use('/passports', passports);
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
// App Store integration routes.
|
|
2
|
+
//
|
|
3
|
+
// /verify — client-initiated verify. Payload schema mirrors aistro
|
|
4
|
+
// (`{ receipt?, signedTransaction?, ... }`, `.or('receipt','signedTransaction')`)
|
|
5
|
+
// so aistro iOS clients can hit this endpoint without modification.
|
|
6
|
+
// JWS path takes priority; falls back to legacy receipt verifyReceipt.
|
|
7
|
+
// /webhook — App Store Server Notifications V2. Stubbed; full state machine
|
|
8
|
+
// lands in A1-followup.
|
|
9
|
+
|
|
10
|
+
import { Request, Response, Router } from 'express';
|
|
11
|
+
import Joi from 'joi';
|
|
12
|
+
|
|
13
|
+
import handleAppStoreNotification from '../../integrations/app-store/handlers';
|
|
14
|
+
import { ingestVerifiedAppStorePurchase } from '../../integrations/app-store/handlers/subscription';
|
|
15
|
+
import { peekNotificationRouting } from '../../integrations/app-store/notification-routing';
|
|
16
|
+
import logger from '../../libs/logger';
|
|
17
|
+
import { authenticate } from '../../libs/security';
|
|
18
|
+
import { Customer, PaymentMethod } from '../../store/models';
|
|
19
|
+
|
|
20
|
+
const router = Router();
|
|
21
|
+
const userAuth = authenticate<Customer>({ component: false, ensureLogin: true });
|
|
22
|
+
|
|
23
|
+
const verifyBodySchema = Joi.object<{
|
|
24
|
+
platform?: 'ios';
|
|
25
|
+
receipt?: string;
|
|
26
|
+
signedTransaction?: string;
|
|
27
|
+
// legacy fields tolerated for aistro-shape compatibility (ignored server-side)
|
|
28
|
+
developerPayload?: string;
|
|
29
|
+
language?: string;
|
|
30
|
+
}>({
|
|
31
|
+
platform: Joi.string().valid('ios'),
|
|
32
|
+
receipt: Joi.string().empty(['', null]),
|
|
33
|
+
signedTransaction: Joi.string().empty(['', null]),
|
|
34
|
+
developerPayload: Joi.string().empty(['', null]),
|
|
35
|
+
language: Joi.string().empty(['', null]),
|
|
36
|
+
}).or('receipt', 'signedTransaction');
|
|
37
|
+
|
|
38
|
+
router.post('/verify', userAuth, async (req: Request, res: Response) => {
|
|
39
|
+
try {
|
|
40
|
+
const did = (req as any).user?.did;
|
|
41
|
+
if (!did) {
|
|
42
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
43
|
+
return;
|
|
44
|
+
}
|
|
45
|
+
const input = await verifyBodySchema.validateAsync(req.body, { stripUnknown: true });
|
|
46
|
+
|
|
47
|
+
const method = await PaymentMethod.findOne({
|
|
48
|
+
where: { type: 'app_store', active: true, livemode: !!req.livemode },
|
|
49
|
+
});
|
|
50
|
+
if (!method) {
|
|
51
|
+
res.status(503).json({ error: 'app_store PaymentMethod not configured' });
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
const client = method.getAppStoreClient();
|
|
55
|
+
|
|
56
|
+
const result = await ingestVerifiedAppStorePurchase({
|
|
57
|
+
customerDid: did,
|
|
58
|
+
paymentMethod: method,
|
|
59
|
+
client,
|
|
60
|
+
signedTransaction: input.signedTransaction,
|
|
61
|
+
receipt: input.receipt,
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
res.json({
|
|
65
|
+
success: true,
|
|
66
|
+
subscription_id: result.subscription.id,
|
|
67
|
+
isFirstSubscribe: result.isFirstSubscribe,
|
|
68
|
+
active: result.subscription.status === 'active',
|
|
69
|
+
expires_at: result.subscription.current_period_end,
|
|
70
|
+
transaction: {
|
|
71
|
+
original_transaction_id: result.transaction.originalTransactionId,
|
|
72
|
+
transaction_id: result.transaction.transactionId,
|
|
73
|
+
product_id: result.transaction.productId,
|
|
74
|
+
environment: result.transaction.environment,
|
|
75
|
+
},
|
|
76
|
+
});
|
|
77
|
+
} catch (err: any) {
|
|
78
|
+
const message = err?.message || (typeof err === 'string' ? err : null) || 'verify failed';
|
|
79
|
+
logger.error('app_store verify failed', { message, stack: err?.stack });
|
|
80
|
+
res.status(400).json({ success: false, error: { message, code: err?.code } });
|
|
81
|
+
}
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
// Restore-side input caps. A real client's `Transaction.currentEntitlements`
|
|
85
|
+
// holds one entry per active subscription — practical ceiling is well under
|
|
86
|
+
// 10 for any single Apple ID. Cap an order of magnitude above realistic to
|
|
87
|
+
// catch buggy clients without blocking legitimate uses, and cap per-item
|
|
88
|
+
// length so a 1MB JSON body can't pack hundreds of strings.
|
|
89
|
+
// Apple JWS is typically 1-3 KB; legacy base64 receipts can run 5-50 KB.
|
|
90
|
+
const RESTORE_MAX_ITEMS = 50;
|
|
91
|
+
const JWS_MAX_LENGTH = 8 * 1024;
|
|
92
|
+
const RECEIPT_MAX_LENGTH = 64 * 1024;
|
|
93
|
+
// Verify pool size. Each restore item triggers an Apple JWS verify + DB
|
|
94
|
+
// upsert. Promise.all over an unbounded list lets an authenticated caller
|
|
95
|
+
// fan out into many concurrent Apple verifications; bound the pool so the
|
|
96
|
+
// worst case is still recoverable on a single Worker invocation.
|
|
97
|
+
const RESTORE_CONCURRENCY = 5;
|
|
98
|
+
|
|
99
|
+
const restoreBodySchema = Joi.object<{
|
|
100
|
+
receipts?: string[];
|
|
101
|
+
signedTransactions?: string[];
|
|
102
|
+
}>({
|
|
103
|
+
receipts: Joi.array().items(Joi.string().max(RECEIPT_MAX_LENGTH)).max(RESTORE_MAX_ITEMS).default([]),
|
|
104
|
+
signedTransactions: Joi.array().items(Joi.string().max(JWS_MAX_LENGTH)).max(RESTORE_MAX_ITEMS).default([]),
|
|
105
|
+
}).or('receipts', 'signedTransactions');
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* Restore purchases (StoreKit-style).
|
|
109
|
+
*
|
|
110
|
+
* Mobile client posts when the user reinstalls / switches device. We re-verify
|
|
111
|
+
* each receipt/signedTransaction and either return the existing Subscription
|
|
112
|
+
* or create one. Partial success is allowed — per-item failures land in the
|
|
113
|
+
* `errors` array; per-item successes land in `restored`.
|
|
114
|
+
*/
|
|
115
|
+
router.post('/restore', userAuth, async (req: Request, res: Response) => {
|
|
116
|
+
try {
|
|
117
|
+
const did = (req as any).user?.did;
|
|
118
|
+
if (!did) {
|
|
119
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
const input = await restoreBodySchema.validateAsync(req.body, { stripUnknown: true });
|
|
123
|
+
|
|
124
|
+
const method = await PaymentMethod.findOne({
|
|
125
|
+
where: { type: 'app_store', active: true, livemode: !!req.livemode },
|
|
126
|
+
});
|
|
127
|
+
if (!method) {
|
|
128
|
+
res.status(503).json({ error: 'app_store PaymentMethod not configured' });
|
|
129
|
+
return;
|
|
130
|
+
}
|
|
131
|
+
const client = method.getAppStoreClient();
|
|
132
|
+
|
|
133
|
+
// Dedupe by raw value before verifying. A buggy client that posts the
|
|
134
|
+
// same JWS twice (or duplicates between `signedTransactions` and
|
|
135
|
+
// `receipts`) shouldn't double-charge Apple's verifier or write twice
|
|
136
|
+
// through `ingestVerifiedAppStorePurchase`.
|
|
137
|
+
const seen = new Set<string>();
|
|
138
|
+
const items: Array<{ kind: 'jws' | 'receipt'; value: string }> = [
|
|
139
|
+
...(input.signedTransactions ?? []).map((v) => ({ kind: 'jws' as const, value: v })),
|
|
140
|
+
...(input.receipts ?? []).map((v) => ({ kind: 'receipt' as const, value: v })),
|
|
141
|
+
].filter((item) => {
|
|
142
|
+
if (seen.has(item.value)) return false;
|
|
143
|
+
seen.add(item.value);
|
|
144
|
+
return true;
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
// Bounded concurrency: process in fixed-size batches. Each item hits
|
|
148
|
+
// Apple's verifier + at least one DB write, and Promise.all over an
|
|
149
|
+
// arbitrary list lets a single authenticated request fan out into
|
|
150
|
+
// many concurrent Apple calls. Batching of `RESTORE_CONCURRENCY`
|
|
151
|
+
// caps the worst case while keeping a typical (1-3 item) restore
|
|
152
|
+
// single-batch fast.
|
|
153
|
+
type ItemResult =
|
|
154
|
+
| {
|
|
155
|
+
ok: true;
|
|
156
|
+
subscription_id: string;
|
|
157
|
+
isFirstSubscribe: boolean;
|
|
158
|
+
original_transaction_id: string;
|
|
159
|
+
product_id: string;
|
|
160
|
+
}
|
|
161
|
+
| { ok: false; error: string };
|
|
162
|
+
const results: ItemResult[] = [];
|
|
163
|
+
for (let i = 0; i < items.length; i += RESTORE_CONCURRENCY) {
|
|
164
|
+
const batch = items.slice(i, i + RESTORE_CONCURRENCY);
|
|
165
|
+
// eslint-disable-next-line no-await-in-loop -- intentional: batches must complete sequentially to bound concurrency
|
|
166
|
+
const batchResults = await Promise.all(
|
|
167
|
+
batch.map(async (item): Promise<ItemResult> => {
|
|
168
|
+
try {
|
|
169
|
+
const r = await ingestVerifiedAppStorePurchase({
|
|
170
|
+
customerDid: did,
|
|
171
|
+
paymentMethod: method,
|
|
172
|
+
client,
|
|
173
|
+
signedTransaction: item.kind === 'jws' ? item.value : undefined,
|
|
174
|
+
receipt: item.kind === 'receipt' ? item.value : undefined,
|
|
175
|
+
});
|
|
176
|
+
return {
|
|
177
|
+
ok: true,
|
|
178
|
+
subscription_id: r.subscription.id,
|
|
179
|
+
isFirstSubscribe: r.isFirstSubscribe,
|
|
180
|
+
original_transaction_id: r.transaction.originalTransactionId,
|
|
181
|
+
product_id: r.transaction.productId,
|
|
182
|
+
};
|
|
183
|
+
} catch (err: any) {
|
|
184
|
+
return { ok: false, error: err?.message ?? 'restore failed' };
|
|
185
|
+
}
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
results.push(...batchResults);
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
res.json({
|
|
192
|
+
restored: results.filter((r) => r.ok),
|
|
193
|
+
errors: results.filter((r) => !r.ok),
|
|
194
|
+
});
|
|
195
|
+
} catch (err: any) {
|
|
196
|
+
logger.error('app_store restore failed', { error: err?.message, stack: err?.stack });
|
|
197
|
+
res.status(400).json({ error: err?.message ?? 'restore failed' });
|
|
198
|
+
}
|
|
199
|
+
});
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* App Store Server Notifications V2 — Apple S2S webhook.
|
|
203
|
+
*
|
|
204
|
+
* Request body: `{ signedPayload: "<JWS>" }` per Apple's spec. Selection +
|
|
205
|
+
* verification failures (malformed / forged / not-for-us) are acked with 2xx so
|
|
206
|
+
* Apple doesn't retry-storm; a failure while PROCESSING a verified notification
|
|
207
|
+
* is transient and returns 5xx so Apple retries.
|
|
208
|
+
*/
|
|
209
|
+
router.post('/webhook', async (req: Request, res: Response) => {
|
|
210
|
+
const signedPayload = (req.body?.signedPayload as string | undefined) ?? '';
|
|
211
|
+
if (!signedPayload) {
|
|
212
|
+
logger.warn('app_store webhook missing signedPayload');
|
|
213
|
+
res.json({ skipped: true, reason: 'no signedPayload' });
|
|
214
|
+
return;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// --- Select the matching method + verify. Failures here are NOT retryable. ---
|
|
218
|
+
let notification: any;
|
|
219
|
+
let client: any;
|
|
220
|
+
try {
|
|
221
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'app_store' } });
|
|
222
|
+
if (methods.length === 0) {
|
|
223
|
+
logger.warn('app_store webhook: no PaymentMethod configured');
|
|
224
|
+
res.json({ skipped: true, reason: 'no app_store PaymentMethod' });
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
// Read bundleId/environment from the UNVERIFIED payload to pick the method.
|
|
229
|
+
// Verifying against the wrong env/bundle client (e.g. methods[0]) would throw
|
|
230
|
+
// before the correct method is ever tried, and the old catch then 200'd —
|
|
231
|
+
// silently discarding valid notifications (PR #1381 review P1).
|
|
232
|
+
const routing = peekNotificationRouting(signedPayload);
|
|
233
|
+
const matched = methods.find((m) => {
|
|
234
|
+
const settings = PaymentMethod.decryptSettings(m.settings);
|
|
235
|
+
if (settings.app_store?.bundle_id !== routing?.bundleId) return false;
|
|
236
|
+
if (!routing?.environment) return true;
|
|
237
|
+
return settings.app_store?.environment === routing.environment.toLowerCase();
|
|
238
|
+
});
|
|
239
|
+
if (!matched) {
|
|
240
|
+
logger.warn('app_store webhook: no matching PaymentMethod', {
|
|
241
|
+
bundleId: routing?.bundleId,
|
|
242
|
+
environment: routing?.environment,
|
|
243
|
+
});
|
|
244
|
+
res.json({ skipped: true, reason: 'no matching PaymentMethod' });
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
client = matched.getAppStoreClient();
|
|
249
|
+
notification = await client.verifyNotificationPayload(signedPayload);
|
|
250
|
+
} catch (err: any) {
|
|
251
|
+
// Malformed / forged / not-for-us → ack so Apple stops retrying.
|
|
252
|
+
logger.warn('app_store webhook: verification/selection failed — acking', { error: err?.message });
|
|
253
|
+
res.json({ skipped: true, reason: 'verification failed' });
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// --- Process the verified notification. Failures here ARE transient. ---
|
|
258
|
+
try {
|
|
259
|
+
await handleAppStoreNotification(notification, client);
|
|
260
|
+
res.json({ received: true });
|
|
261
|
+
} catch (err: any) {
|
|
262
|
+
logger.error('app_store webhook processing failed — will retry', { error: err?.message, stack: err?.stack });
|
|
263
|
+
res.status(500).json({ error: err?.message ?? 'processing failed' });
|
|
264
|
+
}
|
|
265
|
+
});
|
|
266
|
+
|
|
267
|
+
export default router;
|