Update pricing model to include half-year pricing and enhance validation
- Introduced a new pricing field, `PricePerConfigPerHalfYear`, to the `PricingSettings` model, allowing for more flexible pricing options. - Updated the `UpdatePricingSettingsCommand` and its validator to include the new half-year pricing, ensuring proper validation against the quarterly and yearly rates. - Modified the `PricingSettingsDto` and related frontend components to accommodate the new half-year pricing field, including validation logic to prevent pricing discrepancies. - Enhanced API documentation and frontend forms to reflect the updated pricing structure and validation rules.
This commit is contained in:
@@ -15,21 +15,38 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
||||
const [pricePerConfigPerQuarter, setPricePerConfigPerQuarter] = useState(
|
||||
settings.pricePerConfigPerQuarter != null ? String(settings.pricePerConfigPerQuarter) : '',
|
||||
)
|
||||
const [pricePerConfigPerHalfYear, setPricePerConfigPerHalfYear] = useState(
|
||||
settings.pricePerConfigPerHalfYear != null ? String(settings.pricePerConfigPerHalfYear) : '',
|
||||
)
|
||||
const [pricePerConfigPerYear, setPricePerConfigPerYear] = useState(
|
||||
settings.pricePerConfigPerYear != null ? String(settings.pricePerConfigPerYear) : '',
|
||||
)
|
||||
|
||||
// Обе ставки — цена за конфиг в месяц; итог за год (ставка×12) не должен быть дешевле итога за
|
||||
// квартал (ставка×3), иначе выгоднее купить "год" и не продлевать, чем платить за квартал.
|
||||
// Все ставки — цена за конфиг в месяц; итог за более длинный период (ставка × месяцев) не должен
|
||||
// быть дешевле итога за более короткий, иначе выгоднее купить длинный тариф и не продлевать.
|
||||
const isHalfYearCheaperThanQuarter =
|
||||
pricePerConfigPerQuarter !== '' &&
|
||||
pricePerConfigPerHalfYear !== '' &&
|
||||
Number(pricePerConfigPerHalfYear) * 6 < Number(pricePerConfigPerQuarter) * 3
|
||||
|
||||
const isYearCheaperThanHalfYear =
|
||||
pricePerConfigPerHalfYear !== '' &&
|
||||
pricePerConfigPerYear !== '' &&
|
||||
Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerHalfYear) * 6
|
||||
|
||||
const isYearCheaperThanQuarter =
|
||||
pricePerConfigPerHalfYear === '' &&
|
||||
pricePerConfigPerQuarter !== '' &&
|
||||
pricePerConfigPerYear !== '' &&
|
||||
Number(pricePerConfigPerYear) * 12 < Number(pricePerConfigPerQuarter) * 3
|
||||
|
||||
const hasInvalidCombo = isHalfYearCheaperThanQuarter || isYearCheaperThanHalfYear || isYearCheaperThanQuarter
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: () =>
|
||||
updatePricingSettings(
|
||||
pricePerConfigPerQuarter === '' ? null : Number(pricePerConfigPerQuarter),
|
||||
pricePerConfigPerHalfYear === '' ? null : Number(pricePerConfigPerHalfYear),
|
||||
pricePerConfigPerYear === '' ? null : Number(pricePerConfigPerYear),
|
||||
),
|
||||
onSuccess: async () => {
|
||||
@@ -58,6 +75,20 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerQuarterHint')}</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pricePerConfigPerHalfYear">{t('admin.pricing.pricePerConfigPerHalfYear')}</Label>
|
||||
<Input
|
||||
id="pricePerConfigPerHalfYear"
|
||||
type="number"
|
||||
min={0}
|
||||
value={pricePerConfigPerHalfYear}
|
||||
onChange={(e) => setPricePerConfigPerHalfYear(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerHalfYearHint')}</p>
|
||||
{isHalfYearCheaperThanQuarter && (
|
||||
<p className="text-xs text-red-600">{t('admin.pricing.halfYearCheaperThanQuarter')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex flex-col gap-1.5">
|
||||
<Label htmlFor="pricePerConfigPerYear">{t('admin.pricing.pricePerConfigPerYear')}</Label>
|
||||
<Input
|
||||
@@ -68,12 +99,15 @@ export function PricingSettingsEditor({ settings }: { settings: PricingSettingsD
|
||||
onChange={(e) => setPricePerConfigPerYear(e.target.value)}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">{t('admin.pricing.pricePerConfigPerYearHint')}</p>
|
||||
{isYearCheaperThanHalfYear && (
|
||||
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanHalfYear')}</p>
|
||||
)}
|
||||
{isYearCheaperThanQuarter && (
|
||||
<p className="text-xs text-red-600">{t('admin.pricing.yearCheaperThanQuarter')}</p>
|
||||
)}
|
||||
</div>
|
||||
<div>
|
||||
<Button type="submit" disabled={mutation.isPending || isYearCheaperThanQuarter}>
|
||||
<Button type="submit" disabled={mutation.isPending || hasInvalidCombo}>
|
||||
{t('admin.roles.save')}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -5,9 +5,13 @@ export function getPricingSettings() {
|
||||
return apiRequest<PricingSettingsDto>('/admin/pricing')
|
||||
}
|
||||
|
||||
export function updatePricingSettings(pricePerConfigPerQuarter: number | null, pricePerConfigPerYear: number | null) {
|
||||
export function updatePricingSettings(
|
||||
pricePerConfigPerQuarter: number | null,
|
||||
pricePerConfigPerHalfYear: number | null,
|
||||
pricePerConfigPerYear: number | null,
|
||||
) {
|
||||
return apiRequest<PricingSettingsDto>('/admin/pricing', {
|
||||
method: 'PUT',
|
||||
body: { pricePerConfigPerQuarter, pricePerConfigPerYear },
|
||||
body: { pricePerConfigPerQuarter, pricePerConfigPerHalfYear, pricePerConfigPerYear },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -74,7 +74,7 @@ function AdminRolesPage() {
|
||||
<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, 3)}</td>
|
||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerQuarter, role.maxConfigs, 6)}</td>
|
||||
<td className="py-2">{totalPrice(pricing?.pricePerConfigPerHalfYear, 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)}>
|
||||
|
||||
@@ -175,9 +175,11 @@ export type RoleDto = {
|
||||
isSystem: boolean
|
||||
}
|
||||
|
||||
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль. */
|
||||
/** Глобальная справочная цена за конфиг (видна только админу) — одна на весь сервис, не per-роль.
|
||||
* Все поля — цена за конфиг В МЕСЯЦ при данном тарифе; итог за период = ставка × месяцы. */
|
||||
export type PricingSettingsDto = {
|
||||
pricePerConfigPerQuarter: number | null
|
||||
pricePerConfigPerHalfYear: number | null
|
||||
pricePerConfigPerYear: number | null
|
||||
}
|
||||
|
||||
|
||||
@@ -271,8 +271,12 @@ const resources = {
|
||||
title: 'Справочная цена конфига',
|
||||
pricePerConfigPerQuarter: 'Цена за конфиг в месяц / оплата раз в 3 мес',
|
||||
pricePerConfigPerQuarterHint: 'Минимальный период оплаты. Видно только админу. Итог за период = ставка × число месяцев × квота роли.',
|
||||
pricePerConfigPerHalfYear: 'Цена за конфиг в месяц / оплата раз в полгода',
|
||||
pricePerConfigPerHalfYearHint: 'Может быть ниже квартальной (скидка за оплату на полгода вперёд), но итог за полгода не должен быть дешевле итога за квартал.',
|
||||
halfYearCheaperThanQuarter: 'Цена за полгода (в пересчёте на 6 месяцев) не может быть меньше цены за 3 месяца.',
|
||||
pricePerConfigPerYear: 'Цена за конфиг в месяц / оплата раз в год',
|
||||
pricePerConfigPerYearHint: 'Может быть ниже квартальной (скидка за годовую оплату), но итог за год не должен быть дешевле итога за квартал.',
|
||||
pricePerConfigPerYearHint: 'Может быть ниже полугодовой (скидка за годовую оплату), но итог за год не должен быть дешевле итога за полгода.',
|
||||
yearCheaperThanHalfYear: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за полгода.',
|
||||
yearCheaperThanQuarter: 'Цена за год (в пересчёте на 12 месяцев) не может быть меньше цены за 3 месяца.',
|
||||
updated: 'Цена обновлена.',
|
||||
},
|
||||
@@ -703,8 +707,12 @@ const resources = {
|
||||
title: 'Reference config price',
|
||||
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.',
|
||||
pricePerConfigPerHalfYear: 'Price per config per month / billed every 6 months',
|
||||
pricePerConfigPerHalfYearHint: 'Can be lower than the quarterly rate (discount for a 6-month prepayment), but the 6-month total must not be cheaper than the 3-month total.',
|
||||
halfYearCheaperThanQuarter: 'The 6-month price (over 6 months) cannot be lower than the 3-month price.',
|
||||
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.',
|
||||
pricePerConfigPerYearHint: 'Can be lower than the 6-month rate (annual discount), but the yearly total must not be cheaper than the 6-month total.',
|
||||
yearCheaperThanHalfYear: 'The annual price (over 12 months) cannot be lower than the 6-month price.',
|
||||
yearCheaperThanQuarter: 'The annual price (over 12 months) cannot be lower than the 3-month price.',
|
||||
updated: 'Pricing updated.',
|
||||
},
|
||||
|
||||
Reference in New Issue
Block a user