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
@@ -7,6 +7,8 @@ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes,
7
7
  import Stripe from 'stripe';
8
8
  import type { LiteralUnion } from 'type-fest';
9
9
 
10
+ import { AppStoreClient } from '../../integrations/app-store/client';
11
+ import { GooglePlayClient } from '../../integrations/google-play/client';
10
12
  import { STRIPE_API_VERSION, createIdGenerator } from '../../libs/util';
11
13
  import { sequelize } from '../sequelize';
12
14
  import type { PaymentMethodSettings } from './types';
@@ -17,6 +19,8 @@ const nextId = createIdGenerator('pm', 24);
17
19
  const stripeClients = new Map<string, Stripe>();
18
20
  const evmClients = new Map<string, JsonRpcProvider>();
19
21
  const ocapClients = new Map<string, OcapClient>();
22
+ const googlePlayClients = new Map<string, GooglePlayClient>();
23
+ const appStoreClients = new Map<string, AppStoreClient>();
20
24
 
21
25
  // eslint-disable-next-line prettier/prettier
22
26
  export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCreationAttributes<PaymentMethod>> {
@@ -27,7 +31,10 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
27
31
  declare livemode: boolean;
28
32
  declare locked: boolean;
29
33
 
30
- declare type: LiteralUnion<'stripe' | 'arcblock' | 'ethereum' | 'bitcoin' | 'base', string>;
34
+ declare type: LiteralUnion<
35
+ 'stripe' | 'arcblock' | 'ethereum' | 'bitcoin' | 'base' | 'app_store' | 'google_play',
36
+ string
37
+ >;
31
38
 
32
39
  declare name: string;
33
40
 
@@ -153,6 +160,15 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
153
160
  tmp.stripe.secret_key = security.encrypt(tmp.stripe.secret_key);
154
161
  tmp.stripe.webhook_signing_secret = security.encrypt(tmp.stripe.webhook_signing_secret);
155
162
  }
163
+ if (tmp.google_play) {
164
+ tmp.google_play.service_account_json = security.encrypt(tmp.google_play.service_account_json);
165
+ }
166
+ if (tmp.app_store?.private_key_pem) {
167
+ tmp.app_store.private_key_pem = security.encrypt(tmp.app_store.private_key_pem);
168
+ }
169
+ if (tmp.app_store?.shared_secret) {
170
+ tmp.app_store.shared_secret = security.encrypt(tmp.app_store.shared_secret);
171
+ }
156
172
 
157
173
  return tmp;
158
174
  }
@@ -163,6 +179,15 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
163
179
  tmp.stripe.secret_key = security.decrypt(tmp.stripe.secret_key);
164
180
  tmp.stripe.webhook_signing_secret = security.decrypt(tmp.stripe.webhook_signing_secret);
165
181
  }
182
+ if (tmp.google_play) {
183
+ tmp.google_play.service_account_json = security.decrypt(tmp.google_play.service_account_json);
184
+ }
185
+ if (tmp.app_store?.private_key_pem) {
186
+ tmp.app_store.private_key_pem = security.decrypt(tmp.app_store.private_key_pem);
187
+ }
188
+ if (tmp.app_store?.shared_secret) {
189
+ tmp.app_store.shared_secret = security.decrypt(tmp.app_store.shared_secret);
190
+ }
166
191
 
167
192
  return tmp;
168
193
  }
@@ -207,6 +232,10 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
207
232
  const host = this.settings.arcblock?.api_host;
208
233
  const cached = ocapClients.has(host);
209
234
  if (!cached) {
235
+ // 1.30.9: the default `@ocap/client` entry went CBOR-only, but current ABT
236
+ // wallets only decode protobuf-encoded tx bytes. Use `@ocap/client/legacy`
237
+ // (protobuf client) so encode / decode / broadcast all stay protobuf,
238
+ // matching the wallet signatures and what the chain node expects to hash.
210
239
  const created = new OcapClient(host);
211
240
  ocapClients.set(host, created);
212
241
  return created;
@@ -236,6 +265,42 @@ export class PaymentMethod extends Model<InferAttributes<PaymentMethod>, InferCr
236
265
  return client as JsonRpcProvider;
237
266
  }
