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
+3 -3
View File
@@ -169,7 +169,7 @@ status, createdAt }`. `expiresAt` всегда `null` (лимиты по сро
| Метод | Путь | Тело запроса | Тело ответа |
| ----- | ----------------------------------------- | ---------------------------------------------------------------------------- | ------------- |
| GET | `/api/support/roles` | — | `RoleDto[]` (без `admin` и без текущей роли пользователя) — для выбора существующей роли в заявке |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена, что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
| GET | `/api/support/pricing` | — | `PricingSettingsDto` — та же цена (включая скидочную лесенку `discountTiers`), что и `/api/admin/pricing`, для справки в диалоге заявки на роль |
| GET | `/api/support/tickets` | query: `type?, status?, page=1, pageSize=20` | `PagedList<TicketSummaryDto>` (только свои) |
| GET | `/api/support/tickets/{id}` | — | `TicketDetailDto` (404, если не свой) |
| POST | `/api/support/tickets/bug-reports` | multipart: `message` + `files[]` (до 5, изображения до 5 МБ) | `TicketDetailDto` |
@@ -266,8 +266,8 @@ reject/approve владением тикета не ограничены. Еди
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit, billingEnabled }` | `RoleDto` (то же ограничение на `admin`; включение `billingEnabled` ретроактивно выдаёт грейс-период уже назначенным пользователям без `PaidUntil`) |
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого) |
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц** + скидочная лесенка `discountTiers: { minConfigs, discountPercent }[]`, одна на весь сервис — не per-роль) |
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerHalfYear?, pricePerConfigPerYear?, discountTiers: { minConfigs, discountPercent }[] }` | `PricingSettingsDto` (`400`, если итог более длинного тарифа дешевле итога более короткого, либо `discountTiers` не уникальны/не прогрессивны — см. domain-model.md#pricingdiscounttier). `discountTiers` при сохранении полностью заменяет прежний набор |
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
approve/reject над `ActivationRequest`.
+35 -3
View File
@@ -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` | |