From dc16a65b62b0c14abfa66eeaba50915a977a3ecf Mon Sep 17 00:00:00 2001 From: aecsocket Date: Thu, 18 Dec 2025 18:02:29 +0000 Subject: [PATCH] Improve support for non-USD Tremendous gift cards (#4887) * Improve support for non-USD Tremendous gift cards * add forex info to tremendous payout methods * fix: partially fix DEV-535 * feat: wip * eur/usd to usd/eur * feat: better denom picking * feat: qa changes * fix: intl --------- Co-authored-by: Calum H. (IMB11) Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com> --- .../TremendousDetailsStage.vue | 390 +++++++++++++++++- apps/frontend/src/locales/en-US/index.json | 12 + .../src/providers/creator-withdraw.ts | 35 +- apps/labrinth/src/models/v3/payouts.rs | 4 + apps/labrinth/src/queue/payouts/mod.rs | 37 +- 5 files changed, 436 insertions(+), 42 deletions(-) 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) + }} + min, + {{ + formatMoney( + fixedDenominationMax ?? + selectedMethodDetails.interval?.standard?.max ?? + effectiveMaxAmount, + ) + }} max withdrawal amount. + + You need at least {{ formatMoney(effectiveMinAmount) + }} + to use this gift card. +
- - + + + +
+ +
+
+ + + + {{ 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); }