payment-kit 1.24.3 → 1.25.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 (117) hide show
  1. package/api/src/crons/overdue-detection.ts +10 -1
  2. package/api/src/index.ts +3 -0
  3. package/api/src/libs/credit-utils.ts +21 -0
  4. package/api/src/libs/discount/discount.ts +13 -0
  5. package/api/src/libs/env.ts +5 -0
  6. package/api/src/libs/error.ts +14 -0
  7. package/api/src/libs/exchange-rate/coingecko-provider.ts +193 -0
  8. package/api/src/libs/exchange-rate/coinmarketcap-provider.ts +180 -0
  9. package/api/src/libs/exchange-rate/index.ts +5 -0
  10. package/api/src/libs/exchange-rate/service.ts +583 -0
  11. package/api/src/libs/exchange-rate/token-address-mapping.ts +84 -0
  12. package/api/src/libs/exchange-rate/token-addresses.json +2147 -0
  13. package/api/src/libs/exchange-rate/token-data-provider.ts +142 -0
  14. package/api/src/libs/exchange-rate/types.ts +114 -0
  15. package/api/src/libs/exchange-rate/validator.ts +319 -0
  16. package/api/src/libs/invoice-quote.ts +158 -0
  17. package/api/src/libs/invoice.ts +143 -7
  18. package/api/src/libs/math-utils.ts +46 -0
  19. package/api/src/libs/notification/template/billing-discrepancy.ts +3 -4
  20. package/api/src/libs/notification/template/customer-auto-recharge-failed.ts +174 -79
  21. package/api/src/libs/notification/template/customer-credit-grant-granted.ts +2 -3
  22. package/api/src/libs/notification/template/customer-credit-insufficient.ts +3 -3
  23. package/api/src/libs/notification/template/customer-credit-low-balance.ts +3 -3
  24. package/api/src/libs/notification/template/customer-revenue-succeeded.ts +2 -3
  25. package/api/src/libs/notification/template/customer-reward-succeeded.ts +9 -4
  26. package/api/src/libs/notification/template/exchange-rate-alert.ts +202 -0
  27. package/api/src/libs/notification/template/subscription-slippage-exceeded.ts +203 -0
  28. package/api/src/libs/notification/template/subscription-slippage-warning.ts +212 -0
  29. package/api/src/libs/notification/template/subscription-will-canceled.ts +2 -2
  30. package/api/src/libs/notification/template/subscription-will-renew.ts +22 -8
  31. package/api/src/libs/payment.ts +1 -1
  32. package/api/src/libs/price.ts +4 -1
  33. package/api/src/libs/queue/index.ts +8 -0
  34. package/api/src/libs/quote-service.ts +1132 -0
  35. package/api/src/libs/quote-validation.ts +388 -0
  36. package/api/src/libs/session.ts +686 -39
  37. package/api/src/libs/slippage.ts +135 -0
  38. package/api/src/libs/subscription.ts +185 -15
  39. package/api/src/libs/util.ts +64 -3
  40. package/api/src/locales/en.ts +50 -0
  41. package/api/src/locales/zh.ts +48 -0
  42. package/api/src/queues/auto-recharge.ts +295 -21
  43. package/api/src/queues/exchange-rate-health.ts +242 -0
  44. package/api/src/queues/invoice.ts +48 -1
  45. package/api/src/queues/notification.ts +190 -3
  46. package/api/src/queues/payment.ts +177 -7
  47. package/api/src/queues/subscription.ts +436 -6
  48. package/api/src/routes/auto-recharge-configs.ts +71 -6
  49. package/api/src/routes/checkout-sessions.ts +1730 -81
  50. package/api/src/routes/connect/auto-recharge-auth.ts +2 -0
  51. package/api/src/routes/connect/change-payer.ts +2 -0
  52. package/api/src/routes/connect/change-payment.ts +61 -8
  53. package/api/src/routes/connect/change-plan.ts +161 -17
  54. package/api/src/routes/connect/collect.ts +9 -6
  55. package/api/src/routes/connect/delegation.ts +1 -0
  56. package/api/src/routes/connect/pay.ts +157 -0
  57. package/api/src/routes/connect/setup.ts +32 -10
  58. package/api/src/routes/connect/shared.ts +159 -13
  59. package/api/src/routes/connect/subscribe.ts +32 -9
  60. package/api/src/routes/credit-grants.ts +99 -0
  61. package/api/src/routes/exchange-rate-providers.ts +248 -0
  62. package/api/src/routes/exchange-rates.ts +87 -0
  63. package/api/src/routes/index.ts +4 -0
  64. package/api/src/routes/invoices.ts +280 -2
  65. package/api/src/routes/meter-events.ts +3 -0
  66. package/api/src/routes/payment-links.ts +13 -0
  67. package/api/src/routes/prices.ts +84 -2
  68. package/api/src/routes/subscriptions.ts +526 -15
  69. package/api/src/store/migrations/20251220-dynamic-pricing.ts +245 -0
  70. package/api/src/store/migrations/20251223-exchange-rate-provider-type.ts +28 -0
  71. package/api/src/store/migrations/20260110-add-quote-locked-at.ts +23 -0
  72. package/api/src/store/migrations/20260112-add-checkout-session-slippage-percent.ts +22 -0
  73. package/api/src/store/migrations/20260113-add-price-quote-slippage-fields.ts +45 -0
  74. package/api/src/store/migrations/20260116-subscription-slippage.ts +21 -0
  75. package/api/src/store/migrations/20260120-auto-recharge-slippage.ts +21 -0
  76. package/api/src/store/models/auto-recharge-config.ts +12 -0
  77. package/api/src/store/models/checkout-session.ts +7 -0
  78. package/api/src/store/models/exchange-rate-provider.ts +225 -0
  79. package/api/src/store/models/index.ts +6 -0
  80. package/api/src/store/models/payment-intent.ts +6 -0
  81. package/api/src/store/models/price-quote.ts +284 -0
  82. package/api/src/store/models/price.ts +53 -5
  83. package/api/src/store/models/subscription.ts +11 -0
  84. package/api/src/store/models/types.ts +61 -1
  85. package/api/tests/libs/change-payment-plan.spec.ts +282 -0
  86. package/api/tests/libs/exchange-rate-service.spec.ts +341 -0
  87. package/api/tests/libs/quote-service.spec.ts +199 -0
  88. package/api/tests/libs/session.spec.ts +464 -0
  89. package/api/tests/libs/slippage.spec.ts +109 -0
  90. package/api/tests/libs/token-data-provider.spec.ts +267 -0
  91. package/api/tests/models/exchange-rate-provider.spec.ts +121 -0
  92. package/api/tests/models/price-dynamic.spec.ts +100 -0
  93. package/api/tests/models/price-quote.spec.ts +112 -0
  94. package/api/tests/routes/exchange-rate-providers.spec.ts +215 -0
  95. package/api/tests/routes/subscription-slippage.spec.ts +254 -0
  96. package/blocklet.yml +1 -1
  97. package/package.json +7 -6
  98. package/src/components/customer/credit-overview.tsx +14 -0
  99. package/src/components/discount/discount-info.tsx +8 -2
  100. package/src/components/invoice/list.tsx +146 -16
  101. package/src/components/invoice/table.tsx +276 -71
  102. package/src/components/invoice-pdf/template.tsx +3 -7
  103. package/src/components/metadata/form.tsx +6 -8
  104. package/src/components/price/form.tsx +519 -149
  105. package/src/components/promotion/active-redemptions.tsx +5 -3
  106. package/src/components/quote/info.tsx +234 -0
  107. package/src/hooks/subscription.ts +132 -2
  108. package/src/locales/en.tsx +145 -0
  109. package/src/locales/zh.tsx +143 -1
  110. package/src/pages/admin/billing/invoices/detail.tsx +41 -4
  111. package/src/pages/admin/products/exchange-rate-providers/edit-dialog.tsx +354 -0
  112. package/src/pages/admin/products/exchange-rate-providers/index.tsx +363 -0
  113. package/src/pages/admin/products/index.tsx +12 -1
  114. package/src/pages/customer/invoice/detail.tsx +36 -12
  115. package/src/pages/customer/subscription/change-payment.tsx +65 -3
  116. package/src/pages/customer/subscription/change-plan.tsx +207 -38
  117. package/src/pages/customer/subscription/detail.tsx +599 -419
