You've already forked AstralRinth
forked from didirus/AstralRinth
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:
@@ -111,28 +111,163 @@
|
|||||||
</Combobox>
|
</Combobox>
|
||||||
</div>
|
</div>
|
||||||
<span v-if="selectedMethodDetails" class="text-secondary">
|
<span v-if="selectedMethodDetails" class="text-secondary">
|
||||||
{{ formatMoney(effectiveMinAmount) }} min,
|
{{ formatMoney(fixedDenominationMin ?? effectiveMinAmount)
|
||||||
{{ formatMoney(selectedMethodDetails.interval?.standard?.max ?? effectiveMaxAmount) }}
|
}}<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.
|
max withdrawal amount.
|
||||||
</span>
|
</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>
|
||||||
|
|
||||||
<div class="flex flex-col gap-2.5">
|
<div class="flex flex-col gap-2.5">
|
||||||
<label>
|
<label>
|
||||||
<span class="text-md font-semibold text-contrast"
|
<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>
|
</label>
|
||||||
|
|
||||||
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
|
<div v-if="showGiftCardSelector && useFixedDenominations" class="flex flex-col gap-2.5">
|
||||||
<Chips
|
<template v-if="useDenominationSuggestions">
|
||||||
v-model="selectedDenomination"
|
<input
|
||||||
:items="denominationOptions"
|
v-model.number="denominationSearchInput"
|
||||||
:format-label="(amt: number) => formatMoney(amt)"
|
type="number"
|
||||||
:never-empty="false"
|
step="0.01"
|
||||||
:capitalize="false"
|
:min="0"
|
||||||
/>
|
:placeholder="formatMessage(messages.enterDenominationPlaceholder)"
|
||||||
<span v-if="denominationOptions.length === 0" class="text-error text-sm">
|
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
|
No denominations available for your current balance
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -153,8 +288,8 @@
|
|||||||
:amount="formData.amount || 0"
|
:amount="formData.amount || 0"
|
||||||
:fee="calculatedFee"
|
:fee="calculatedFee"
|
||||||
:fee-loading="feeLoading"
|
:fee-loading="feeLoading"
|
||||||
:exchange-rate="exchangeRate"
|
:exchange-rate="giftCardExchangeRate"
|
||||||
:local-currency="showPayPalCurrencySelector ? selectedCurrency : undefined"
|
:local-currency="giftCardCurrencyCode"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Checkbox v-model="agreedTerms">
|
<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 selectedGiftCardId = ref<string | null>(withdrawData.value.selection.methodId || null)
|
||||||
|
|
||||||
|
const denominationSearchInput = ref<number | undefined>(undefined)
|
||||||
|
const hasTouchedSuggestions = ref(false)
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ value: 'USD', label: 'USD' },
|
{ value: 'USD', label: 'USD' },
|
||||||
{ value: 'AUD', label: 'AUD' },
|
{ value: 'AUD', label: 'AUD' },
|
||||||
@@ -373,6 +511,8 @@ const rewardOptions = ref<
|
|||||||
fixed?: { values: number[] }
|
fixed?: { values: number[] }
|
||||||
standard?: { min: number; max: number }
|
standard?: { min: number; max: number }
|
||||||
}
|
}
|
||||||
|
currencyCode?: string | null
|
||||||
|
exchangeRate?: number | null
|
||||||
}
|
}
|
||||||
}>
|
}>
|
||||||
>([])
|
>([])
|
||||||
@@ -390,24 +530,187 @@ const selectedMethodDetails = computed(() => {
|
|||||||
return option?.methodDetails || null
|
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 useFixedDenominations = computed(() => {
|
||||||
const hasFixed = !!selectedMethodDetails.value?.interval?.fixed?.values
|
const interval = selectedMethodDetails.value?.interval
|
||||||
debug('Use fixed denominations:', hasFixed, selectedMethodDetails.value?.interval)
|
if (!interval) return false
|
||||||
return hasFixed
|
|
||||||
|
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 denominationOptions = computed(() => {
|
||||||
const fixedValues = selectedMethodDetails.value?.interval?.fixed?.values
|
const interval = selectedMethodDetails.value?.interval
|
||||||
if (!fixedValues) return []
|
if (!interval) return []
|
||||||
|
|
||||||
const filtered = fixedValues
|
let values: number[] = []
|
||||||
.filter((amount) => amount <= roundedMaxAmount.value)
|
|
||||||
.sort((a, b) => a - b)
|
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(
|
debug(
|
||||||
'Denomination options (filtered by max):',
|
'Denomination options (filtered by max):',
|
||||||
filtered,
|
filtered,
|
||||||
'from',
|
'from',
|
||||||
fixedValues,
|
values,
|
||||||
'max:',
|
'max:',
|
||||||
roundedMaxAmount.value,
|
roundedMaxAmount.value,
|
||||||
)
|
)
|
||||||
@@ -426,6 +729,20 @@ const effectiveMaxAmount = computed(() => {
|
|||||||
return roundedMaxAmount.value
|
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({
|
const selectedDenomination = computed({
|
||||||
get: () => formData.value.amount,
|
get: () => formData.value.amount,
|
||||||
set: (value) => {
|
set: (value) => {
|
||||||
@@ -542,6 +859,8 @@ onMounted(async () => {
|
|||||||
id: m.id,
|
id: m.id,
|
||||||
name: m.name,
|
name: m.name,
|
||||||
interval: m.interval,
|
interval: m.interval,
|
||||||
|
currencyCode: m.currency_code,
|
||||||
|
exchangeRate: m.exchange_rate,
|
||||||
},
|
},
|
||||||
}))
|
}))
|
||||||
|
|
||||||
@@ -564,6 +883,8 @@ watch(
|
|||||||
selectedGiftCardId.value = null
|
selectedGiftCardId.value = null
|
||||||
calculatedFee.value = 0
|
calculatedFee.value = 0
|
||||||
exchangeRate.value = null
|
exchangeRate.value = null
|
||||||
|
denominationSearchInput.value = undefined
|
||||||
|
hasTouchedSuggestions.value = false
|
||||||
|
|
||||||
// Clear currency when switching away from PayPal International
|
// Clear currency when switching away from PayPal International
|
||||||
if (newMethod !== 'paypal' && withdrawData.value.providerData.type === 'tremendous') {
|
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() {
|
async function switchToDirectPaypal() {
|
||||||
withdrawData.value.selection.country = {
|
withdrawData.value.selection.country = {
|
||||||
id: 'US',
|
id: 'US',
|
||||||
@@ -649,5 +979,21 @@ const messages = defineMessages({
|
|||||||
defaultMessage:
|
defaultMessage:
|
||||||
'You selected USD for PayPal International. <direct-paypal-link>Switch to direct PayPal</direct-paypal-link> for better fees (≈2% instead of ≈6%).',
|
'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>
|
</script>
|
||||||
|
|||||||
@@ -812,6 +812,12 @@
|
|||||||
"dashboard.creator-withdraw-modal.tax-form-required.header": {
|
"dashboard.creator-withdraw-modal.tax-form-required.header": {
|
||||||
"message": "Tax form required"
|
"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": {
|
"dashboard.creator-withdraw-modal.tremendous-details.payment-method": {
|
||||||
"message": "Payment method"
|
"message": "Payment method"
|
||||||
},
|
},
|
||||||
@@ -824,6 +830,12 @@
|
|||||||
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
|
"dashboard.creator-withdraw-modal.tremendous-details.reward-plural": {
|
||||||
"message": "Rewards"
|
"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": {
|
"dashboard.creator-withdraw-modal.tremendous-details.unverified-email-header": {
|
||||||
"message": "Unverified email"
|
"message": "Unverified email"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -58,6 +58,8 @@ export interface PayoutMethod {
|
|||||||
fiat?: string | null
|
fiat?: string | null
|
||||||
blockchain?: string[]
|
blockchain?: string[]
|
||||||
}
|
}
|
||||||
|
currency_code?: string | null
|
||||||
|
exchange_rate?: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PaymentOption {
|
export interface PaymentOption {
|
||||||
@@ -284,9 +286,12 @@ interface PayoutPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function buildPayoutPayload(data: WithdrawData): 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') {
|
if (data.selection.provider === 'paypal' || data.selection.provider === 'venmo') {
|
||||||
return {
|
return {
|
||||||
amount: data.calculation.amount,
|
amount,
|
||||||
method: data.selection.provider,
|
method: data.selection.provider,
|
||||||
method_id: data.selection.methodId!,
|
method_id: data.selection.methodId!,
|
||||||
}
|
}
|
||||||
@@ -301,7 +306,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
|
|||||||
methodDetails.currency = data.providerData.currency
|
methodDetails.currency = data.providerData.currency
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
amount: data.calculation.amount,
|
amount,
|
||||||
method: 'tremendous',
|
method: 'tremendous',
|
||||||
method_id: data.selection.methodId!,
|
method_id: data.selection.methodId!,
|
||||||
method_details: methodDetails,
|
method_details: methodDetails,
|
||||||
@@ -317,7 +322,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
|
|||||||
|
|
||||||
if (rail.type === 'crypto') {
|
if (rail.type === 'crypto') {
|
||||||
return {
|
return {
|
||||||
amount: data.calculation.amount,
|
amount,
|
||||||
method: 'muralpay',
|
method: 'muralpay',
|
||||||
method_id: data.selection.methodId!,
|
method_id: data.selection.methodId!,
|
||||||
method_details: {
|
method_details: {
|
||||||
@@ -346,7 +351,7 @@ function buildPayoutPayload(data: WithdrawData): PayoutPayload {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
amount: data.calculation.amount,
|
amount,
|
||||||
method: 'muralpay',
|
method: 'muralpay',
|
||||||
method_id: data.selection.methodId!,
|
method_id: data.selection.methodId!,
|
||||||
method_details: {
|
method_details: {
|
||||||
@@ -480,7 +485,7 @@ export function createWithdrawContext(
|
|||||||
label: paymentMethodMessages.paypalInternational,
|
label: paymentMethodMessages.paypalInternational,
|
||||||
icon: PayPalColorIcon,
|
icon: PayPalColorIcon,
|
||||||
methodId: internationalPaypalMethod.id,
|
methodId: internationalPaypalMethod.id,
|
||||||
fee: '≈ 3.84%',
|
fee: '≈ 3.84%, min $0.25',
|
||||||
type: 'tremendous',
|
type: 'tremendous',
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -642,12 +647,16 @@ export function createWithdrawContext(
|
|||||||
)
|
)
|
||||||
|
|
||||||
if (selectedMethod?.interval) {
|
if (selectedMethod?.interval) {
|
||||||
|
const userMax = Math.floor(maxWithdrawAmount.value * 100) / 100
|
||||||
if (selectedMethod.interval.standard) {
|
if (selectedMethod.interval.standard) {
|
||||||
const { min, max } = 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) {
|
||||||
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) {
|
if (selectedMethod?.interval?.standard) {
|
||||||
const { min, max } = 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
|
const accountDetails = withdrawData.value.providerData.accountDetails
|
||||||
@@ -736,7 +749,11 @@ export function createWithdrawContext(
|
|||||||
)
|
)
|
||||||
if (selectedMethod?.interval?.standard) {
|
if (selectedMethod?.interval?.standard) {
|
||||||
const { min, max } = 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
|
return !!withdrawData.value.stageValidation?.paypalDetails
|
||||||
|
|||||||
@@ -239,6 +239,10 @@ pub struct PayoutMethod {
|
|||||||
pub image_logo_url: Option<String>,
|
pub image_logo_url: Option<String>,
|
||||||
pub interval: PayoutInterval,
|
pub interval: PayoutInterval,
|
||||||
pub fee: PayoutMethodFee,
|
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)]
|
#[derive(Serialize, Deserialize, Clone)]
|
||||||
|
|||||||
@@ -157,6 +157,8 @@ fn create_muralpay_methods() -> Vec<PayoutMethod> {
|
|||||||
min: Decimal::ZERO,
|
min: Decimal::ZERO,
|
||||||
max: Some(Decimal::ZERO),
|
max: Some(Decimal::ZERO),
|
||||||
},
|
},
|
||||||
|
currency_code: None,
|
||||||
|
exchange_rate: None,
|
||||||
})
|
})
|
||||||
.collect()
|
.collect()
|
||||||
}
|
}
|
||||||
@@ -450,6 +452,8 @@ impl PayoutsQueue {
|
|||||||
min: Decimal::from(1) / Decimal::from(4),
|
min: Decimal::from(1) / Decimal::from(4),
|
||||||
max: Some(Decimal::from(1)),
|
max: Some(Decimal::from(1)),
|
||||||
},
|
},
|
||||||
|
currency_code: None,
|
||||||
|
exchange_rate: None,
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut venmo = paypal_us.clone();
|
let mut venmo = paypal_us.clone();
|
||||||
@@ -817,13 +821,19 @@ async fn get_tremendous_payout_methods(
|
|||||||
products: Vec<Product>,
|
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
|
let response = queue
|
||||||
.make_tremendous_request::<(), TremendousResponse>(
|
.make_tremendous_request::<(), TremendousResponse>(
|
||||||
Method::GET,
|
Method::GET,
|
||||||
"products",
|
"products",
|
||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await?;
|
.await
|
||||||
|
.wrap_err("failed to fetch Tremendous products data")?;
|
||||||
|
|
||||||
let mut methods = Vec::new();
|
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 {
|
let method = PayoutMethod {
|
||||||
id: product.id,
|
id: product.id,
|
||||||
type_: PayoutMethodType::Tremendous,
|
type_: PayoutMethodType::Tremendous,
|
||||||
@@ -927,15 +947,15 @@ async fn get_tremendous_payout_methods(
|
|||||||
let mut values = product
|
let mut values = product
|
||||||
.skus
|
.skus
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|x| PayoutDecimal(x.min))
|
.map(|x| PayoutDecimal(x.min * currency_to_usd))
|
||||||
.collect::<Vec<_>>();
|
.collect::<Vec<_>>();
|
||||||
values.sort_by(|a, b| a.0.cmp(&b.0));
|
values.sort_by(|a, b| a.0.cmp(&b.0));
|
||||||
|
|
||||||
PayoutInterval::Fixed { values }
|
PayoutInterval::Fixed { values }
|
||||||
} else if let Some(first) = product.skus.first() {
|
} else if let Some(first) = product.skus.first() {
|
||||||
PayoutInterval::Standard {
|
PayoutInterval::Standard {
|
||||||
min: first.min,
|
min: first.min * currency_to_usd,
|
||||||
max: first.max,
|
max: first.max * currency_to_usd,
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PayoutInterval::Standard {
|
PayoutInterval::Standard {
|
||||||
@@ -944,15 +964,10 @@ async fn get_tremendous_payout_methods(
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
fee,
|
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);
|
methods.push(method);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user