From 2a342f6adc9ee4e2d0c157b69edc7213e1dc2d1b Mon Sep 17 00:00:00 2001 From: Fringg Date: Wed, 13 May 2026 10:24:08 +0300 Subject: [PATCH] fix(topup): preserve canonical RUB amount to avoid FX round-trip shortfall MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/pages/TopUpAmount.tsx | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/src/pages/TopUpAmount.tsx b/src/pages/TopUpAmount.tsx index 0c470bf..92f6952 100644 --- a/src/pages/TopUpAmount.tsx +++ b/src/pages/TopUpAmount.tsx @@ -170,7 +170,8 @@ export default function TopUpAmount() { : converted.toFixed(2); }; - const [amount, setAmount] = useState(getInitialAmount); + const initialDisplayAmount = getInitialAmount(); + const [amount, setAmount] = useState(initialDisplayAmount); const [error, setError] = useState(null); const [selectedOption, setSelectedOption] = useState( getPreferredOptionId(method?.options), @@ -334,7 +335,22 @@ export default function TopUpAmount() { 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) { starsPaymentMutation.mutate(amountKopeks); } else {