payment-kit 1.20.10 → 1.20.12

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 (83) hide show
  1. package/README.md +25 -24
  2. package/api/src/index.ts +2 -0
  3. package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
  4. package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
  5. package/api/src/integrations/stripe/resource.ts +253 -2
  6. package/api/src/libs/currency.ts +31 -0
  7. package/api/src/libs/discount/coupon.ts +1061 -0
  8. package/api/src/libs/discount/discount.ts +349 -0
  9. package/api/src/libs/discount/nft.ts +239 -0
  10. package/api/src/libs/discount/redemption.ts +636 -0
  11. package/api/src/libs/discount/vc.ts +73 -0
  12. package/api/src/libs/invoice.ts +50 -16
  13. package/api/src/libs/math-utils.ts +6 -0
  14. package/api/src/libs/price.ts +43 -0
  15. package/api/src/libs/session.ts +242 -57
  16. package/api/src/libs/subscription.ts +2 -6
  17. package/api/src/locales/en.ts +38 -38
  18. package/api/src/queues/auto-recharge.ts +1 -1
  19. package/api/src/queues/discount-status.ts +200 -0
  20. package/api/src/queues/subscription.ts +98 -5
  21. package/api/src/queues/usage-record.ts +1 -1
  22. package/api/src/routes/auto-recharge-configs.ts +5 -3
  23. package/api/src/routes/checkout-sessions.ts +755 -64
  24. package/api/src/routes/connect/change-payment.ts +6 -1
  25. package/api/src/routes/connect/change-plan.ts +6 -1
  26. package/api/src/routes/connect/setup.ts +6 -1
  27. package/api/src/routes/connect/shared.ts +80 -9
  28. package/api/src/routes/connect/subscribe.ts +12 -2
  29. package/api/src/routes/coupons.ts +518 -0
  30. package/api/src/routes/index.ts +4 -0
  31. package/api/src/routes/invoices.ts +44 -3
  32. package/api/src/routes/meter-events.ts +2 -1
  33. package/api/src/routes/payment-currencies.ts +1 -0
  34. package/api/src/routes/promotion-codes.ts +482 -0
  35. package/api/src/routes/subscriptions.ts +23 -2
  36. package/api/src/store/migrations/20250904-discount.ts +136 -0
  37. package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
  38. package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
  39. package/api/src/store/models/checkout-session.ts +12 -0
  40. package/api/src/store/models/coupon.ts +144 -4
  41. package/api/src/store/models/discount.ts +23 -10
  42. package/api/src/store/models/index.ts +13 -2
  43. package/api/src/store/models/promotion-code.ts +295 -18
  44. package/api/src/store/models/types.ts +30 -1
  45. package/api/tests/libs/session.spec.ts +48 -27
  46. package/blocklet.yml +1 -1
  47. package/doc/vendor_fulfillment_system.md +38 -38
  48. package/package.json +20 -20
  49. package/src/app.tsx +2 -0
  50. package/src/components/customer/link.tsx +1 -1
  51. package/src/components/discount/discount-info.tsx +178 -0
  52. package/src/components/invoice/table.tsx +140 -48
  53. package/src/components/invoice-pdf/styles.ts +6 -0
  54. package/src/components/invoice-pdf/template.tsx +59 -33
  55. package/src/components/metadata/form.tsx +14 -5
  56. package/src/components/payment-link/actions.tsx +42 -0
  57. package/src/components/price/form.tsx +91 -65
  58. package/src/components/product/vendor-config.tsx +5 -3
  59. package/src/components/promotion/active-redemptions.tsx +534 -0
  60. package/src/components/promotion/currency-multi-select.tsx +350 -0
  61. package/src/components/promotion/currency-restrictions.tsx +117 -0
  62. package/src/components/promotion/product-select.tsx +292 -0
  63. package/src/components/promotion/promotion-code-form.tsx +534 -0
  64. package/src/components/subscription/portal/list.tsx +6 -1
  65. package/src/components/subscription/vendor-service-list.tsx +13 -2
  66. package/src/locales/en.tsx +253 -26
  67. package/src/locales/zh.tsx +222 -1
  68. package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
  69. package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
  70. package/src/pages/admin/products/coupons/create.tsx +612 -0
  71. package/src/pages/admin/products/coupons/detail.tsx +538 -0
  72. package/src/pages/admin/products/coupons/edit.tsx +127 -0
  73. package/src/pages/admin/products/coupons/index.tsx +210 -3
  74. package/src/pages/admin/products/index.tsx +22 -3
  75. package/src/pages/admin/products/products/detail.tsx +12 -2
  76. package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
  77. package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
  78. package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
  79. package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
  80. package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
  81. package/src/pages/admin/products/vendors/index.tsx +17 -5
  82. package/src/pages/customer/subscription/detail.tsx +5 -0
  83. package/vite.config.ts +4 -3
