diff --git a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue
index 27f4cc9ce..c0d24ffb6 100644
--- a/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue
+++ b/apps/frontend/src/components/ui/dashboard/withdraw-stages/TremendousDetailsStage.vue
@@ -111,28 +111,163 @@
- {{ formatMoney(effectiveMinAmount) }} min,
- {{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
+ {{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
+ }}
+ ({{
+ formatAmountForDisplay(
+ fixedDenominationMin ?? effectiveMinAmount,
+ selectedMethodCurrencyCode,
+ selectedMethodExchangeRate,
+ )
+ }})
+ min,
+ {{
+ formatMoney(
+ fixedDenominationMax ??
+ selectedMethodDetails.interval?.standard?.max ??
+ effectiveMaxAmount,
+ )
+ }}
+ ({{
+ formatAmountForDisplay(
+ fixedDenominationMax ??
+ selectedMethodDetails.interval?.standard?.max ??
+ effectiveMaxAmount,
+ selectedMethodCurrencyCode,
+ selectedMethodExchangeRate,
+ )
+ }})
max withdrawal amount.
+
+ You need at least {{ formatMoney(effectiveMinAmount)
+ }}
+ ({{
+ formatAmountForDisplay(
+ effectiveMinAmount,
+ selectedMethodCurrencyCode,
+ selectedMethodExchangeRate,
+ )
+ }})
+ to use this gift card.
+
-
-
+
+
+
+
+ {{ formatMessage(messages.enterAmountHint) }}
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ noSuggestionsMessage }}
+
+
+
+
+
+ {{ formatMessage(messages.selectDenominationRequired) }}
+
+
+
+
No denominations available for your current balance
@@ -153,8 +288,8 @@
:amount="formData.amount || 0"
:fee="calculatedFee"
:fee-loading="feeLoading"
- :exchange-rate="exchangeRate"
- :local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
+ :exchange-rate="giftCardExchangeRate"
+ :local-currency="giftCardCurrencyCode"
/>
@@ -285,6 +420,9 @@ const formData = ref>({
const selectedGiftCardId = ref(withdrawData.value.selection.methodId || null)
+const denominationSearchInput = ref(undefined)
+const hasTouchedSuggestions = ref(false)
+
const currencyOptions = [
{ value: 'USD', label: 'USD' },
{ value: 'AUD', label: 'AUD' },
@@ -373,6 +511,8 @@ const rewardOptions = ref<
fixed?: { values: number[] }
standard?: { min: number; max: number }
}
+ currencyCode?: string | null
+ exchangeRate?: number | null
}
}>
>([])
@@ -390,24 +530,187 @@ const selectedMethodDetails = computed(() => {
return option?.methodDetails || null
})
+const selectedMethodCurrencyCode = computed(() => selectedMethodDetails.value?.currencyCode || null)
+const selectedMethodExchangeRate = computed(() => selectedMethodDetails.value?.exchangeRate || null)
+
+const giftCardCurrencyCode = computed(() => {
+ if (showPayPalCurrencySelector.value) {
+ return selectedCurrency.value !== 'USD' ? selectedCurrency.value : undefined
+ }
+
+ if (
+ showGiftCardSelector.value &&
+ selectedMethodCurrencyCode.value &&
+ selectedMethodCurrencyCode.value !== 'USD'
+ ) {
+ return selectedMethodCurrencyCode.value
+ }
+ return undefined
+})
+
+const giftCardExchangeRate = computed(() => {
+ if (showPayPalCurrencySelector.value) {
+ return exchangeRate.value
+ }
+
+ if (
+ showGiftCardSelector.value &&
+ selectedMethodCurrencyCode.value &&
+ selectedMethodCurrencyCode.value !== 'USD'
+ ) {
+ return selectedMethodExchangeRate.value
+ }
+ return exchangeRate.value
+})
+
+function formatAmountForDisplay(
+ usdAmount: number,
+ currencyCode: string | null | undefined,
+ rate: number | null | undefined,
+): string {
+ if (!currencyCode || currencyCode === 'USD' || !rate) {
+ return formatMoney(usdAmount)
+ }
+ const localAmount = usdAmount * rate
+ try {
+ return new Intl.NumberFormat('en-US', {
+ style: 'currency',
+ currency: currencyCode,
+ minimumFractionDigits: 2,
+ maximumFractionDigits: 2,
+ }).format(localAmount)
+ } catch {
+ return `${currencyCode} ${localAmount.toFixed(2)}`
+ }
+}
+
const useFixedDenominations = computed(() => {
- const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
- debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
- return hasFixed
+ const interval = selectedMethodDetails.value?.interval
+ if (!interval) return false
+
+ if (interval.fixed?.values?.length) {
+ debug('Use fixed denominations: true (has fixed values)')
+ return true
+ }
+
+ // treat min=max as single fixed value
+ if (interval.standard) {
+ const { min, max } = interval.standard
+ const isSingleValue = min === max
+ debug('Use fixed denominations:', isSingleValue, '(min=max:', min, '=', max, ')')
+ return isSingleValue
+ }
+ return false
+})
+
+const useDenominationSuggestions = computed(() => {
+ if (!useFixedDenominations.value) return false
+ const interval = selectedMethodDetails.value?.interval
+ if (!interval?.fixed?.values) return false
+ return interval.fixed.values.length > 35
+})
+
+const denominationSuggestions = computed(() => {
+ const input = denominationSearchInput.value
+ if (!input || input <= 0) return []
+
+ const allDenominations = denominationOptions.value
+ if (allDenominations.length === 0) return []
+
+ // convert local currency input to USD for filtering (denominations are stored in USD)
+ const exchangeRate = selectedMethodExchangeRate.value
+ const inputInUsd = exchangeRate ? input / exchangeRate : input
+
+ const rangeSize = inputInUsd * 0.2
+ let lowerBound = inputInUsd - rangeSize / 2
+ let upperBound = inputInUsd + rangeSize / 2
+
+ const minAvailable = allDenominations[0]
+ const maxAvailable = allDenominations[allDenominations.length - 1]
+
+ // shift range when hitting boundaries to maintain ~20% total range
+ if (upperBound > maxAvailable) {
+ const overflow = upperBound - maxAvailable
+ upperBound = maxAvailable
+ lowerBound = Math.max(minAvailable, lowerBound - overflow)
+ } else if (lowerBound < minAvailable) {
+ const underflow = minAvailable - lowerBound
+ lowerBound = minAvailable
+ upperBound = Math.min(maxAvailable, upperBound + underflow)
+ }
+
+ return allDenominations
+ .filter((amt) => amt >= lowerBound && amt <= upperBound)
+ .sort((a, b) => a - b)
+})
+
+const maxDisplayedSuggestions = 10
+const displayedSuggestions = computed(() => {
+ const all = denominationSuggestions.value
+ if (all.length <= maxDisplayedSuggestions) return all
+
+ const input = denominationSearchInput.value
+ if (!input) return all.slice(0, maxDisplayedSuggestions)
+
+ const exchangeRate = selectedMethodExchangeRate.value
+ const inputInUsd = exchangeRate ? input / exchangeRate : input
+
+ // select values closest to input, then sort ascending for display
+ const closest = [...all]
+ .sort((a, b) => Math.abs(a - inputInUsd) - Math.abs(b - inputInUsd))
+ .slice(0, maxDisplayedSuggestions)
+
+ return closest.sort((a, b) => a - b)
+})
+
+const noSuggestionsMessage = computed(() => {
+ if (!denominationSearchInput.value || denominationSearchInput.value <= 0) {
+ return null
+ }
+ if (denominationSuggestions.value.length === 0) {
+ const maxDenom = fixedDenominationMax.value
+ if (maxDenom) {
+ const maxInLocal = formatAmountForDisplay(
+ maxDenom,
+ selectedMethodCurrencyCode.value,
+ selectedMethodExchangeRate.value,
+ )
+ return `No denominations near this amount. The highest available is ${maxInLocal}.`
+ }
+ return 'No denominations near this amount'
+ }
+ return null
+})
+
+const hasSelectedDenomination = computed(() => {
+ return (
+ formData.value.amount !== undefined &&
+ formData.value.amount > 0 &&
+ denominationOptions.value.includes(formData.value.amount)
+ )
})
const denominationOptions = computed(() => {
- const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
- if (!fixedValues) return []
+ const interval = selectedMethodDetails.value?.interval
+ if (!interval) return []
- const filtered = fixedValues
- .filter((amount) => amount <= roundedMaxAmount.value)
- .sort((a, b) => a - b)
+ let values: number[] = []
+
+ if (interval.fixed?.values) {
+ values = [...interval.fixed.values]
+ } else if (interval.standard && interval.standard.min === interval.standard.max) {
+ // min=max case: treat as single fixed value
+ values = [interval.standard.min]
+ }
+
+ if (values.length === 0) return []
+
+ const filtered = values.filter((amount) => amount <= roundedMaxAmount.value).sort((a, b) => a - b)
debug(
'Denomination options (filtered by max):',
filtered,
'from',
- fixedValues,
+ values,
'max:',
roundedMaxAmount.value,
)
@@ -426,6 +729,20 @@ const effectiveMaxAmount = computed(() => {
return roundedMaxAmount.value
})
+const fixedDenominationMin = computed(() => {
+ if (!useFixedDenominations.value) return null
+ const options = denominationOptions.value
+ if (options.length === 0) return null
+ return options[0]
+})
+
+const fixedDenominationMax = computed(() => {
+ if (!useFixedDenominations.value) return null
+ const options = denominationOptions.value
+ if (options.length === 0) return null
+ return options[options.length - 1]
+})
+
const selectedDenomination = computed({
get: () => formData.value.amount,
set: (value) => {
@@ -542,6 +859,8 @@ onMounted(async () => {
id: m.id,
name: m.name,
interval: m.interval,
+ currencyCode: m.currency_code,
+ exchangeRate: m.exchange_rate,
},
}))
@@ -564,6 +883,8 @@ watch(
selectedGiftCardId.value = null
calculatedFee.value = 0
exchangeRate.value = null
+ denominationSearchInput.value = undefined
+ hasTouchedSuggestions.value = false
// Clear currency when switching away from PayPal International
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
@@ -573,6 +894,15 @@ watch(
},
)
+watch(selectedGiftCardId, (newId, oldId) => {
+ if (oldId && newId !== oldId) {
+ // Reset denomination search when gift card changes
+ denominationSearchInput.value = undefined
+ hasTouchedSuggestions.value = false
+ formData.value.amount = undefined
+ }
+})
+
async function switchToDirectPaypal() {
withdrawData.value.selection.country = {
id: 'US',
@@ -649,5 +979,21 @@ const messages = defineMessages({
defaultMessage:
'You selected USD for PayPal International. Switch to direct PayPal for better fees (≈2% instead of ≈6%).',
},
+ enterDenominationPlaceholder: {
+ id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder',
+ defaultMessage: 'Enter amount',
+ },
+ enterAmountHint: {
+ id: 'dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint',
+ defaultMessage: 'Enter an amount to see available gift card denominations',
+ },
+ selectDenominationHint: {
+ id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint',
+ defaultMessage: 'Select a denomination:',
+ },
+ selectDenominationRequired: {
+ id: 'dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required',
+ defaultMessage: 'Please select a denomination to continue',
+ },
})
diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json
index 20c9b08d2..3936c2922 100644
--- a/apps/frontend/src/locales/en-US/index.json
+++ b/apps/frontend/src/locales/en-US/index.json
@@ -812,6 +812,12 @@
"dashboard.creator-withdraw-modal.tax-form-required.header": {
"message": "Tax form required"
},
+ "dashboard.creator-withdraw-modal.tremendous-details.enter-amount-hint": {
+ "message": "Enter an amount to see available gift card denominations"
+ },
+ "dashboard.creator-withdraw-modal.tremendous-details.enter-denomination-placeholder": {
+ "message": "Enter amount"
+ },
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
"message": "Payment method"
},
@@ -824,6 +830,12 @@
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
"message": "Rewards"
},
+ "dashboard.creator-withdraw-modal.tremendous-details.select-denomination-hint": {
+ "message": "Select a denomination:"
+ },
+ "dashboard.creator-withdraw-modal.tremendous-details.select-denomination-required": {
+ "message": "Please select a denomination to continue"
+ },
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
"message": "Unverified email"
},
diff --git a/apps/frontend/src/providers/creator-withdraw.ts b/apps/frontend/src/providers/creator-withdraw.ts
index 002caa5b2..86c570f20 100644
--- a/apps/frontend/src/providers/creator-withdraw.ts
+++ b/apps/frontend/src/providers/creator-withdraw.ts
@@ -58,6 +58,8 @@ export interface PayoutMethod {
fiat?: string | null
blockchain?: string[]
}
+ currency_code?: string | null
+ exchange_rate?: number | null
}
export interface PaymentOption {
@@ -284,9 +286,12 @@ interface PayoutPayload {
}
function buildPayoutPayload(data: WithdrawData): PayoutPayload {
+ // Round amount to 2 decimal places for API
+ const amount = Math.round(data.calculation.amount * 100) / 100
+
if (data.selection.provider === 'paypal' || data.selection.provider === 'venmo') {
return {
- amount: data.calculation.amount,
+ amount,
method: data.selection.provider,
method_id: data.selection.methodId!,
}
@@ -301,7 +306,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
methodDetails.currency = data.providerData.currency
}
return {
- amount: data.calculation.amount,
+ amount,
method: 'tremendous',
method_id: data.selection.methodId!,
method_details: methodDetails,
@@ -317,7 +322,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
if (rail.type === 'crypto') {
return {
- amount: data.calculation.amount,
+ amount,
method: 'muralpay',
method_id: data.selection.methodId!,
method_details: {
@@ -346,7 +351,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
}
return {
- amount: data.calculation.amount,
+ amount,
method: 'muralpay',
method_id: data.selection.methodId!,
method_details: {
@@ -480,7 +485,7 @@ export function createWithdrawContext(
label: paymentMethodMessages.paypalInternational,
icon: PayPalColorIcon,
methodId: internationalPaypalMethod.id,
- fee: '≈ 3.84%',
+ fee: '≈ 3.84%, min $0.25',
type: 'tremendous',
})
}
@@ -642,12 +647,16 @@ export function createWithdrawContext(
)
if (selectedMethod?.interval) {
+ const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
if (selectedMethod.interval.standard) {
const { min, max } = selectedMethod.interval.standard
- if (amount < min || amount > max) return false
+ const effectiveMax = Math.min(userMax, max)
+ const effectiveMin = Math.min(min, effectiveMax)
+ if (amount < effectiveMin || amount > effectiveMax) return false
}
if (selectedMethod.interval.fixed) {
- if (!selectedMethod.interval.fixed.values.includes(amount)) return false
+ const validValues = selectedMethod.interval.fixed.values.filter((v) => v <= userMax)
+ if (!validValues.includes(amount)) return false
}
}
@@ -711,7 +720,11 @@ export function createWithdrawContext(
)
if (selectedMethod?.interval?.standard) {
const { min, max } = selectedMethod.interval.standard
- if (amount < min || amount > max) return false
+ // Use effective limits that account for user's available balance
+ const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
+ const effectiveMax = Math.min(userMax, max)
+ const effectiveMin = Math.min(min, effectiveMax)
+ if (amount < effectiveMin || amount > effectiveMax) return false
}
const accountDetails = withdrawData.value.providerData.accountDetails
@@ -736,7 +749,11 @@ export function createWithdrawContext(
)
if (selectedMethod?.interval?.standard) {
const { min, max } = selectedMethod.interval.standard
- if (amount < min || amount > max) return false
+ // Use effective limits that account for user's available balance
+ const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
+ const effectiveMax = Math.min(userMax, max)
+ const effectiveMin = Math.min(min, effectiveMax)
+ if (amount < effectiveMin || amount > effectiveMax) return false
}
return !!withdrawData.value.stageValidation?.paypalDetails
diff --git a/apps/labrinth/src/models/v3/payouts.rs b/apps/labrinth/src/models/v3/payouts.rs
index 7abbe36b9..c431ca751 100644
--- a/apps/labrinth/src/models/v3/payouts.rs
+++ b/apps/labrinth/src/models/v3/payouts.rs
@@ -239,6 +239,10 @@ pub struct PayoutMethod {
pub image_logo_url: Option,
pub interval: PayoutInterval,
pub fee: PayoutMethodFee,
+ pub currency_code: Option,
+ /// USD to the given `currency_code`.
+ #[serde(with = "rust_decimal::serde::float_option")]
+ pub exchange_rate: Option,
}
#[derive(Serialize, Deserialize, Clone)]
diff --git a/apps/labrinth/src/queue/payouts/mod.rs b/apps/labrinth/src/queue/payouts/mod.rs
index 46f10865a..b68bcdb05 100644
--- a/apps/labrinth/src/queue/payouts/mod.rs
+++ b/apps/labrinth/src/queue/payouts/mod.rs
@@ -157,6 +157,8 @@ fn create_muralpay_methods() -> Vec {
min: Decimal::ZERO,
max: Some(Decimal::ZERO),
},
+ currency_code: None,
+ exchange_rate: None,
})
.collect()
}
@@ -450,6 +452,8 @@ impl PayoutsQueue {
min: Decimal::from(1) / Decimal::from(4),
max: Some(Decimal::from(1)),
},
+ currency_code: None,
+ exchange_rate: None,
};
let mut venmo = paypal_us.clone();
@@ -817,13 +821,19 @@ async fn get_tremendous_payout_methods(
products: Vec,
}
+ let forex: TremendousForexResponse = queue
+ .make_tremendous_request(Method::GET, "forex", None::<()>)
+ .await
+ .wrap_err("failed to fetch Tremendous forex data")?;
+
let response = queue
.make_tremendous_request::<(), TremendousResponse>(
Method::GET,
"products",
None,
)
- .await?;
+ .await
+ .wrap_err("failed to fetch Tremendous products data")?;
let mut methods = Vec::new();
@@ -903,6 +913,16 @@ async fn get_tremendous_payout_methods(
},
};
+ let Some(currency) = product.currency_codes.first() else {
+ // cards with multiple currencies are not supported
+ continue;
+ };
+ let Some(&usd_to_currency) = forex.forex.get(currency) else {
+ warn!("No Tremendous forex data for {currency}");
+ continue;
+ };
+ let currency_to_usd = dec!(1) / usd_to_currency;
+
let method = PayoutMethod {
id: product.id,
type_: PayoutMethodType::Tremendous,
@@ -927,15 +947,15 @@ async fn get_tremendous_payout_methods(
let mut values = product
.skus
.into_iter()
- .map(|x| PayoutDecimal(x.min))
+ .map(|x| PayoutDecimal(x.min * currency_to_usd))
.collect::>();
values.sort_by(|a, b| a.0.cmp(&b.0));
PayoutInterval::Fixed { values }
} else if let Some(first) = product.skus.first() {
PayoutInterval::Standard {
- min: first.min,
- max: first.max,
+ min: first.min * currency_to_usd,
+ max: first.max * currency_to_usd,
}
} else {
PayoutInterval::Standard {
@@ -944,15 +964,10 @@ async fn get_tremendous_payout_methods(
}
},
fee,
+ currency_code: Some(currency.clone()),
+ exchange_rate: Some(usd_to_currency),
};
- // we do not support interval gift cards with non US based currencies since we cannot do currency conversions properly
- if let PayoutInterval::Fixed { .. } = method.interval
- && !product.currency_codes.contains(&"USD".to_string())
- {
- continue;
- }
-
methods.push(method);
}