238
267
 
268
+ getGooglePlayClient() {
269
+ if (this.type !== 'google_play') {
270
+ throw new Error('payment method is not google_play');
271
+ }
272
+ if (!this.settings.google_play) {
273
+ throw new Error('payment method config insufficient for google_play');
274
+ }
275
+
276
+ if (googlePlayClients.has(this.id)) {
277
+ return googlePlayClients.get(this.id) as GooglePlayClient;
278
+ }
279
+
280
+ const settings = PaymentMethod.decryptSettings(this.settings);
281
+ const client = GooglePlayClient.fromSettings(settings.google_play!);
282
+ googlePlayClients.set(this.id, client);
283
+ return client;
284
+ }
285
+
286
+ getAppStoreClient() {
287
+ if (this.type !== 'app_store') {
288
+ throw new Error('payment method is not app_store');
289
+ }
290
+ if (!this.settings.app_store) {
291
+ throw new Error('payment method config insufficient for app_store');
292
+ }
293
+
294
+ if (appStoreClients.has(this.id)) {
295
+ return appStoreClients.get(this.id) as AppStoreClient;
296
+ }
297
+
298
+ const settings = PaymentMethod.decryptSettings(this.settings);
299
+ const client = AppStoreClient.fromSettings(settings.app_store!);
300
+ appStoreClients.set(this.id, client);
301
+ return client;
302
+ }
303
+
239
304
  public static async supportAutoCharge(id: string) {
240
305
  const method = await PaymentMethod.findByPk(id);
241
306
  return method && CHARGE_SUPPORTED_CHAIN_TYPES.includes(method.type);
@@ -438,23 +438,32 @@ export class Price extends Model<InferAttributes<Price>, InferCreationAttributes
438
438
  }
439
439
  });
440
440
 
441
- // expand upsell
441
+ // expand upsell — batch load to avoid N+1 queries
442
442
  if (upsell) {
443
- await Promise.all(
444
- prices.map(async (x) => {
445
- if (x.upsell?.upsells_to_id) {
446
- const to = await Price.findByPk(x.upsell?.upsells_to_id);
447
- if (to) {
448
- // @ts-ignore
449
- x.upsell.upsells_to = to;
450
- }
451
- }
452
- if (x.recurring?.meter_id) {
443
+ const upsellToIds = [...new Set(prices.map((x) => x.upsell?.upsells_to_id).filter(Boolean))] as string[];
444
+ const meterIds = [...new Set(prices.map((x) => x.recurring?.meter_id).filter(Boolean))] as string[];
445
+
446
+ const [upsellPrices, meters] = await Promise.all([
447
+ upsellToIds.length > 0 ? Price.findAll({ where: { id: upsellToIds } }) : [],
448
+ meterIds.length > 0 ? Meter.findAll({ where: { id: meterIds } }) : [],
449
+ ]);
450
+
451
+ const upsellPriceMap = new Map(upsellPrices.map((p) => [p.id, p]));
452
+ const meterMap = new Map(meters.map((m) => [m.id, m]));
453
+
454
+ prices.forEach((x) => {
455
+ if (x.upsell?.upsells_to_id) {
456
+ const to = upsellPriceMap.get(x.upsell.upsells_to_id);
457
+ if (to) {
453
458
  // @ts-ignore
454
- x.meter = await Meter.findByPk(x.recurring?.meter_id);
459
+ x.upsell.upsells_to = to;
455
460
  }
456
- })
457
- );
461
+ }
462
+ if (x.recurring?.meter_id) {
463
+ // @ts-ignore
464
+ x.meter = meterMap.get(x.recurring.meter_id) ?? null;
465
+ }
466
+ });
458
467
  }
459
468
 
460
469
  // @ts-ignore
@@ -68,6 +68,11 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
68
68
  declare updated_at: CreationOptional<Date>;
69
69
  declare type: LiteralUnion<'refund' | 'stake_return', string>;
70
70
 
71
+ // Refund origin (added for IAP support).
72
+ // merchant_initiated —商户主动调 API 退款 (Stripe / 链上)
73
+ // platform_initiated — Apple/Google webhook 推过来的退款,我们被动接收
74
+ declare source?: LiteralUnion<'merchant_initiated' | 'platform_initiated', string>;
75
+
71
76
  public static readonly GENESIS_ATTRIBUTES = {
72
77
  id: {
73
78
  type: DataTypes.STRING(30),
@@ -192,6 +197,11 @@ export class Refund extends Model<InferAttributes<Refund>, InferCreationAttribut
192
197
  allowNull: true,
193
198
  defaultValue: 'refund',
194
199
  },
200
+ source: {
201
+ type: DataTypes.STRING(30),
202
+ allowNull: true,
203
+ defaultValue: 'merchant_initiated',
204
+ },
195
205
  },
196
206
  {
197
207
  sequelize,
@@ -129,6 +129,11 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
129
129
 
130
130
  declare recovered_from?: string;
131
131
 
132
+ // Payment channel (D-005). Stripe / arcblock / ethereum / base / app_store / google_play
133
+ declare channel?: LiteralUnion<'stripe' | 'arcblock' | 'ethereum' | 'base' | 'app_store' | 'google_play', string>;
134
+ // IAP platform environment (D-005). Orthogonal to livemode.
135
+ declare environment?: LiteralUnion<'production' | 'sandbox', string>;
136
+
132
137
  // TODO: following fields not supported
133
138
  // application
134
139
  // application_fee_percent
@@ -346,6 +351,15 @@ export class Subscription extends Model<InferAttributes<Subscription>, InferCrea
346
351
  allowNull: true,
347
352
  defaultValue: null,
348
353
  },
354
+ channel: {
355
+ type: DataTypes.STRING(20),
356
+ allowNull: true,
357
+ },
358
+ environment: {
359
+ type: DataTypes.STRING(20),
360
+ allowNull: true,
361
+ defaultValue: 'production',
362
+ },
349
363
  },