@@ -1,63 +1,63 @@
1
- # 多供应商发货系统完整实现方案
1
+ # Multi-Vendor Fulfillment System Complete Implementation
2
2
 
3
- ## 📋 项目概述
3
+ ## 📋 Project Overview
4
4
 
5
- 本文档总结了 Payment Kit 中多供应商发货系统的完整实现,包括架构设计、流程逻辑、技术实现和问题解决方案。
5
+ This document summarizes the complete implementation of the multi-vendor fulfillment system in Payment Kit, including architecture design, process logic, technical implementation, and problem-solving solutions.
6
6
 
7
- ## 🎯 系统目标
7
+ ## 🎯 System Goals
8
8
 
9
- ### 核心需求
9
+ ### Core Requirements
10
10
 
11
- 1. **多供应商支持**: 一个订单可以包含多个供应商的商品
12
- 2. **异步发货**: 不同供应商可以独立、并行发货
13
- 3. **状态协调**: 统一管理所有供应商的发货状态
14
- 4. **容错机制**: 处理发货失败、重试、超时等异常情况
15
- 5. **分成处理**: 所有供应商发货完成后,自动进行佣金分成
16
- 6. **退款保障**: 任何供应商失败即全额退款,确保用户权益
11
+ 1. **Multi-vendor Support**: A single order can contain products from multiple vendors
12
+ 2. **Asynchronous Fulfillment**: Different vendors can fulfill orders independently and in parallel
13
+ 3. **State Coordination**: Unified management of fulfillment status across all vendors
14
+ 4. **Fault Tolerance**: Handle fulfillment failures, retries, timeouts, and other exceptional situations
15
+ 5. **Commission Processing**: Automatic commission distribution after all vendors complete fulfillment
16
+ 6. **Refund Protection**: Full refund if any vendor fails, ensuring user rights
17
17
 
18
- ### 业务规则
18
+ ### Business Rules
19
19
 
20
- - **全部成功**: 所有供应商发货成功进行佣金分成
21
- - **任何失败**: 有任何供应商失败、超时或超过重试次数**对已成功的供应商发起退货请求**全额退款给用户
22
- - **超时时间**: 5分钟无响应视为超时
23
- - **重试次数**: 最多3次重试
24
- - **退货机制**: 对已完成发货的供应商发起退货请求
20
+ - **All Success**: All vendors fulfill successfully Process commission distribution
21
+ - **Any Failure**: If any vendor fails, times out, or exceeds retry limit **Initiate return requests for successful vendors** Full refund to user
22
+ - **Timeout Period**: 5 minutes of no response is considered timeout
23
+ - **Retry Limit**: Maximum 3 retries
24
+ - **Return Mechanism**: Initiate return requests for vendors that have completed fulfillment
25
25
 
26
- ## 🏗️ 系统架构
26
+ ## 🏗️ System Architecture
27
27
 
28
- ### 架构模式: 协调器模式 (Coordinator Pattern)
28
+ ### Architecture Pattern: Coordinator Pattern
29
29
 
30
- #### 🔄 **核心流程 (文字版)**
30
+ #### 🔄 **Core Process Flow (Text Version)**
31
31
 
