payment-kit 1.28.0 → 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 (74) hide show
  1. package/api/src/crons/index.ts +22 -0
  2. package/api/src/crons/retry-pending-events.ts +58 -0
  3. package/api/src/integrations/app-store/apple-root-certs.ts +26 -0
  4. package/api/src/integrations/app-store/client.ts +369 -0
  5. package/api/src/integrations/app-store/handlers/index.ts +46 -0
  6. package/api/src/integrations/app-store/handlers/subscription.ts +635 -0
  7. package/api/src/integrations/app-store/node-apple-receipt-verify.d.ts +17 -0
  8. package/api/src/integrations/app-store/notification-routing.ts +18 -0
  9. package/api/src/integrations/app-store/signed-data-verifier.ts +150 -0
  10. package/api/src/integrations/google-play/client.ts +276 -0
  11. package/api/src/integrations/google-play/handlers/index.ts +69 -0
  12. package/api/src/integrations/google-play/handlers/subscription.ts +565 -0
  13. package/api/src/integrations/google-play/handlers/voided.ts +106 -0
  14. package/api/src/integrations/google-play/setup.ts +43 -0
  15. package/api/src/integrations/google-play/verify.ts +251 -0
  16. package/api/src/integrations/iap-reconcile.ts +415 -0
  17. package/api/src/libs/audit.ts +38 -8
  18. package/api/src/libs/entitlement.ts +399 -0
  19. package/api/src/libs/env.ts +2 -0
  20. package/api/src/libs/security.ts +51 -0
  21. package/api/src/libs/subscription.ts +13 -1
  22. package/api/src/libs/util.ts +13 -0
  23. package/api/src/queues/event.ts +25 -19
  24. package/api/src/queues/webhook.ts +12 -2
  25. package/api/src/routes/entitlements.ts +105 -0
  26. package/api/src/routes/events.ts +2 -2
  27. package/api/src/routes/index.ts +12 -2
  28. package/api/src/routes/integrations/app-store.ts +267 -0
  29. package/api/src/routes/integrations/google-play.ts +324 -0
  30. package/api/src/routes/payment-methods.ts +130 -0
  31. package/api/src/store/migrations/20260526-iap-foundation.ts +105 -0
  32. package/api/src/store/models/customer.ts +14 -0
  33. package/api/src/store/models/entitlement-grant.ts +118 -0
  34. package/api/src/store/models/entitlement-product.ts +48 -0
  35. package/api/src/store/models/entitlement.ts +86 -0
  36. package/api/src/store/models/index.ts +9 -0
  37. package/api/src/store/models/invoice.ts +20 -0
  38. package/api/src/store/models/payment-method.ts +62 -1
  39. package/api/src/store/models/refund.ts +10 -0
  40. package/api/src/store/models/subscription.ts +14 -0
  41. package/api/src/store/models/types.ts +32 -0
  42. package/api/tests/integrations/app-store/client.spec.ts +335 -0
  43. package/api/tests/integrations/app-store/handlers.spec.ts +480 -0
  44. package/api/tests/integrations/app-store/notifications.spec.ts +381 -0
  45. package/api/tests/integrations/app-store/signed-data-verifier.spec.ts +72 -0
  46. package/api/tests/integrations/app-store/webhook-routing.spec.ts +27 -0
  47. package/api/tests/integrations/google-play/handlers.spec.ts +341 -0
  48. package/api/tests/integrations/google-play/verify.spec.ts +215 -0
  49. package/api/tests/integrations/iap-reconcile.spec.ts +237 -0
  50. package/api/tests/libs/entitlement.spec.ts +347 -0
  51. package/blocklet.yml +1 -1
  52. package/cloudflare/migrations/0004_iap_foundation.sql +72 -0
  53. package/cloudflare/migrations/0005_iap_tenant_backfill.sql +112 -0
  54. package/cloudflare/run-build.js +1 -0
  55. package/cloudflare/shims/blocklet-sdk/verify-session.ts +44 -0
  56. package/cloudflare/shims/queue.ts +28 -2
  57. package/cloudflare/shims/sequelize-d1/model.ts +19 -0
  58. package/cloudflare/shims/sequelize-d1/operators.ts +14 -1
  59. package/cloudflare/tests/shims/queue-delayed-persist.spec.ts +87 -0
  60. package/cloudflare/worker.ts +59 -4
  61. package/cloudflare/wrangler.jsonc +7 -1
  62. package/cloudflare/wrangler.staging.json +2 -1
  63. package/package.json +10 -6
  64. package/scripts/seed-google-play.ts +79 -0
  65. package/src/components/payment-method/app-store.tsx +103 -0
  66. package/src/components/payment-method/form.tsx +7 -1
  67. package/src/components/payment-method/google-play.tsx +85 -0
  68. package/src/components/subscription/list.tsx +20 -0
  69. package/src/locales/en.tsx +63 -0
  70. package/src/locales/zh.tsx +63 -0
  71. package/src/pages/admin/billing/subscriptions/detail.tsx +80 -0
  72. package/src/pages/admin/customers/customers/detail.tsx +6 -0
  73. package/src/pages/admin/settings/payment-methods/create.tsx +12 -0
  74. package/src/pages/admin/settings/payment-methods/index.tsx +1 -1
