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.
Files changed (241) hide show
  1. package/__blocklet__.js +37 -0
  2. package/api/ocap-1.30-subpath-shims.d.ts +35 -0
  3. package/api/src/crons/index.ts +32 -0
  4. package/api/src/crons/metering-subscription-detection.ts +12 -14
  5. package/api/src/crons/overdue-detection.ts +51 -74
  6. package/api/src/crons/retry-pending-events.ts +58 -0
  7. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  8. package/api/src/integrations/app-store/client.ts +369 -0
  9. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  10. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  11. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  12. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  13. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  14. package/api/src/integrations/arcblock/nft.ts +6 -2
  15. package/api/src/integrations/arcblock/stake.ts +3 -2
  16. package/api/src/integrations/arcblock/token.ts +4 -4
  17. package/api/src/integrations/blocklet/notification.ts +1 -1
  18. package/api/src/integrations/ethereum/tx.ts +29 -0
  19. package/api/src/integrations/google-play/client.ts +276 -0
  20. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  21. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  22. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  23. package/api/src/integrations/google-play/setup.ts +43 -0
  24. package/api/src/integrations/google-play/verify.ts +251 -0
  25. package/api/src/integrations/iap-reconcile.ts +415 -0
  26. package/api/src/integrations/stripe/handlers/invoice.ts +70 -53
  27. package/api/src/integrations/stripe/handlers/payment-intent.ts +8 -1
  28. package/api/src/integrations/stripe/resource.ts +8 -0
  29. package/api/src/libs/audit.ts +70 -24
  30. package/api/src/libs/auth.ts +49 -2
  31. package/api/src/libs/chain-error.ts +31 -0
  32. package/api/src/libs/entitlement.ts +399 -0
  33. package/api/src/libs/env.ts +2 -0
  34. package/api/src/libs/error.ts +15 -0
  35. package/api/src/libs/event.ts +42 -1
  36. package/api/src/libs/invoice.ts +69 -34
  37. package/api/src/libs/notification/template/customer-auto-recharge-daily-limit-exceeded.ts +1 -3
  38. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +1 -3
  39. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +1 -3
  40. package/api/src/libs/notification/template/customer-credit-insufficient.ts +1 -3
  41. package/api/src/libs/notification/template/customer-credit-low-balance.ts +1 -3
  42. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +1 -3
  43. package/api/src/libs/notification/template/customer-reward-succeeded.ts +1 -3
  44. package/api/src/libs/notification/template/one-time-payment-refund-succeeded.ts +1 -3
  45. package/api/src/libs/notification/template/one-time-payment-succeeded.ts +1 -3
  46. package/api/src/libs/notification/template/subscription-renew-failed.ts +1 -3
  47. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +1 -3
  48. package/api/src/libs/notification/template/subscription-slippage-warning.ts +1 -3
  49. package/api/src/libs/notification/template/subscription-succeeded.ts +1 -1
  50. package/api/src/libs/pagination.ts +14 -9
  51. package/api/src/libs/payment.ts +25 -10
  52. package/api/src/libs/security.ts +51 -0
  53. package/api/src/libs/session.ts +1 -1
  54. package/api/src/libs/subscription.ts +13 -1
  55. package/api/src/libs/timing.ts +35 -0
  56. package/api/src/libs/util.ts +29 -15
  57. package/api/src/libs/wallet-migration.ts +72 -53
  58. package/api/src/queues/auto-recharge.ts +1 -1
  59. package/api/src/queues/credit-consume.ts +94 -12
  60. package/api/src/queues/credit-grant.ts +4 -0
  61. package/api/src/queues/event.ts +39 -21
  62. package/api/src/queues/invoice.ts +1 -0
  63. package/api/src/queues/payment.ts +83 -15
  64. package/api/src/queues/refund.ts +84 -71
  65. package/api/src/queues/subscription.ts +1 -0
  66. package/api/src/queues/webhook.ts +12 -2
  67. package/api/src/routes/checkout-sessions.ts +82 -43
  68. package/api/src/routes/connect/change-payment.ts +2 -0
  69. package/api/src/routes/connect/change-plan.ts +2 -0
  70. package/api/src/routes/connect/pay.ts +12 -3
  71. package/api/src/routes/connect/setup.ts +3 -1
  72. package/api/src/routes/connect/shared.ts +52 -39
  73. package/api/src/routes/connect/subscribe.ts +4 -1
  74. package/api/src/routes/credit-grants.ts +25 -17
  75. package/api/src/routes/donations.ts +2 -2
  76. package/api/src/routes/entitlements.ts +105 -0
  77. package/api/src/routes/events.ts +2 -2
  78. package/api/src/routes/index.ts +12 -2
  79. package/api/src/routes/integrations/app-store.ts +267 -0
  80. package/api/src/routes/integrations/google-play.ts +324 -0
  81. package/api/src/routes/meter-events.ts +16 -6
  82. package/api/src/routes/payment-links.ts +1 -1
  83. package/api/src/routes/payment-methods.ts +131 -1
  84. package/api/src/routes/settings.ts +1 -1
  85. package/api/src/routes/tax-rates.ts +1 -1
  86. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  87. package/api/src/store/models/customer.ts +37 -1
  88. package/api/src/store/models/entitlement-grant.ts +118 -0
  89. package/api/src/store/models/entitlement-product.ts +48 -0
  90. package/api/src/store/models/entitlement.ts +86 -0
  91. package/api/src/store/models/index.ts +9 -0
  92. package/api/src/store/models/invoice.ts +20 -0
  93. package/api/src/store/models/payment-method.ts +66 -1
  94. package/api/src/store/models/price.ts +23 -14
  95. package/api/src/store/models/refund.ts +10 -0
  96. package/api/src/store/models/subscription.ts +14 -0
  97. package/api/src/store/models/types.ts +32 -0
  98. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  99. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  100. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  101. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  102. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  103. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  104. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  105. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  106. package/api/tests/libs/entitlement.spec.ts +347 -0
  107. package/api/tests/libs/wallet-migration.spec.ts +4 -4
  108. package/api/tests/queues/credit-consume-batch.spec.ts +5 -2
  109. package/api/tests/queues/credit-consume.spec.ts +8 -4
  110. package/api/tests/routes/credit-grants.spec.ts +1 -0
  111. package/blocklet.yml +1 -1
  112. package/cloudflare/MIGRATION-CHALLENGES.md +676 -0
  113. package/cloudflare/MIGRATION-RUNBOOK.md +777 -0
  114. package/cloudflare/README.md +499 -0
  115. package/cloudflare/STAGING-MIGRATION-GUIDE.md +602 -0
  116. package/cloudflare/build.ts +151 -0
  117. package/cloudflare/did-connect-auth.ts +527 -0
  118. package/cloudflare/docs/2026-04-22-sdk-1.30.9-upgrade-retro.md +324 -0
  119. package/cloudflare/docs/2026-04-24-queue-ops-followup.md +218 -0
  120. package/cloudflare/docs/cf-queues-ops-alert-analysis.md +663 -0
  121. package/cloudflare/docs/cf-workers-local-dev-and-fixes.md +284 -0
  122. package/cloudflare/docs/cleanup-tasks-2026-05.md +62 -0
  123. package/cloudflare/docs/payment-kit-platform-analysis-2026-04-20.md +354 -0
  124. package/cloudflare/frontend-shims/buffer-polyfill.ts +9 -0
  125. package/cloudflare/frontend-shims/js-sdk.ts +43 -0
  126. package/cloudflare/frontend-shims/mime-types.ts +46 -0
  127. package/cloudflare/frontend-shims/session.ts +24 -0
  128. package/cloudflare/frontend-shims/vite-plugin-noop.ts +6 -0
  129. package/cloudflare/index.html +40 -0
  130. package/cloudflare/migrate-to-d1.js +252 -0
  131. package/cloudflare/migrations/0001_initial_schema.sql +82 -0
  132. package/cloudflare/migrations/0002_indexes.sql +75 -0
  133. package/cloudflare/migrations/0003_locks_and_constraints.sql +18 -0
  134. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  135. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  136. package/cloudflare/run-build.js +391 -0
  137. package/cloudflare/scripts/test-decrypt.js +102 -0
  138. package/cloudflare/shims/arcblock-ws.ts +20 -0
  139. package/cloudflare/shims/axios-http-adapter.ts +4 -0
  140. package/cloudflare/shims/axios-lite.ts +117 -0
  141. package/cloudflare/shims/blocklet-sdk/auth-service.ts +33 -0
  142. package/cloudflare/shims/blocklet-sdk/cdn.ts +3 -0
  143. package/cloudflare/shims/blocklet-sdk/component-api.ts +35 -0
  144. package/cloudflare/shims/blocklet-sdk/component.ts +18 -0
  145. package/cloudflare/shims/blocklet-sdk/config.ts +8 -0
  146. package/cloudflare/shims/blocklet-sdk/did.ts +14 -0
  147. package/cloudflare/shims/blocklet-sdk/env.ts +12 -0
  148. package/cloudflare/shims/blocklet-sdk/eventbus.ts +3 -0
  149. package/cloudflare/shims/blocklet-sdk/fallback.ts +3 -0
  150. package/cloudflare/shims/blocklet-sdk/index.ts +11 -0
  151. package/cloudflare/shims/blocklet-sdk/logger.ts +11 -0
  152. package/cloudflare/shims/blocklet-sdk/middlewares.ts +15 -0
  153. package/cloudflare/shims/blocklet-sdk/notification.ts +11 -0
  154. package/cloudflare/shims/blocklet-sdk/security.ts +53 -0
  155. package/cloudflare/shims/blocklet-sdk/session.ts +8 -0
  156. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  157. package/cloudflare/shims/blocklet-sdk/verify-sign.ts +38 -0
  158. package/cloudflare/shims/blocklet-sdk/wallet-authenticator.ts +3 -0
  159. package/cloudflare/shims/blocklet-sdk/wallet-handler.ts +6 -0
  160. package/cloudflare/shims/blocklet-sdk/wallet.ts +103 -0
  161. package/cloudflare/shims/cookie-parser.ts +3 -0
  162. package/cloudflare/shims/cors.ts +21 -0
  163. package/cloudflare/shims/cron.ts +189 -0
  164. package/cloudflare/shims/crypto-js-warn.ts +7 -0
  165. package/cloudflare/shims/did-space-js.ts +17 -0
  166. package/cloudflare/shims/did-space.ts +11 -0
  167. package/cloudflare/shims/error.ts +18 -0
  168. package/cloudflare/shims/express-compat/index.ts +80 -0
  169. package/cloudflare/shims/express-compat/types.ts +41 -0
  170. package/cloudflare/shims/fastq.ts +105 -0
  171. package/cloudflare/shims/lock.ts +115 -0
  172. package/cloudflare/shims/mime-types.ts +56 -0
  173. package/cloudflare/shims/nedb-storage.ts +9 -0
  174. package/cloudflare/shims/node-child-process.ts +9 -0
  175. package/cloudflare/shims/node-fs.ts +20 -0
  176. package/cloudflare/shims/node-http.ts +13 -0
  177. package/cloudflare/shims/node-https.ts +4 -0
  178. package/cloudflare/shims/node-misc.ts +15 -0
  179. package/cloudflare/shims/node-net.ts +8 -0
  180. package/cloudflare/shims/node-os.ts +14 -0
  181. package/cloudflare/shims/node-tty.ts +8 -0
  182. package/cloudflare/shims/node-zlib.ts +17 -0
  183. package/cloudflare/shims/noop.ts +26 -0
  184. package/cloudflare/shims/payment-vendor.ts +14 -0
  185. package/cloudflare/shims/querystring.ts +12 -0
  186. package/cloudflare/shims/queue.ts +611 -0
  187. package/cloudflare/shims/rolldown-runtime.ts +43 -0
  188. package/cloudflare/shims/sequelize-d1/datatypes.ts +24 -0
  189. package/cloudflare/shims/sequelize-d1/helpers.ts +46 -0
  190. package/cloudflare/shims/sequelize-d1/index.ts +34 -0
  191. package/cloudflare/shims/sequelize-d1/model.ts +1176 -0
  192. package/cloudflare/shims/sequelize-d1/operators.ts +306 -0
  193. package/cloudflare/shims/sequelize-d1/retry.ts +85 -0
  194. package/cloudflare/shims/sequelize-d1/sequelize-class.ts +119 -0
  195. package/cloudflare/shims/sequelize-d1/timing.ts +81 -0
  196. package/cloudflare/shims/sequelize-d1/types.ts +35 -0
  197. package/cloudflare/shims/stripe-cf.ts +29 -0
  198. package/cloudflare/shims/ws-lite.ts +103 -0
  199. package/cloudflare/shims/xss.ts +3 -0
  200. package/cloudflare/tests/shims/cron.spec.ts +210 -0
  201. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  202. package/cloudflare/tests/shims/queue-scheduled.spec.ts +186 -0
  203. package/cloudflare/vite.config.ts +162 -0
  204. package/cloudflare/worker.ts +1608 -0
  205. package/cloudflare/wrangler.json +63 -0
  206. package/cloudflare/wrangler.jsonc +75 -0
  207. package/cloudflare/wrangler.staging.json +67 -0
  208. package/cloudflare/wrangler.toml +28 -0
  209. package/jest.config.js +4 -12
  210. package/package.json +30 -22
  211. package/scripts/seed-google-play.ts +79 -0
  212. package/src/app.tsx +62 -4
  213. package/src/components/customer/link.tsx +9 -13
  214. package/src/components/customer/notification-preference.tsx +3 -2
  215. package/src/components/filter-toolbar.tsx +4 -0
  216. package/src/components/invoice/list.tsx +9 -1
  217. package/src/components/invoice-pdf/utils.ts +2 -1
  218. package/src/components/layout/admin.tsx +39 -5
  219. package/src/components/layout/user-cf.tsx +77 -0
  220. package/src/components/payment-intent/actions.tsx +23 -3
  221. package/src/components/payment-method/app-store.tsx +103 -0
  222. package/src/components/payment-method/form.tsx +7 -1
  223. package/src/components/payment-method/google-play.tsx +85 -0
  224. package/src/components/safe-did-address.tsx +75 -0
  225. package/src/components/subscription/list.tsx +20 -0
  226. package/src/libs/patch-user-card.ts +25 -0
  227. package/src/libs/util.ts +5 -7
  228. package/src/locales/en.tsx +63 -0
  229. package/src/locales/zh.tsx +63 -0
  230. package/src/pages/admin/billing/meter-events/index.tsx +4 -0
  231. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  232. package/src/pages/admin/customers/customers/detail.tsx +8 -2
  233. package/src/pages/admin/customers/customers/index.tsx +2 -2
  234. package/src/pages/admin/overview.tsx +3 -1
  235. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  236. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
  237. package/src/pages/customer/subscription/detail.tsx +4 -4
  238. package/tsconfig.api.json +1 -6
  239. package/tsconfig.json +3 -4
  240. package/tsconfig.types.json +2 -1
  241. 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
- const list = results.map((r) => ({
418
- currency: currencyMap.get(r.currency_id),
419
- total_pending: r.total_pending,
420
- customer_count: r.customer_count,
421
- event_count: r.event_count,
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) {
@@ -1,4 +1,4 @@
1
- import { Joi } from '@arcblock/validator';
1
+ import Joi from 'joi';
2
2
  import { Router } from 'express';
3
3
  import pick from 'lodash/pick';
4
4
  import { Op } from 'sequelize';