32
32
  ```
33
33
  1. Payment Success
34
34
 
35
35
  2. vendor-commission.ts
36
- ├─ 检查供应商配置
37
- ├─ 无供应商直接冷钱包转账
38
- └─ 有供应商调用协调器
36
+ ├─ Check vendor configuration
37
+ ├─ No vendors Direct cold wallet transfer
38
+ └─ Has vendors Call coordinator
39
39
 
40
40
  3. vendor-fulfillment-coordinator.ts
41
- ├─ 启动发货流程
42
- ├─ 初始化 vendor_info 状态
43
- ├─ 为每个供应商创建发货任务
44
- └─ 被动等待通知
41
+ ├─ Start fulfillment process
42
+ ├─ Initialize vendor_info status
43
+ ├─ Create fulfillment tasks for each vendor
44
+ └─ Wait passively for notifications
45
45
 
46
- 4. vendor-fulfillment.ts (并行执行)
47
- ├─ 供应商A发货通知协调器
48
- ├─ 供应商B发货通知协调器
49
- ├─ 供应商C发货通知协调器
50
- └─ 重试机制 (最多3)
46
+ 4. vendor-fulfillment.ts (parallel execution)
47
+ ├─ Vendor A fulfills Notify coordinator
48
+ ├─ Vendor B fulfills Notify coordinator
49
+ ├─ Vendor C fulfills Notify coordinator
50
+ └─ Retry mechanism (max 3 times)
51
51
 
52
52
  5. vendor-fulfillment-coordinator.ts
53
- ├─ 接收所有通知
54
- ├─ 更新 vendor_info 状态 (事务+锁)
55
- ├─ 检查整体状态
56
- ├─ 全部成功分成 + 冷钱包
57
- └─ 任何失败 → 🔄 对已成功供应商发起退货请求全额退款
53
+ ├─ Receive all notifications
54
+ ├─ Update vendor_info status (transaction + lock)
55
+ ├─ Check overall status
56
+ ├─ All success Commission distribution + cold wallet
57
+ └─ Any failure → 🔄 Initiate return requests for successful vendors Full refund
58
58
  ```
59
59
 
60
- #### 📊 **详细架构图**
60
+ #### 📊 **Detailed Architecture Diagram**
61
61
 
