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
@@ -6,7 +6,7 @@ import pick from 'lodash/pick';
6
6
  import { InferAttributes, Op, WhereOptions } from 'sequelize';
7
7
  import cloneDeep from 'lodash/cloneDeep';
8
8
  import merge from 'lodash/merge';
9
- import { Joi } from '@arcblock/validator';
9
+ import Joi from 'joi';
10
10
  import { ensureWebhookRegistered, cleanupStripeWebhook, validateStripeKeys } from '../integrations/stripe/setup';
11
11
  import logger from '../libs/logger';
12
12
  import { authenticate } from '../libs/security';
@@ -183,6 +183,124 @@ router.post('/', auth, async (req, res) => {
183
183
  return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
184
184
  }
185
185
 
186
+ if (raw.type === 'google_play') {
187
+ if (!raw.settings.google_play?.package_name) {
188
+ return res.status(400).json({ error: 'google_play package_name is required' });
189
+ }
190
+ if (!raw.settings.google_play?.service_account_json) {
191
+ return res.status(400).json({ error: 'google_play service_account_json is required' });
192
+ }
193
+ try {
194
+ const parsed = JSON.parse(raw.settings.google_play.service_account_json);
195
+ if (!parsed.client_email || !parsed.private_key) {
196
+ return res.status(400).json({ error: 'service_account_json missing client_email or private_key' });
197
+ }
198
+ } catch {
199
+ return res.status(400).json({ error: 'service_account_json is not valid JSON' });
200
+ }
201
+
202
+ const exist = await PaymentMethod.findOne({
203
+ where: { type: 'google_play', livemode: raw.livemode },
204
+ });
205
+ if (exist) {
206
+ return res.status(400).json({ error: 'google_play payment method already exists for this livemode' });
207
+ }
208
+
209
+ raw.settings = pick(PaymentMethod.encryptSettings(raw.settings), ['google_play']) as PaymentMethodSettings;
210
+ raw.logo = raw.logo || getUrl('/methods/google-play.png');
211
+ raw.features = { recurring: true, refund: true, dispute: false };
212
+ raw.confirmation = { type: 'callback' };
213
+
214
+ const method = await PaymentMethod.create(raw as TPaymentMethod);
215
+
216
+ // Create a default USD currency for the PaymentMethod (mirrors stripe path).
217
+ const currency = await PaymentCurrency.create({
218
+ livemode: method.livemode,
219
+ active: method.active,
220
+ locked: true,
221
+ is_base_currency: false,
222
+ payment_method_id: method.id,
223
+ type: 'standard',
224
+ name: 'Dollar',
225
+ description: 'US Dollar (Google Play)',
226
+ logo: getUrl('/currencies/dollar.png'),
227
+ symbol: 'USD',
228
+ decimal: 2,
229
+ maximum_precision: 2,
230
+ minimum_payment_amount: '1',
231
+ maximum_payment_amount: '100000000000',
232
+ contract: '',
233
+ metadata: {},
234
+ });
235
+ await method.update({ default_currency_id: currency.id });
236
+
237
+ return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
238
+ }
239
+
240
+ if (raw.type === 'app_store') {
241
+ if (!raw.settings.app_store?.bundle_id) {
242
+ return res.status(400).json({ error: 'app_store bundle_id is required' });
243
+ }
244
+ const env = raw.settings.app_store?.environment;
245
+ if (env !== 'production' && env !== 'sandbox') {
246
+ return res.status(400).json({ error: 'app_store environment must be production or sandbox' });
247
+ }
248
+ // Server API credentials are optional — StoreKit 2 JWS verify doesn't need them.
249
+ // But if any of the three is set, all three must be set together.
250
+ const hasAnyServerCred = !!(
251
+ raw.settings.app_store?.issuer_id ||
252
+ raw.settings.app_store?.key_id ||
253
+ raw.settings.app_store?.private_key_pem
254
+ );
255
+ if (hasAnyServerCred) {
256
+ if (
257
+ !raw.settings.app_store?.issuer_id ||
258
+ !raw.settings.app_store?.key_id ||
259
+ !raw.settings.app_store?.private_key_pem
260
+ ) {
261
+ return res.status(400).json({
262
+ error: 'app_store Server API credentials must include all of issuer_id, key_id, private_key_pem',
263
+ });
264
+ }
265
+ }
266
+
267
+ const exist = await PaymentMethod.findOne({
268
+ where: { type: 'app_store', livemode: raw.livemode },
269
+ });
270
+ if (exist) {
271
+ return res.status(400).json({ error: 'app_store payment method already exists for this livemode' });
272
+ }
273
+
274
+ raw.settings = pick(PaymentMethod.encryptSettings(raw.settings), ['app_store']) as PaymentMethodSettings;
275
+ raw.logo = raw.logo || getUrl('/methods/app-store.png');
276
+ raw.features = { recurring: true, refund: false, dispute: false };
277
+ raw.confirmation = { type: 'callback' };
278
+
279
+ const method = await PaymentMethod.create(raw as TPaymentMethod);
280
+
281
+ const currency = await PaymentCurrency.create({
282
+ livemode: method.livemode,
283
+ active: method.active,
284
+ locked: true,
285
+ is_base_currency: false,
286
+ payment_method_id: method.id,
287
+ type: 'standard',
288
+ name: 'Dollar',
289
+ description: 'US Dollar (App Store)',
290
+ logo: getUrl('/currencies/dollar.png'),
291
+ symbol: 'USD',
292
+ decimal: 2,
293
+ maximum_precision: 2,
294
+ minimum_payment_amount: '1',
295
+ maximum_payment_amount: '100000000000',
296
+ contract: '',
297
+ metadata: {},
298
+ });
299
+ await method.update({ default_currency_id: currency.id });
300
+
301
+ return res.json({ ...method.toJSON(), payment_currencies: [currency.toJSON()] });
302
+ }
303
+
186
304
  // FIXME: support bitcoin payment methods
187
305
 
188
306
  return res.status(400).json({ error: 'payment method type is not supported' });
@@ -259,6 +377,18 @@ router.get('/types', auth, (_, res) => {
259
377
  description: 'Pay with base compatible chains',
260
378
  logo: getUrl('/methods/base.png'),
261
379
  },
380
+ {
381
+ type: 'google_play',
382
+ name: 'Google Play',
383
+ description: 'Subscriptions purchased via Google Play in-app billing',
384
+ logo: getUrl('/methods/google-play.png'),
385
+ },
386
+ {
387
+ type: 'app_store',
388
+ name: 'App Store',
389
+ description: 'Subscriptions purchased via Apple App Store StoreKit',
390
+ logo: getUrl('/methods/app-store.png'),
391
+ },
262
392
  ]);
