Implement discount tiers for pricing settings and enhance related functionalities
CI / Backend (build + test) (push) Successful in 1m20s
CI / Frontend (lint + typecheck + build) (push) Successful in 32s

- Introduced a new `DiscountTierDto` to represent volume discount tiers, allowing roles with a config quota at or above specified thresholds to receive discounts on pricing.
- Updated the `PricingSettingsDto` to include a list of discount tiers, enhancing the pricing model to support more flexible pricing strategies.
- Modified the `GetPricingSettingsQueryHandler` and `UpdatePricingSettingsCommandHandler` to handle discount tiers, ensuring they are correctly retrieved and updated in the database.
- Enhanced validation in `UpdatePricingSettingsCommandValidator` to enforce uniqueness and progressive discount tiers, preventing invalid configurations.
- Updated frontend components to support the new discount tier functionality, including forms for adding and managing discount tiers in the admin interface.
- Revised API documentation to reflect the new discount tier features and their usage in pricing settings.
This commit is contained in:
Leonid Pershin
2026-07-19 15:51:01 +03:00
parent 6a2d2d2318
commit 0dcaf1203f
27 changed files with 1645 additions and 43 deletions
@@ -9,6 +9,8 @@ import { HttpError } from '@/shared/api/client'
import type { PricingSettingsDto } from '@/shared/api/types'
import { updatePricingSettings } from './api'
type TierRow = { minConfigs: string; discountPercent: string }
export function PricingSettingsEditor({ settings }: { settings: PricingSettingsDto }) {
const { t } = useTranslation()
const queryClient = useQueryClient()
@@ -21,6 +23,31 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState(
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
)
const [tiers, setTiers] = useState<TierRow[]>(
settings.discountTiers.map((t) => ({ minConfigs: String(t.minConfigs), discountPercent: String(t.discountPercent) })),
)
const updateTier = (index: number, patch: Partial<TierRow>) =>
setTiers((rows) => rows.map((row, i) => (i === index ? { ...row, ...patch } : row)))
const removeTier = (index: number) => setTiers((rows) => rows.filter((_, i) => i !== index))
const addTier = () => setTiers((rows) => [...rows, { minConfigs: '', discountPercent: '' }])
// Зеркалит бэкенд-инварианты UpdatePricingSettingsCommandValidator: пороги не повторяются, скидка
// на большем пороге не меньше скидки на меньшем (иначе лесенка не прогрессивная).
const tierErrors = (() => {
if (tiers.some((t) => t.minConfigs === '' || t.discountPercent === '')) return null
const parsed = tiers.map((t) => ({ minConfigs: Number(t.minConfigs), discountPercent: Number(t.discountPercent) }))
if (parsed.some((t) => !Number.isInteger(t.minConfigs) || t.minConfigs < 1)) return t('admin.pricing.discountTierInvalidMinConfigs')
if (parsed.some((t) => !Number.isInteger(t.discountPercent) || t.discountPercent < 1 || t.discountPercent > 99))
return t('admin.pricing.discountTierInvalidPercent')
if (new Set(parsed.map((t) => t.minConfigs)).size !== parsed.length) return t('admin.pricing.discountTiersDuplicate')
const sorted = [...parsed].sort((a, b) => a.minConfigs - b.minConfigs)
for (let i = 1; i < sorted.length; i++) {
if (sorted[i].discountPercent < sorted[i - 1].discountPercent) return t('admin.pricing.discountTiersNotProgressive')
}
return null
})()
const hasIncompleteTier = tiers.some((t) => t.minConfigs === '' || t.discountPercent === '')
// Все ставки — цена за конфиг в месяц; итог за более длинный период (ставка × месяцев) не должен
// быть дешевле итога за более короткий, иначе выгоднее купить длинный тариф и не продлевать.
@@ -48,6 +75,7 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear),
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
tiers.map((t) => ({ minConfigs: Number(t.minConfigs), discountPercent: Number(t.discountPercent) })),
),
onSuccess: async () => {
toast.success(t('admin.pricing.updated'))
@@ -106,8 +134,40 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
)}
</div>
<div className="flex flex-col gap-2">
<Label>{t('admin.pricing.discountTiersTitle')}</Label>
<p className="text-xs text-muted-foreground">{t('admin.pricing.discountTiersHint')}</p>
{tiers.map((tier, index) => (
<div key={index} className="flex items-center gap-2">
<Input
type="number"
min={1}
placeholder={t('admin.pricing.discountTierMinConfigs')}
value={tier.minConfigs}
onChange={(e) => updateTier(index, { minConfigs: e.target.value })}
/>
<Input
type="number"
min={1}
max={99}
placeholder={t('admin.pricing.discountTierPercent')}
value={tier.discountPercent}
onChange={(e) => updateTier(index, { discountPercent: e.target.value })}
/>
<Button type="button" variant="ghost" size="sm" onClick={() => removeTier(index)}>
{t('admin.pricing.removeDiscountTier')}
</Button>
</div>
))}
{tierErrors && <p className="text-xs text-red-600">{tierErrors}</p>}
<div>
<Button type="button" variant="outline" size="sm" onClick={addTier}>
{t('admin.pricing.addDiscountTier')}
</Button>
</div>
</div>
<div>
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo}>
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo || !!tierErrors || hasIncompleteTier}>
{t('admin.roles.save')}
</Button>
</div>
+3 -2
View File
@@ -1,5 +1,5 @@
import { apiRequest } from '@/shared/api/client'
import type { PricingSettingsDto } from '@/shared/api/types'
import type { DiscountTierDto, PricingSettingsDto } from '@/shared/api/types'
export function getPricingSettings() {
return apiRequest<PricingSettingsDto>('/admin/pricing')
@@ -9,9 +9,10 @@ export function updatePricingSettings(
pricePerConfigPerQuarter: number | null,
pricePerConfigPerHalfYear: number | null,
pricePerConfigPerYear: number | null,
discountTiers: DiscountTierDto[],
) {
return apiRequest<PricingSettingsDto>('/admin/pricing', {
method: 'PUT',
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear },
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear, discountTiers },
})
}
@@ -9,6 +9,7 @@ import { Label } from '@/shared/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogTrigger } from '@/shared/ui/dialog'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/shared/ui/select'
import { HttpError } from '@/shared/api/client'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import { createRoleRequestTicket, getSupportPricing, listSelectableRoles } from './api'
type Mode = 'existing' | 'new'
@@ -80,11 +81,19 @@ export function CreateRoleRequestDialog() {
? Number(newRoleMaxConfigs)
: undefined
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота.
const totalPrice = (monthlyRate: number | null | undefined, months: number) =>
monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0
? t('admin.roles.noPrice')
: `${monthlyRate * months * maxConfigsForPricing}`
// Ставки — цена за конфиг В МЕСЯЦ; итог за период = ставка × месяцев × квота, затем скидка по
// лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов та же лесенка, что в
// admin/roles.tsx и на реальной оплате (CreatePaymentRequestCommandHandler).
const totalPrice = (monthlyRate: number | null | undefined, months: number) => {
if (monthlyRate == null || maxConfigsForPricing == null || maxConfigsForPricing < 0) return t('admin.roles.noPrice')
const original = monthlyRate * months * maxConfigsForPricing
const percent = resolveDiscountPercent(pricingQuery.data?.discountTiers ?? [], maxConfigsForPricing)
if (percent <= 0) return `${original}`
const discounted = applyDiscount(original, percent)
return t('support.pricingDiscounted', { price: discounted, original, percent })
}
const showPricing = maxConfigsForPricing != null && maxConfigsForPricing >= 0
+24 -6
View File
@@ -8,6 +8,7 @@ import { Badge } from '@/shared/ui/badge'
import { listRoles, deleteRole } from '@/features/admin/roles/api'
import { RoleFormDialog } from '@/features/admin/roles/RoleFormDialog'
import { getPricingSettings } from '@/features/admin/pricing/api'
import { applyDiscount, resolveDiscountPercent } from '@/shared/lib/pricing'
import type { RoleDto } from '@/shared/api/types'
export const Route = createFileRoute('/admin/roles')({ component: AdminRolesPage })
@@ -20,9 +21,26 @@ function AdminRolesPage() {
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
// Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота.
const totalPrice = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) =>
monthlyRate == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${monthlyRate * months * maxConfigs}`
// Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота,
// затем скидка по лесенке (см. shared/lib/pricing) — за роль с большей квотой конфигов.
const priceCell = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => {
if (monthlyRate == null || maxConfigs < 0) return <span>{t('admin.roles.noPrice')}</span>
const original = monthlyRate * months * maxConfigs
const percent = resolveDiscountPercent(pricing?.discountTiers ?? [], maxConfigs)
if (percent <= 0) return <span>{original} </span>
const discounted = applyDiscount(original, percent)
return (
<span className="flex flex-col">
<span className="text-xs text-muted-foreground line-through">{original} </span>
<span className="flex items-center gap-1">
{discounted}
<Badge variant="success">-{percent}%</Badge>
</span>
</span>
)
}
const deleteMutation = useMutation({
mutationFn: deleteRole,
@@ -74,9 +92,9 @@ function AdminRolesPage() {
</td>
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerHalfYear, role.maxConfigs, 6)}</td>
<td className="py-2">{priceCell(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
<td className="py-2 text-right">
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
{t('admin.roles.edit')}
+10 -1
View File
@@ -178,12 +178,21 @@ export type RoleDto = {
billingEnabled: boolean
}
/** Одна ступень скидочной лесенки за объём: роль с квотой maxConfigs >= minConfigs получает скидку
* discountPercent от итоговой цены периода. Действует наивысший подходящий порог (не суммируется). */
export type DiscountTierDto = {
minConfigs: number
discountPercent: number
}
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы. */
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы, скидка
* (discountTiers) применяется к этому итогу по квоте роли — см. resolveDiscountPercent/applyDiscount. */
export type PricingSettingsDto = {
pricePerConfigPerQuarter: number | null
pricePerConfigPerHalfYear: number | null
pricePerConfigPerYear: number | null
discountTiers: DiscountTierDto[]
}
export type PaymentPeriod = 'Quarter' | 'HalfYear' | 'Year'
+22
View File
@@ -188,6 +188,7 @@ const resources = {
pricingHalfYear: 'Полгода: {{price}}',
pricingYear: 'Год: {{price}}',
pricingDisclaimer: 'Цены на данный момент ознакомительные.',
pricingDiscounted: '{{price}} ₽ (вместо {{original}} ₽, скидка {{percent}}%)',
justification: 'Обоснование',
ticketCreated: 'Обращение отправлено.',
roleRequestPending: 'У вас уже есть необработанная заявка на роль.',
@@ -340,6 +341,16 @@ const resources = {
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
discountTiersTitle: 'Скидка за объём',
discountTiersHint: 'Роль с квотой конфигов не меньше порога получает указанную скидку от итоговой цены за период. Действует наивысший подходящий порог, скидки не суммируются.',
discountTierMinConfigs: 'От скольки конфигов',
discountTierPercent: 'Скидка, %',
addDiscountTier: 'Добавить ступень',
removeDiscountTier: 'Удалить',
discountTierInvalidMinConfigs: 'Порог — целое число конфигов, не меньше 1.',
discountTierInvalidPercent: 'Скидка — целое число от 1 до 99.',
discountTiersDuplicate: 'Пороги скидочной лесенки не должны повторяться.',
discountTiersNotProgressive: 'Скидка на более высоком пороге не может быть меньше скидки на более низком.',
},
billing: {
settingsTitle: 'Настройки биллинга',
@@ -715,6 +726,7 @@ const resources = {
pricingHalfYear: '6 months: {{price}}',
pricingYear: 'Year: {{price}}',
pricingDisclaimer: 'Prices are indicative only at this time.',
pricingDiscounted: '{{price}} ₽ (instead of {{original}} ₽, {{percent}}% off)',
justification: 'Justification',
ticketCreated: 'Ticket submitted.',
roleRequestPending: 'You already have a pending role request.',
@@ -867,6 +879,16 @@ const resources = {
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
discountTiersTitle: 'Volume discount',
discountTiersHint: 'A role with a config quota at or above the threshold gets the listed discount off the period total. The highest applicable threshold wins — discounts do not stack.',
discountTierMinConfigs: 'From how many configs',
discountTierPercent: 'Discount, %',
addDiscountTier: 'Add tier',
removeDiscountTier: 'Remove',
discountTierInvalidMinConfigs: 'Threshold must be a whole number of configs, at least 1.',
discountTierInvalidPercent: 'Discount must be a whole number from 1 to 99.',
discountTiersDuplicate: 'Discount tier thresholds cannot repeat.',
discountTiersNotProgressive: 'A higher threshold cannot have a smaller discount than a lower one.',
},
billing: {
settingsTitle: 'Billing settings',
+14
View File
@@ -0,0 +1,14 @@
import type { DiscountTierDto } from '@/shared/api/types'
/** Зеркалит backend PricingDiscount.ResolvePercent — действует наивысший порог, квоте не
* превышающий (не суммируется с другими). 0, если тиров нет или квота ниже всех порогов. */
export function resolveDiscountPercent(tiers: DiscountTierDto[], maxConfigs: number): number {
const applicable = tiers.filter((t) => maxConfigs >= t.minConfigs).sort((a, b) => b.minConfigs - a.minConfigs)
return applicable[0]?.discountPercent ?? 0
}
/** Зеркалит backend PricingDiscount.Apply — округление к ближайшему целому, .5 от нуля. */
export function applyDiscount(amount: number, discountPercent: number): number {
if (discountPercent <= 0) return amount
return Math.round((amount * (100 - discountPercent)) / 100)
}