62
62
  ```mermaid
63
63
  graph TD
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "payment-kit",
3
- "version": "1.20.10",
3
+ "version": "1.20.12",
4
4
  "scripts": {
5
5
  "dev": "blocklet dev --open",
6
6
  "lint": "tsc --noEmit && eslint src api/src --ext .mjs,.js,.jsx,.ts,.tsx",
@@ -45,32 +45,32 @@
45
45
  },
46
46
  "dependencies": {
47
47
  "@abtnode/cron": "^1.16.52-beta-20250912-112002-e3499e9c",
48
- "@arcblock/did": "^1.24.9",
49
- "@arcblock/did-connect-react": "^3.1.40",
48
+ "@arcblock/did": "^1.25.1",
49
+ "@arcblock/did-connect-react": "^3.1.41",
50
50
  "@arcblock/did-connect-storage-nedb": "^1.8.0",
51
- "@arcblock/did-util": "^1.24.9",
52
- "@arcblock/jwt": "^1.24.9",
53
- "@arcblock/ux": "^3.1.40",
54
- "@arcblock/validator": "^1.24.9",
55
- "@blocklet/did-space-js": "^1.1.23",
51
+ "@arcblock/did-util": "^1.25.1",
52
+ "@arcblock/jwt": "^1.25.1",
53
+ "@arcblock/ux": "^3.1.41",
54
+ "@arcblock/validator": "^1.25.1",
55
+ "@blocklet/did-space-js": "^1.1.24",
56
56
  "@blocklet/error": "^0.2.5",
57
57
  "@blocklet/js-sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
58
58
  "@blocklet/logger": "^1.16.52-beta-20250912-112002-e3499e9c",
59
- "@blocklet/payment-react": "1.20.10",
60
- "@blocklet/payment-vendor": "1.20.10",
59
+ "@blocklet/payment-react": "1.20.12",
60
+ "@blocklet/payment-vendor": "1.20.12",
61
61
  "@blocklet/sdk": "^1.16.52-beta-20250912-112002-e3499e9c",
62
- "@blocklet/ui-react": "^3.1.40",
63
- "@blocklet/uploader": "^0.2.10",
62
+ "@blocklet/ui-react": "^3.1.41",
63
+ "@blocklet/uploader": "^0.2.11",
64
64
  "@blocklet/xss": "^0.2.7",
65
65
  "@mui/icons-material": "^7.1.2",
66
66
  "@mui/lab": "7.0.0-beta.14",
67
67
  "@mui/material": "^7.1.2",
68
68
  "@mui/system": "^7.1.1",
69
- "@ocap/asset": "^1.24.9",
70
- "@ocap/client": "^1.24.9",
71
- "@ocap/mcrypto": "^1.24.9",
72
- "@ocap/util": "^1.24.9",
73
- "@ocap/wallet": "^1.24.9",
69
+ "@ocap/asset": "^1.25.1",
70
+ "@ocap/client": "^1.25.1",
71
+ "@ocap/mcrypto": "^1.25.1",
72
+ "@ocap/util": "^1.25.1",
73
+ "@ocap/wallet": "^1.25.1",
74
74
  "@stripe/react-stripe-js": "^2.9.0",
75
75
  "@stripe/stripe-js": "^2.4.0",
76
76
  "ahooks": "^3.8.5",
@@ -126,7 +126,7 @@
126
126
  "devDependencies": {
127
127
  "@abtnode/types": "^1.16.52-beta-20250912-112002-e3499e9c",
128
128
  "@arcblock/eslint-config-ts": "^0.3.3",
129
- "@blocklet/payment-types": "1.20.10",
129
+ "@blocklet/payment-types": "1.20.12",
130
130
  "@types/cookie-parser": "^1.4.9",
131
131
  "@types/cors": "^2.8.19",
132
132
  "@types/debug": "^4.1.12",
@@ -157,7 +157,7 @@
157
157
  "vite": "^7.0.0",
158
158
  "vite-node": "^3.2.4",
159
159
  "vite-plugin-babel-import": "^2.0.5",
160
- "vite-plugin-blocklet": "^0.10.1",
160
+ "vite-plugin-blocklet": "^0.11.0",
161
161
  "vite-plugin-node-polyfills": "^0.23.0",
162
162
  "vite-plugin-svgr": "^4.3.0",
163
163
  "vite-tsconfig-paths": "^5.1.4",
@@ -173,5 +173,5 @@
173
173
  "parser": "typescript"
174
174
  }
175
175
  },
176
- "gitHead": "1659d63120ced92167ec8681e51db96801b910ec"
176
+ "gitHead": "e6c432f89c2bf305efc903c0809f4a0557c07774"
177
177
  }
package/src/app.tsx CHANGED
@@ -58,6 +58,8 @@ function App() {
58
58
  <Route key="admin-index" path="/admin" element={<AdminPage />} />,
59
59
  <Route key="admin-tabs" path="/admin/:group" element={<AdminPage />} />,
60
60
  <Route key="admin-sub" path="/admin/:group/:page" element={<AdminPage />} />,
61
+ <Route key="admin-detail" path="/admin/:group/:page/:id" element={<AdminPage />} />,
62
+ <Route key="admin-nested" path="/admin/:group/:page/:subpage/:id" element={<AdminPage />} />,
61
63
  <Route key="admin-fallback" path="/admin/*" element={<AdminPage />} />,
62
64
  <Route key="integrations-index" path="/integrations" element={<IntegrationsPage />} />
63
65
  <Route key="integrations-tabs" path="/integrations/:group" element={<IntegrationsPage />} />
@@ -40,7 +40,7 @@ export default function CustomerLink({
40
40
  popupInfoType={InfoType.Minimal}
41
41
  showDid={size !== 'small'}
42
42
  popupShowDid
43
- {...(customer.metadata.anonymous === true
43
+ {...(customer?.metadata?.anonymous === true
44
44
  ? {
45
45
  user: {
46
46
  fullName: customer.name || customer.email,
@@ -0,0 +1,178 @@
1
+ import { Box, Typography, Stack } from '@mui/material';
2
+ import { LocalOfferOutlined } from '@mui/icons-material';
3
+ import { formatTime, formatAmount, findCurrency, usePaymentContext, useMobile } from '@blocklet/payment-react';
4
+ import { useLocaleContext } from '@arcblock/ux/lib/Locale/context';
5
+
6
+ type DiscountStats = {
7
+ subscription_id: string;
8
+ total_discount_records: number;
9
+ total_savings: Record<
10
+ string,
11
+ {
12
+ amount: string;
13
+ currency: any;
14
+ formattedAmount: string;
15
+ }
16
+ >;
17
+ coupons_used: string[];
18
+ promotion_codes_used: any[];
19
+ discount_records: any[];
20
+ };
21
+
22
+ type DiscountInfoProps = {
23
+ discountStats?: DiscountStats | null;
24
+ };
25
+
26
+ export default function DiscountInfo({ discountStats = null }: DiscountInfoProps) {
27
+ const { t } = useLocaleContext();
28
+ const { settings } = usePaymentContext();
29
+ const { isMobile } = useMobile();
30
+
31
+ if (!discountStats || discountStats.total_discount_records === 0) {
32
+ return null;
33
+ }
34
+
35
+ // Get primary coupon info
36
+ const primaryRecord = discountStats.discount_records?.[0];
37
+ const coupon = primaryRecord?.coupon;
38
+
39
+ if (!coupon) {
40
+ return null;
41
+ }
42
+
43
+ // Use coupon name instead of promotion code
44
+ const couponName = coupon.name || t('admin.coupon.discount');
45
+
46
+ // Format discount type
47
+ const getDiscountType = () => {
48
+ if (coupon.percent_off && coupon.percent_off > 0) {
49
+ return t('admin.coupon.couponTermsPercentage', { percent: coupon.percent_off });
50
+ }
51
+ if (coupon.amount_off && coupon.amount_off !== '0') {
52
+ const currency = findCurrency(settings.paymentMethods, coupon.currency_id) || settings.baseCurrency;
53
+ return t('admin.coupon.couponTermsFixedAmount', {
54
+ amount: formatAmount(coupon.amount_off, currency.decimal),
55
+ symbol: currency.symbol,
56
+ });
57
+ }
58
+ return t('admin.coupon.noDiscount');
59
+ };
60
+
61
+ // Format duration
62
+ const getDuration = () => {
63
+ if (coupon.duration === 'repeating' && coupon.duration_in_months) {
64
+ return t(`admin.coupon.couponTermsDuration.${coupon.duration}`, { months: coupon.duration_in_months });
65
+ }
66
+ return t(`admin.coupon.couponTermsDuration.${coupon.duration}`);
67
+ };
68
+
69
+ // Format total savings
70
+ const totalSavingsEntries = Object.entries(discountStats.total_savings);
71
+ const savingsText =
72
+ totalSavingsEntries.length > 0
73
+ ? totalSavingsEntries.map(([, savings]) => savings.formattedAmount).join(', ')
74
+ : '--';
75
+
76
+ // Get validity period
77
+ const getValidityPeriod = () => {
78
+ if (coupon.duration !== 'repeating') {
79
+ return '';
80
+ }
81
+ if (primaryRecord?.discount_start && primaryRecord?.discount_end) {
82
+ const startDate = formatTime(primaryRecord.discount_start * 1000);
83
+ const endDate = formatTime(primaryRecord.discount_end * 1000);
84
+ return `${startDate} ~ ${endDate}`;
85
+ }
86
+ return '';
87
+ };
88
+
89
+ return (
90
+ <Box
91
+ sx={{
92
+ bgcolor: 'grey.50',
93
+ borderRadius: 1.5,
94
+ p: 2.5,
95
+ my: 2,
96
+ border: '1px solid',
97
+ borderColor: 'divider',
98
+ }}>
99
+ <Stack spacing={1}>
100
+ <Stack
101
+ direction={isMobile ? 'column' : 'row'}
102
+ spacing={2}
103
+ sx={{
104
+ alignItems: isMobile ? 'flex-start' : 'center',
105
+ }}>
106
+ <Stack
107
+ direction="row"
108
+ spacing={1}
109
+ sx={{
110
+ alignItems: 'center',
111
+ flex: 1,
112
+ }}>
113
+ <LocalOfferOutlined sx={{ color: 'primary.main', fontSize: 20 }} />
114
+ <Typography variant="subtitle2" sx={{ fontWeight: 600, color: 'text.primary' }}>
115
+ {couponName}
116
+ </Typography>
117
+ </Stack>
118
+
119
+ <Box sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
120
+ <Typography variant="body2" sx={{ fontWeight: 500, color: 'text.secondary' }}>
121
+ {getDiscountType()}
122
+ </Typography>
123
+ {getDuration() && (
124
+ <>
125
+ <Typography
126
+ variant="body2"
127
+ sx={{
128
+ color: 'text.disabled',
129
+ }}>
130
+ |
131
+ </Typography>
132
+ <Typography
133
+ variant="body2"
134
+ sx={{
135
+ color: 'text.secondary',
136
+ }}>
137
+ {getDuration()}
138
+ </Typography>
139
+ </>
140
+ )}
141
+ </Box>
142
+ </Stack>
143
+
144
+ <Stack
145
+ direction={isMobile ? 'column' : 'row'}
146
+ spacing={2}
147
+ sx={{
148
+ alignItems: isMobile ? 'flex-start' : 'center',
149
+ }}>
150
+ <Box sx={{ flex: 1 }}>
151
+ <Typography
152
+ variant="body2"
153
+ sx={{
154
+ color: 'text.secondary',
155
+ }}>
156
+ {t('admin.discount.totalSaved')}:{' '}
157
+ <Typography component="span" sx={{ color: 'success.main', fontWeight: 600, ml: 0.5 }}>
158
+ {savingsText}
159
+ </Typography>
160
+ </Typography>
161
+ </Box>
162
+
163
+ {getValidityPeriod() && (
164
+ <Box>
165
+ <Typography
166
+ variant="body2"
167
+ sx={{
168
+ color: 'text.primary',
169
+ }}>
170
+ {t('admin.coupon.discountPeriod')}: {getValidityPeriod()}
171
+ </Typography>
172
+ </Box>
173
+ )}
174
+ </Stack>
175
+ </Stack>
176
+ </Box>
177
+ );
178
+ }
@@ -63,7 +63,7 @@ export function getAppliedBalance(invoice: TInvoiceExpanded) {
63
63
  return '0';
64
64
  }
65
65
 
66
- export function getInvoiceRows(invoice: TInvoiceExpanded) {
66
+ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => string) {
67
67
  const detail: InvoiceDetailItem[] = invoice.lines.map((line) => {
68
68
  const price = line.quantity
69
69
  ? toBN(line.amount).div(toBN(line.quantity)).toString()
@@ -99,14 +99,61 @@ export function getInvoiceRows(invoice: TInvoiceExpanded) {
99
99
  {
100
100
  key: 'common.subtotal',
101
101
  value: formatAmount(invoice.subtotal, invoice.paymentCurrency.decimal),
102
- color: 'text.primary',
103
- },
104
- {
105
- key: 'common.total',
106
- value: formatAmount(invoice.total, invoice.paymentCurrency.decimal),
107
- color: 'text.primary',
102
+ color: 'text.secondary',
108
103
  },
109
104
  ];
105
+
106
+ // Add discount information from discountDetails
107
+ if ((invoice as any).discountDetails && (invoice as any).discountDetails.length > 0) {
108
+ (invoice as any).discountDetails.forEach((discount: any) => {
109
+ if (discount.coupon) {
110
+ let discountAmount = '0';
111
+
112
+ // Calculate discount amount from total_discount_amounts
113
+ if (invoice.total_discount_amounts && invoice.total_discount_amounts.length > 0) {
114
+ const matchingDiscount = invoice.total_discount_amounts.find(
115
+ (tda: any) => tda.discount === discount.id || tda.coupon === discount.coupon_id
116
+ );
117
+ if (matchingDiscount) {
118
+ discountAmount = matchingDiscount.amount.toString();
119
+ }
120
+ }
121
+
122
+ if (toBN(discountAmount).gt(toBN(0))) {
123
+ const couponName = discount.coupon.name || t('common.coupon');
124
+ const formattedDiscountAmount = formatAmount(discountAmount, invoice.paymentCurrency.decimal);
125
+
126
+ // Format discount display following Stripe style: coupon_name (discount_type)
127
+ let discountLabel = couponName;
128
+ if (discount.coupon.percent_off) {
129
+ const discountText = (t as any)('admin.coupon.couponTermsPercentage', {
130
+ percent: discount.coupon.percent_off,
131
+ });
132
+ discountLabel = `${couponName} (${discountText})`;
133
+ } else if (discount.coupon.amount_off) {
134
+ const fixedAmount = formatAmount(discount.coupon.amount_off, invoice.paymentCurrency.decimal);
135
+ const discountText = (t as any)('admin.coupon.couponTermsFixedAmount', {
136
+ amount: fixedAmount,
137
+ symbol: invoice.paymentCurrency.symbol,
138
+ });
139
+ discountLabel = `${couponName} (${discountText})`;
140
+ }
141
+
142
+ summary.push({
143
+ key: discountLabel,
144
+ value: `-${formattedDiscountAmount}`,
145
+ color: 'text.secondary',
146
+ });
147
+ }
148
+ }
149
+ });
150
+ }
151
+
152
+ summary.push({
153
+ key: 'common.total',
154
+ value: formatAmount(invoice.total, invoice.paymentCurrency.decimal),
155
+ color: 'text.secondary',
156
+ });
110
157
  if (invoice.amount_paid !== '0') {
111
158
  summary.push({
112
159
  key: 'payment.customer.invoice.amountPaid',
@@ -126,7 +173,7 @@ export function getInvoiceRows(invoice: TInvoiceExpanded) {
126
173
  summary.push({
127
174
  key: 'payment.customer.invoice.amountDue',
128
175
  value: formatAmount(invoice.amount_remaining, invoice.paymentCurrency.decimal),
129
- color: 'text.primary',
176
+ color: invoice.amount_remaining !== '0' ? 'text.primary' : 'text.secondary',
130
177
  });
131
178
 
132
179
  return {
@@ -137,7 +184,7 @@ export function getInvoiceRows(invoice: TInvoiceExpanded) {
137
184
 
138
185
  export default function InvoiceTable({ invoice, simple = false, emptyNodeText = '' }: Props) {
139
186
  const { t, locale } = useLocaleContext();
140
- const { detail, summary } = getInvoiceRows(invoice);
187
+ const { detail, summary } = getInvoiceRows(invoice, t);
141
188
  const [state, setState] = useSetState({
142
189
  subscriptionId: '',
143
190
  subscriptionItemId: '',
@@ -246,6 +293,34 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
246
293
  width: 200,
247
294
  align: 'right',
248
295
  },
296
+ {
297
+ label: t('common.discount'),
298
+ name: 'discount',
299
+ width: 200,
300
+ align: 'right',
301
+ options: {
302
+ customBodyRenderLite: (_: string, index: number) => {
303
+ const item = detail[index] as InvoiceDetailItem;
304
+ // Calculate discount amount for this line item
305
+ let itemDiscountAmount = toBN(0);
306
+ if (item.raw.discount_amounts && item.raw.discount_amounts.length > 0) {
307
+ item.raw.discount_amounts.forEach((discount: any) => {
308
+ if (discount.amount) {
309
+ itemDiscountAmount = itemDiscountAmount.add(toBN(discount.amount));
310
+ }
311
+ });
312
+ }
313
+
314
+ return (
315
+ <Typography component="span" sx={{ color: 'text.secondary' }}>
316
+ {itemDiscountAmount.gt(toBN(0))
317
+ ? `-${formatAmount(itemDiscountAmount.toString(), invoice.paymentCurrency.decimal)}`
318
+ : '-'}
319
+ </Typography>
320
+ );
321
+ },
322
+ },
323
+ },
249
324
  {
250
325
  label: t('common.amount'),
251
326
  name: 'amount',
@@ -299,49 +374,72 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
299
374
  emptyNodeText={emptyNodeText || t('payment.customer.invoice.emptyList')}
300
375
  />
301
376
  {!isEmpty(detail) && invoice.status !== 'void' && (
302
- <Stack
377
+ <Box
303
378
  className="invoice-summary"
304
379
  sx={{
305
380
  display: 'flex',
306
- flexDirection: {
307
- xs: 'column',
308
- md: 'row',
381
+ flexDirection: 'column',
382
+ alignItems: 'flex-end',
383
+ float: 'right',
384
+ pr: 2,
385
+ mt: {
386
+ xs: 1,
387
+ md: 1,
309
388
  },
310
- justifyContent: 'flex-end',
311
- alignItems: {
312
- xs: 'flex-end',
313
- md: 'center',
389
+ width: {
390
+ xs: '100%',
391
+ md: 'auto',
314
392
  },
315
- gap: {
316
- xs: 1,
317
- md: 3,
393
+ minWidth: {
394
+ md: '280px',
318
395
  },
319
- mt: {
320
- xs: 1,
321
- md: 2,
396
+ maxWidth: {
397
+ xs: '100%',
398
+ md: '400px',
322
399
  },
323
400
  }}>
324
- {summary.map((line) => (
325
- <Box key={line.key}>
326
- <Typography
327
- component="span"
328
- variant="body1"
329
- sx={{
330
- color: 'text.secondary',
331
- }}>
332
- {t(line.key)}:&nbsp;
333
- </Typography>
334
- <Typography
335
- component="span"
336
- variant="body1"
401
+ {summary.map((line, index) => {
402
+ const isTotal = line.key === 'common.total';
403
+ const showDivider = isTotal && index > 0;
404
+
405
+ return (
406
+ <Stack
407
+ key={line.key}
408
+ direction="row"
337
409
  sx={{
338
- color: 'text.primary',
410
+ justifyContent: 'flex-end',
411
+ width: '100%',
412
+ py: 0.5,
413
+
414
+ ...(showDivider && {
415
+ borderTop: '1px solid',
416
+ borderTopColor: 'divider',
417
+ pt: 1,
418
+ mt: 0.5,
419
+ }),
339
420
  }}>
340
- {line.value}
341
- </Typography>
342
- </Box>
343
- ))}
344
- </Stack>
421
+ <Typography
422
+ variant="body2"
423
+ sx={{
424
+ color: 'text.primary',
425
+ fontWeight: isTotal ? 500 : 400,
426
+ }}>
427
+ {line.key.startsWith('common.') || line.key.startsWith('payment.') ? t(line.key) : line.key}
428
+ </Typography>
429
+ <Typography
430
+ variant="body2"
431
+ sx={{
432
+ color: 'text.primary',
433
+ fontWeight: isTotal ? 500 : 400,
434
+ minWidth: '80px',
435
+ textAlign: 'right',
436
+ }}>
437
+ {line.value}
438
+ </Typography>
439
+ </Stack>
440
+ );
441
+ })}
442
+ </Box>
345
443
  )}
346
444
  {state.subscriptionId && state.subscriptionItemId && (
347
445
  <UsageRecordDialog
@@ -359,9 +457,6 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
359
457
  }
360
458
 
361
459
  const Root = styled(Box)`
