1
0

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) <contact@cal.engineer>
Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
aecsocket
2025-12-18 18:02:29 +00:00
committed by GitHub
parent 514c6f6e34
commit dc16a65b62
5 changed files with 436 additions and 42 deletions

View File

@@ -111,28 +111,163 @@
</Combobox>
</div>
<span v-if="selectedMethodDetails" class="text-secondary">
{{ formatMoney(effectiveMinAmount) }} min,
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMin ?? effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
min,
{{
formatMoney(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
fixedDenominationMax ??
selectedMethodDetails.interval?.standard?.max ??
effectiveMaxAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
max withdrawal amount.
</span>
<span
v-if="selectedMethodDetails && effectiveMinAmount > roundedMaxAmount"
class="text-sm text-red"
>
You need at least {{ formatMoney(effectiveMinAmount)
}}<template v-if="selectedMethodCurrencyCode && selectedMethodCurrencyCode !== 'USD'">
({{
formatAmountForDisplay(
effectiveMinAmount,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
}})</template
>
to use this gift card.
</span>
</div>
<div class="flex flex-col gap-2.5">
<label>
<span class="text-md font-semibold text-contrast"
>{{ formatMessage(formFieldLabels.amount) }} <span class="text-red">*</span></span
>{{ formatMessage(formFieldLabels.amount) }}
<template v-if="useDenominationSuggestions"> ({{ selectedMethodCurrencyCode }})</template>
<span class="text-red">*</span></span
>
</label>
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
<Chips
v-model="selectedDenomination"
:items="denominationOptions"
:format-label="(amt: number) => formatMoney(amt)"
:never-empty="false"
:capitalize="false"
/>
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
<template v-if="useDenominationSuggestions">
<input
v-model.number="denominationSearchInput"
type="number"
step="0.01"
:min="0"
:placeholder="formatMessage(messages.enterDenominationPlaceholder)"
class="w-full rounded-[14px] bg-surface-4 px-4 py-3 text-contrast placeholder:text-secondary sm:py-2.5"
@input="hasTouchedSuggestions = true"
/>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span v-if="!denominationSearchInput" class="text-sm text-secondary">
{{ formatMessage(messages.enterAmountHint) }}
</span>
</Transition>
</template>
<Transition
enter-active-class="transition-all duration-300 ease-out"
enter-from-class="opacity-0 max-h-0"
enter-to-class="opacity-100 max-h-96"
leave-active-class="transition-all duration-200 ease-in"
leave-from-class="opacity-100 max-h-96"
leave-to-class="opacity-0 max-h-0"
>
<div
v-if="!useDenominationSuggestions || displayedSuggestions.length > 0"
class="overflow-hidden"
:class="[useDenominationSuggestions ? 'p-[2px]' : '']"
>
<Chips
v-model="selectedDenomination"
:items="useDenominationSuggestions ? displayedSuggestions : denominationOptions"
:format-label="
(amt: number) =>
formatAmountForDisplay(
amt,
selectedMethodCurrencyCode,
selectedMethodExchangeRate,
)
"
:never-empty="false"
:capitalize="false"
/>
</div>
</Transition>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
useDenominationSuggestions &&
denominationSearchInput &&
displayedSuggestions.length === 0
"
class="text-sm text-secondary"
>
{{ noSuggestionsMessage }}
</span>
</Transition>
<Transition
enter-active-class="transition-opacity duration-200 ease-out"
enter-from-class="opacity-0"
enter-to-class="opacity-100"
leave-active-class="transition-opacity duration-150 ease-in"
leave-from-class="opacity-100"
leave-to-class="opacity-0"
>
<span
v-if="
useDenominationSuggestions &&
hasTouchedSuggestions &&
displayedSuggestions.length > 0 &&
!hasSelectedDenomination
"
class="text-sm text-orange"
>
{{ formatMessage(messages.selectDenominationRequired) }}
</span>
</Transition>
<span
v-if="!useDenominationSuggestions && denominationOptions.length === 0"
class="text-error text-sm"
>
No denominations available for your current balance
</span>
</div>
@@ -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"
/>
<Checkbox v-model="agreedTerms">
@@ -285,6 +420,9 @@ const formData = ref<Record<string, any>>({
const selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
const denominationSearchInput = ref<number | undefined>(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. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> 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',
},
})
</script>

View File

@@ -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"
},

View File

@@ -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

View File

@@ -239,6 +239,10 @@ pub struct PayoutMethod {
pub image_logo_url: Option<String>,
pub interval: PayoutInterval,
pub fee: PayoutMethodFee,
pub currency_code: Option<String>,
/// USD to the given `currency_code`.
#[serde(with = "rust_decimal::serde::float_option")]
pub exchange_rate: Option<Decimal>,
}
#[derive(Serialize, Deserialize, Clone)]

View File

@@ -157,6 +157,8 @@ fn create_muralpay_methods() -> Vec<PayoutMethod> {
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<Product>,
}
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::<Vec<_>>();
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);
}