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
@@ -1,13 +1,36 @@
1
1
  /* eslint-disable react-hooks/exhaustive-deps */
2
- import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
3
2
  import { PaymentProvider } from '@blocklet/payment-react';
4
3
  import Dashboard from '@blocklet/ui-react/lib/Dashboard';
5
4
  import { styled } from '@mui/system';
6
5
  import { useEffect } from 'react';
7
6
 
8
- import { Typography } from '@mui/material';
7
+ import { Box, CircularProgress } from '@mui/material';
9
8
  import { useSessionContext } from '../../contexts/session';
10
9
 
10
+ function LayoutLoading() {
11
+ return (
12
+ <Box
13
+ sx={{
14
+ minHeight: '100vh',
15
+ display: 'flex',
16
+ alignItems: 'center',
17
+ justifyContent: 'center',
18
+ }}>
19
+ <CircularProgress />
20
+ </Box>
21
+ );
22
+ }
23
+
24
+ // window.blocklet is bootstrapped synchronously in public/index.html (CF) but
25
+ // the full payload is overlaid from /__blocklet__.js. Don't render children
26
+ // (which may fire API requests on mount) until the critical fields are in
27
+ // place — otherwise requests go out against a half-initialized config and
28
+ // can cascade into auth failures / unintended redirects.
29
+ function isBlockletReady() {
30
+ const b = (window as any).blocklet;
31
+ return !!(b && b.appPid && b.prefix);
32
+ }
33
+
11
34
  const Root = styled(Dashboard)<{ padding: string }>`
12
35
  width: 100%;
13
36
  background-color: ${({ theme }) => theme.palette.background.default};
@@ -62,12 +85,11 @@ const Root = styled(Dashboard)<{ padding: string }>`
62
85
  `;
63
86
 
64
87
  export default function Layout(props: any) {
65
- const { t } = useLocaleContext();
66
88
  const { session, connectApi, events } = useSessionContext();
67
89
 
68
90
  useEffect(() => {
69
91
  events.once('logout', () => {
70
- window.location.href = '/';
92
+ window.location.href = window?.blocklet?.prefix || '/';
71
93
  });
72
94
  }, []);
73
95
 
@@ -78,6 +100,14 @@ export default function Layout(props: any) {
78
100
  }
79
101
  }, [session.initialized]);
80
102
 
103
+ // Show a loading state — not a "Redirecting..." text and not a blank page —
104
+ // while either window.blocklet or the session is still resolving. Admin
105
+ // pages fire authenticated API requests on mount, so children must not
106
+ // render until both are fully in place.
107
+ if (!isBlockletReady() || !session.initialized) {
108
+ return <LayoutLoading />;
109
+ }
110
+
81
111
  if (session.user) {
82
112
  return (
83
113
  <PaymentProvider session={session} connect={connectApi}>
@@ -86,5 +116,9 @@ export default function Layout(props: any) {
86
116
  );
87
117
  }
88
118
 
89
- return <Typography>{t('common.redirecting')}</Typography>;
119
+ // Session initialized but not logged in: the effect above triggers a
120
+ // redirect-based login. Keep showing the loading state (instead of a
121
+ // "Redirecting..." text) so the user isn't confused when the actual
122
+ // navigation hasn't happened yet.
123
+ return <LayoutLoading />;
90
124
  }
@@ -0,0 +1,77 @@
1
+ /* eslint-disable react-hooks/exhaustive-deps */
2
+ import { PaymentProvider } from '@blocklet/payment-react';
3
+ import { useEffect } from 'react';
4
+ import { Header } from '@blocklet/ui-react';
5
+ import { Box, CircularProgress } from '@mui/material';
6
+ import { useSessionContext } from '../../contexts/session';
7
+
8
+ function LayoutLoading() {
9
+ return (
10
+ <Box
11
+ sx={{
12
+ minHeight: '100vh',
13
+ display: 'flex',
14
+ alignItems: 'center',
15
+ justifyContent: 'center',
16
+ }}>
17
+ <CircularProgress />
18
+ </Box>
19
+ );
20
+ }
21
+
22
+ // window.blocklet is bootstrapped synchronously in public/index.html but the
23
+ // full payload is overlaid from /__blocklet__.js. Treat the layout as "not
24
+ // ready" until the overlay has populated the fields the downstream components
25
+ // (Header, PaymentProvider) rely on, so we never render children against a
26
+ // half-initialized blocklet config.
27
+ function isBlockletReady() {
28
+ const b = (window as any).blocklet;
29
+ return !!(b && b.appPid && b.prefix);
30
+ }
31
+
32
+ /**
33
+ * CF Workers layout for customer pages.
34
+ * Uses Dashboard component for consistent header + SessionUser dropdown.
35
+ * Adds "My Billing" to the user dropdown via sessionManagerProps.menu.
36
+ */
37
+ export default function UserLayoutCF(props: any) {
38
+ const { session, connectApi } = useSessionContext();
39
+
40
+ useEffect(() => {
41
+ if (session.initialized && !session.user) {
42
+ session.login(() => {});
43
+ }
44
+ }, [session.initialized]);
45
+
46
+ // Show a loading state (not a blank page, not a redirect) while either
47
+ // window.blocklet or the session is still being resolved. Any child that
48
+ // fires API calls on mount (useRequest / useEffect) must not run until
49
+ // both are in place — otherwise the request goes out without the right
50
+ // auth cookies/headers and may cascade into a redirect.
51
+ if (!isBlockletReady() || !session.initialized) {
52
+ return <LayoutLoading />;
53
+ }
54
+
55
+ // Session initialized but not logged in: the effect above will trigger the
56
+ // DID Connect login modal. Keep showing the loading state instead of a
57
+ // blank screen so the user doesn't perceive it as "bounced to home".
58
+ if (!session.user) {
59
+ return <LayoutLoading />;
60
+ }
61
+
62
+ return (
63
+ <PaymentProvider session={session} connect={connectApi}>
64
+ <Header
65
+ meta={undefined}
66
+ addons={undefined}
67
+ sessionManagerProps={undefined}
68
+ homeLink={undefined}
69
+ theme={undefined}
70
+ hideNavMenu={undefined}
71
+ maxWidth={false}
72
+ sx={{ borderBottom: '1px solid', borderColor: 'divider' }}
73
+ />
74
+ <Box sx={{ flex: 1, maxWidth: 1200, mx: 'auto', px: 3, py: 3, width: '100%' }}>{props.children}</Box>
75
+ </PaymentProvider>
76
+ );
77
+ }
@@ -1,7 +1,8 @@
1
1
  import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
2
  import Toast from '@arcblock/ux/lib/Toast';
3
- import { ConfirmDialog, api, formatAmountPrecisionLimit, formatBNStr, formatError } from '@blocklet/payment-react';
3
+ import { ConfirmDialog, api, formatAmountPrecisionLimit, formatError } from '@blocklet/payment-react';
4
4
  import type { TPaymentIntentExpanded } from '@blocklet/payment-types';
5
+ import { fromUnitToToken } from '@ocap/util';
5
6
  import { useRequest, useSetState } from 'ahooks';
6
7
  import { useNavigate } from 'react-router-dom';
7
8
  import type { LiteralUnion } from 'type-fest';
@@ -21,6 +22,25 @@ import { useState } from 'react';
21
22
  import Actions from '../actions';
22
23
  import ClickBoundary from '../click-boundary';
23
24
 
25
+ /**
26
+ * Format the refundable amount the server returned (raw BN string) into a human-readable
27
+ * token string by **flooring** to `precision` decimal places — never rounding up.
28
+ *
29
+ * `formatBNStr` rounds (via toLocaleString / toFixed), which can produce a value whose
30
+ * `fromTokenToUnit` round-trip exceeds the original raw amount by sub-cent dust and gets
31
+ * rejected by the server with "refund amount exceeds the available amount". Flooring keeps
32
+ * the round-trip ≤ original. The trailing-zero trim mirrors `formatBNStr` so the input
33
+ * field looks identical for amounts that don't get truncated.
34
+ */
35
+ function formatRefundableAmount(raw: string | undefined, decimals: number, precision: number = 6): string {
36
+ if (!raw) return '0';
37
+ const tokenAmount = fromUnitToToken(raw, decimals);
38
+ const [whole = '0', frac = ''] = tokenAmount.split('.');
39
+ if (precision <= 0) return whole;
40
+ const truncated = frac.substring(0, precision).replace(/0+$/, '');
41
+ return truncated ? `${whole}.${truncated}` : whole;
42
+ }
43
+
24
44
  type Props = {
25
45
  data: TPaymentIntentExpanded;
26
46
  variant?: LiteralUnion<'compact' | 'normal', string>;
@@ -185,7 +205,7 @@ export function PaymentIntentActionsInner({ data, variant = 'compact', onChange
185
205
  },
186
206
  {
187
207
  onSuccess: (res: any) => {
188
- const amount = formatBNStr(res?.amount, data.paymentCurrency.decimal);
208
+ const amount = formatRefundableAmount(res?.amount, data.paymentCurrency.decimal);
189
209
  setRefundMaxAmount(amount);
190
210
  },
191
211
  manual: true,
@@ -218,7 +238,7 @@ export function PaymentIntentActionsInner({ data, variant = 'compact', onChange
218
238
  handler: () => {
219
239
  runRefundAmountAsync().then((res) => {
220
240
  reset();
221
- const curAmount = formatBNStr(res?.amount, data.paymentCurrency.decimal);
241
+ const curAmount = formatRefundableAmount(res?.amount, data.paymentCurrency.decimal);
222
242
  if (Number(curAmount) <= 0) {
223
243
  Toast.info(t('admin.paymentIntent.refundForm.empty'));
224
244
  return;
@@ -0,0 +1,103 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { FormInput } from '@blocklet/payment-react';
3
+ import { Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/material';
4
+ import { Controller, useFormContext } from 'react-hook-form';
5
+
6
+ export default function AppStoreMethodForm({ checkDisabled }: { checkDisabled: (key: string) => boolean }) {
7
+ const { t } = useLocaleContext();
8
+ const { control } = useFormContext();
9
+
10
+ return (
11
+ <>
12
+ <FormInput
13
+ name="name"
14
+ type="text"
15
+ rules={{ required: true }}
16
+ label={t('admin.paymentMethod.name.label')}
17
+ placeholder={t('admin.paymentMethod.name.tip')}
18
+ disabled={checkDisabled('name')}
19
+ inputProps={{ maxLength: 32 }}
20
+ />
21
+ <FormInput
22
+ name="description"
23
+ type="text"
24
+ rules={{ required: true }}
25
+ label={t('admin.paymentMethod.description.label')}
26
+ placeholder={t('admin.paymentMethod.description.tip')}
27
+ inputProps={{ maxLength: 255 }}
28
+ />
29
+ <FormInput
30
+ name="settings.app_store.bundle_id"
31
+ type="text"
32
+ rules={{ required: true }}
33
+ label={t('admin.paymentMethod.app_store.bundle_id.label')}
34
+ placeholder={t('admin.paymentMethod.app_store.bundle_id.tip')}
35
+ disabled={checkDisabled('settings.app_store.bundle_id')}
36
+ />
37
+
38
+ <Stack spacing={0.5}>
39
+ <Typography variant="body2">{t('admin.paymentMethod.app_store.environment.label')}</Typography>
40
+ <Controller
41
+ name="settings.app_store.environment"
42
+ control={control}
43
+ rules={{ required: true }}
44
+ render={({ field }) => (
45
+ <ToggleButtonGroup
46
+ {...field}
47
+ exclusive
48
+ disabled={checkDisabled('settings.app_store.environment')}
49
+ onChange={(_, value: string | null) => {
50
+ if (value !== null) field.onChange(value);
51
+ }}>
52
+ <ToggleButton value="production">
53
+ {t('admin.paymentMethod.app_store.environment.production')}
54
+ </ToggleButton>
55
+ <ToggleButton value="sandbox">{t('admin.paymentMethod.app_store.environment.sandbox')}</ToggleButton>
56
+ </ToggleButtonGroup>
57
+ )}
58
+ />
59
+ <Typography variant="caption" color="text.secondary">
60
+ {t('admin.paymentMethod.app_store.environment.tip')}
61
+ </Typography>
62
+ </Stack>
63
+
64
+ <FormInput
65
+ name="settings.app_store.shared_secret"
66
+ type="password"
67
+ label={t('admin.paymentMethod.app_store.shared_secret.label')}
68
+ placeholder={t('admin.paymentMethod.app_store.shared_secret.tip')}
69
+ disabled={checkDisabled('settings.app_store.shared_secret')}
70
+ />
71
+
72
+ <Typography variant="subtitle2" sx={{ mt: 2 }}>
73
+ {t('admin.paymentMethod.app_store.serverApi.heading')}
74
+ </Typography>
75
+ <Typography variant="caption" color="text.secondary" sx={{ mt: -1, display: 'block' }}>
76
+ {t('admin.paymentMethod.app_store.serverApi.tip')}
77
+ </Typography>
78
+ <FormInput
79
+ name="settings.app_store.issuer_id"
80
+ type="text"
81
+ label={t('admin.paymentMethod.app_store.issuer_id.label')}
82
+ placeholder={t('admin.paymentMethod.app_store.issuer_id.tip')}
83
+ disabled={checkDisabled('settings.app_store.issuer_id')}
84
+ />
85
+ <FormInput
86
+ name="settings.app_store.key_id"
87
+ type="text"
88
+ label={t('admin.paymentMethod.app_store.key_id.label')}
89
+ placeholder={t('admin.paymentMethod.app_store.key_id.tip')}
90
+ disabled={checkDisabled('settings.app_store.key_id')}
91
+ />
92
+ <FormInput
93
+ name="settings.app_store.private_key_pem"
94
+ type="text"
95
+ multiline
96
+ rows={5}
97
+ label={t('admin.paymentMethod.app_store.private_key_pem.label')}
98
+ placeholder={t('admin.paymentMethod.app_store.private_key_pem.tip')}
99
+ disabled={checkDisabled('settings.app_store.private_key_pem')}
100
+ />
101
+ </>
102
+ );
103
+ }
@@ -3,11 +3,13 @@ import { Stack, ToggleButton, ToggleButtonGroup, Typography } from '@mui/materia
3
3
  import { styled } from '@mui/system';
4
4
  import { Controller, useFormContext, useWatch } from 'react-hook-form';
5
5
 
6
+ import AppStoreMethodForm from './app-store';
6
7
  import ArcBlockMethodForm from './arcblock';
8
+ import BaseMethodForm from './base';
7
9
  import BitcoinMethodForm from './bitcoin';
8
10
  import EthereumMethodForm from './ethereum';
11
+ import GooglePlayMethodForm from './google-play';
9
12
  import StripeMethodForm from './stripe';
10
- import BaseMethodForm from './base';
11
13
 
12
14
  export default function PaymentMethodForm({
13
15
  action = 'create',
@@ -47,6 +49,8 @@ export default function PaymentMethodForm({
47
49
  <ToggleButton value="stripe">Stripe</ToggleButton>
48
50
  <ToggleButton value="ethereum">Ethereum</ToggleButton>
49
51
  <ToggleButton value="base">Base</ToggleButton>
52
+ <ToggleButton value="google_play">Google Play</ToggleButton>
53
+ <ToggleButton value="app_store">App Store</ToggleButton>
50
54
  <ToggleButton value="bitcoin" disabled>
51
55
  Bitcoin
52
56
  </ToggleButton>
@@ -60,6 +64,8 @@ export default function PaymentMethodForm({
60
64
  {type === 'arcblock' && <ArcBlockMethodForm checkDisabled={checkDisabled} />}
61
65
  {type === 'ethereum' && <EthereumMethodForm checkDisabled={checkDisabled} />}
62
66
  {type === 'base' && <BaseMethodForm checkDisabled={checkDisabled} />}
67
+ {type === 'google_play' && <GooglePlayMethodForm checkDisabled={checkDisabled} />}
68
+ {type === 'app_store' && <AppStoreMethodForm checkDisabled={checkDisabled} />}
63
69
  {type === 'bitcoin' && <BitcoinMethodForm checkDisabled={checkDisabled} />}
64
70
  </Root>
65
71
  );
@@ -0,0 +1,85 @@
1
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
2
+ import { FormInput } from '@blocklet/payment-react';
3
+ import { useFormContext, useWatch } from 'react-hook-form';
4
+ import { Alert, Typography } from '@mui/material';
5
+
6
+ export default function GooglePlayMethodForm({ checkDisabled }: { checkDisabled: (key: string) => boolean }) {
7
+ const { t } = useLocaleContext();
8
+ const { control } = useFormContext();
9
+ const serviceAccountJson = useWatch({ control, name: 'settings.google_play.service_account_json' }) as
10
+ | string
11
+ | undefined;
12
+
13
+ // Quick client-side JSON sanity check — the server re-validates and decrypts.
14
+ let parseError: string | null = null;
15
+ let clientEmail: string | null = null;
16
+ if (serviceAccountJson) {
17
+ try {
18
+ const parsed = JSON.parse(serviceAccountJson);
19
+ if (!parsed.client_email || !parsed.private_key) {
20
+ parseError = t('admin.paymentMethod.google_play.service_account_json.missingFields');
21
+ } else {
22
+ clientEmail = parsed.client_email;
23
+ }
24
+ } catch {
25
+ parseError = t('admin.paymentMethod.google_play.service_account_json.invalidJson');
26
+ }
27
+ }
28
+
29
+ return (
30
+ <>
31
+ <FormInput
32
+ name="name"
33
+ type="text"
34
+ rules={{ required: true }}
35
+ label={t('admin.paymentMethod.name.label')}
36
+ placeholder={t('admin.paymentMethod.name.tip')}
37
+ disabled={checkDisabled('name')}
38
+ inputProps={{ maxLength: 32 }}
39
+ />
40
+ <FormInput
41
+ name="description"
42
+ type="text"
43
+ rules={{ required: true }}
44
+ label={t('admin.paymentMethod.description.label')}
45
+ placeholder={t('admin.paymentMethod.description.tip')}
46
+ inputProps={{ maxLength: 255 }}
47
+ />
48
+ <FormInput
49
+ name="settings.google_play.package_name"
50
+ type="text"
51
+ rules={{ required: true }}
52
+ label={t('admin.paymentMethod.google_play.package_name.label')}
53
+ placeholder={t('admin.paymentMethod.google_play.package_name.tip')}
54
+ disabled={checkDisabled('settings.google_play.package_name')}
55
+ />
56
+ <FormInput
57
+ name="settings.google_play.service_account_json"
58
+ type="text"
59
+ multiline
60
+ rows={6}
61
+ rules={{ required: true }}
62
+ label={t('admin.paymentMethod.google_play.service_account_json.label')}
63
+ placeholder={t('admin.paymentMethod.google_play.service_account_json.tip')}
64
+ disabled={checkDisabled('settings.google_play.service_account_json')}
65
+ />
66
+ {parseError && (
67
+ <Alert severity="error" sx={{ mt: -1 }}>
68
+ {parseError}
69
+ </Alert>
70
+ )}
71
+ {clientEmail && (
72
+ <Typography variant="caption" color="text.secondary" sx={{ mt: -1, display: 'block' }}>
73
+ {t('admin.paymentMethod.google_play.service_account_json.detectedClient')}: {clientEmail}
74
+ </Typography>
75
+ )}
76
+ <FormInput
77
+ name="settings.google_play.pubsub_topic_name"
78
+ type="text"
79
+ label={t('admin.paymentMethod.google_play.pubsub_topic_name.label')}
80
+ placeholder={t('admin.paymentMethod.google_play.pubsub_topic_name.tip')}
81
+ disabled={checkDisabled('settings.google_play.pubsub_topic_name')}
82
+ />
83
+ </>
84
+ );
85
+ }
@@ -0,0 +1,75 @@
1
+ import DidAddress from '@arcblock/ux/lib/DID';
2
+ import { Typography } from '@mui/material';
3
+ import { Component, type ReactNode } from 'react';
4
+
5
+ /**
6
+ * Matches DIDs that @arcblock/did-motif can parse:
7
+ * z-prefixed base58 DID (z1..., z2..., zNK...) — standard ArcBlock format
8
+ * did:method:identifier — W3C DID format
9
+ */
10
+ function isValidDid(did: unknown): did is string {
11
+ return typeof did === 'string' && /^(z[0-9a-zA-Z]{20,}|did:[a-z]+:[A-Za-z0-9]+)/.test(did);
12
+ }
13
+
14
+ interface ErrorBoundaryProps {
15
+ fallback: ReactNode;
16
+ children: ReactNode;
17
+ }
18
+
19
+ /**
20
+ * Inline error boundary — catches DIDParsingError (or any render error) from DidAddress
21
+ * and shows a fallback instead of crashing the whole page.
22
+ */
23
+ class DidErrorBoundary extends Component<ErrorBoundaryProps, { hasError: boolean }> {
24
+ // eslint-disable-next-line react/state-in-constructor
25
+ override state = { hasError: false };
26
+
27
+ static getDerivedStateFromError() {
28
+ return { hasError: true };
29
+ }
30
+
31
+ override componentDidCatch() {
32
+ // Swallow — DIDParsingError is non-fatal, we just show a fallback
33
+ }
34
+
35
+ override render() {
36
+ if (this.state.hasError) return this.props.fallback;
37
+ return this.props.children;
38
+ }
39
+ }
40
+
41
+ export interface SafeDidAddressProps {
42
+ did?: string | null;
43
+ [key: string]: any;
44
+ }
45
+
46
+ /**
47
+ * SafeDidAddress — drop-in replacement for `@arcblock/ux/lib/DID` that:
48
+ * 1. Shows an em-dash placeholder for empty/null DIDs
49
+ * 2. Pre-validates the DID format before rendering
50
+ * 3. Catches any render errors from did-motif via an error boundary
51
+ *
52
+ * Use this anywhere the backing data may have invalid/missing DIDs (e.g. migrated
53
+ * customer records with empty did fields).
54
+ */
55
+ // eslint-disable-next-line react/require-default-props
56
+ export default function SafeDidAddress({ did, ...rest }: SafeDidAddressProps) {
57
+ if (!did || !isValidDid(did)) {
58
+ return (
59
+ <Typography variant="body2" color="text.secondary" component="span">
60
+ {did || '—'}
61
+ </Typography>
62
+ );
63
+ }
64
+
65
+ return (
66
+ <DidErrorBoundary
67
+ fallback={
68
+ <Typography variant="body2" color="text.secondary" component="span">
69
+ {did}
70
+ </Typography>
71
+ }>
72
+ <DidAddress did={did} {...rest} />
73
+ </DidErrorBoundary>
74
+ );
75
+ }
@@ -152,6 +152,26 @@ export default function SubscriptionList({
152
152
  },
153
153
  },
154
154
  },
155
+ {
156
+ label: t('admin.subscription.channel'),
157
+ name: 'channel',
158
+ options: {
159
+ filter: true,
160
+ customBodyRenderLite: (_: string, index: number) => {
161
+ const item = data.list[index] as TSubscriptionExpanded;
162
+ const channel = (item as any).channel || (item as any).paymentMethod?.type;
163
+ if (!channel) return null;
164
+ let color: 'primary' | 'secondary' | 'default' = 'default';
165
+ if (channel === 'google_play' || channel === 'app_store') color = 'primary';
166
+ else if (channel === 'stripe') color = 'secondary';
167
+ return (
168
+ <Link to={`/admin/billing/${item.id}`}>
169
+ <Status label={channel} color={color as any} />
170
+ </Link>
171
+ );
172
+ },
173
+ },
174
+ },
155
175
  {
156
176
  label: t('common.createdAt'),
157
177
  name: 'created_at',
@@ -0,0 +1,25 @@
1
+ // UserCard (@arcblock/ux) calls sdk.user.getUserPublicInfo per mount without
2
+ // in-flight dedup. Pages that render many rows with the same DID fire N
3
+ // concurrent requests before the first response warms sessionStorage.
4
+ // Patch the bundled SDK singleton so concurrent calls with the same args
5
+ // share one Promise.
6
+ // Deep import into ux's nested js-sdk copy (intentional; that bundle exposes
7
+ // the SDK singleton UserCard actually calls into — patching any other copy
8
+ // of @blocklet/js-sdk doesn't affect UserCard).
9
+ // eslint-disable-next-line import/extensions
10
+ import { getBlockletSDK } from '@arcblock/ux/lib/node_modules/@blocklet/js-sdk/dist/index.js';
11
+
12
+ type PublicInfoArgs = { did?: string; name?: string };
13
+
14
+ const sdk = getBlockletSDK() as { user: { getUserPublicInfo: (args: PublicInfoArgs) => Promise<unknown> } };
15
+ const original = sdk.user.getUserPublicInfo.bind(sdk.user);
16
+ const inflight = new Map<string, Promise<unknown>>();
17
+
18
+ sdk.user.getUserPublicInfo = (args: PublicInfoArgs) => {
19
+ const key = `${args?.did || ''}|${args?.name || ''}`;
20
+ const pending = inflight.get(key);
21
+ if (pending) return pending;
22
+ const promise = original(args).finally(() => inflight.delete(key));
23
+ inflight.set(key, promise);
24
+ return promise;
25
+ };
package/src/libs/util.ts CHANGED
@@ -14,8 +14,6 @@ import type {
14
14
  TProductExpanded,
15
15
  TSubscriptionExpanded,
16
16
  } from '@blocklet/payment-types';
17
- import { Hasher } from '@ocap/mcrypto';
18
- import { hexToNumber } from '@ocap/util';
19
17
  import { isEmpty, isObject } from 'lodash';
20
18
  import cloneDeep from 'lodash/cloneDeep';
21
19
  import isEqual from 'lodash/isEqual';
@@ -249,11 +247,11 @@ export function getCategoricalColors(specifier: string = '4e79a7f28e2ce1575976b7
249
247
 
250
248
  export function stringToColor(str: string) {
251
249
  const colors = getCategoricalColors();
252
-
253
- // @ts-ignore
254
- const hash = Hasher.SHA3.hash256(str).slice(-12);
255
- // @ts-ignore
256
- const index = Math.abs(hexToNumber(hash)) % colors.length;
250
+ let hash = 0;
251
+ for (let i = 0; i < str.length; i += 1) {
252
+ hash = (hash * 31 + str.charCodeAt(i)) | 0;
253
+ }
254
+ const index = Math.abs(hash) % colors.length;
257
255
  return colors[index];
258
256
  }
259
257
 
@@ -1310,6 +1310,55 @@ export default flat({
1310
1310
  tip: 'Number of blocks required since transaction execution',
1311
1311
  },
1312
1312
  },
1313
+ google_play: {
1314
+ package_name: {
1315
+ label: 'Package Name',
1316
+ tip: 'e.g. com.example.app, configured in Play Console',
1317
+ },
1318
+ service_account_json: {
1319
+ label: 'Service Account JSON',
1320
+ tip: 'Paste the full service account credentials JSON downloaded from Google Cloud Console',
1321
+ invalidJson: 'Not valid JSON',
1322
+ missingFields: 'JSON is missing client_email or private_key',
1323
+ detectedClient: 'Detected client',
1324
+ },
1325
+ pubsub_topic_name: {
1326
+ label: 'Pub/Sub Topic (optional)',
1327
+ tip: 'projects/<project-id>/topics/<topic-name>, for receiving RTDN',
1328
+ },
1329
+ },
1330
+ app_store: {
1331
+ bundle_id: {
1332
+ label: 'Bundle ID',
1333
+ tip: 'e.g. com.example.app, configured in App Store Connect',
1334
+ },
1335
+ environment: {
1336
+ label: 'Environment',
1337
+ tip: 'StoreKit 2 JWS environment must match this setting',
1338
+ production: 'Production',
1339
+ sandbox: 'Sandbox',
1340
+ },
1341
+ shared_secret: {
1342
+ label: 'Shared Secret (for StoreKit 1)',
1343
+ tip: 'App-Specific Shared Secret for legacy receipt verification. Not needed for StoreKit 2 JWS. Find it at App Store Connect → App Information → App-Specific Shared Secret',
1344
+ },
1345
+ serverApi: {
1346
+ heading: 'Server API Credentials (optional)',
1347
+ tip: 'Not required for StoreKit 2 JWS verification. Only needed when querying App Store Server API for subscription status. Provide all three or none.',
1348
+ },
1349
+ issuer_id: {
1350
+ label: 'Issuer ID',
1351
+ tip: 'App Store Connect API Issuer ID',
1352
+ },
1353
+ key_id: {
1354
+ label: 'Key ID',
1355
+ tip: 'App Store Connect API Key ID',
1356
+ },
1357
+ private_key_pem: {
1358
+ label: 'Private Key (.p8 contents)',
1359
+ tip: 'Paste the contents of the .p8 file downloaded from App Store Connect',
1360
+ },
1361
+ },
1313
1362
  },
1314
1363
  paymentCurrency: {
1315
1364
  name: 'Payment Currency',
@@ -1484,7 +1533,19 @@ export default flat({
1484
1533
  attention: 'Past due subscriptions',
1485
1534
  product: 'Product',
1486
1535
  collectionMethod: 'Billing',
1536
+ channel: 'Channel',
1487
1537
  currentPeriod: 'Current Period',
1538
+ iap: {
1539
+ googlePlayTitle: 'Google Play Purchase',
1540
+ appStoreTitle: 'App Store Purchase',
1541
+ purchaseToken: 'Purchase Token',
1542
+ orderId: 'Order ID',
1543
+ productId: 'Product ID',
1544
+ originalTransactionId: 'Original Transaction ID',
1545
+ transactionId: 'Transaction ID',
1546
+ expiryTime: 'Expires At',
1547
+ environment: 'Environment',
1548
+ },
1488
1549
  trialingPeriod: 'Trial Period',
1489
1550
  trialEnd: 'Trial ends {prefix} {date}',
1490
1551
  willEnd: 'Will end {prefix} {date}',
@@ -1605,6 +1666,8 @@ export default flat({
1605
1666
  email: 'Email',
1606
1667
  phone: 'Phone',
1607
1668
  invoicePrefix: 'Invoice Prefix',
1669
+ googlePlayUuid: 'Google Play UUID',
1670
+ appStoreUuid: 'App Store UUID',
1608
1671
  balance: 'Balance ({currency})',
1609
1672
  summary: {
1610
1673
  refund: 'Refunds',