@@ -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
+ }
@@ -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',
@@ -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',
@@ -1265,6 +1265,55 @@ export default flat({
1265
1265
  tip: '交易标记为确认需要的区块数',
1266
1266
  },
1267
1267
  },
1268
+ google_play: {
1269
+ package_name: {
1270
+ label: '应用包名',
1271
+ tip: '例如 com.example.app,在 Play Console 中配置',
1272
+ },
1273
+ service_account_json: {
1274
+ label: '服务账号 JSON',
1275
+ tip: '从 Google Cloud Console 下载的 service account credentials JSON,整段粘贴',
1276
+ invalidJson: '不是合法的 JSON',
1277
+ missingFields: 'JSON 缺少 client_email 或 private_key 字段',
1278
+ detectedClient: '识别到客户端',
1279
+ },
1280
+ pubsub_topic_name: {
1281
+ label: 'Pub/Sub 主题名(可选)',
1282
+ tip: 'projects/<project-id>/topics/<topic-name>,用于接收 RTDN',
1283
+ },
1284
+ },
1285
+ app_store: {
1286
+ bundle_id: {
1287
+ label: '应用 Bundle ID',
1288
+ tip: '例如 com.example.app,在 App Store Connect 中配置',
1289
+ },
1290
+ environment: {
1291
+ label: '环境',
1292
+ tip: 'StoreKit 2 JWS 中携带的 environment 必须与此匹配',
1293
+ production: '正式环境',
1294
+ sandbox: '沙盒环境',
1295
+ },
1296
+ shared_secret: {
1297
+ label: 'Shared Secret(StoreKit 1 用)',
1298
+ tip: 'App-Specific Shared Secret,用于 legacy receipt 校验。StoreKit 2 JWS 不需要。在 App Store Connect → App Information → App-Specific Shared Secret',
1299
+ },
1300
+ serverApi: {
1301
+ heading: 'Server API 凭据(可选)',
1302
+ tip: 'StoreKit 2 JWS 校验不需要凭据;只在调用 App Store Server API 查询订阅状态时使用。三项需同时填写。',
1303
+ },
1304
+ issuer_id: {
1305
+ label: 'Issuer ID',
1306
+ tip: 'App Store Connect API 的 Issuer ID',
1307
+ },
1308
+ key_id: {
1309
+ label: 'Key ID',
1310
+ tip: 'App Store Connect API 的 Key ID',
1311
+ },
1312
+ private_key_pem: {
1313
+ label: '私钥 (.p8 内容)',
1314
+ tip: '从 App Store Connect 下载的 .p8 文件内容,整段粘贴',
1315
+ },
1316
+ },
1268
1317
  },
