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.
- package/README.md +25 -24
- package/api/src/index.ts +2 -0
- package/api/src/integrations/stripe/handlers/invoice.ts +63 -5
- package/api/src/integrations/stripe/handlers/payment-intent.ts +1 -0
- package/api/src/integrations/stripe/resource.ts +253 -2
- package/api/src/libs/currency.ts +31 -0
- package/api/src/libs/discount/coupon.ts +1061 -0
- package/api/src/libs/discount/discount.ts +349 -0
- package/api/src/libs/discount/nft.ts +239 -0
- package/api/src/libs/discount/redemption.ts +636 -0
- package/api/src/libs/discount/vc.ts +73 -0
- package/api/src/libs/invoice.ts +50 -16
- package/api/src/libs/math-utils.ts +6 -0
- package/api/src/libs/price.ts +43 -0
- package/api/src/libs/session.ts +242 -57
- package/api/src/libs/subscription.ts +2 -6
- package/api/src/locales/en.ts +38 -38
- package/api/src/queues/auto-recharge.ts +1 -1
- package/api/src/queues/discount-status.ts +200 -0
- package/api/src/queues/subscription.ts +98 -5
- package/api/src/queues/usage-record.ts +1 -1
- package/api/src/routes/auto-recharge-configs.ts +5 -3
- package/api/src/routes/checkout-sessions.ts +755 -64
- package/api/src/routes/connect/change-payment.ts +6 -1
- package/api/src/routes/connect/change-plan.ts +6 -1
- package/api/src/routes/connect/setup.ts +6 -1
- package/api/src/routes/connect/shared.ts +80 -9
- package/api/src/routes/connect/subscribe.ts +12 -2
- package/api/src/routes/coupons.ts +518 -0
- package/api/src/routes/index.ts +4 -0
- package/api/src/routes/invoices.ts +44 -3
- package/api/src/routes/meter-events.ts +2 -1
- package/api/src/routes/payment-currencies.ts +1 -0
- package/api/src/routes/promotion-codes.ts +482 -0
- package/api/src/routes/subscriptions.ts +23 -2
- package/api/src/store/migrations/20250904-discount.ts +136 -0
- package/api/src/store/migrations/20250910-timestamp-fields.ts +116 -0
- package/api/src/store/migrations/20250916-add-description-fields.ts +30 -0
- package/api/src/store/models/checkout-session.ts +12 -0
- package/api/src/store/models/coupon.ts +144 -4
- package/api/src/store/models/discount.ts +23 -10
- package/api/src/store/models/index.ts +13 -2
- package/api/src/store/models/promotion-code.ts +295 -18
- package/api/src/store/models/types.ts +30 -1
- package/api/tests/libs/session.spec.ts +48 -27
- package/blocklet.yml +1 -1
- package/doc/vendor_fulfillment_system.md +38 -38
- package/package.json +20 -20
- package/src/app.tsx +2 -0
- package/src/components/customer/link.tsx +1 -1
- package/src/components/discount/discount-info.tsx +178 -0
- package/src/components/invoice/table.tsx +140 -48
- package/src/components/invoice-pdf/styles.ts +6 -0
- package/src/components/invoice-pdf/template.tsx +59 -33
- package/src/components/metadata/form.tsx +14 -5
- package/src/components/payment-link/actions.tsx +42 -0
- package/src/components/price/form.tsx +91 -65
- package/src/components/product/vendor-config.tsx +5 -3
- package/src/components/promotion/active-redemptions.tsx +534 -0
- package/src/components/promotion/currency-multi-select.tsx +350 -0
- package/src/components/promotion/currency-restrictions.tsx +117 -0
- package/src/components/promotion/product-select.tsx +292 -0
- package/src/components/promotion/promotion-code-form.tsx +534 -0
- package/src/components/subscription/portal/list.tsx +6 -1
- package/src/components/subscription/vendor-service-list.tsx +13 -2
- package/src/locales/en.tsx +253 -26
- package/src/locales/zh.tsx +222 -1
- package/src/pages/admin/billing/subscriptions/detail.tsx +5 -0
- package/src/pages/admin/products/coupons/applicable-products.tsx +166 -0
- package/src/pages/admin/products/coupons/create.tsx +612 -0
- package/src/pages/admin/products/coupons/detail.tsx +538 -0
- package/src/pages/admin/products/coupons/edit.tsx +127 -0
- package/src/pages/admin/products/coupons/index.tsx +210 -3
- package/src/pages/admin/products/index.tsx +22 -3
- package/src/pages/admin/products/products/detail.tsx +12 -2
- package/src/pages/admin/products/promotion-codes/actions.tsx +103 -0
- package/src/pages/admin/products/promotion-codes/create.tsx +235 -0
- package/src/pages/admin/products/promotion-codes/detail.tsx +416 -0
- package/src/pages/admin/products/promotion-codes/list.tsx +247 -0
- package/src/pages/admin/products/promotion-codes/verification-config.tsx +327 -0
- package/src/pages/admin/products/vendors/index.tsx +17 -5
- package/src/pages/customer/subscription/detail.tsx +5 -0
- 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
|
-
|
|
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
|
-
-
|
|
23
|
-
-
|
|
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
|
-
###
|
|
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
|
-
├─
|
|
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
|
-
├─
|
|
48
|
-
├─
|
|
49
|
-
├─
|
|
50
|
-
└─
|
|
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
|
-
├─
|
|
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.
|
|
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.
|
|
49
|
-
"@arcblock/did-connect-react": "^3.1.
|
|
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.
|
|
52
|
-
"@arcblock/jwt": "^1.
|
|
53
|
-
"@arcblock/ux": "^3.1.
|
|
54
|
-
"@arcblock/validator": "^1.
|
|
55
|
-
"@blocklet/did-space-js": "^1.1.
|
|
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.
|
|
60
|
-
"@blocklet/payment-vendor": "1.20.
|
|
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.
|
|
63
|
-
"@blocklet/uploader": "^0.2.
|
|
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.
|
|
70
|
-
"@ocap/client": "^1.
|
|
71
|
-
"@ocap/mcrypto": "^1.
|
|
72
|
-
"@ocap/util": "^1.
|
|
73
|
-
"@ocap/wallet": "^1.
|
|
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.
|
|
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.
|
|
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": "
|
|
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
|
|
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.
|
|
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
|
-
<
|
|
377
|
+
<Box
|
|
303
378
|
className="invoice-summary"
|
|
304
379
|
sx={{
|
|
305
380
|
display: 'flex',
|
|
306
|
-
flexDirection:
|
|
307
|
-
|
|
308
|
-
|
|
381
|
+
flexDirection: 'column',
|
|
382
|
+
alignItems: 'flex-end',
|
|
383
|
+
float: 'right',
|
|
384
|
+
pr: 2,
|
|
385
|
+
mt: {
|
|
386
|
+
xs: 1,
|
|
387
|
+
md: 1,
|
|
309
388
|
},
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
md: 'center',
|
|
389
|
+
width: {
|
|
390
|
+
xs: '100%',
|
|
391
|
+
md: 'auto',
|
|
314
392
|
},
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
md: 3,
|
|
393
|
+
minWidth: {
|
|
394
|
+
md: '280px',
|
|
318
395
|
},
|
|
319
|
-
|
|
320
|
-
xs:
|
|
321
|
-
md:
|
|
396
|
+
maxWidth: {
|
|
397
|
+
xs: '100%',
|
|
398
|
+
md: '400px',
|
|
322
399
|
},
|
|
323
400
|
}}>
|
|
324
|
-
{summary.map((line) =>
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
{t(line.key)}:
|
|
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
|
-
|
|
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
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
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',
|