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:
@@ -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)}
|
||||
/>
|
||||
<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>
|
||||
<Button type="submit" disabled={mutation.isPending}>
|
||||
<Button type="submit" disabled={mutation.isPending || isYearCheaperThanQuarter}>
|
||||
{t('admin.roles.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -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() {
|
||||
<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.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" />
|
||||
<th className="py-2" />
|
||||
@@ -71,8 +73,9 @@ function AdminRolesPage() {
|
||||
</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">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs)}</td>
|
||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerYear, role.maxConfigs)}</td>
|
||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 3)}</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">
|
||||
<Button size="sm" variant="outline" onClick={() => setEditing(role)}>
|
||||
{t('admin.roles.edit')}
|
||||
|
||||
@@ -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: {
|
||||
|
||||
Reference in New Issue
Block a user