payment-kit 1.27.2 → 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/__blocklet__.js +37 -0
- package/api/ocap-1.30-subpath-shims.d.ts +35 -0
- package/api/src/crons/index.ts +32 -0
- package/api/src/crons/metering-subscription-detection.ts +12 -14
- package/api/src/crons/overdue-detection.ts +51 -74
- 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/arcblock/nft.ts +6 -2
- package/api/src/integrations/arcblock/stake.ts +3 -2
- package/api/src/integrations/arcblock/token.ts +4 -4
- package/api/src/integrations/blocklet/notification.ts +1 -1
- package/api/src/integrations/ethereum/tx.ts +29 -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/integrations/stripe/handlers/invoice.ts +70 -53
- package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
- package/api/src/integrations/stripe/resource.ts +8 -0
- package/api/src/libs/audit.ts +70 -24
- package/api/src/libs/auth.ts +49 -2
- package/api/src/libs/chain-error.ts +31 -0
- package/api/src/libs/entitlement.ts +399 -0
- package/api/src/libs/env.ts +2 -0
- package/api/src/libs/error.ts +15 -0
- package/api/src/libs/event.ts +42 -1
- package/api/src/libs/invoice.ts +69 -34
- package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
- package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
- package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
- package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
- package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
- package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
- package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
- package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
- package/api/src/libs/pagination.ts +14 -9
- package/api/src/libs/payment.ts +25 -10
- package/api/src/libs/security.ts +51 -0
- package/api/src/libs/session.ts +1 -1
- package/api/src/libs/subscription.ts +13 -1
- package/api/src/libs/timing.ts +35 -0
- package/api/src/libs/util.ts +29 -15
- package/api/src/libs/wallet-migration.ts +72 -53
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/credit-consume.ts +94 -12
- package/api/src/queues/credit-grant.ts +4 -0
- package/api/src/queues/event.ts +39 -21
- package/api/src/queues/invoice.ts +1 -0
- package/api/src/queues/payment.ts +83 -15
- package/api/src/queues/refund.ts +84 -71
- package/api/src/queues/subscription.ts +1 -0
- package/api/src/queues/webhook.ts +12 -2
- package/api/src/routes/checkout-sessions.ts +82 -43
- package/api/src/routes/connect/change-payment.ts +2 -0
- package/api/src/routes/connect/change-plan.ts +2 -0
- package/api/src/routes/connect/pay.ts +12 -3
- package/api/src/routes/connect/setup.ts +3 -1
- package/api/src/routes/connect/shared.ts +52 -39
- package/api/src/routes/connect/subscribe.ts +4 -1
- package/api/src/routes/credit-grants.ts +25 -17
- package/api/src/routes/donations.ts +2 -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/meter-events.ts +16 -6
- package/api/src/routes/payment-links.ts +1 -1
- package/api/src/routes/payment-methods.ts +131 -1
- package/api/src/routes/settings.ts +1 -1
- package/api/src/routes/tax-rates.ts +1 -1
- package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
- package/api/src/store/models/customer.ts +37 -1
- 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 +66 -1
- package/api/src/store/models/price.ts +23 -14
- 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/api/tests/libs/wallet-migration.spec.ts +4 -4
- package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
- package/api/tests/queues/credit-consume.spec.ts +8 -4
- package/api/tests/routes/credit-grants.spec.ts +1 -0
- package/blocklet.yml +1 -1
- package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
- package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
- package/cloudflare/README.md +499 -0
- package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
- package/cloudflare/build.ts +151 -0
- package/cloudflare/did-connect-auth.ts +527 -0
- package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
- package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
- package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
- package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
- package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
- package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
- package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
- package/cloudflare/frontend-shims/js-sdk.ts +43 -0
- package/cloudflare/frontend-shims/mime-types.ts +46 -0
- package/cloudflare/frontend-shims/session.ts +24 -0
- package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
- package/cloudflare/index.html +40 -0
- package/cloudflare/migrate-to-d1.js +252 -0
- package/cloudflare/migrations/0001_initial_schema.sql +82 -0
- package/cloudflare/migrations/0002_indexes.sql +75 -0
- package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -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 +391 -0
- package/cloudflare/scripts/test-decrypt.js +102 -0
- package/cloudflare/shims/arcblock-ws.ts +20 -0
- package/cloudflare/shims/axios-http-adapter.ts +4 -0
- package/cloudflare/shims/axios-lite.ts +117 -0
- package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
- package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
- package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
- package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
- package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
- package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
- package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
- package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
- package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
- package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
- package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
- package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
- package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
- package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
- package/cloudflare/shims/cookie-parser.ts +3 -0
- package/cloudflare/shims/cors.ts +21 -0
- package/cloudflare/shims/cron.ts +189 -0
- package/cloudflare/shims/crypto-js-warn.ts +7 -0
- package/cloudflare/shims/did-space-js.ts +17 -0
- package/cloudflare/shims/did-space.ts +11 -0
- package/cloudflare/shims/error.ts +18 -0
- package/cloudflare/shims/express-compat/index.ts +80 -0
- package/cloudflare/shims/express-compat/types.ts +41 -0
- package/cloudflare/shims/fastq.ts +105 -0
- package/cloudflare/shims/lock.ts +115 -0
- package/cloudflare/shims/mime-types.ts +56 -0
- package/cloudflare/shims/nedb-storage.ts +9 -0
- package/cloudflare/shims/node-child-process.ts +9 -0
- package/cloudflare/shims/node-fs.ts +20 -0
- package/cloudflare/shims/node-http.ts +13 -0
- package/cloudflare/shims/node-https.ts +4 -0
- package/cloudflare/shims/node-misc.ts +15 -0
- package/cloudflare/shims/node-net.ts +8 -0
- package/cloudflare/shims/node-os.ts +14 -0
- package/cloudflare/shims/node-tty.ts +8 -0
- package/cloudflare/shims/node-zlib.ts +17 -0
- package/cloudflare/shims/noop.ts +26 -0
- package/cloudflare/shims/payment-vendor.ts +14 -0
- package/cloudflare/shims/querystring.ts +12 -0
- package/cloudflare/shims/queue.ts +611 -0
- package/cloudflare/shims/rolldown-runtime.ts +43 -0
- package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
- package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
- package/cloudflare/shims/sequelize-d1/index.ts +34 -0
- package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
- package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
- package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
- package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
- package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
- package/cloudflare/shims/sequelize-d1/types.ts +35 -0
- package/cloudflare/shims/stripe-cf.ts +29 -0
- package/cloudflare/shims/ws-lite.ts +103 -0
- package/cloudflare/shims/xss.ts +3 -0
- package/cloudflare/tests/shims/cron.spec.ts +210 -0
- package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
- package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
- package/cloudflare/vite.config.ts +162 -0
- package/cloudflare/worker.ts +1608 -0
- package/cloudflare/wrangler.json +63 -0
- package/cloudflare/wrangler.jsonc +75 -0
- package/cloudflare/wrangler.staging.json +67 -0
- package/cloudflare/wrangler.toml +28 -0
- package/jest.config.js +4 -12
- package/package.json +30 -22
- package/scripts/seed-google-play.ts +79 -0
- package/src/app.tsx +62 -4
- package/src/components/customer/link.tsx +9 -13
- package/src/components/customer/notification-preference.tsx +3 -2
- package/src/components/filter-toolbar.tsx +4 -0
- package/src/components/invoice/list.tsx +9 -1
- package/src/components/invoice-pdf/utils.ts +2 -1
- package/src/components/layout/admin.tsx +39 -5
- package/src/components/layout/user-cf.tsx +77 -0
- package/src/components/payment-intent/actions.tsx +23 -3
- 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/safe-did-address.tsx +75 -0
- package/src/components/subscription/list.tsx +20 -0
- package/src/libs/patch-user-card.ts +25 -0
- package/src/libs/util.ts +5 -7
- package/src/locales/en.tsx +63 -0
- package/src/locales/zh.tsx +63 -0
- package/src/pages/admin/billing/meter-events/index.tsx +4 -0
- package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
- package/src/pages/admin/customers/customers/detail.tsx +8 -2
- package/src/pages/admin/customers/customers/index.tsx +2 -2
- package/src/pages/admin/overview.tsx +3 -1
- package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
- package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
- package/src/pages/customer/subscription/detail.tsx +4 -4
- package/tsconfig.api.json +1 -6
- package/tsconfig.json +3 -4
- package/tsconfig.types.json +2 -1
- package/vite.config.ts +6 -1
|
@@ -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;
|
|
@@ -0,0 +1,324 @@
|
|
|
1
|
+
// Google Play Real-Time Developer Notification webhook receiver.
|
|
2
|
+
//
|
|
3
|
+
// Pub/Sub Push body:
|
|
4
|
+
// {
|
|
5
|
+
// "message": { "data": "<base64 JSON>", "messageId": "...", "publishTime": "..." },
|
|
6
|
+
// "subscription": "projects/<project>/subscriptions/<sub>"
|
|
7
|
+
// }
|
|
8
|
+
//
|
|
9
|
+
// Auth: Pub/Sub puts a Google-signed JWT in `Authorization: Bearer <jwt>`.
|
|
10
|
+
// We verify the JWT claims here (signature verification is TODO — see verify.ts).
|
|
11
|
+
|
|
12
|
+
import { Request, Response, Router } from 'express';
|
|
13
|
+
import Joi from 'joi';
|
|
14
|
+
|
|
15
|
+
import handleGooglePlayEvent, { GooglePlayRtdnPayload } from '../../integrations/google-play/handlers';
|
|
16
|
+
import { ingestVerifiedGooglePlayPurchase } from '../../integrations/google-play/handlers/subscription';
|
|
17
|
+
import { decodePubSubMessage, verifyPubSubJwt } from '../../integrations/google-play/verify';
|
|
18
|
+
import logger from '../../libs/logger';
|
|
19
|
+
import { authenticate } from '../../libs/security';
|
|
20
|
+
import { googlePlayEndpoint } from '../../libs/util';
|
|
21
|
+
import { Customer, PaymentMethod } from '../../store/models';
|
|
22
|
+
|
|
23
|
+
const router = Router();
|
|
24
|
+
const userAuth = authenticate<Customer>({ component: false, ensureLogin: true });
|
|
25
|
+
|
|
26
|
+
const verifyBodySchema = Joi.object<{
|
|
27
|
+
purchaseToken: string;
|
|
28
|
+
subscriptionId: string;
|
|
29
|
+
}>({
|
|
30
|
+
purchaseToken: Joi.string().required(),
|
|
31
|
+
subscriptionId: Joi.string().required(),
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Client-initiated verify (aistro-shape).
|
|
36
|
+
* Mobile client POSTs after StoreKit / BillingClient finishes the purchase.
|
|
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
|
+
// Resolve the Google Play PaymentMethod for THIS livemode. Without the
|
|
48
|
+
// livemode filter a testmode request would silently fall through to the
|
|
49
|
+
// production method (and vice versa), and its encrypted credentials may
|
|
50
|
+
// not even decrypt under the current process key.
|
|
51
|
+
const method = await PaymentMethod.findOne({
|
|
52
|
+
where: { type: 'google_play', active: true, livemode: !!req.livemode },
|
|
53
|
+
});
|
|
54
|
+
if (!method) {
|
|
55
|
+
res.status(503).json({ error: 'google_play PaymentMethod not configured' });
|
|
56
|
+
return;
|
|
57
|
+
}
|
|
58
|
+
const client = method.getGooglePlayClient();
|
|
59
|
+
|
|
60
|
+
const result = await ingestVerifiedGooglePlayPurchase({
|
|
61
|
+
customerDid: did,
|
|
62
|
+
paymentMethod: method,
|
|
63
|
+
client,
|
|
64
|
+
purchaseToken: input.purchaseToken,
|
|
65
|
+
subscriptionId: input.subscriptionId,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
res.json({
|
|
69
|
+
success: true,
|
|
70
|
+
subscription_id: result.subscription.id,
|
|
71
|
+
isFirstSubscribe: result.isFirstSubscribe,
|
|
72
|
+
active: result.subscription.status === 'active',
|
|
73
|
+
expires_at: result.subscription.current_period_end,
|
|
74
|
+
purchase: {
|
|
75
|
+
order_id: result.purchase.orderId,
|
|
76
|
+
expiry_time_millis: result.purchase.expiryTimeMillis,
|
|
77
|
+
acknowledgement_state: result.purchase.acknowledgementState,
|
|
78
|
+
},
|
|
79
|
+
});
|
|
80
|
+
} catch (err: any) {
|
|
81
|
+
// google-play-billing-validator surfaces some failures via `errorMessage`
|
|
82
|
+
// rather than throwing with a populated message; fall back through both.
|
|
83
|
+
const message = err?.message || err?.errorMessage || (typeof err === 'string' ? err : null) || 'verify failed';
|
|
84
|
+
logger.error('google_play verify failed', {
|
|
85
|
+
message,
|
|
86
|
+
errKeys: err ? Object.keys(err) : [],
|
|
87
|
+
stack: err?.stack,
|
|
88
|
+
});
|
|
89
|
+
res.status(400).json({
|
|
90
|
+
success: false,
|
|
91
|
+
error: { message, raw: err?.errorMessage ?? null },
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
// Restore-side input caps. BillingClient `queryPurchases()` typically returns
|
|
97
|
+
// 1-2 active subs per Play account; cap an order of magnitude higher to
|
|
98
|
+
// tolerate misbehaving clients without blocking legitimate uses. Each
|
|
99
|
+
// purchaseToken is a base64-ish blob (~150-200 chars); 2 KB is plenty.
|
|
100
|
+
// Play subscription IDs are bounded to 40 chars by the console — give 256.
|
|
101
|
+
const RESTORE_MAX_ITEMS = 50;
|
|
102
|
+
const PURCHASE_TOKEN_MAX_LENGTH = 2 * 1024;
|
|
103
|
+
const SUBSCRIPTION_ID_MAX_LENGTH = 256;
|
|
104
|
+
// Verify pool size. Each restore item triggers a Google Developer API
|
|
105
|
+
// purchases.subscriptions.get call + a DB upsert. Bound the pool so an
|
|
106
|
+
// authenticated request can't fan out into many concurrent Google calls.
|
|
107
|
+
const RESTORE_CONCURRENCY = 5;
|
|
108
|
+
|
|
109
|
+
const restoreBodySchema = Joi.object<{
|
|
110
|
+
purchases: Array<{ purchaseToken: string; subscriptionId: string }>;
|
|
111
|
+
}>({
|
|
112
|
+
purchases: Joi.array()
|
|
113
|
+
.items(
|
|
114
|
+
Joi.object({
|
|
115
|
+
purchaseToken: Joi.string().max(PURCHASE_TOKEN_MAX_LENGTH).required(),
|
|
116
|
+
subscriptionId: Joi.string().max(SUBSCRIPTION_ID_MAX_LENGTH).required(),
|
|
117
|
+
})
|
|
118
|
+
)
|
|
119
|
+
.min(1)
|
|
120
|
+
.max(RESTORE_MAX_ITEMS)
|
|
121
|
+
.required(),
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
/**
|
|
125
|
+
* Restore purchases for Google Play.
|
|
126
|
+
*
|
|
127
|
+
* BillingClient on Android exposes `queryPurchases()` which returns active
|
|
128
|
+
* purchases from the Play cache. The mobile client iterates that list and
|
|
129
|
+
* posts each {purchaseToken, subscriptionId} pair here. We re-verify and
|
|
130
|
+
* either return the existing local Subscription or create one. Partial
|
|
131
|
+
* success is reported per item.
|
|
132
|
+
*/
|
|
133
|
+
router.post('/restore', userAuth, async (req: Request, res: Response) => {
|
|
134
|
+
try {
|
|
135
|
+
const did = (req as any).user?.did;
|
|
136
|
+
if (!did) {
|
|
137
|
+
res.status(401).json({ error: 'unauthenticated' });
|
|
138
|
+
return;
|
|
139
|
+
}
|
|
140
|
+
const input = await restoreBodySchema.validateAsync(req.body, { stripUnknown: true });
|
|
141
|
+
|
|
142
|
+
const method = await PaymentMethod.findOne({
|
|
143
|
+
where: { type: 'google_play', active: true, livemode: !!req.livemode },
|
|
144
|
+
});
|
|
145
|
+
if (!method) {
|
|
146
|
+
res.status(503).json({ error: 'google_play PaymentMethod not configured' });
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
149
|
+
const client = method.getGooglePlayClient();
|
|
150
|
+
|
|
151
|
+
// Dedupe by purchaseToken — a single token is unique to one Google
|
|
152
|
+
// Play purchase, so duplicates in the request would otherwise double-
|
|
153
|
+
// call Google's verifier and re-upsert the same Subscription row.
|
|
154
|
+
const seen = new Set<string>();
|
|
155
|
+
const purchases = input.purchases.filter((p) => {
|
|
156
|
+
if (seen.has(p.purchaseToken)) return false;
|
|
157
|
+
seen.add(p.purchaseToken);
|
|
158
|
+
return true;
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// Bounded concurrency: process in fixed-size batches. Each item hits
|
|
162
|
+
// Google's Developer API + at least one DB write; Promise.all over
|
|
163
|
+
// an arbitrary list lets a single authenticated request fan out into
|
|
164
|
+
// many concurrent Google calls.
|
|
165
|
+
type ItemResult =
|
|
166
|
+
| {
|
|
167
|
+
ok: true;
|
|
168
|
+
subscription_id: string;
|
|
169
|
+
isFirstSubscribe: boolean;
|
|
170
|
+
product_id: string;
|
|
171
|
+
}
|
|
172
|
+
| { ok: false; error: string; product_id: string };
|
|
173
|
+
const results: ItemResult[] = [];
|
|
174
|
+
for (let i = 0; i < purchases.length; i += RESTORE_CONCURRENCY) {
|
|
175
|
+
const batch = purchases.slice(i, i + RESTORE_CONCURRENCY);
|
|
176
|
+
// eslint-disable-next-line no-await-in-loop -- intentional: batches must complete sequentially to bound concurrency
|
|
177
|
+
const batchResults = await Promise.all(
|
|
178
|
+
batch.map(async (p): Promise<ItemResult> => {
|
|
179
|
+
try {
|
|
180
|
+
const r = await ingestVerifiedGooglePlayPurchase({
|
|
181
|
+
customerDid: did,
|
|
182
|
+
paymentMethod: method,
|
|
183
|
+
client,
|
|
184
|
+
purchaseToken: p.purchaseToken,
|
|
185
|
+
subscriptionId: p.subscriptionId,
|
|
186
|
+
});
|
|
187
|
+
return {
|
|
188
|
+
ok: true,
|
|
189
|
+
subscription_id: r.subscription.id,
|
|
190
|
+
isFirstSubscribe: r.isFirstSubscribe,
|
|
191
|
+
product_id: p.subscriptionId,
|
|
192
|
+
};
|
|
193
|
+
} catch (err: any) {
|
|
194
|
+
return {
|
|
195
|
+
ok: false,
|
|
196
|
+
error: err?.message ?? 'restore failed',
|
|
197
|
+
product_id: p.subscriptionId,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
})
|
|
201
|
+
);
|
|
202
|
+
results.push(...batchResults);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
res.json({
|
|
206
|
+
restored: results.filter((r) => r.ok),
|
|
207
|
+
errors: results.filter((r) => !r.ok),
|
|
208
|
+
});
|
|
209
|
+
} catch (err: any) {
|
|
210
|
+
logger.error('google_play restore failed', { error: err?.message, stack: err?.stack });
|
|
211
|
+
res.status(400).json({ error: err?.message ?? 'restore failed' });
|
|
212
|
+
}
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
// In-process dedup of recently-seen Pub/Sub messageIds. Pub/Sub guarantees the
|
|
216
|
+
// same messageId on retries, so if we've already started handling this exact
|
|
217
|
+
// message we can skip duplicate delivery (Google retries even on 2xx if its
|
|
218
|
+
// timer expires before our response). Map<messageId, expiryEpochMs>; we cap
|
|
219
|
+
// the map to avoid unbounded growth.
|
|
220
|
+
const seenMessageIds = new Map<string, number>();
|
|
221
|
+
const MESSAGE_DEDUP_TTL_MS = 10 * 60 * 1000; // 10 min — Pub/Sub retries within
|
|
222
|
+
// ack deadline (default 10s) but
|
|
223
|
+
// can also redeliver on cron, so
|
|
224
|
+
// keep a comfortable window.
|
|
225
|
+
const MESSAGE_DEDUP_MAX_SIZE = 1000;
|
|
226
|
+
|
|
227
|
+
/** True if this messageId was already processed SUCCESSFULLY within the TTL. */
|
|
228
|
+
function wasHandled(messageId: string): boolean {
|
|
229
|
+
const exp = seenMessageIds.get(messageId);
|
|
230
|
+
return !!exp && exp > Date.now();
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/**
|
|
234
|
+
* Mark a messageId as successfully handled. Called ONLY after processing
|
|
235
|
+
* succeeds — so a failed/transient attempt is NOT deduped away and Pub/Sub's
|
|
236
|
+
* retry is allowed to run (PR #1381 review P1). NOTE: in-memory, so it does not
|
|
237
|
+
* survive Worker restarts — durable idempotency is a follow-up.
|
|
238
|
+
*/
|
|
239
|
+
function markHandled(messageId: string): void {
|
|
240
|
+
const now = Date.now();
|
|
241
|
+
if (seenMessageIds.size > MESSAGE_DEDUP_MAX_SIZE) {
|
|
242
|
+
for (const [id, exp] of seenMessageIds) {
|
|
243
|
+
if (exp < now) seenMessageIds.delete(id);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
seenMessageIds.set(messageId, now + MESSAGE_DEDUP_TTL_MS);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
router.post('/webhook', async (req: Request, res: Response) => {
|
|
250
|
+
const expectedEmail = process.env.GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT;
|
|
251
|
+
// Fail CLOSED: in production the push service account MUST be configured. A
|
|
252
|
+
// sandbox/test bypass has to be explicit (PR #1381 review P1).
|
|
253
|
+
const allowUnverifiedSender =
|
|
254
|
+
process.env.GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER === 'true' || process.env.NODE_ENV === 'test';
|
|
255
|
+
|
|
256
|
+
// --- Phase 1: authenticate + select. Failures here are rejections / not-for-us,
|
|
257
|
+
// NOT processing failures. ---
|
|
258
|
+
let payload: GooglePlayRtdnPayload;
|
|
259
|
+
let client: ReturnType<PaymentMethod['getGooglePlayClient']>;
|
|
260
|
+
let messageId: string | undefined;
|
|
261
|
+
try {
|
|
262
|
+
if (!expectedEmail && !allowUnverifiedSender) {
|
|
263
|
+
logger.error(
|
|
264
|
+
'google_play webhook refusing: GOOGLE_PUBSUB_PUSH_SERVICE_ACCOUNT unset ' +
|
|
265
|
+
'(set GOOGLE_PUBSUB_ALLOW_UNVERIFIED_SENDER=true only for sandbox)'
|
|
266
|
+
);
|
|
267
|
+
res.status(403).json({ error: 'sender verification not configured' });
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const authHeader = req.get('authorization') || req.get('Authorization');
|
|
272
|
+
if (authHeader) {
|
|
273
|
+
const token = authHeader.replace(/^Bearer\s+/i, '');
|
|
274
|
+
await verifyPubSubJwt(token, { expectedAudience: googlePlayEndpoint(), expectedEmail });
|
|
275
|
+
} else if (!allowUnverifiedSender) {
|
|
276
|
+
logger.warn('google_play webhook missing Authorization header');
|
|
277
|
+
res.status(401).json({ error: 'missing authorization' });
|
|
278
|
+
return;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
messageId = req.body?.message?.messageId;
|
|
282
|
+
// Skip only messages we already handled SUCCESSFULLY (mark happens post-success).
|
|
283
|
+
if (messageId && wasHandled(messageId)) {
|
|
284
|
+
logger.info('google_play webhook: duplicate Pub/Sub messageId, skipping', { messageId });
|
|
285
|
+
res.json({ deduped: true });
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
payload = decodePubSubMessage<GooglePlayRtdnPayload>(req.body);
|
|
290
|
+
|
|
291
|
+
const methods = await PaymentMethod.findAll({ where: { type: 'google_play' } });
|
|
292
|
+
const method = methods.find((m) => {
|
|
293
|
+
const settings = PaymentMethod.decryptSettings(m.settings);
|
|
294
|
+
return settings.google_play?.package_name === payload.packageName;
|
|
295
|
+
});
|
|
296
|
+
if (!method) {
|
|
297
|
+
logger.warn('google_play webhook: no matching PaymentMethod for packageName', {
|
|
298
|
+
packageName: payload.packageName,
|
|
299
|
+
});
|
|
300
|
+
// Not for us → ack so Pub/Sub doesn't retry a misconfigured topic forever.
|
|
301
|
+
res.json({ skipped: true });
|
|
302
|
+
return;
|
|
303
|
+
}
|
|
304
|
+
client = method.getGooglePlayClient();
|
|
305
|
+
} catch (err: any) {
|
|
306
|
+
// Auth / decode / selection failure → forged or malformed; reject.
|
|
307
|
+
logger.warn('google_play webhook: auth/decode failed', { error: err?.message });
|
|
308
|
+
res.status(401).json({ error: 'unauthorized' });
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
// --- Phase 2: process the verified event. Failure here is transient → 5xx so
|
|
313
|
+
// Pub/Sub retries; mark the messageId handled ONLY after success. ---
|
|
314
|
+
try {
|
|
315
|
+
await handleGooglePlayEvent(payload, client);
|
|
316
|
+
if (messageId) markHandled(messageId);
|
|
317
|
+
res.json({ received: true });
|
|
318
|
+
} catch (err: any) {
|
|
319
|
+
logger.error('google_play webhook processing failed — will retry', { error: err?.message, stack: err?.stack });
|
|
320
|
+
res.status(500).json({ error: err?.message ?? 'processing failed' });
|
|
321
|
+
}
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
export default router;
|
|
@@ -414,12 +414,22 @@ router.get('/overdue-summary', auth, async (req, res) => {
|
|
|
414
414
|
currencyIds.length > 0 ? await PaymentCurrency.findAll({ where: { id: { [Op.in]: currencyIds } } }) : [];
|
|
415
415
|
const currencyMap = new Map(currencies.map((c) => [c.id, c]));
|
|
416
416
|
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
417
|
+
// Filter out results whose currency no longer exists (e.g. deleted currency)
|
|
418
|
+
const list = results
|
|
419
|
+
.map((r) => {
|
|
420
|
+
const currency = currencyMap.get(r.currency_id);
|
|
421
|
+
if (!currency) {
|
|
422
|
+
logger.warn('overdue-summary: currency not found, skipping row', { currency_id: r.currency_id });
|
|
423
|
+
return null;
|
|
424
|
+
}
|
|
425
|
+
return {
|
|
426
|
+
currency,
|
|
427
|
+
total_pending: r.total_pending,
|
|
428
|
+
customer_count: r.customer_count,
|
|
429
|
+
event_count: r.event_count,
|
|
430
|
+
};
|
|
431
|
+
})
|
|
432
|
+
.filter((item): item is NonNullable<typeof item> => item !== null);
|
|
423
433
|
|
|
424
434
|
return res.json({ list });
|
|
425
435
|
} catch (err) {
|