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)}
/>
setEditing(role)}>
{t('admin.roles.edit')}
diff --git a/frontend/src/shared/lib/i18n.ts b/frontend/src/shared/lib/i18n.ts
index c69ad05..cb11217 100644
--- a/frontend/src/shared/lib/i18n.ts
+++ b/frontend/src/shared/lib/i18n.ts
@@ -255,6 +255,7 @@ const resources = {
maxIpLimit: 'Лимит IP на конфиг',
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
totalPerQuarter: 'Итого / 3 мес',
+ totalHalfYear: 'Итого / полгода',
totalPerYear: 'Итого / год',
noPrice: '—',
system: 'системная',
@@ -268,10 +269,11 @@ const resources = {
},
pricing: {
title: 'Справочная цена конфига',
- pricePerConfigPerQuarter: 'Цена за конфиг / 3 мес',
- pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу.',
- pricePerConfigPerYear: 'Цена за конфиг / год',
- pricePerConfigPerYearHint: 'Задаётся независимо от квартальной цены (можно сделать скидку за год).',
+ pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
+ pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
+ pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
+ pricePerConfigPerYearHint: 'Может быть ниже квартальной (скидка за годовую оплату), но итог за год не должен быть дешевле итога за квартал.',
+ yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
updated: 'Цена обновлена.',
},
nodes: {
@@ -685,6 +687,7 @@ const resources = {
maxIpLimit: 'IP limit per config',
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
totalPerQuarter: 'Total / 3 months',
+ totalHalfYear: 'Total / 6 months',
totalPerYear: 'Total / year',
noPrice: '—',
system: 'system',
@@ -698,10 +701,11 @@ const resources = {
},
pricing: {
title: 'Reference config price',
- pricePerConfigPerQuarter: 'Price per config / 3 months',
- pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only.',
- pricePerConfigPerYear: 'Price per config / year',
- pricePerConfigPerYearHint: 'Set independently from the quarterly price (allows an annual discount).',
+ pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
+ pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
+ pricePerConfigPerYear: 'Price per config per month / billed yearly',
+ pricePerConfigPerYearHint: 'Can be lower than the quarterly rate (annual discount), but the yearly total must not be cheaper than the quarterly total.',
+ yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
updated: 'Pricing updated.',
},
nodes: {