263
393
  });
264
394
 
@@ -3,7 +3,7 @@ import pick from 'lodash/pick';
3
3
  import { Op, type WhereOptions } from 'sequelize';
4
4
 
5
5
  import Joi from 'joi';
6
- import { merge } from 'lodash';
6
+ import merge from 'lodash/merge';
7
7
  import { PaymentCurrency } from '../store/models/payment-currency';
8
8
  import { PaymentMethod } from '../store/models/payment-method';
9
9
  import { authenticate } from '../libs/security';
@@ -77,7 +77,7 @@ router.get('/', auth, async (req, res) => {
77
77
  literal(`(
78
78
  SELECT COUNT(DISTINCT ii.invoice_id)
79
79
  FROM invoice_items AS ii
80
- WHERE ii.tax_rate_id = TaxRate.id
80
+ WHERE ii.tax_rate_id = tax_rates.id
81
81
  )`),
82
82
  'invoice_count',
83
83
  ],
@@ -0,0 +1,105 @@
1
+ import { DataTypes } from 'sequelize';
2
+ import { createIndexIfNotExists, safeApplyColumnChanges, type Migration } from '../migrate';
3
+ import models from '../models';
4
+
5
+ export const up: Migration = async ({ context }) => {
6
+ // 1. Customer: per-channel UUID for IAP appAccountToken / obfuscatedAccountId mapping (D-004)
7
+ await safeApplyColumnChanges(context, {
8
+ customers: [
9
+ { name: 'app_store_uuid', field: { type: DataTypes.STRING(36), allowNull: true } },
10
+ { name: 'google_play_uuid', field: { type: DataTypes.STRING(36), allowNull: true } },
11
+ ],
12
+ });
13
+ // SQLite/D1 allows multiple NULLs in UNIQUE columns, so a plain UNIQUE index
14
+ // is functionally equivalent to a partial one (WHERE col IS NOT NULL).
15
+ await createIndexIfNotExists(context, 'customers', ['app_store_uuid'], 'idx_customers_app_store_uuid', {
16
+ unique: true,
17
+ });
18
+ await createIndexIfNotExists(context, 'customers', ['google_play_uuid'], 'idx_customers_google_play_uuid', {
19
+ unique: true,
20
+ });
21
+
22
+ // 2. Subscription: channel + environment (D-005)
23
+ await safeApplyColumnChanges(context, {
24
+ subscriptions: [
25
+ { name: 'channel', field: { type: DataTypes.STRING(20), allowNull: true } },
26
+ {
27
+ name: 'environment',
28
+ field: { type: DataTypes.STRING(20), allowNull: true, defaultValue: 'production' },
29
+ },
30
+ ],
31
+ });
32
+
33
+ // 3. Invoice: three-segment amounts for cross-channel accounting (D-001 A)
34
+ await safeApplyColumnChanges(context, {
35
+ invoices: [
36
+ { name: 'gross_amount', field: { type: DataTypes.STRING(32), allowNull: true } },
37
+ { name: 'platform_fee', field: { type: DataTypes.STRING(32), defaultValue: '0' } },
38
+ { name: 'net_amount', field: { type: DataTypes.STRING(32), allowNull: true } },
39
+ ],
40
+ });
41
+ // Backfill: existing Stripe / on-chain rows have no platform fee, so gross = net = total
42
+ await context.sequelize.query(
43
+ 'UPDATE invoices SET gross_amount = total, net_amount = total WHERE gross_amount IS NULL'
44
+ );
45
+
46
+ // 4. Refund: source (merchant_initiated | platform_initiated)
47
+ await safeApplyColumnChanges(context, {
48
+ refunds: [
49
+ {
50
+ name: 'source',
51
+ field: { type: DataTypes.STRING(30), allowNull: true, defaultValue: 'merchant_initiated' },
52
+ },
53
+ ],
54
+ });
55
+
56
+ // 5. Entitlement tables (D-003 B)
57
+ await context.createTable('entitlements', models.Entitlement.GENESIS_ATTRIBUTES);
58
+ await createIndexIfNotExists(context, 'entitlements', ['key'], 'idx_entitlements_key', { unique: true });
59
+
60
+ await context.createTable('entitlement_products', models.EntitlementProduct.GENESIS_ATTRIBUTES);
61
+
62
+ await context.createTable('entitlement_grants', models.EntitlementGrant.GENESIS_ATTRIBUTES);
63
+ await createIndexIfNotExists(
64
+ context,
65
+ 'entitlement_grants',
66
+ ['customer_id', 'entitlement_id', 'status'],
67
+ 'idx_entitlement_grants_lookup'
68
+ );
69
+ await createIndexIfNotExists(
70
+ context,
71
+ 'entitlement_grants',
72
+ ['source_subscription_id'],
73
+ 'idx_entitlement_grants_source_sub'
74
+ );
75
+
76
+ // Note: PaymentMethod.type ENUM extension to include 'app_store' / 'google_play'
77
+ // is intentionally NOT changed at DB level — on D1/SQLite the column is already TEXT
78
+ // with no CHECK constraint, so any value is accepted. The model-level type union
79
+ // is updated in models/payment-method.ts when A1/A2 lands.
80
+ };
81
+
82
+ export const down: Migration = async ({ context }) => {
83
+ await context.removeIndex('entitlement_grants', 'idx_entitlement_grants_source_sub');
84
+ await context.removeIndex('entitlement_grants', 'idx_entitlement_grants_lookup');
85
+ await context.dropTable('entitlement_grants');
86
+
87
+ await context.dropTable('entitlement_products');
88
+
89
+ await context.removeIndex('entitlements', 'idx_entitlements_key');
90
+ await context.dropTable('entitlements');
91
+
92
+ await context.removeColumn('refunds', 'source');
93
+
94
+ await context.removeColumn('invoices', 'net_amount');
95
+ await context.removeColumn('invoices', 'platform_fee');
96
+ await context.removeColumn('invoices', 'gross_amount');
97
+
98
+ await context.removeColumn('subscriptions', 'environment');
99
+ await context.removeColumn('subscriptions', 'channel');
100
+
101
+ await context.removeIndex('customers', 'idx_customers_google_play_uuid');
102
+ await context.removeIndex('customers', 'idx_customers_app_store_uuid');
103
+ await context.removeColumn('customers', 'google_play_uuid');
104
+ await context.removeColumn('customers', 'app_store_uuid');
105
+ };
@@ -13,9 +13,9 @@ import {
13
13
  } from 'sequelize';
14
14
 
15
15
  import merge from 'lodash/merge';
16
+ import { getLock } from '../../libs/lock';
16
17
  import { createEvent } from '../../libs/audit';
17
18
  import CustomError from '../../libs/error';
18
- import { getLock } from '../../libs/lock';
19
19
  import { createCodeGenerator, createIdGenerator } from '../../libs/util';
20
20
  import type { CustomerAddress, CustomerPreferences, CustomerShipping } from './types';
21
21
 
@@ -57,6 +57,10 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
57
57
  };
