From 6dfd51ae7c306e6bf44d1ab11a4d68ead8f07c6f Mon Sep 17 00:00:00 2001 From: Leonid Pershin Date: Sat, 18 Jul 2026 19:56:21 +0300 Subject: [PATCH] Enhance pricing validation and update related components - Added validation logic in `UpdatePricingSettingsCommandValidator` to ensure the annual price does not fall below the equivalent quarterly price, preventing potential pricing discrepancies. - Updated `PricingSettings` model documentation to clarify that both pricing fields represent monthly rates, with calculations for total costs based on the number of months. - Modified frontend components to reflect the new validation, including error messages when the annual price is cheaper than the quarterly price. - Adjusted API documentation to accurately describe the pricing structure and validation rules for the pricing endpoints. --- .../UpdatePricingSettingsCommandValidator.cs | 14 +++++++++++ .../Pricing/PricingSettings.cs | 8 ++++++- docs/api-design.md | 4 ++-- docs/domain-model.md | 23 ++++++++++++++----- .../admin/pricing/PricingSettingsEditor.tsx | 12 +++++++++- frontend/src/routes/admin/roles.tsx | 11 +++++---- frontend/src/shared/lib/i18n.ts | 20 +++++++++------- 7 files changed, 70 insertions(+), 22 deletions(-) diff --git a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs index d8e6926..47f23a5 100644 --- a/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs +++ b/backend/src/PnvPanel.Application/Admin/Pricing/UpdatePricingSettingsCommandValidator.cs @@ -13,5 +13,19 @@ public sealed class UpdatePricingSettingsCommandValidator RuleFor(x => x.PricePerConfigPerYear!.Value) .GreaterThanOrEqualTo(0) .When(x => x.PricePerConfigPerYear.HasValue); + + // Обе ставки — цена за конфиг В МЕСЯЦ при соответствующем тарифе оплаты; итог за период = + // ставка × число месяцев. Годовая ставка может быть ниже квартальной (скидка за годовую + // оплату), но итог за год (ставка×12) не должен быть дешевле итога за квартал (ставка×3) — + // иначе выгоднее купить "год" и не продлевать, чем платить за квартал. + RuleFor(x => x.PricePerConfigPerYear) + .Must( + (command, year) => + !year.HasValue + || !command.PricePerConfigPerQuarter.HasValue + || (decimal)year.Value * 12 >= (decimal)command.PricePerConfigPerQuarter.Value * 3 + ) + .WithMessage("Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.") + .When(x => x.PricePerConfigPerQuarter.HasValue && x.PricePerConfigPerYear.HasValue); } } diff --git a/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs index de6e773..eb9ac2a 100644 --- a/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs +++ b/backend/src/PnvPanel.Domain/Pricing/PricingSettings.cs @@ -5,12 +5,18 @@ namespace PnvPanel.Domain.Pricing; /// /// Единственная строка в таблице — глобальная справочная цена за один конфиг, редактируется админом. /// Не биллинг: без статусов оплаты, дат окончания, интеграций с платёжными системами. Итоговая цена -/// для роли (цена × MaxConfigs) считается на фронте, здесь не хранится. +/// за период считается на фронте (ставка × MaxConfigs × число месяцев в периоде), здесь не хранится. /// public sealed class PricingSettings : Entity { + /// Цена за конфиг В МЕСЯЦ при оплате раз в 3 месяца (квартальный тариф). public int? PricePerConfigPerQuarter { get; private set; } + + /// Цена за конфиг В МЕСЯЦ при оплате раз в год (годовой тариф). Может быть ниже + /// квартальной ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12) + /// оказался дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator. public int? PricePerConfigPerYear { get; private set; } + public DateTimeOffset UpdatedAt { get; private set; } private PricingSettings() { } diff --git a/docs/api-design.md b/docs/api-design.md index 338e914..33be0cc 100644 --- a/docs/api-design.md +++ b/docs/api-design.md @@ -248,8 +248,8 @@ reject/approve владением тикета не ограничены. Еди | PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` | | 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?, pricePerConfigPerYear? }` | `PricingSettingsDto` | +| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) | +| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`409`/`400`, если годовая ставка×12 дешевле квартальной×3) | Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через approve/reject над `ActivationRequest`. diff --git a/docs/domain-model.md b/docs/domain-model.md index 3e245d5..ef76079 100644 --- a/docs/domain-model.md +++ b/docs/domain-model.md @@ -305,14 +305,25 @@ UI **настойчиво напоминает** привязать его (ед | Поле | Тип | Заметки | | --------------------------- | ----------------- | --------------------------------------------------- | | `Id` | `Guid` | PK | -| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг за минимальный период оплаты (3 месяца), руб. | -| `PricePerConfigPerYear` | `int?` | Цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку) | +| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. | +| `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже квартальной (скидка за годовую оплату) | | `UpdatedAt` | `DateTimeOffset` | | -Итоговая цена для роли = `цена × AppRole.MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес → -600₽/3мес; 400₽/конфиг/год → 1200₽/год) — считается на фронте (таблица ролей в админке), нигде не -хранится. Для ролей с `MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается -как «не задано». +Оба поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × число_месяцев × +AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не хранится: +- 3 месяца = `PricePerConfigPerQuarter × 3 × MaxConfigs` +- полгода = `PricePerConfigPerQuarter × 6 × MaxConfigs` (используется квартальная ставка — отдельного + полугодового тарифа нет, это просто два квартальных периода подряд) +- год = `PricePerConfigPerYear × 12 × MaxConfigs` + +Например, `user` с `MaxConfigs=3`, ставка 200₽/мес по квартальному тарифу → 600₽/3мес, 1200₽/полгода; +при равной годовой ставке (200₽/мес) → 2400₽/год (линейный рост, скидки нет). Для ролей с +`MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается как «не задано». + +**Инвариант**: `UpdatePricingSettingsCommandValidator` не даёт сохранить годовую ставку настолько +низкой, что итог за год (`×12`) окажется дешевле итога за квартал (`×3`) — иначе выгоднее купить «год» +и не продлевать, чем платить за квартал. Формально: `PricePerConfigPerYear × 12 ≥ +PricePerConfigPerQuarter × 3`. `GET/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в `GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO). diff --git a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx index 28cab2d..0823bae 100644 --- a/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx +++ b/frontend/src/features/admin/pricing/PricingSettingsEditor.tsx @@ -19,6 +19,13 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '', ) + // Обе ставки — цена за конфиг в месяц; итог за год (ставка×12) не должен быть дешевле итога за + // квартал (ставка×3), иначе выгоднее купить "год" и не продлевать, чем платить за квартал. + const isYearCheaperThanQuarter = + pricePerConfigPerQuarter !== '' && + pricePerConfigPerYear !== '' && + Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerQuarter) * 3 + const mutation = useMutation({ mutationFn: () => updatePricingSettings( @@ -61,9 +68,12 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD onChange={(e) => setPricePerConfigPerYear(e.target.value)} />