362
- .invoice-summary {
363
- padding-right: 16px;
364
- }
365
460
  @media (max-width: ${({ theme }) => theme.breakpoints.values.md}px) {
366
461
  .MuiTable-root > .MuiTableBody-root > .MuiTableRow-root > td.MuiTableCell-root {
367
462
  > div {
@@ -370,8 +465,5 @@ const Root = styled(Box)`
370
465
  font-size: 14px;
371
466
  }
372
467
  }
373
- .invoice-summary {
374
- padding-right: 20px;
375
- }
376
468
  }
377
469
  `;
@@ -4,6 +4,7 @@ export const colorDark = '#222';
4
4
  export const colorDark2 = '#888';
5
5
  export const colorGray = '#e3e3e3';
6
6
  export const colorWhite = '#fff';
7
+ export const colorGreen = '#4caf50';
7
8
 
8
9
  export const pdfStyles: InvoiceStyles = {
9
10
  template: {
@@ -29,6 +30,9 @@ export const pdfStyles: InvoiceStyles = {
29
30
  gray: {
30
31
  color: colorDark2,
31
32
  },
33
+ green: {
34
+ color: colorGreen,
35
+ },
32
36
  'bg-dark': {
33
37
  backgroundColor: colorDark2,
34
38
  },
@@ -55,8 +59,10 @@ export const pdfStyles: InvoiceStyles = {
55
59
  'w-60': { width: '60%' },
56
60
  'w-40': { width: '40%' },
57
61
  'w-48': { width: '48%' },
62
+ 'w-38': { width: '38%' },
58
63
  'w-17': { width: '17%' },
59
64
  'w-18': { width: '18%' },
65
+ 'w-15': { width: '15%' },
60
66
  row: {
61
67
  borderBottom: `1px solid ${colorGray}`,
62
68
  marginBottom: '2px',