Update pricing model to include half-year pricing and enhance validation
CI / Backend (build + test) (push) Successful in 1m24s
CI / Frontend (lint + typecheck + build) (push) Failing after 15s

- 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:
Leonid Pershin
2026-07-18 20:17:32 +03:00
parent 6dfd51ae7c
commit 32221af503
16 changed files with 1133 additions and 39 deletions
@@ -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>