Implement discount tiers for pricing settings and enhance related functionalities
- 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:
@@ -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'
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
Reference in New Issue
Block a user