{t('admin.pricing.pricePerConfigPerYearHint')}

+ {isYearCheaperThanQuarter && ( +

{t('admin.pricing.yearCheaperThanQuarter')}

+ )}
-
diff --git a/frontend/src/routes/admin/roles.tsx b/frontend/src/routes/admin/roles.tsx index 53eb2bc..64ca125 100644 --- a/frontend/src/routes/admin/roles.tsx +++ b/frontend/src/routes/admin/roles.tsx @@ -20,8 +20,9 @@ function AdminRolesPage() { const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles }) const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings }) - const totalPrice = (price: number | null | undefined, maxConfigs: number) => - price == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${price * maxConfigs} ₽` + // Ставки — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцев × квота. + const totalPrice = (monthlyRate: number | null | undefined, maxConfigs: number, months: number) => + monthlyRate == null || maxConfigs < 0 ? t('admin.roles.noPrice') : `${monthlyRate * months * maxConfigs} ₽` const deleteMutation = useMutation({ mutationFn: deleteRole, @@ -58,6 +59,7 @@ function AdminRolesPage() { {t('admin.roles.maxConfigs')} {t('admin.roles.maxIpLimit')} {t('admin.roles.totalPerQuarter')} + {t('admin.roles.totalHalfYear')} {t('admin.roles.totalPerYear')} @@ -71,8 +73,9 @@ function AdminRolesPage() { {role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs} {role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit} - {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs)} - {totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs)} + {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)} + {totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 6)} + {totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}