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:
+3
-3
@@ -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
@@ -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