fix(topup): preserve canonical RUB amount to avoid FX round-trip shortfall

User report: a user in EN locale paid 1.65 USD for a 150₽ subscription, but the
bot received 14960 kopeks (149.60₽) — short by 40 kopeks, blocking the
subscription purchase.

Root cause: TopUpAmount.tsx displays the prefilled RUB amount converted via
`.toFixed(2)`. With an exchange rate like 90.66 RUB/USD, 150₽ → 1.6545 USD →
displayed "1.65" (rounded down by 0.0045 USD ≈ 0.4 RUB). When the user submits
without editing, `convertToRub("1.65")` runs again and returns 1.65 × 90.66 =
149.589₽ — less than the 150₽ the user is trying to pay. Math.round/Math.ceil
on this only handles sub-kopek IEEE-754 fractions, not the deeper FX display-
rounding direction.

Fix: when the user does not edit the prefilled amount (`amount === initialDisplayAmount`)
and `initialAmountRubles > 0` is known, bypass the FX round-trip and send
`Math.round(initialAmountRubles * 100)` directly. Math.ceil for non-RUB targets
when the user does type a custom amount still helps with sub-kopek fractions.

`?amount=150` from a renew CTA → "1.65 USD" displayed → user clicks pay → bot
receives 15000 kopeks instead of 14960. Subscription renews as expected.
This commit is contained in:
Fringg
2026-05-13 10:24:08 +03:00
parent 172850e13a
commit 2a342f6adc

View File

@@ -170,7 +170,8 @@ export default function TopUpAmount() {
: converted.toFixed(2); : converted.toFixed(2);
}; };
const [amount, setAmount] = useState(getInitialAmount); const initialDisplayAmount = getInitialAmount();
const [amount, setAmount] = useState(initialDisplayAmount);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [selectedOption, setSelectedOption] = useState<string | null>( const [selectedOption, setSelectedOption] = useState<string | null>(
getPreferredOptionId(method?.options), getPreferredOptionId(method?.options),
@@ -334,7 +335,22 @@ export default function TopUpAmount() {
return; return;
} }
const amountKopeks = Math.round(amountRubles * 100); // Сохраняем canonical RUB amount если юзер НЕ редактировал префилл.
// Display-rounding в `.toFixed(2)` теряет точность: 150₽ при rate=90.66 → "1.65" USD
// (округление вниз с 1.6545), back-конвертация даёт 1.65 × 90.66 = 149.589₽ < 150₽
// → юзер не может купить подписку 150₽. С canonical RUB обходим FX round-trip.
//
// Math.ceil для не-RUB локалей покрывает остаточные sub-копеечные ошибки
// floating-point, когда юзер реально вводит свой amount.
const userEditedAmount = amount.trim() !== initialDisplayAmount.trim();
let amountKopeks: number;
if (!userEditedAmount && initialAmountRubles && initialAmountRubles > 0) {
amountKopeks = Math.round(initialAmountRubles * 100);
} else if (targetCurrency === 'RUB') {
amountKopeks = Math.round(amountRubles * 100);
} else {
amountKopeks = Math.ceil(amountRubles * 100);
}
if (isStarsMethod) { if (isStarsMethod) {
starsPaymentMutation.mutate(amountKopeks); starsPaymentMutation.mutate(amountKopeks);
} else { } else {