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:
+35
-3
@@ -335,8 +335,8 @@ UI **настойчиво напоминает** привязать его (ед
|
||||
- год = `PricePerConfigPerYear × 12 × MaxConfigs`
|
||||
|
||||
Например, `user` с `MaxConfigs=3` и одинаковой ставкой 200₽/мес на всех трёх тарифах → 600₽/3мес,
|
||||
1200₽/полгода, 2400₽/год (линейный рост, скидки нет). Для ролей с `MaxConfigs = -1` (unlimited, в
|
||||
т.ч. `admin`) итог не считается — отображается как «не задано».
|
||||
1200₽/полгода, 2400₽/год (линейный рост, скидки за тариф нет). Для ролей с `MaxConfigs = -1`
|
||||
(unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано».
|
||||
|
||||
**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить более длинный тариф настолько
|
||||
дешёвым, что его итог окажется дешевле итога более короткого — иначе выгоднее купить длинный тариф и
|
||||
@@ -355,6 +355,38 @@ PricePerConfigPerQuarter × 3`).
|
||||
admin- и user-facing путями безопасно. Сидируется пустой строкой при старте (`IPricingSettingsSeeder`,
|
||||
если таблица пуста) и заново после полного сброса панели (см. «Полный сброс панели» выше).
|
||||
|
||||
#### PricingDiscountTier — скидка за объём (лесенка порогов)
|
||||
|
||||
Стимул брать роль с бОльшей квотой конфигов разом: плоская таблица (не навигационная коллекция —
|
||||
см. конвенцию проекта на TicketComment) с FK на `PricingSettingsId`, глобальная, не привязана к
|
||||
конкретной роли — как и сам `PricingSettings`.
|
||||
|
||||
| Поле | Тип | Заметки |
|
||||
| ------------------- | -------- | ------------------------------------------------------------------ |
|
||||
| `Id` | `Guid` | PK |
|
||||
| `PricingSettingsId` | `Guid` | FK → PricingSettings |
|
||||
| `MinConfigs` | `int` | Порог: скидка действует при `AppRole.MaxConfigs >= MinConfigs` |
|
||||
| `DiscountPercent` | `int` | Скидка в процентах от итоговой цены периода, 1–99 |
|
||||
|
||||
Действует **наивысший подходящий порог** (не суммируется с другими) — `PricingDiscount.ResolvePercent`
|
||||
(`Domain/Pricing`): из тиров с `MinConfigs <= MaxConfigs` берётся тот, у которого `MinConfigs`
|
||||
максимален. Например, при порогах `3+ → 5%` и `6+ → 10%` роль с `MaxConfigs=8` получает 10%, а не 15%.
|
||||
Скидка применяется к уже посчитанному итогу периода: `PricingDiscount.Apply(итог, процент)`, округление
|
||||
до целого рубля (`MidpointRounding.AwayFromZero`). Роли с `MaxConfigs = -1` (unlimited) скидку не
|
||||
получают — как и обычный расчёт цены, для них итог не считается.
|
||||
|
||||
**Инвариант**: `UpdatePricingSettingsCommandValidator` требует уникальности порогов и прогрессивности
|
||||
лесенки — на более высоком пороге скидка не может быть меньше, чем на более низком (иначе взять роль с
|
||||
бОльшей квотой может оказаться менее выгодно, что противоречит смыслу скидки за объём).
|
||||
|
||||
Применяется в двух местах, зеркалящих друг друга: реальная оплата (`CreatePaymentRequestCommandHandler`
|
||||
— `AmountSnapshot` уже с учётом скидки) и ознакомительная оценка (`PricingSettingsDto.DiscountTiers` +
|
||||
`resolveDiscountPercent`/`applyDiscount` на фронте, `frontend/src/shared/lib/pricing.ts`) — используется
|
||||
и в списке ролей в админке (`admin/roles.tsx`), и в оценке стоимости при смене роли тикетом
|
||||
(`CreateRoleRequestDialog.tsx`). `UpdatePricingSettingsCommand` при сохранении полностью заменяет набор
|
||||
тиров (удаляет старые, вставляет новые) — операция редкая (правит только `admin`), сложность
|
||||
инкрементального diff не оправдана.
|
||||
|
||||
### Billing — подписка по сроку
|
||||
|
||||
Опциональная подсистема: включается per-роль (`AppRole.BillingEnabled`), недоступна для `admin`.
|
||||
@@ -393,7 +425,7 @@ Singleton (как `PricingSettings`) — реквизиты для оплаты
|
||||
| `Id` | `Guid` | PK |
|
||||
| `UserId` | `Guid` | FK → AppUser (заявитель) |
|
||||
| `Period` | `PaymentPeriod` | `Quarter` (3 мес) / `HalfYear` (6 мес) / `Year` (12 мес) |
|
||||
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`. Последующее изменение прайса админом не меняет уже созданные заявки |
|
||||
| `AmountSnapshot` | `int` | Сумма, замороженная на момент создания: `ставка PricingSettings за период × MaxConfigs роли × число месяцев`, затем скидка по лесенке `PricingDiscountTier` (см. выше), если применима. Последующее изменение прайса/лесенки админом не меняет уже созданные заявки |
|
||||
| `Status` | `PaymentRequestStatus` | `AwaitingPayment` → `AwaitingConfirmation` → `Confirmed`/`Rejected`, либо `Cancelled` из `AwaitingPayment` |
|
||||
| `DecidedBy`/`DecidedAt`/`RejectionReason` | | Кто/когда решил, причина отказа (опционально) |
|
||||
| `CreatedAt` | `DateTimeOffset` | |
|
||||
|
||||
Reference in New Issue
Block a user