@@ -5,12 +5,16 @@ import {
5
5
  Table,
6
6
  getPriceUintAmountByCurrency,
7
7
  formatNumber,
8
+ formatTime,
9
+ formatExchangeRate,
10
+ getUsdAmountFromTokenUnits,
11
+ formatUsdAmount,
8
12
  formatCreditForCheckout,
9
13
  } from '@blocklet/payment-react';
10
14
  import type { TInvoiceExpanded, TInvoiceItem } from '@blocklet/payment-types';
11
15
  import { InfoOutlined } from '@mui/icons-material';
12
16
  import { Box, Stack, Tooltip, Typography } from '@mui/material';
13
- import { toBN } from '@ocap/util';
17
+ import { toBN, fromUnitToToken, fromTokenToUnit, BN } from '@ocap/util';
14
18
  import { useSetState } from 'ahooks';
15
19
 
16
20
  import { styled } from '@mui/system';
@@ -37,6 +41,11 @@ type InvoiceDetailItem = {
37
41
  quantity: number;
38
42
  rawQuantity: number;
39
43
  price: string;
44
+ referencePrice?: {
45
+ amount: string;
46
+ currency: string;
47
+ decimals: number;
48
+ } | null;
40
49
  amount: string;
41
50
  raw: TInvoiceItem;
42
51
  price_id: string;
@@ -45,8 +54,11 @@ type InvoiceDetailItem = {
45
54
 
46
55
  type InvoiceSummaryItem = {
47
56
  key: string;
48
- value: string;
57
+ value: string | React.ReactNode;
49
58
  color: string;
59
+ usdReference?: string; // ≈ $X.XX format
60
+ tooltip?: React.ReactNode;
61
+ desc?: React.ReactNode;
50
62
  };
51
63
 
52
64
  export function getAppliedBalance(invoice: TInvoiceExpanded) {
@@ -71,11 +83,54 @@ export function getAppliedBalance(invoice: TInvoiceExpanded) {
71
83
  return '0';
72
84
  }
73
85
 
74
- export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => string) {
86
+ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => string, locale?: string) {
75
87
  const detail: InvoiceDetailItem[] = invoice.lines.map((line) => {
76
- const price = line.quantity
77
- ? toBN(line.amount).div(toBN(line.quantity)).toString()
78
- : getPriceUintAmountByCurrency(line.price, invoice.paymentCurrency) || line.amount;
88
+ // Calculate actual payment unit price (in payment currency)
89
+ // line.amount is in unit format (smallest unit)
90
+ // line.quantity can be fractional (e.g., 0.5)
91
+ let actualUnitPrice: string;
92
+ if (line.quantity) {
93
+ if (Number.isInteger(line.quantity)) {
94
+ // If quantity is integer, use BN for precision
95
+ actualUnitPrice = toBN(line.amount).div(toBN(line.quantity)).toString();
96
+ } else {
97
+ // If quantity is fractional, use floating point division
98
+ const amountToken = fromUnitToToken(line.amount, invoice.paymentCurrency.decimal);
99
+ const unitPriceToken = (Number(amountToken) / line.quantity).toFixed(invoice.paymentCurrency.decimal);
100
+ const unitPriceResult = fromTokenToUnit(unitPriceToken, invoice.paymentCurrency.decimal);
101
+ actualUnitPrice = typeof unitPriceResult === 'string' ? unitPriceResult : unitPriceResult.toString();
102
+ }
103
+ } else {
104
+ actualUnitPrice = getPriceUintAmountByCurrency(line.price, invoice.paymentCurrency) || line.amount;
105
+ }
106
+
107
+ // Get quote info from metadata if available (for dynamic pricing)
108
+ const quoteInfo = (line.metadata as any)?.quote;
109
+ let referencePrice: { amount: string; currency: string; decimals: number } | null = null;
110
+
111
+ if (quoteInfo?.base_amount && quoteInfo?.quantity) {
112
+ // Calculate USD unit price from quote
113
+ // base_amount is in USD with 8 decimals (token format, e.g., "10.50")
114
+ // quantity can be fractional (e.g., 0.5)
115
+ const USD_DECIMALS = 8;
116
+ const baseAmount = Number(quoteInfo.base_amount);
117
+ const quantity = Number(quoteInfo.quantity);
118
+
119
+ if (quantity > 0 && !Number.isNaN(baseAmount)) {
120
+ // Use floating point division for accuracy with fractional quantities
121
+ const unitPriceToken = (baseAmount / quantity).toFixed(USD_DECIMALS);
122
+ // Convert from token format to unit format (smallest unit)
123
+ const unitPriceUnitResult = fromTokenToUnit(unitPriceToken, USD_DECIMALS);
124
+ const unitPriceUnit =
125
+ typeof unitPriceUnitResult === 'string' ? unitPriceUnitResult : unitPriceUnitResult.toString();
126
+
127
+ referencePrice = {
128
+ amount: unitPriceUnit,
129
+ currency: quoteInfo.base_currency || 'USD',
130
+ decimals: USD_DECIMALS,
131
+ };
132
+ }
133
+ }
79
134
 
80
135
  const creditInfo = (line.price as any).credit;
81
136
  let credits: { total: number; currency: string } | undefined;
@@ -89,25 +144,143 @@ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => st
89
144
 
90
145
  return {
91
146
  id: line.id,
92
- product: `${line.description} ${
93
- line.price.product.unit_label ? ` (per ${line.price.product.unit_label})` : ''
94
- }`.trim(),
147
+ product: line.description.trim(),
95
148
  credits,
96
149
  quantity: line.quantity,
97
150
  rawQuantity: line.metadata?.quantity || 0,
98
- price: !line.proration ? formatAmount(price, invoice.paymentCurrency.decimal) : '',
99
- amount: formatAmount(line.amount, invoice.paymentCurrency.decimal),
151
+ price: !line.proration
152
+ ? `${formatAmount(actualUnitPrice, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`
153
+ : '',
154
+ referencePrice, // Add reference price (USD)
155
+ amount: `${formatAmount(line.amount, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
100
156
  raw: line,
101
157
  price_id: line.price.id,
102
158
  product_id: line.price.product.id,
103
159
  };
104
160
  });
105
161
 
162
+ const quoteLine = invoice.lines.find((line) => (line.metadata as any)?.quote?.exchange_rate);
163
+ const quoteInfo = quoteLine ? (quoteLine.metadata as any).quote : null;
164
+
165
+ // Calculate USD amount and exchange rate for subtotal
166
+ let subtotalUsdAmount: string | null = null;
167
+ let formattedRate: string | null = null;
168
+ let rateLine: string | null = null;
169
+ let exchangeRateTooltip: React.ReactNode = null;
170
+
171
+ if (quoteInfo) {
172
+ const providers = quoteInfo?.providers || [];
173
+ const providerNames = providers.map((provider: any) => provider.provider_name).filter(Boolean);
174
+ // Unified provider display format: "provider_name (n sources)" for multiple, specific name for single
175
+ let providerDisplay: string;
176
+ if (providers.length > 1) {
177
+ providerDisplay = `${providerNames[0] || providers[0]?.provider_id || 'data'} (${providers.length} sources)`;
178
+ } else if (providers.length === 1) {
179
+ providerDisplay = providerNames[0] || providers[0]?.provider_id || '—';
180
+ } else {
181
+ providerDisplay = quoteInfo?.rate_provider_name || quoteInfo?.rate_provider_id;
182
+ }
183
+ const rateTimestamp = quoteInfo?.rate_timestamp_ms ? formatTime(quoteInfo.rate_timestamp_ms) : '—';
184
+
185
+ // Calculate USD amount
186
+ if (quoteInfo.exchange_rate && invoice.subtotal) {
187
+ const calculatedUsd = getUsdAmountFromTokenUnits(
188
+ new BN(invoice.subtotal),
189
+ invoice.paymentCurrency.decimal,
190
+ quoteInfo.exchange_rate
191
+ );
192
+ if (calculatedUsd) {
193
+ subtotalUsdAmount = formatUsdAmount(calculatedUsd, locale);
194
+ }
195
+ }
196
+
197
+ // Format exchange rate
198
+ formattedRate = formatExchangeRate(quoteInfo.exchange_rate || null);
199
+ if (formattedRate) {
200
+ const currencyMap = {
201
+ USD: '$',
202
+ CNY: '¥',
203
+ };
204
+ const currencySymbol = currencyMap[quoteInfo.base_currency as keyof typeof currencyMap];
205
+ rateLine = `1 ${invoice.paymentCurrency.symbol} ≈ ${currencySymbol ? `${currencySymbol}${formattedRate}` : `${formattedRate} ${quoteInfo.base_currency || 'USD'}`}`;
206
+ }
207
+
208
+ // Create tooltip content (same style as list.tsx)
209
+ exchangeRateTooltip = (
210
+ <Stack spacing={0.5} sx={{ p: 1 }}>
211
+ <Stack direction="row" justifyContent="space-between" spacing={2}>
212
+ <Typography variant="caption" sx={{ color: 'text.secondary' }}>
213
+ {t('payment.customer.invoice.quote.providers')}:
214
+ </Typography>
215
+ <Typography variant="caption" sx={{ color: 'text.primary' }}>
216
+ {providerDisplay || '—'}
217
+ </Typography>
218
+ </Stack>
219
+ {rateLine && (
220
+ <Stack direction="row" justifyContent="space-between" spacing={2}>
221
+ <Typography variant="caption" sx={{ color: 'text.secondary' }}>
222
+ {t('payment.customer.invoice.quote.exchangeRate')}:
223
+ </Typography>
224
+ <Typography variant="caption" sx={{ color: 'text.primary' }}>
225
+ {rateLine}
226
+ </Typography>
227
+ </Stack>
228
+ )}
229
+ <Stack direction="row" justifyContent="space-between" spacing={2}>
230
+ <Typography variant="caption" sx={{ color: 'text.secondary' }}>
231
+ {t('payment.customer.invoice.quote.rateTimestamp')}:
232
+ </Typography>
233
+ <Typography variant="caption" sx={{ color: 'text.primary' }}>
234
+ {rateTimestamp}
235
+ </Typography>
236
+ </Stack>
237
+ </Stack>
238
+ );
239
+ }
240
+
241
+ // Build subtotal value with USD reference
242
+ const subtotalValue = (() => {
243
+ if (!subtotalUsdAmount || !rateLine) {
244
+ return null;
245
+ }
246
+ const rateText = (t as any)('payment.customer.invoice.quote.atPaymentRate', { rate: rateLine });
247
+ return (
248
+ <Stack alignItems="flex-end" sx={{ minWidth: '100px', textAlign: 'right' }}>
249
+ <Tooltip
250
+ title={exchangeRateTooltip}
251
+ placement="top"
252
+ arrow
253
+ slotProps={{
254
+ tooltip: {
255
+ sx: {
256
+ backgroundColor: 'background.paper',
257
+ boxShadow: 1,
258
+ },
259
+ },
260
+ }}>
261
+ <Typography
262
+ component="span"
263
+ variant="caption"
264
+ sx={{
265
+ color: 'text.secondary',
266
+ fontSize: '0.75rem',
267
+ fontWeight: 400,
268
+ lineHeight: 1.2,
269
+ cursor: 'help',
270
+ }}>
271
+ ≈ ${subtotalUsdAmount} {rateText}
272
+ </Typography>
273
+ </Tooltip>
274
+ </Stack>
275
+ );
276
+ })();
277
+
106
278
  const summary: InvoiceSummaryItem[] = [
107
279
  {
108
280
  key: 'common.subtotal',
109
- value: formatAmount(invoice.subtotal, invoice.paymentCurrency.decimal),
281
+ value: `${formatAmount(invoice.subtotal, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
110
282
  color: 'text.secondary',
283
+ desc: subtotalValue,
111
284
  },
112
285
  ];
113
286
 
@@ -149,7 +322,7 @@ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => st
149
322
 
150
323
  summary.push({
151
324
  key: discountLabel,
152
- value: `-${formattedDiscountAmount}`,
325
+ value: `-${formattedDiscountAmount} ${invoice.paymentCurrency.symbol}`,
153
326
  color: 'text.secondary',
154
327
  });
155
328
  }
@@ -159,13 +332,13 @@ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => st
159
332
 
160
333
  summary.push({
161
334
  key: 'common.total',
162
- value: formatAmount(invoice.total, invoice.paymentCurrency.decimal),
335
+ value: `${formatAmount(invoice.total, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
163
336
  color: 'text.secondary',
164
337
  });
165
338
  if (invoice.amount_paid !== '0') {
166
339
  summary.push({
167
340
  key: 'payment.customer.invoice.amountPaid',
168
- value: formatAmount(invoice.amount_paid, invoice.paymentCurrency.decimal),
341
+ value: `${formatAmount(invoice.amount_paid, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
169
342
  color: 'text.secondary',
170
343
  });
171
344
  }
@@ -174,13 +347,13 @@ export function getInvoiceRows(invoice: TInvoiceExpanded, t: (key: string) => st
174
347
  if (appliedBalance !== '0') {
175
348
  summary.push({
176
349
  key: 'payment.customer.invoice.amountApplied',
177
- value: formatAmount(appliedBalance, invoice.paymentCurrency.decimal),
350
+ value: `${formatAmount(appliedBalance, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
178
351
  color: 'text.secondary',
179
352
  });
180
353
  }
181
354
  summary.push({
182
355
  key: 'payment.customer.invoice.amountDue',
183
- value: formatAmount(invoice.amount_remaining, invoice.paymentCurrency.decimal),
356
+ value: `${formatAmount(invoice.amount_remaining, invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`,
184
357
  color: invoice.amount_remaining !== '0' ? 'text.primary' : 'text.secondary',
185
358
  });
186
359
 
@@ -194,7 +367,7 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
194
367
  const { t, locale } = useLocaleContext();
195
368
  const isAdmin = mode === 'admin';
196
369
  const navigate = useNavigate();
197
- const { detail, summary } = getInvoiceRows(invoice, t);
370
+ const { detail, summary } = getInvoiceRows(invoice, t, locale);
198
371
  const [state, setState] = useSetState({
199
372
  subscriptionId: '',
200
373
  subscriptionItemId: '',
@@ -225,6 +398,7 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
225
398
  options: {
226
399
  customBodyRenderLite: (_: string, index: number) => {
227
400
  const item = detail[index] as InvoiceDetailItem;
401
+
228
402
  return (
229
403
  <Box>
230
404
  <Typography
@@ -301,31 +475,20 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
301
475
  name: 'price',
302
476
  width: 200,
303
477
  align: 'right',
304
- },
305
-
306
- {
307
- label: t('common.discount'),
308
- name: 'discount',
309
- width: 200,
310
- align: 'right',
311
478
  options: {
312
479
  customBodyRenderLite: (_: string, index: number) => {
313
480
  const item = detail[index] as InvoiceDetailItem;
314
- // Calculate discount amount for this line item
315
- let itemDiscountAmount = toBN(0);
316
- if (item.raw.discount_amounts && item.raw.discount_amounts.length > 0) {
317
- item.raw.discount_amounts.forEach((discount: any) => {
318
- if (discount.amount) {
319
- itemDiscountAmount = itemDiscountAmount.add(toBN(discount.amount));
320
- }
321
- });
322
- }
323
-
324
481
  return (
325
- <Typography component="span" sx={{ color: 'text.secondary' }}>
326
- {itemDiscountAmount.gt(toBN(0))
327
- ? `-${formatAmount(itemDiscountAmount.toString(), invoice.paymentCurrency.decimal)}`
328
- : '-'}
482
+ <Typography
483
+ component="div"
484
+ variant="body2"
485
+ sx={{
486
+ textAlign: {
487
+ xs: 'left',
488
+ md: 'right',
489
+ },
490
+ }}>
491
+ {item.price}
329
492
  </Typography>
330
493
  );
331
494
  },
@@ -344,6 +507,7 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
344
507
  },
345
508
  },
346
509
  },
510
+
347
511
  ...(isAdmin
348
512
  ? [
349
513
  {
@@ -384,6 +548,34 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
384
548
  },
385
549
  ]
386
550
  : []),
551
+ {
552
+ label: t('common.discount'),
553
+ name: 'discount',
554
+ width: 200,
555
+ align: 'right',
556
+ options: {
557
+ customBodyRenderLite: (_: string, index: number) => {
558
+ const item = detail[index] as InvoiceDetailItem;
559
+ // Calculate discount amount for this line item
560
+ let itemDiscountAmount = toBN(0);
561
+ if (item.raw.discount_amounts && item.raw.discount_amounts.length > 0) {
562
+ item.raw.discount_amounts.forEach((discount: any) => {
563
+ if (discount.amount) {
564
+ itemDiscountAmount = itemDiscountAmount.add(toBN(discount.amount));
565
+ }
566
+ });
567
+ }
568
+
569
+ return (
570
+ <Typography component="span" sx={{ color: 'text.secondary' }}>
571
+ {itemDiscountAmount.gt(toBN(0))
572
+ ? `-${formatAmount(itemDiscountAmount.toString(), invoice.paymentCurrency.decimal)} ${invoice.paymentCurrency.symbol}`
573
+ : '-'}
574
+ </Typography>
575
+ );
576
+ },
577
+ },
578
+ },
387
579
  ...(simple
388
580
  ? []
389
581
  : [
@@ -447,40 +639,53 @@ export default function InvoiceTable({ invoice, simple = false, emptyNodeText =
447
639
  const showDivider = isTotal && index > 0;
448
640
 
449
641
  return (
450
- <Stack
451
- key={line.key}
452
- direction="row"
453
- sx={{
454
- justifyContent: 'flex-end',
455
- width: '100%',
456
- py: 0.5,
457
-
458
- ...(showDivider && {
459
- borderTop: '1px solid',
460
- borderTopColor: 'divider',
461
- pt: 1,
462
- mt: 0.5,
463
- }),
464
- }}>
465
- <Typography
466
- variant="body2"
642
+ <>
643
+ <Stack
644
+ key={line.key}
645
+ direction="row"
467
646
  sx={{
468
- color: 'text.primary',
469
- fontWeight: isTotal ? 500 : 400,
647
+ justifyContent: 'flex-end',
648
+ width: '100%',
649
+ py: 0.5,
650
+ gap: 2,
651
+
652
+ ...(showDivider && {
653
+ borderTop: '1px solid',
654
+ borderTopColor: 'divider',
655
+ pt: 1,
656
+ mt: 0.5,
657
+ }),
470
658
  }}>
471
- {line.key.startsWith('common.') || line.key.startsWith('payment.') ? t(line.key) : line.key}
472
- </Typography>
473
- <Typography
474
- variant="body2"
475
- sx={{
476
- color: 'text.primary',
477
- fontWeight: isTotal ? 500 : 400,
478
- minWidth: '80px',
479
- textAlign: 'right',
480
- }}>
481
- {line.value}
482
- </Typography>
483
- </Stack>
659
+ <Typography
660
+ variant="body2"
661
+ sx={{
662
+ color: 'text.primary',
663
+ fontWeight: isTotal ? 500 : 400,
664
+ }}>
665
+ {line.key.startsWith('common.') || line.key.startsWith('payment.') ? t(line.key) : line.key}
666
+ </Typography>
667
+ <Typography
668
+ variant="body2"
669
+ sx={{
670
+ color: 'text.primary',
671
+ fontWeight: isTotal ? 500 : 400,
672
+ minWidth: '100px',
673
+ textAlign: 'right',
674
+ }}>
675
+ {line.value}
676
+ </Typography>
677
+ </Stack>
678
+ {(() => {
679
+ if (line.tooltip && line.desc) {
680
+ return (
681
+ <Tooltip title={line.tooltip} placement="top" arrow>
682
+ <Box component="span">{line.desc}</Box>
683
+ </Tooltip>
684
+ );
685
+ }
686
+ return line.desc;
687
+ })()}
688
+ </>
484
689
  );
485
690
  })}
486
691
  </Box>
@@ -125,9 +125,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
125
125
  <span style={composeStyles('dark right')}>{line.quantity}</span>
126
126
  </div>
127
127
  <div style={composeStyles('w-15 p-4-8 pb-15')}>
128
- <span style={composeStyles('dark right')}>
129
- {line.price ? `${line.price} ${data.paymentCurrency.symbol}` : ''}
130
- </span>
128
+ <span style={composeStyles('dark right')}>{line.price || ''}</span>
131
129
  </div>
132
130
  <div style={composeStyles('w-15 p-4-8 pb-15')}>
133
131
  <span style={composeStyles('dark right')}>
@@ -139,9 +137,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
139
137
  </span>
140
138
  </div>
141
139
  <div style={composeStyles('w-17 p-4-8 pb-15')}>
142
- <span style={composeStyles('dark right')}>
143
- {line.amount} {data.paymentCurrency.symbol}
144
- </span>
140
+ <span style={composeStyles('dark right')}>{line.amount}</span>
145
141
  </div>
146
142
  </div>
147
143
  );
@@ -212,7 +208,7 @@ export function InvoiceTemplate({ data, t }: InvoicePDFProps) {
212
208
  ...composeStyles('bold dark text-right'),
213
209
  minWidth: '80px',
214
210
  }}>
215
- {line.value} {data.paymentCurrency.symbol}
211
+ {line.value}
216
212
  </span>
217
213
  </div>
218
214
  </div>
@@ -294,15 +294,13 @@ export default function MetadataForm({
294
294
  <Tooltip
295
295
  title={formatError || t('common.metadata.formatJson')}
296
296
  placement="top"
297
- componentsProps={{
297
+ slotProps={{
298
298
  tooltip: {
299
- sx: formatError
300
- ? {
301
- maxWidth: 300,
302
- backgroundColor: 'error.main',
303
- opacity: 0.8,
304
- }
305
- : {},
299
+ sx: {
300
+ maxWidth: 300,
301
+ backgroundColor: 'error.main',
302
+ opacity: 0.8,
303
+ },
306
304
  },
307
305
  }}>
308
306
  <IconButton