350
364
  {
351
365
  sequelize,
@@ -310,6 +310,22 @@ export type PaymentMethodSettings = {
310
310
  native_symbol: string;
311
311
  confirmation: number;
312
312
  };
313
+ google_play?: {
314
+ package_name: string;
315
+ service_account_json: string; // encrypted JSON string of GCP service account credentials
316
+ pubsub_topic_name: string; // projects/<project>/topics/<topic>
317
+ };
318
+ app_store?: {
319
+ bundle_id: string;
320
+ environment: LiteralUnion<'production' | 'sandbox', string>;
321
+ /** App-Specific Shared Secret — only needed when verifying StoreKit 1 (legacy) receipts via Apple's verifyReceipt endpoint. StoreKit 2 JWS path does not use this. */
322
+ shared_secret?: string; // encrypted
323
+ // App Store Server API credentials — only needed when calling Apple back for state refresh.
324
+ // StoreKit 2 JWS verification (the happy path) does NOT need any of these.
325
+ issuer_id?: string;
326
+ key_id?: string;
327
+ private_key_pem?: string; // encrypted .p8 file contents
328
+ };
313
329
  };
314
330
 
315
331
  export type VaultConfig = {
@@ -366,6 +382,22 @@ export type PaymentDetails = {
366
382
  block_height: number;
367
383
  confirmations: number;
368
384
  };
385
+ google_play?: {
386
+ purchase_token: string;
387
+ order_id?: string;
388
+ product_id: string;
389
+ subscription_id?: string;
390
+ expiry_time_millis?: string;
391
+ environment?: LiteralUnion<'production' | 'sandbox', string>;
392
+ };
393
+ app_store?: {
394
+ original_transaction_id: string;
395
+ transaction_id?: string;
396
+ product_id: string;
397
+ web_order_line_item_id?: string;
398
+ environment?: LiteralUnion<'Production' | 'Sandbox', string>;
399
+ expires_at?: number; // unix seconds
400
+ };
369
401
  };
370
402
 