1269
1318
  paymentCurrency: {
1270
1319
  name: '支付货币',
@@ -1452,7 +1501,19 @@ export default flat({
1452
1501
  product: '产品',
1453
1502
  attention: '将过期的订阅',
1454
1503
  collectionMethod: '计费',
1504
+ channel: '渠道',
1455
1505
  currentPeriod: '当前周期',
1506
+ iap: {
1507
+ googlePlayTitle: 'Google Play 订阅详情',
1508
+ appStoreTitle: 'App Store 订阅详情',
1509
+ purchaseToken: '购买令牌',
1510
+ orderId: '订单号',
1511
+ productId: '产品 ID',
1512
+ originalTransactionId: '原始交易 ID',
1513
+ transactionId: '本次交易 ID',
1514
+ expiryTime: '过期时间',
1515
+ environment: '环境',
1516
+ },
1456
1517
  trialingPeriod: '试用期',
1457
1518
  trialEnd: '试用期结束于 {date}',
1458
1519
  willEnd: '将于 {date} 结束',
@@ -1569,6 +1630,8 @@ export default flat({
1569
1630
  email: '电子邮件',
1570
1631
  phone: '电话',
1571
1632
  invoicePrefix: '账单前缀',
1633
+ googlePlayUuid: 'Google Play UUID',
1634
+ appStoreUuid: 'App Store UUID',
1572
1635
  balance: '余额 ({currency})',
1573
1636
  summary: {
1574
1637
  refund: '退款金额',
@@ -335,6 +335,86 @@ export default function SubscriptionDetail(props: { id: string }) {
335
335
  )}
336
336
  </InfoRowGroup>
337
337
  </Box>
338
+
339
+ {/* IAP details — Google Play / App Store */}
340
+ {(data.payment_details?.google_play || data.payment_details?.app_store) && (
341
+ <>
342
+ <Divider />
343
+ <Box className="section" sx={{ containerType: 'inline-size' }}>
344
+ <SectionHeader
345
+ title={
346
+ data.payment_details?.google_play
347
+ ? t('admin.subscription.iap.googlePlayTitle')
348
+ : t('admin.subscription.iap.appStoreTitle')
349
+ }
350
+ />
351
+ <InfoRowGroup
352
+ sx={{
353
+ '.info-row-value': { wordBreak: 'break-all' },
354
+ }}>
355
+ {(data as any).channel && (
356
+ <InfoRow label={t('admin.subscription.channel')} value={(data as any).channel} />
357
+ )}
358
+ {data.payment_details?.google_play && (
359
+ <>
360
+ <InfoRow
361
+ label={t('admin.subscription.iap.purchaseToken')}
362
+ value={data.payment_details.google_play.purchase_token}
363
+ />
364
+ <InfoRow
365
+ label={t('admin.subscription.iap.orderId')}
366
+ value={data.payment_details.google_play.order_id || '—'}
367
+ />
368
+ <InfoRow
369
+ label={t('admin.subscription.iap.productId')}
370
+ value={data.payment_details.google_play.product_id}
371
+ />
372
+ <InfoRow
373
+ label={t('admin.subscription.iap.expiryTime')}
374
+ value={
375
+ data.payment_details.google_play.expiry_time_millis
376
+ ? formatTime(Number(data.payment_details.google_play.expiry_time_millis))
377
+ : '—'
378
+ }
379
+ />
380
+ <InfoRow
381
+ label={t('admin.subscription.iap.environment')}
382
+ value={data.payment_details.google_play.environment || (data as any).environment || '—'}
383
+ />
384
+ </>
385
+ )}
386
+ {data.payment_details?.app_store && (
387
+ <>
388
+ <InfoRow
389
+ label={t('admin.subscription.iap.originalTransactionId')}
390
+ value={data.payment_details.app_store.original_transaction_id}
391
+ />
392
+ <InfoRow
393
+ label={t('admin.subscription.iap.transactionId')}
394
+ value={data.payment_details.app_store.transaction_id || '—'}
395
+ />
396
+ <InfoRow
397
+ label={t('admin.subscription.iap.productId')}
398
+ value={data.payment_details.app_store.product_id}
399
+ />
400
+ <InfoRow
401
+ label={t('admin.subscription.iap.expiryTime')}
402
+ value={
403
+ data.payment_details.app_store.expires_at
404
+ ? formatTime(data.payment_details.app_store.expires_at * 1000)
405
+ : '—'
406
+ }
407
+ />
408
+ <InfoRow
409
+ label={t('admin.subscription.iap.environment')}
410
+ value={data.payment_details.app_store.environment || (data as any).environment || '—'}
411
+ />
412
+ </>
413
+ )}
414
+ </InfoRowGroup>
415
+ </Box>
416
+ </>
417
+ )}
338
418
  <Divider />
339
419
  {/* Discount Information */}
340
420
  {(data as any).discountStats && (
@@ -349,6 +349,12 @@ export default function CustomerDetail(props: { id: string }) {
349
349
  <InfoRow label={t('common.createdAt')} value={formatTime(data.customer.created_at)} />
350
350
 
351
351
  <InfoRow label={t('admin.customer.invoicePrefix')} value={data.customer.invoice_prefix} />
352
+ {(data.customer as any).google_play_uuid && (
353
+ <InfoRow label={t('admin.customer.googlePlayUuid')} value={(data.customer as any).google_play_uuid} />
354
+ )}
355
+ {(data.customer as any).app_store_uuid && (
356
+ <InfoRow label={t('admin.customer.appStoreUuid')} value={(data.customer as any).app_store_uuid} />
357
+ )}
352
358
  <InfoRow label={t('common.updatedAt')} value={formatTime(data.customer.updated_at)} />
353
359
  {state.editing.customer && (
354
360
  <EditCustomer
@@ -52,6 +52,18 @@ export default function PaymentMethodCreate() {
52
52
  explorer_host: '',
53
53
  logo: '',
54
54
  },
55
+ google_play: {
56
+ package_name: '',
57
+ service_account_json: '',
58
+ pubsub_topic_name: '',
59
+ },
60
+ app_store: {
61
+ bundle_id: '',
62
+ environment: 'sandbox',
63
+ issuer_id: '',
64
+ key_id: '',
65
+ private_key_pem: '',
66
+ },
55
67
  },
56
68
  },
57
69
  });
@@ -453,7 +453,7 @@ export default function PaymentMethods() {
453
453
  addons={
454
454
  <>
455
455
  <Switch checked={method.active} disabled sx={{ cursor: 'default' }} />
456
- {method.type !== 'arcblock' && (
456
+ {!['arcblock', 'google_play', 'app_store'].includes(method.type) && (
457
457
  <IconButton
458
458
  onClick={(e) => {
459
459
  e.stopPropagation();