fix: withdraw flow fixes (#5296)

* fix: dev-741 currency exchanging bug

* fix: remove redundant balance available check

* fix: lint/fmt

* fix: #5245

* fix: hide max if it's less than min
This commit is contained in:
Calum H.
2026-02-04 14:56:14 +00:00
committed by GitHub
parent 34cbc7e0c1
commit 16204d30f8
3 changed files with 196 additions and 58 deletions

View File

@@ -90,66 +90,41 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMin ?? effectiveMinAmount) / selectedMethodExchangeRate
: (fixedDenominationMin ?? effectiveMinAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
fixedDenominationMin ?? effectiveMinAmount,
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min,
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount) / selectedMethodExchangeRate
: (fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount),
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
min<template v-if="displayMinUsd <= roundedMaxAmount"
>, {{ formatMoney(displayMaxUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
displayMaxLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max</template
>
max withdrawal amount.
withdrawal amount.
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least
{{
formatMoney(
selectedMethodCurrencyCode &&
selectedMethodCurrencyCode !== 'USD' &&
selectedMethodExchangeRate
? effectiveMinAmount / selectedMethodExchangeRate
: effectiveMinAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
{{ formatMoney(displayMinUsd)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'"
>({{
formatAmountForDisplay(
effectiveMinAmount,
displayMinLocal,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
@@ -307,7 +282,19 @@
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
No denominations available for your current balance
<template v-if="rawFixedDenominationMin !== null">
The minimum denomination is
{{
formatAmountForDisplay(
rawFixedDenominationMin,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{ formatMoney(convertToUsd(rawFixedDenominationMin)) }})</template
>, which exceeds your balance of {{ formatMoney(roundedMaxAmount) }}.
</template>
<template v-else>No denominations available for your current balance</template>
</span>
</div>
@@ -324,7 +311,7 @@
<WithdrawFeeBreakdown
v-if="allRequiredFieldsFilled && formData.amount && formData.amount > 0"
:amount="formData.amount || 0"
:amount="amountForFeeBreakdown"
:fee="calculatedFee"
:fee-loading="feeLoading"
:exchange-rate="showGiftCardSelector ? selectedMethodExchangeRate : giftCardExchangeRate"
@@ -720,6 +707,34 @@ const hasSelectedDenomination = computed(() => {
)
})
// Convert local currency amount to USD using the selected method's exchange rate
const convertToUsd = (localAmount: number): number => {
const exchangeRate = selectedMethodExchangeRate.value
if (
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD' &&
exchangeRate &&
exchangeRate > 0
) {
return localAmount / exchangeRate
}
return localAmount
}
// Convert USD amount to local currency using the selected method's exchange rate
const convertToLocalCurrency = (usdAmount: number): number => {
const exchangeRate = selectedMethodExchangeRate.value
if (
selectedMethodCurrencyCode.value &&
selectedMethodCurrencyCode.value !== 'USD' &&
exchangeRate &&
exchangeRate > 0
) {
return usdAmount * exchangeRate
}
return usdAmount
}
const denominationOptions = computed(() => {
const interval = selectedMethodDetails.value?.interval
if (!interval) return []
@@ -735,30 +750,56 @@ const denominationOptions = computed(() => {
if (values.length === 0) return []
const filtered = values.filter((amount) => amount <= roundedMaxAmount.value).sort((a, b) => a - b)
// Convert USD balance to local currency for comparison with denomination values
// (denomination values are in local currency, e.g., 50 INR, 45000 IDR)
const maxInLocalCurrency = convertToLocalCurrency(roundedMaxAmount.value)
const filtered = values.filter((amount) => amount <= maxInLocalCurrency).sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
values,
'max:',
'max (local currency):',
maxInLocalCurrency,
'max (USD):',
roundedMaxAmount.value,
)
return filtered
})
const effectiveMinAmount = computed(() => {
return selectedMethodDetails.value?.interval?.standard?.min || 0.01
const min = selectedMethodDetails.value?.interval?.standard?.min || 0.01
// Convert from local currency to USD for display/validation
return convertToUsd(min)
})
const effectiveMaxAmount = computed(() => {
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
return Math.min(roundedMaxAmount.value, methodMax)
// Convert method max from local currency to USD, then compare with USD balance
const methodMaxInUsd = convertToUsd(methodMax)
return Math.min(roundedMaxAmount.value, methodMaxInUsd)
}
return roundedMaxAmount.value
})
// Get the minimum fixed denomination from the full list (not filtered by user balance)
const rawFixedDenominationMin = computed(() => {
const interval = selectedMethodDetails.value?.interval
if (!interval) return null
let values: number[] = []
if (interval.fixed?.values) {
values = [...interval.fixed.values]
} else if (interval.standard && interval.standard.min === interval.standard.max) {
values = [interval.standard.min]
}
if (values.length === 0) return null
return Math.min(...values)
})
const fixedDenominationMin = computed(() => {
if (!useFixedDenominations.value) return null
const options = denominationOptions.value
@@ -773,6 +814,63 @@ const fixedDenominationMax = computed(() => {
return options[options.length - 1]
})
// - Fixed denominations: convert from local currency to USD
// - Variable amounts: effectiveMinAmount/effectiveMaxAmount are already in USD
const displayMinUsd = computed(() => {
if (fixedDenominationMin.value !== null) {
// Fixed denomination is in local currency, convert to USD
return convertToUsd(fixedDenominationMin.value)
}
// If no affordable denominations but there are fixed values, show the raw minimum
if (rawFixedDenominationMin.value !== null) {
return convertToUsd(rawFixedDenominationMin.value)
}
// effectiveMinAmount is already in USD
return effectiveMinAmount.value
})
const displayMaxUsd = computed(() => {
if (fixedDenominationMax.value !== null) {
// Fixed denomination is in local currency, convert to USD
return convertToUsd(fixedDenominationMax.value)
}
// For variable amounts, use effectiveMaxAmount (already in USD)
// But also check if there's a method max from the interval
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
const methodMaxUsd = convertToUsd(methodMax)
return Math.min(effectiveMaxAmount.value, methodMaxUsd)
}
return effectiveMaxAmount.value
})
// Display values in local currency (for showing in parentheses)
const displayMinLocal = computed(() => {
if (fixedDenominationMin.value !== null) {
// Fixed denomination is already in local currency
return fixedDenominationMin.value
}
// If no affordable denominations but there are fixed values, show the raw minimum
if (rawFixedDenominationMin.value !== null) {
return rawFixedDenominationMin.value
}
// Convert USD to local currency
return convertToLocalCurrency(effectiveMinAmount.value)
})
const displayMaxLocal = computed(() => {
if (fixedDenominationMax.value !== null) {
// Fixed denomination is already in local currency
return fixedDenominationMax.value
}
// Check for method max
const methodMax = selectedMethodDetails.value?.interval?.standard?.max
if (methodMax !== undefined && methodMax !== null) {
return Math.min(convertToLocalCurrency(effectiveMaxAmount.value), methodMax)
}
return convertToLocalCurrency(effectiveMaxAmount.value)
})
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
@@ -791,6 +889,21 @@ const allRequiredFieldsFilled = computed(() => {
return true
})
// Amount to display in WithdrawFeeBreakdown (expects local currency for gift cards)
const amountForFeeBreakdown = computed(() => {
const amount = formData.value.amount ?? 0
if (!showGiftCardSelector.value) {
// Non-gift-card: amount is in USD
return amount
}
if (useFixedDenominations.value) {
// Fixed denominations: amount is already in local currency
return amount
}
// Variable amount gift card: amount is in USD, convert to local currency
return convertToLocalCurrency(amount)
})
const calculateFeesDebounced = useDebounceFn(async () => {
const amount = formData.value.amount
if (!amount || amount <= 0) {
@@ -852,7 +965,15 @@ watch(
watch(
[() => formData.value.amount, selectedGiftCardId, deliveryEmail, selectedCurrency],
() => {
withdrawData.value.calculation.amount = formData.value.amount ?? 0
let amountForBackend = formData.value.amount ?? 0
// - Fixed denominations (chips): formData.amount is already in local currency
// - Variable amounts (RevenueInputField): formData.amount is in USD, needs conversion
if (showGiftCardSelector.value && !useFixedDenominations.value && amountForBackend > 0) {
amountForBackend = convertToLocalCurrency(amountForBackend)
}
withdrawData.value.calculation.amount = amountForBackend
if (showGiftCardSelector.value && selectedGiftCardId.value) {
withdrawData.value.selection.methodId = selectedGiftCardId.value

View File

@@ -651,15 +651,27 @@ export function createWithdrawContext(
)
if (selectedMethod?.interval) {
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
const userMaxUsd = Math.floor(maxWithdrawAmount.value * 100) / 100
const exchangeRate = selectedMethod.exchange_rate
const isNonUsdCurrency =
selectedMethod.currency_code &&
selectedMethod.currency_code !== 'USD' &&
exchangeRate &&
exchangeRate > 0
const userMaxInLocalCurrency = isNonUsdCurrency ? userMaxUsd * exchangeRate : userMaxUsd
if (selectedMethod.interval.standard) {
const { min, max } = selectedMethod.interval.standard
const effectiveMax = Math.min(userMax, max)
const effectiveMax = Math.min(userMaxInLocalCurrency, max)
const effectiveMin = Math.min(min, effectiveMax)
if (amount < effectiveMin || amount > effectiveMax) return false
}
if (selectedMethod.interval.fixed) {
const validValues = selectedMethod.interval.fixed.values.filter((v) => v <= userMax)
const validValues = selectedMethod.interval.fixed.values.filter(
(v) => v <= userMaxInLocalCurrency,
)
if (!validValues.includes(amount)) return false
}
}
@@ -817,6 +829,7 @@ export function createWithdrawContext(
calculation: {
amount: 0,
fee: null,
netUsd: null,
exchangeRate: null,
},
providerData: {

View File

@@ -498,9 +498,13 @@ pub async fn create_payout(
let balance = get_user_balance(user.id, &pool)
.await
.wrap_internal_err("failed to calculate user balance")?;
if balance.available < body.amount || body.amount < Decimal::ZERO {
// Note: We only check for negative amounts here. The full balance validation
// happens later in payout_flow.validate() which correctly handles currency
// conversion (body.amount may be in local currency for gift cards, not USD).
if body.amount < Decimal::ZERO {
return Err(ApiError::InvalidInput(
"You do not have enough funds to make this payout!".to_string(),
"Amount must be positive!".to_string(),
));
}