371
403
  export type PaymentBeneficiary = {
@@ -0,0 +1,335 @@
1
+ // Unit tests for AppStoreClient.
2
+ // All synthetic JWS fixtures here can't pass real Apple signature verification,
3
+ // so we set APP_STORE_SKIP_SIGNATURE_VERIFY=true to exercise the decode/validate
4
+ // surface. A dedicated test below confirms real-mode rejects synthetic JWSes.
5
+
6
+ /* eslint-disable import/first */
7
+ process.env.APP_STORE_SKIP_SIGNATURE_VERIFY = 'true';
8
+
9
+ import { AppStoreClient, AppStoreSettings } from '../../../src/integrations/app-store/client';
10
+
11
+ const mockConfigApple = jest.fn();
12
+ const mockValidateAppleReceipt = jest.fn();
13
+ jest.mock('node-apple-receipt-verify', () => ({
14
+ config: (...args: any[]) => mockConfigApple(...args),
15
+ validate: (...args: any[]) => mockValidateAppleReceipt(...args),
16
+ }));
17
+
18
+ beforeEach(() => {
19
+ mockConfigApple.mockReset();
20
+ mockValidateAppleReceipt.mockReset();
21
+ });
22
+
23
+ const baseSettings: AppStoreSettings = {
24
+ bundle_id: 'com.example.app',
25
+ environment: 'sandbox',
26
+ };
27
+
28
+ const makeJws = (payload: object, header: object = { alg: 'ES256' }): string => {
29
+ const h = Buffer.from(JSON.stringify(header)).toString('base64url');
30
+ const p = Buffer.from(JSON.stringify(payload)).toString('base64url');
31
+ return `${h}.${p}.signature-placeholder`;
32
+ };
33
+
34
+ describe('AppStoreClient.fromSettings', () => {
35
+ it('rejects missing bundle_id', () => {
36
+ expect(() => AppStoreClient.fromSettings({ ...baseSettings, bundle_id: '' })).toThrow(/bundle_id/);
37
+ });
38
+
39
+ it('rejects bad environment', () => {
40
+ expect(() => AppStoreClient.fromSettings({ ...baseSettings, environment: 'staging' as any })).toThrow(
41
+ /environment/
42
+ );
43
+ });
44
+
45
+ it('accepts minimal valid settings (no Server API creds)', () => {
46
+ const c = AppStoreClient.fromSettings(baseSettings);
47
+ expect(c.bundleId).toBe('com.example.app');
48
+ expect(c.environment).toBe('sandbox');
49
+ });
50
+ });
51
+
52
+ describe('AppStoreClient.verifyJwsTransaction (mock-phase decode)', () => {
53
+ let client: AppStoreClient;
54
+ beforeEach(() => {
55
+ client = AppStoreClient.fromSettings(baseSettings);
56
+ });
57
+
58
+ it('decodes a well-formed JWS payload', async () => {
59
+ const jws = makeJws({
60
+ transactionId: 'txn_1',
61
+ originalTransactionId: 'orig_1',
62
+ productId: 'pro_monthly',
63
+ expiresDate: 1800000000000,
64
+ appAccountToken: 'uuid-from-client',
65
+ environment: 'Sandbox',
66
+ bundleId: 'com.example.app',
67
+ });
68
+ const decoded = await client.verifyJwsTransaction(jws);
69
+ expect(decoded.transactionId).toBe('txn_1');
70
+ expect(decoded.originalTransactionId).toBe('orig_1');
71
+ expect(decoded.productId).toBe('pro_monthly');
72
+ expect(decoded.appAccountToken).toBe('uuid-from-client');
73
+ expect(decoded.environment).toBe('Sandbox');
74
+ });
75
+
76
+ it('rejects malformed JWS (not 3 segments)', async () => {
77
+ await expect(client.verifyJwsTransaction('not-a-jws')).rejects.toThrow(/format invalid/);
78
+ await expect(client.verifyJwsTransaction('a.b')).rejects.toThrow(/format invalid/);
79
+ });
80
+
81
+ it('rejects JWS whose payload is not valid JSON', async () => {
82
+ const h = Buffer.from(JSON.stringify({ alg: 'ES256' })).toString('base64url');
83
+ const broken = Buffer.from('this is not json').toString('base64url');
84
+ await expect(client.verifyJwsTransaction(`${h}.${broken}.sig`)).rejects.toThrow(/not valid JSON/);
85
+ });
86
+
87
+ it('rejects JWS whose bundleId disagrees with configured bundle_id', async () => {
88
+ const jws = makeJws({
89
+ transactionId: 'txn_1',
90
+ originalTransactionId: 'orig_1',
91
+ productId: 'pro_monthly',
92
+ environment: 'Sandbox',
93
+ bundleId: 'com.evil.app',
94
+ });
95
+ await expect(client.verifyJwsTransaction(jws)).rejects.toThrow(/bundleId mismatch/);
96
+ });
97
+
98
+ it('passes when JWS payload omits bundleId (older StoreKit 2 payloads)', async () => {
99
+ const jws = makeJws({
100
+ transactionId: 'txn_1',
101
+ originalTransactionId: 'orig_1',
102
+ productId: 'pro_monthly',
103
+ environment: 'Sandbox',
104
+ });
105
+ const decoded = await client.verifyJwsTransaction(jws);
106
+ expect(decoded.transactionId).toBe('txn_1');
107
+ });
108
+ });
109
+
110
+ describe('AppStoreClient write methods', () => {
111
+ it('refundSubscription is gated unless APP_STORE_WRITE_ENABLED', async () => {
112
+ const c = AppStoreClient.fromSettings(baseSettings);
113
+ await expect(c.refundSubscription('orig_1')).rejects.toThrow(/APP_STORE_WRITE_ENABLED/);
114
+ });
115
+
116
+ it('cancelSubscription is gated unless APP_STORE_WRITE_ENABLED', async () => {
117
+ const c = AppStoreClient.fromSettings(baseSettings);
118
+ await expect(c.cancelSubscription('orig_1')).rejects.toThrow(/APP_STORE_WRITE_ENABLED/);
119
+ });
120
+
121
+ it('getSubscriptionStatus errors clearly when Server API creds are not configured', async () => {
122
+ const c = AppStoreClient.fromSettings(baseSettings);
123
+ await expect(c.getSubscriptionStatus('orig_1')).rejects.toThrow(/issuer_id\/key_id\/private_key_pem/);
124
+ });
125
+ });
126
+
127
+ // `getSubscriptionStatus` against a mocked Apple SDK — we don't hit the real API.
128
+ const mockGetAllSubscriptionStatuses = jest.fn();
129
+ jest.mock('../../../src/integrations/app-store/signed-data-verifier', () => {
130
+ const actual = jest.requireActual('../../../src/integrations/app-store/signed-data-verifier');
131
+ return {
132
+ ...actual,
133
+ getAllSubscriptionStatuses: (...args: any[]) => mockGetAllSubscriptionStatuses(...args),
134
+ };
135
+ });
136
+
137
+ describe('AppStoreClient.getSubscriptionStatus (Server API)', () => {
138
+ beforeEach(() => {
139
+ mockGetAllSubscriptionStatuses.mockReset();
140
+ });
141
+
142
+ const credSettings: AppStoreSettings = {
143
+ bundle_id: 'com.example.app',
144
+ environment: 'sandbox',
145
+ issuer_id: 'iss_1',
146
+ key_id: 'KEY_ID_1',
147
+ private_key_pem: '-----BEGIN PRIVATE KEY-----\nfake\n-----END PRIVATE KEY-----',
148
+ };
149
+
150
+ it('returns decoded transaction payload when Apple returns matching txn', async () => {
151
+ const inner = (() => {
152
+ const h = Buffer.from(JSON.stringify({ alg: 'ES256' })).toString('base64url');
153
+ const p = Buffer.from(
154
+ JSON.stringify({
155
+ transactionId: 'txn_latest',
156
+ originalTransactionId: 'orig_X',
157
+ productId: 'sub_06',
158
+ bundleId: 'com.example.app',
159
+ environment: 'Sandbox',
160
+ expiresDate: 1900000000000,
161
+ })
162
+ ).toString('base64url');
163
+ return `${h}.${p}.sig`;
164
+ })();
165
+ mockGetAllSubscriptionStatuses.mockResolvedValue({
166
+ data: [{ lastTransactions: [{ originalTransactionId: 'orig_X', signedTransactionInfo: inner }] }],
167
+ });
168
+ const c = AppStoreClient.fromSettings(credSettings);
169
+ const result = await c.getSubscriptionStatus('orig_X');
170
+ expect(result?.transactionId).toBe('txn_latest');
171
+ expect(mockGetAllSubscriptionStatuses).toHaveBeenCalledWith(
172
+ 'orig_X',
173
+ expect.objectContaining({ bundleId: 'com.example.app' })
174
+ );
175
+ });
176
+
177
+ it('returns null when Apple returns no matching transaction', async () => {
178
+ mockGetAllSubscriptionStatuses.mockResolvedValue({ data: [] });
179
+ const c = AppStoreClient.fromSettings(credSettings);
180
+ expect(await c.getSubscriptionStatus('orig_unknown')).toBeNull();
181
+ });
182
+ });
183
+
184
+ describe('AppStoreClient.verifyNotificationPayload (mock-phase decode)', () => {
185
+ let client: AppStoreClient;
186
+ beforeEach(() => {
187
+ client = AppStoreClient.fromSettings(baseSettings);
188
+ });
189
+
190
+ it('decodes a well-formed signedPayload', async () => {
191
+ const jws = makeJws({
192
+ notificationType: 'DID_RENEW',
193
+ subtype: 'BILLING_RECOVERY',
194
+ notificationUUID: 'uuid-notif-1',
195
+ version: '2.0',
196
+ signedDate: 1700000000000,
197
+ data: {
198
+ bundleId: 'com.example.app',
199
+ environment: 'Sandbox',
200
+ signedTransactionInfo: 'inner-jws',
201
+ status: 1,
202
+ },
203
+ });
204
+ const decoded = await client.verifyNotificationPayload(jws);
205
+ expect(decoded.notificationType).toBe('DID_RENEW');
206
+ expect(decoded.subtype).toBe('BILLING_RECOVERY');
207
+ expect(decoded.data.environment).toBe('Sandbox');
208
+ expect(decoded.data.signedTransactionInfo).toBe('inner-jws');
209
+ });
210
+
211
+ it('rejects malformed JWS (not 3 segments)', async () => {
212
+ await expect(client.verifyNotificationPayload('a.b')).rejects.toThrow(/JWS format invalid/);
213
+ });
214
+
215
+ it('rejects when notification bundleId disagrees with configured one', async () => {
216
+ const jws = makeJws({
217
+ notificationType: 'DID_RENEW',
218
+ notificationUUID: 'uuid-1',
219
+ version: '2.0',
220
+ signedDate: 1700000000000,
221
+ data: { bundleId: 'com.evil.app', environment: 'Sandbox' },
222
+ });
223
+ await expect(client.verifyNotificationPayload(jws)).rejects.toThrow(/bundleId mismatch/);
224
+ });
225
+ });
226
+
227
+ describe('AppStoreClient.verifyLegacyReceipt', () => {
228
+ const withSecret: AppStoreSettings = { ...baseSettings, shared_secret: 'sk_test_secret' };
229
+
230
+ it('refuses when shared_secret is not configured', async () => {
231
+ const c = AppStoreClient.fromSettings(baseSettings);
232
+ await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/shared_secret is required/);
233
+ });
234
+
235
+ it('configures node-apple-receipt-verify with provided secret + both envs', async () => {
236
+ mockValidateAppleReceipt.mockResolvedValue([
237
+ {
238
+ bundleId: 'com.example.app',
239
+ productId: 'sub_06',
240
+ transactionId: 'txn_1',
241
+ originalTransactionId: 'orig_1',
242
+ purchaseDate: 1700000000000,
243
+ expirationDate: 1800000000000,
244
+ },
245
+ ]);
246
+ const c = AppStoreClient.fromSettings(withSecret);
247
+ await c.verifyLegacyReceipt('base64-receipt');
248
+ expect(mockConfigApple).toHaveBeenCalledWith(
249
+ expect.objectContaining({
250
+ secret: 'sk_test_secret',
251
+ environment: ['production', 'sandbox'],
252
+ })
253
+ );
254
+ });
255
+
256
+ it('normalizes one-item receipt to AppStoreTransactionPayload', async () => {
257
+ mockValidateAppleReceipt.mockResolvedValue([
258
+ {
259
+ bundleId: 'com.example.app',
260
+ productId: 'sub_06',
261
+ transactionId: 'txn_1',
262
+ originalTransactionId: 'orig_1',
263
+ purchaseDate: 1700000000000,
264
+ expirationDate: 1800000000000,
265
+ },
266
+ ]);
267
+ const c = AppStoreClient.fromSettings(withSecret);
268
+ const result = await c.verifyLegacyReceipt('base64-receipt');
269
+ expect(result).toEqual({
270
+ transactionId: 'txn_1',
271
+ originalTransactionId: 'orig_1',
272
+ productId: 'sub_06',
273
+ purchaseDate: 1700000000000,
274
+ expiresDate: 1800000000000,
275
+ appAccountToken: undefined,
276
+ environment: 'Sandbox',
277
+ bundleId: 'com.example.app',
278
+ webOrderLineItemId: undefined,
279
+ });
280
+ });
281
+
282
+ it('picks the item with the latest expirationDate when receipt contains multiple', async () => {
283
+ mockValidateAppleReceipt.mockResolvedValue([
284
+ { productId: 'sub_06', transactionId: 'old', expirationDate: 1500000000000 },
285
+ { productId: 'sub_06', transactionId: 'new', expirationDate: 1800000000000 },
286
+ { productId: 'sub_06', transactionId: 'mid', expirationDate: 1600000000000 },
287
+ ]);
288
+ const c = AppStoreClient.fromSettings(withSecret);
289
+ const result = await c.verifyLegacyReceipt('base64-receipt');
290
+ expect(result.transactionId).toBe('new');
291
+ });
292
+
293
+ it('filters by expectedProductIds when provided', async () => {
294
+ mockValidateAppleReceipt.mockResolvedValue([
295
+ { productId: 'other_sku', transactionId: 'wrong', expirationDate: 1900000000000 },
296
+ { productId: 'sub_06', transactionId: 'right', expirationDate: 1800000000000 },
297
+ ]);
298
+ const c = AppStoreClient.fromSettings(withSecret);
299
+ const result = await c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] });
300
+ expect(result.transactionId).toBe('right');
301
+ });
302
+
303
+ it('throws when no item matches expectedProductIds', async () => {
304
+ mockValidateAppleReceipt.mockResolvedValue([
305
+ { productId: 'unknown_sku', transactionId: 'x', expirationDate: 1800000000000 },
306
+ ]);
307
+ const c = AppStoreClient.fromSettings(withSecret);
308
+ await expect(c.verifyLegacyReceipt('base64-receipt', { expectedProductIds: ['sub_06'] })).rejects.toThrow(
309
+ /no matching purchases/
310
+ );
311
+ });
312
+
313
+ it('rejects when receipt bundleId disagrees with configured bundle_id', async () => {
314
+ mockValidateAppleReceipt.mockResolvedValue([
315
+ {
316
+ bundleId: 'com.evil.app',
317
+ productId: 'sub_06',
318
+ transactionId: 'txn_1',
319
+ originalTransactionId: 'orig_1',
320
+ expirationDate: 1800000000000,
321
+ },
322
+ ]);
323
+ const c = AppStoreClient.fromSettings(withSecret);
324
+ await expect(c.verifyLegacyReceipt('base64-receipt')).rejects.toThrow(/bundleId mismatch/);
325
+ });
326
+
327
+ it('falls back to transactionId when originalTransactionId is absent', async () => {
328
+ mockValidateAppleReceipt.mockResolvedValue([
329
+ { productId: 'sub_06', transactionId: 'txn_only', expirationDate: 1800000000000 },
330
+ ]);
331
+ const c = AppStoreClient.fromSettings(withSecret);
332
+ const result = await c.verifyLegacyReceipt('base64-receipt');
333
+ expect(result.originalTransactionId).toBe('txn_only');
334
+ });
335
+ });