fix: addon free_threshold calculation fixed

This commit is contained in:
2026-08-12 10:53:16 +07:00
parent 42f168e1ac
commit 3d8b78ec52
5 changed files with 36 additions and 17 deletions

View File

@@ -1,13 +1,14 @@
import type { AddonData } from '../types'; import type { AddonData } from '../types';
import { formatMoney } from '../lib/format'; import { addonPriceForDevices, formatMoney } from '../lib/format';
interface AddonListProps { interface AddonListProps {
addons: AddonData[]; addons: AddonData[];
devices: number;
selected: string[]; selected: string[];
onToggle: (id: string) => void; onToggle: (id: string) => void;
} }
export function AddonList({ addons, selected, onToggle }: AddonListProps) { export function AddonList({ addons, devices, selected, onToggle }: AddonListProps) {
if (addons.length === 0) return null; if (addons.length === 0) return null;
return ( return (
<div className="plan-addons"> <div className="plan-addons">
@@ -18,6 +19,7 @@ export function AddonList({ addons, selected, onToggle }: AddonListProps) {
<div className="addon-list"> <div className="addon-list">
{addons.map((a) => { {addons.map((a) => {
const on = selected.includes(a.id); const on = selected.includes(a.id);
const price = addonPriceForDevices(a, devices);
return ( return (
<button <button
key={a.id} key={a.id}
@@ -31,8 +33,7 @@ export function AddonList({ addons, selected, onToggle }: AddonListProps) {
<span className="addon-name">{a.name}</span> <span className="addon-name">{a.name}</span>
</span> </span>
<span className="addon-price"> <span className="addon-price">
<span className="plus">+</span> {price === 0 ? 'Бесплатно' : <><span className="plus">+</span>{formatMoney(price)}</>}
{formatMoney(a.price)}
</span> </span>
</button> </button>
); );

View File

@@ -14,8 +14,8 @@ import type { AddonData, PricingPlans } from '../types';
const pricing: PricingPlans = { const pricing: PricingPlans = {
device_price: 100, device_price: 100,
addons: [ addons: [
{ id: 'a', name: 'A', price: 50, free_threshold: 0 }, { id: 'a', name: 'A', price: 50, free_threshold: -1 },
{ id: 'b', name: 'B', price: 30, free_threshold: 0 }, { id: 'b', name: 'B', price: 30, free_threshold: -1 },
], ],
}; };
@@ -30,10 +30,20 @@ describe('round2', () => {
describe('sumAddonPrices', () => { describe('sumAddonPrices', () => {
const addons: AddonData[] = pricing.addons; const addons: AddonData[] = pricing.addons;
it('sums selected addon prices', () => { it('sums selected addon prices', () => {
expect(sumAddonPrices(addons, ['a', 'b'])).toBe(80); expect(sumAddonPrices(addons, ['a', 'b'], 6)).toBe(80);
expect(sumAddonPrices(addons, ['a'])).toBe(50); expect(sumAddonPrices(addons, ['a'], 6)).toBe(50);
expect(sumAddonPrices(addons, ['unknown'])).toBe(0); expect(sumAddonPrices(addons, ['unknown'], 6)).toBe(0);
expect(sumAddonPrices(addons, [])).toBe(0); expect(sumAddonPrices(addons, [], 6)).toBe(0);
});
it('makes addons free at their device threshold but keeps -1 paid', () => {
const thresholdAddons: AddonData[] = [
{ id: 'threshold', name: 'Threshold', price: 50, free_threshold: 6 },
{ id: 'paid', name: 'Paid', price: 30, free_threshold: -1 },
];
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 5)).toBe(80);
expect(sumAddonPrices(thresholdAddons, ['threshold', 'paid'], 6)).toBe(30);
}); });
}); });

View File

@@ -55,7 +55,7 @@ export interface PricingBreakdown {
/** /**
* Mirrors the backend `services/subscriptions.py`: * Mirrors the backend `services/subscriptions.py`:
* monthly = devices * device_price + sum(selected addon prices) * monthly = devices * device_price + sum(selected applicable addon prices)
* total = monthly * duration_days / 30 * total = monthly * duration_days / 30
*/ */
export function calculatePricing({ export function calculatePricing({
@@ -65,7 +65,7 @@ export function calculatePricing({
duration, duration,
}: PricingInput): PricingBreakdown { }: PricingInput): PricingBreakdown {
const deviceCost = pricing.device_price * devices; const deviceCost = pricing.device_price * devices;
const addonCost = sumAddonPrices(pricing.addons, addonIds); const addonCost = sumAddonPrices(pricing.addons, addonIds, devices);
const monthly = deviceCost + addonCost; const monthly = deviceCost + addonCost;
const periodTotal = round2((monthly * duration.days) / 30); const periodTotal = round2((monthly * duration.days) / 30);
return { return {
@@ -76,9 +76,16 @@ export function calculatePricing({
}; };
} }
export function sumAddonPrices(addons: AddonData[], selected: string[]): number { export function addonPriceForDevices(addon: AddonData, devices: number): number {
const priceById = new Map(addons.map((a) => [a.id, a.price] as const)); return addon.free_threshold >= 0 && devices >= addon.free_threshold ? 0 : addon.price;
return selected.reduce<number>((sum, id) => sum + (priceById.get(id) ?? 0), 0); }
export function sumAddonPrices(addons: AddonData[], selected: string[], devices: number): number {
const addonById = new Map(addons.map((addon) => [addon.id, addon] as const));
return selected.reduce<number>((sum, id) => {
const addon = addonById.get(id);
return sum + (addon ? addonPriceForDevices(addon, devices) : 0);
}, 0);
} }
/* ── Date ────────────────────────────────────────────────── */ /* ── Date ────────────────────────────────────────────────── */

View File

@@ -43,8 +43,8 @@ function renderPlan(initialEntries: string[] = ['/plan']) {
const PLANS = { const PLANS = {
device_price: 100, device_price: 100,
addons: [ addons: [
{ id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: 0 }, { id: 'dedicated-ip', name: 'Dedicated IP', price: 50, free_threshold: -1 },
{ id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: 0 }, { id: 'multi-hop', name: 'Multi-Hop', price: 30, free_threshold: -1 },
], ],
}; };

View File

@@ -166,6 +166,7 @@ export function PlanPage() {
<Reveal> <Reveal>
<AddonList <AddonList
addons={plans.addons} addons={plans.addons}
devices={devices}
selected={selectedAddons} selected={selectedAddons}
onToggle={toggleAddon} onToggle={toggleAddon}
/> />