58
58
  declare next_invoice_sequence?: number;
59
59
 
60
+ // Per-channel UUID for IAP appAccountToken / obfuscatedAccountId mapping (D-004)
61
+ declare app_store_uuid?: string;
62
+ declare google_play_uuid?: string;
63
+
60
64
  // TODO: following fields not supported
61
65
  // declare preferred_locales?: string[];
62
66
  // declare tax_exempt?: LiteralUnion<'exempt' | 'none' | 'reverse', string>;
@@ -143,6 +147,16 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
143
147
  type: DataTypes.NUMBER,
144
148
  defaultValue: 1,
145
149
  },
150
+ app_store_uuid: {
151
+ type: DataTypes.STRING(36),
152
+ allowNull: true,
153
+ unique: true,
154
+ },
155
+ google_play_uuid: {
156
+ type: DataTypes.STRING(36),
157
+ allowNull: true,
158
+ unique: true,
159
+ },
146
160
  created_at: {
147
161
  type: DataTypes.DATE,
148
162
  defaultValue: DataTypes.NOW,
@@ -156,6 +170,28 @@ export class Customer extends Model<InferAttributes<Customer>, InferCreationAttr
156
170
  };
157
171
 
158
172
  public async getInvoiceNumber() {
173
+ // Optimized path for CF Workers: atomic UPDATE...RETURNING in 1 D1 round trip.
174
+ // Original lock-based approach: lock.acquire → reload → increment(UPDATE + reload) → release
175
+ // = 2 D1 round trips for lock + 3 D1 round trips for data = ~1000ms at ~350ms/RT
176
+ // Optimized: single UPDATE...RETURNING = 1 D1 round trip = ~350ms
177
+ //
178
+ // SQLite serializes writes at the DB level, so the application-level lock is
179
+ // redundant for both D1 and Blocklet Server's SQLite.
180
+ const d1 = (globalThis as any).__CF_ENV__?.DB;
181
+ if (d1) {
182
+ try {
183
+ const sql =
184
+ // eslint-disable-next-line @typescript-eslint/quotes
185
+ 'UPDATE "customers" SET "next_invoice_sequence" = COALESCE("next_invoice_sequence", 1) + 1 WHERE "id" = ? RETURNING "next_invoice_sequence" - 1 as "prev_sequence"';
186
+ const result = await d1.prepare(sql).bind(this.id).first();
187
+ const sequence = (result as any)?.prev_sequence ?? 1;
188
+ return `${this.invoice_prefix}-${padStart(sequence.toString(), 4, '0')}`;
189
+ } catch (err) {
190
+ console.warn('[getInvoiceNumber] D1 RETURNING failed, falling back:', (err as any)?.message);
191
+ }
192
+ }
193
+
194
+ // Blocklet Server path (or D1 fallback): use lock-based approach
159
195
  const lock = getLock(`${this.id}-invoice-number`);
160
196
  await lock.acquire();
161
197
  await this.reload();
@@ -0,0 +1,118 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+ import type { LiteralUnion } from 'type-fest';
4
+
5
+ import { createIdGenerator } from '../../libs/util';
6
+
7
+ export const nextEntitlementGrantId = createIdGenerator('eg', 24);
8
+
9
+ // One Subscription (or other source) grants an Entitlement to a Customer.
10
+ // Multiple grants for the same (customer, entitlement) can coexist across channels;
11
+ // the "currently effective" grant is determined by status + active_until.
12
+ // eslint-disable-next-line prettier/prettier
13
+ export class EntitlementGrant extends Model<InferAttributes<EntitlementGrant>, InferCreationAttributes<EntitlementGrant>> {
14
+ declare id: CreationOptional<string>;
15
+ declare livemode: boolean;
16
+
17
+ declare entitlement_id: string;
18
+ declare customer_id: string;
19
+
20
+ declare source_subscription_id?: string;
21
+ declare source_channel: LiteralUnion<
22
+ 'stripe' | 'arcblock' | 'ethereum' | 'base' | 'app_store' | 'google_play',
23
+ string
24
+ >;
25
+
26
+ declare active_from: number;
27
+ declare active_until: number;
28
+
29
+ declare status: LiteralUnion<'active' | 'canceled' | 'expired' | 'voided', string>;
30
+
31
+ declare metadata?: Record<string, any>;
32
+
33
+ declare created_at: CreationOptional<Date>;
34
+ declare updated_at: CreationOptional<Date>;
35
+
36
+ public static readonly GENESIS_ATTRIBUTES = {
37
+ id: {
38
+ type: DataTypes.STRING(30),
39
+ primaryKey: true,
40
+ allowNull: false,
41
+ defaultValue: nextEntitlementGrantId,
42
+ },
43
+ livemode: {
44
+ type: DataTypes.BOOLEAN,
45
+ allowNull: false,
46
+ },
47
+ entitlement_id: {
48
+ type: DataTypes.STRING(30),
49
+ allowNull: false,
50
+ },
51
+ customer_id: {
52
+ type: DataTypes.STRING(18),
53
+ allowNull: false,
54
+ },
55
+ source_subscription_id: {
56
+ type: DataTypes.STRING(30),
57
+ allowNull: true,
58
+ },
59
+ source_channel: {
60
+ type: DataTypes.STRING(20),
61
+ allowNull: false,
62
+ },
63
+ active_from: {
64
+ type: DataTypes.INTEGER,
65
+ allowNull: false,
66
+ },
67
+ active_until: {
68
+ type: DataTypes.INTEGER,
69
+ allowNull: false,
70
+ },
71
+ status: {
72
+ type: DataTypes.STRING(20),
73
+ allowNull: false,
74
+ defaultValue: 'active',
75
+ },
76
+ metadata: {
77
+ type: DataTypes.JSON,
78
+ allowNull: true,
79
+ },
80
+ created_at: {
81
+ type: DataTypes.DATE,
82
+ defaultValue: DataTypes.NOW,
83
+ allowNull: false,
84
+ },
85
+ updated_at: {
86
+ type: DataTypes.DATE,
87
+ defaultValue: DataTypes.NOW,
88
+ allowNull: false,
89
+ },
90
+ };
91
+
92
+ public static initialize(sequelize: any) {
93
+ this.init(EntitlementGrant.GENESIS_ATTRIBUTES, {
94
+ sequelize,
95
+ modelName: 'EntitlementGrant',
96
+ tableName: 'entitlement_grants',
97
+ createdAt: 'created_at',
98
+ updatedAt: 'updated_at',
99
+ });
100
+ }
101
+
102
+ public static associate(models: any) {
103
+ this.belongsTo(models.Entitlement, {
104
+ foreignKey: 'entitlement_id',
105
+ as: 'entitlement',
106
+ });
107
+ this.belongsTo(models.Customer, {
108
+ foreignKey: 'customer_id',
109
+ as: 'customer',
110
+ });
111
+ this.belongsTo(models.Subscription, {
112
+ foreignKey: 'source_subscription_id',
113
+ as: 'subscription',
114
+ });
115
+ }
116
+ }
117
+
118
+ export type TEntitlementGrant = InferAttributes<EntitlementGrant>;
@@ -0,0 +1,48 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+
4
+ // Join table: which products unlock which entitlements (many-to-many)
5
+ // eslint-disable-next-line prettier/prettier
6
+ export class EntitlementProduct extends Model<InferAttributes<EntitlementProduct>, InferCreationAttributes<EntitlementProduct>> {
7
+ declare entitlement_id: string;
8
+ declare product_id: string;
9
+ declare created_at: CreationOptional<Date>;
10
+ declare updated_at: CreationOptional<Date>;
11
+
12
+ public static readonly GENESIS_ATTRIBUTES = {
13
+ entitlement_id: {
14
+ type: DataTypes.STRING(30),
15
+ primaryKey: true,
16
+ allowNull: false,
17
+ },
18
+ product_id: {
19
+ type: DataTypes.STRING(30),
20
+ primaryKey: true,
21
+ allowNull: false,
22
+ },
23
+ created_at: {
24
+ type: DataTypes.DATE,
25
+ defaultValue: DataTypes.NOW,
26
+ allowNull: false,
27
+ },
28
+ updated_at: {
29
+ type: DataTypes.DATE,
30
+ defaultValue: DataTypes.NOW,
31
+ allowNull: false,
32
+ },
33
+ };
34
+
35
+ public static initialize(sequelize: any) {
36
+ this.init(EntitlementProduct.GENESIS_ATTRIBUTES, {
37
+ sequelize,
38
+ modelName: 'EntitlementProduct',
39
+ tableName: 'entitlement_products',
40
+ createdAt: 'created_at',
41
+ updatedAt: 'updated_at',
42
+ });
43
+ }
44
+
45
+ public static associate() {}
46
+ }
47
+
48
+ export type TEntitlementProduct = InferAttributes<EntitlementProduct>;
@@ -0,0 +1,86 @@
1
+ /* eslint-disable @typescript-eslint/lines-between-class-members */
2
+ import { CreationOptional, DataTypes, InferAttributes, InferCreationAttributes, Model } from 'sequelize';
3
+
4
+ import { createIdGenerator } from '../../libs/util';
5
+
6
+ export const nextEntitlementId = createIdGenerator('ent', 24);
7
+
8
+ // eslint-disable-next-line prettier/prettier
9
+ export class Entitlement extends Model<InferAttributes<Entitlement>, InferCreationAttributes<Entitlement>> {
10
+ declare id: CreationOptional<string>;
11
+ declare livemode: boolean;
12
+
13
+ declare key: string;
14
+ declare name?: string;
15
+ declare description?: string;
16
+
17
+ declare metadata?: Record<string, any>;
18
+
19
+ declare created_at: CreationOptional<Date>;
20
+ declare updated_at: CreationOptional<Date>;
21
+
22
+ public static readonly GENESIS_ATTRIBUTES = {
23
+ id: {
24
+ type: DataTypes.STRING(30),
25
+ primaryKey: true,
26
+ allowNull: false,
27
+ defaultValue: nextEntitlementId,
28
+ },
29
+ livemode: {
30
+ type: DataTypes.BOOLEAN,
31
+ allowNull: false,
32
+ },
33
+ key: {
34
+ type: DataTypes.STRING(64),
35
+ allowNull: false,
36
+ unique: true,
37
+ },
38
+ name: {
39
+ type: DataTypes.STRING(255),
40
+ allowNull: true,
41
+ },
42
+ description: {
43
+ type: DataTypes.TEXT,
44
+ allowNull: true,
45
+ },
46
+ metadata: {
47
+ type: DataTypes.JSON,
48
+ allowNull: true,
49
+ },
50
+ created_at: {
51
+ type: DataTypes.DATE,
52
+ defaultValue: DataTypes.NOW,
53
+ allowNull: false,
54
+ },
55
+ updated_at: {
56
+ type: DataTypes.DATE,
57
+ defaultValue: DataTypes.NOW,
58
+ allowNull: false,
59
+ },
60
+ };
61
+
62
+ public static initialize(sequelize: any) {
63
+ this.init(Entitlement.GENESIS_ATTRIBUTES, {
64
+ sequelize,
65
+ modelName: 'Entitlement',
66
+ tableName: 'entitlements',
67
+ createdAt: 'created_at',
68
+ updatedAt: 'updated_at',
69
+ });
70
+ }
71
+
72
+ public static associate(models: any) {
73
+ this.belongsToMany(models.Product, {
74
+ through: models.EntitlementProduct,
75
+ foreignKey: 'entitlement_id',
76
+ otherKey: 'product_id',
77
+ as: 'products',
78
+ });
79
+ this.hasMany(models.EntitlementGrant, {
80
+ foreignKey: 'entitlement_id',
81
+ as: 'grants',
82
+ });
83
+ }
84
+ }
85
+
86
+ export type TEntitlement = InferAttributes<Entitlement>;
@@ -39,6 +39,9 @@ import { PriceQuote } from './price-quote';
39
39
  import { ArchiveMetadata } from './archive-metadata';
40
40
  import { ArchiveLock } from './archive-lock';
41
41
  import { RevenueSnapshot } from './revenue-snapshot';
42
+ import { Entitlement } from './entitlement';
43
+ import { EntitlementGrant } from './entitlement-grant';
44
+ import { EntitlementProduct } from './entitlement-product';
42
45
 
43
46
  const models = {
44
47
  CheckoutSession,
@@ -81,6 +84,9 @@ const models = {
81
84
  ArchiveMetadata,
82
85
  ArchiveLock,
83
86
  RevenueSnapshot,
87
+ Entitlement,
88
+ EntitlementGrant,
89
+ EntitlementProduct,
84
90
  };
85
91
 
86
92
  export function initialize(sequelize: any) {
@@ -137,6 +143,9 @@ export * from './price-quote';
137
143
  export * from './archive-metadata';
138
144
  export * from './archive-lock';
139
145
  export * from './revenue-snapshot';
146
+ export * from './entitlement';
147
+ export * from './entitlement-grant';
148
+ export * from './entitlement-product';
140
149
 
141
150
  export type TPriceExpanded = TPrice & {
142
151
  object: 'price';
@@ -84,6 +84,14 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
84
84
  declare tax: string;
85
85
  declare total: string;
86
86
 
87
+ // Three-segment amounts for cross-channel accounting (D-001 A).
88
+ // gross_amount = what the user actually paid (= total for non-IAP channels).
89
+ // platform_fee = amount Apple / Google withheld (0 for Stripe / on-chain).
90
+ // net_amount = what we actually received (= gross_amount - platform_fee).
91
+ declare gross_amount?: string;
92
+ declare platform_fee?: string;
93
+ declare net_amount?: string;
94
+
87
95
  declare amount_due: string; // total - amount_paid
88
96
  declare amount_paid: string;
89
97
  declare amount_remaining: string; // amount_due - amount_paid
@@ -250,6 +258,18 @@ export class Invoice extends Model<InferAttributes<Invoice>, InferCreationAttrib
250
258
  type: DataTypes.STRING(32),
251
259
  allowNull: false,
252
260
  },
261
+ gross_amount: {
262
+ type: DataTypes.STRING(32),
263
+ allowNull: true,
264
+ },
265
+ platform_fee: {
266
+ type: DataTypes.STRING(32),
267
+ defaultValue: '0',
268
+ },
269
+ net_amount: {
270
+ type: DataTypes.STRING(32),
271
+ allowNull: true,
272
+ },
253
273
  subtotal: {
254
274
  type: DataTypes.STRING(32),
255
275
  allowNull: false,