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:
@@ -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
|
||||
|
||||
@@ -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: {
|
||||
|
||||
@@ -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(),
|
||||
));
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user