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.
This commit is contained in:
+14
@@ -13,5 +13,19 @@ public sealed class UpdatePricingSettingsCommandValidator
|
|||||||
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
RuleFor(x => x.PricePerConfigPerYear!.Value)
|
||||||
.GreaterThanOrEqualTo(0)
|
.GreaterThanOrEqualTo(0)
|
||||||
.When(x => x.PricePerConfigPerYear.HasValue);
|
.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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,12 +5,18 @@ namespace PnvPanel.Domain.Pricing;
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Единственная строка в таблице — глобальная справочная цена за один конфиг, редактируется админом.
|
/// Единственная строка в таблице — глобальная справочная цена за один конфиг, редактируется админом.
|
||||||
/// Не биллинг: без статусов оплаты, дат окончания, интеграций с платёжными системами. Итоговая цена
|
/// Не биллинг: без статусов оплаты, дат окончания, интеграций с платёжными системами. Итоговая цена
|
||||||
/// для роли (цена × MaxConfigs) считается на фронте, здесь не хранится.
|
/// за период считается на фронте (ставка × MaxConfigs × число месяцев в периоде), здесь не хранится.
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public sealed class PricingSettings : Entity
|
public sealed class PricingSettings : Entity
|
||||||
{
|
{
|
||||||
|
/// <summary>Цена за конфиг В МЕСЯЦ при оплате раз в 3 месяца (квартальный тариф).</summary>
|
||||||
public int? PricePerConfigPerQuarter { get; private set; }
|
public int? PricePerConfigPerQuarter { get; private set; }
|
||||||
|
|
||||||
|
/// <summary>Цена за конфиг В МЕСЯЦ при оплате раз в год (годовой тариф). Может быть ниже
|
||||||
|
/// квартальной ставки (скидка за годовую оплату), но не настолько, чтобы итог за год (×12)
|
||||||
|
/// оказался дешевле итога за квартал (×3) — см. UpdatePricingSettingsCommandValidator.</summary>
|
||||||
public int? PricePerConfigPerYear { get; private set; }
|
public int? PricePerConfigPerYear { get; private set; }
|
||||||
|
|
||||||
public DateTimeOffset UpdatedAt { get; private set; }
|
public DateTimeOffset UpdatedAt { get; private set; }
|
||||||
|
|
||||||
private PricingSettings() { }
|
private PricingSettings() { }
|
||||||
|
|||||||
+2
-2
@@ -248,8 +248,8 @@ reject/approve владением тикета не ограничены. Еди
|
|||||||
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
| PUT | `/api/admin/roles/{id}` | admin | `{ maxConfigs, maxIpLimit }` | `RoleDto` |
|
||||||
| DELETE | `/api/admin/roles/{id}` | admin | — | `204 No Content` (системные `admin`/`user` удалить нельзя) |
|
| 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`, новая роль другая, и это единственный админ) |
|
| PATCH | `/api/admin/users/{id}/role` | admin | `{ roleId }` | `204 No Content` (`409 Roles.CannotRemoveLastAdmin`, если у цели сейчас `admin`, новая роль другая, и это единственный админ) |
|
||||||
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг, одна на весь сервис — не per-роль) |
|
| GET | `/api/admin/pricing` | admin | — | `PricingSettingsDto` (глобальная справочная цена за конфиг **в месяц**, одна на весь сервис — не per-роль) |
|
||||||
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `PricingSettingsDto` |
|
| PUT | `/api/admin/pricing` | admin | `{ pricePerConfigPerQuarter?, pricePerConfigPerYear? }` | `PricingSettingsDto` (`409`/`400`, если годовая ставка×12 дешевле квартальной×3) |
|
||||||
|
|
||||||
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
Нет отдельного эндпоинта «активировать напрямую без запроса» — активация только через
|
||||||
approve/reject над `ActivationRequest`.
|
approve/reject над `ActivationRequest`.
|
||||||
|
|||||||
+17
-6
@@ -305,14 +305,25 @@ UI **настойчиво напоминает** привязать его (ед
|
|||||||
| Поле | Тип | Заметки |
|
| Поле | Тип | Заметки |
|
||||||
| --------------------------- | ----------------- | --------------------------------------------------- |
|
| --------------------------- | ----------------- | --------------------------------------------------- |
|
||||||
| `Id` | `Guid` | PK |
|
| `Id` | `Guid` | PK |
|
||||||
| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг за минимальный период оплаты (3 месяца), руб. |
|
| `PricePerConfigPerQuarter` | `int?` | Цена за конфиг **в месяц** при оплате раз в 3 месяца (минимальный период), руб. |
|
||||||
| `PricePerConfigPerYear` | `int?` | Цена за конфиг за год, руб. Задаётся независимо от квартальной (не производная — позволяет скидку) |
|
| `PricePerConfigPerYear` | `int?` | Цена за конфиг **в месяц** при оплате раз в год, руб. Может быть ниже квартальной (скидка за годовую оплату) |
|
||||||
| `UpdatedAt` | `DateTimeOffset` | |
|
| `UpdatedAt` | `DateTimeOffset` | |
|
||||||
|
|
||||||
Итоговая цена для роли = `цена × AppRole.MaxConfigs` (напр. `user` с `MaxConfigs=3`, 200₽/конфиг/3мес →
|
Оба поля — ставка **за месяц**, не за весь период целиком. Итог за период = `ставка × число_месяцев ×
|
||||||
600₽/3мес; 400₽/конфиг/год → 1200₽/год) — считается на фронте (таблица ролей в админке), нигде не
|
AppRole.MaxConfigs`, считается на фронте (таблица ролей в админке), нигде не хранится:
|
||||||
хранится. Для ролей с `MaxConfigs = -1` (unlimited, в т.ч. `admin`) итог не считается — отображается
|
- 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/PUT /api/admin/pricing` — только `admin` (в отличие от `RoleDto`, цена никогда не попадает в
|
||||||
`GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO).
|
`GET /api/support/roles`, доступный любому активированному пользователю, — это два независимых DTO).
|
||||||
|
|||||||
@@ -19,6 +19,13 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
|||||||
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
|
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Обе ставки — цена за конфиг в месяц; итог за год (ставка×12) не должен быть дешевле итога за
|
||||||
|
// квартал (ставка×3), иначе выгоднее купить "год" и не продлевать, чем платить за квартал.
|
||||||
|
const isYearCheaperThanQuarter =
|
||||||
|
pricePerConfigPerQuarter !== '' &&
|
||||||
|
pricePerConfigPerYear !== '' &&
|
||||||
|
Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerQuarter) * 3
|
||||||
|
|
||||||
const mutation = useMutation({
|
const mutation = useMutation({
|
||||||
mutationFn: () =>
|
mutationFn: () =>
|
||||||
updatePricingSettings(
|
updatePricingSettings(
|
||||||
@@ -61,9 +68,12 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
|||||||
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
||||||
/>
|
/>
|
||||||
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerYearHint')}</p>
|
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerYearHint')}</p>
|
||||||
|
{isYearCheaperThanQuarter && (
|
||||||
|
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<Button type="submit" disabled={mutation.isPending}>
|
<Button type="submit" disabled={mutation.isPending || isYearCheaperThanQuarter}>
|
||||||
{t('admin.roles.save')}
|
{t('admin.roles.save')}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -20,8 +20,9 @@ function AdminRolesPage() {
|
|||||||
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
const { data, isLoading, isError, refetch } = useQuery({ queryKey: ['admin-roles'], queryFn: listRoles })
|
||||||
const { data: pricing } = useQuery({ queryKey: ['admin-pricing'], queryFn: getPricingSettings })
|
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({
|
const deleteMutation = useMutation({
|
||||||
mutationFn: deleteRole,
|
mutationFn: deleteRole,
|
||||||
@@ -58,6 +59,7 @@ function AdminRolesPage() {
|
|||||||
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.maxConfigs')}</th>
|
||||||
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.maxIpLimit')}</th>
|
||||||
<th className="py-2 font-medium">{t('admin.roles.totalPerQuarter')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.totalPerQuarter')}</th>
|
||||||
|
<th className="py-2 font-medium">{t('admin.roles.totalHalfYear')}</th>
|
||||||
<th className="py-2 font-medium">{t('admin.roles.totalPerYear')}</th>
|
<th className="py-2 font-medium">{t('admin.roles.totalPerYear')}</th>
|
||||||
<th className="py-2" />
|
<th className="py-2" />
|
||||||
<th className="py-2" />
|
<th className="py-2" />
|
||||||
@@ -71,8 +73,9 @@ function AdminRolesPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
<td className="py-2">{role.maxConfigs < 0 ? t('unlimited') : role.maxConfigs}</td>
|
||||||
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
<td className="py-2">{role.maxIpLimit < 0 ? t('unlimited') : role.maxIpLimit}</td>
|
||||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs)}</td>
|
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</td>
|
||||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs)}</td>
|
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 6)}</td>
|
||||||
|
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs, 12)}</td>
|
||||||
<td className="py-2 text-right">
|
<td className="py-2 text-right">
|
||||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||||
{t('admin.roles.edit')}
|
{t('admin.roles.edit')}
|
||||||
|
|||||||
@@ -255,6 +255,7 @@ const resources = {
|
|||||||
maxIpLimit: 'Лимит IP на конфиг',
|
maxIpLimit: 'Лимит IP на конфиг',
|
||||||
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
maxIpLimitHint: '−1 = без лимита. Применяется только к новым конфигам.',
|
||||||
totalPerQuarter: 'Итого / 3 мес',
|
totalPerQuarter: 'Итого / 3 мес',
|
||||||
|
totalHalfYear: 'Итого / полгода',
|
||||||
totalPerYear: 'Итого / год',
|
totalPerYear: 'Итого / год',
|
||||||
noPrice: '—',
|
noPrice: '—',
|
||||||
system: 'системная',
|
system: 'системная',
|
||||||
@@ -268,10 +269,11 @@ const resources = {
|
|||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
title: 'Справочная цена конфига',
|
title: 'Справочная цена конфига',
|
||||||
pricePerConfigPerQuarter: 'Цена за конфиг / 3 мес',
|
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
|
||||||
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу.',
|
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
|
||||||
pricePerConfigPerYear: 'Цена за конфиг / год',
|
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
|
||||||
pricePerConfigPerYearHint: 'Задаётся независимо от квартальной цены (можно сделать скидку за год).',
|
pricePerConfigPerYearHint: 'Может быть ниже квартальной (скидка за годовую оплату), но итог за год не должен быть дешевле итога за квартал.',
|
||||||
|
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
|
||||||
updated: 'Цена обновлена.',
|
updated: 'Цена обновлена.',
|
||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
@@ -685,6 +687,7 @@ const resources = {
|
|||||||
maxIpLimit: 'IP limit per config',
|
maxIpLimit: 'IP limit per config',
|
||||||
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
maxIpLimitHint: '−1 = unlimited. Applies to new configs only.',
|
||||||
totalPerQuarter: 'Total / 3 months',
|
totalPerQuarter: 'Total / 3 months',
|
||||||
|
totalHalfYear: 'Total / 6 months',
|
||||||
totalPerYear: 'Total / year',
|
totalPerYear: 'Total / year',
|
||||||
noPrice: '—',
|
noPrice: '—',
|
||||||
system: 'system',
|
system: 'system',
|
||||||
@@ -698,10 +701,11 @@ const resources = {
|
|||||||
},
|
},
|
||||||
pricing: {
|
pricing: {
|
||||||
title: 'Reference config price',
|
title: 'Reference config price',
|
||||||
pricePerConfigPerQuarter: 'Price per config / 3 months',
|
pricePerConfigPerQuarter: 'Price per config per month / billed every 3 months',
|
||||||
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only.',
|
pricePerConfigPerQuarterHint: 'Minimum billing period. Visible to admin only. Total for a period = rate × months × role quota.',
|
||||||
pricePerConfigPerYear: 'Price per config / year',
|
pricePerConfigPerYear: 'Price per config per month / billed yearly',
|
||||||
pricePerConfigPerYearHint: 'Set independently from the quarterly price (allows an annual discount).',
|
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.',
|
updated: 'Pricing updated.',
|
||||||
},
|
},
|
||||||
nodes: {
|
nodes: {
|
||||||
|
|||||||
Reference in New Issue
Block a user