1
0
Files
AstralRinth/apps/frontend/src/pages/dashboard/revenue/withdraw.vue
François-Xavier Talbot 7796273529 Clearer error on TIN mismatch (#4579)
* Clearer error on TIN mismatch

* Remove ctoken code (how did that end up there)

* polish: frontend for TIN/SSN mismatch

* fix: lint

* polish: only banner + change text

* fix: logic

* fix: lint

---------

Co-authored-by: Calum H. (IMB11) <contact@cal.engineer>
2025-10-19 21:03:21 +00:00

631 lines
16 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<template>
<CreatorTaxFormModal
ref="taxFormModalRef"
close-button-text="Continue"
@success="onTaxFormSuccess"
@cancelled="onTaxFormCancelled"
/>
<section class="universal-card">
<Breadcrumbs
current-title="Withdraw"
:link-stack="[{ href: '/dashboard/revenue', label: 'Revenue' }]"
/>
<h2>Withdraw</h2>
<h3>Region</h3>
<Multiselect
id="country-multiselect"
v-model="country"
class="country-multiselect"
placeholder="Select country..."
track-by="id"
label="name"
:options="countries"
:searchable="true"
:close-on-select="true"
:show-labels="false"
:allow-empty="false"
/>
<h3>Withdraw method</h3>
<div class="iconified-input">
<label class="hidden" for="search">Search</label>
<SearchIcon aria-hidden="true" />
<input
id="search"
v-model="search"
name="search"
placeholder="Search options..."
autocomplete="off"
/>
</div>
<div class="withdraw-options-scroll">
<div class="withdraw-options">
<button
v-for="method in payoutMethods
.filter((x) => x.name.toLowerCase().includes(search.toLowerCase()))
.sort((a, b) =>
a.type !== 'tremendous'
? -1
: a.name.toLowerCase().localeCompare(b.name.toLowerCase()),
)"
:key="method.id"
class="withdraw-option button-base"
:class="{ selected: selectedMethodId === method.id }"
@click="() => (selectedMethodId = method.id)"
>
<div class="preview" :class="{ 'show-bg': !method.image_url || method.name === 'ACH' }">
<template v-if="method.image_url && method.name !== 'ACH'">
<div class="preview-badges">
<span class="badge">
{{
getRangeOfMethod(method)
.map($formatMoney)
.map((i) => i.replace('.00', ''))
.join('')
}}
</span>
</div>
<img
v-if="method.image_url && method.name !== 'ACH'"
class="preview-img"
:src="method.image_url"
:alt="method.name"
/>
</template>
<div v-else class="placeholder">
<template v-if="method.type === 'venmo'">
<VenmoIcon class="enlarge" />
</template>
<template v-else>
<PayPalIcon v-if="method.type === 'paypal'" />
<span>{{ method.name }}</span>
</template>
</div>
</div>
<div class="label">
<RadioButtonCheckedIcon v-if="selectedMethodId === method.id" class="radio" />
<RadioButtonIcon v-else class="radio" />
<span>{{ method.name }}</span>
</div>
</button>
</div>
</div>
<h3>Amount</h3>
<p>
You are initiating a transfer of your revenue from Modrinth's Creator Monetization Program.
How much of your
<strong>{{ $formatMoney(userBalance.available) }}</strong> balance would you like to transfer
transfer to {{ selectedMethod.name }}?
</p>
<div class="confirmation-input">
<template v-if="selectedMethod.interval.fixed">
<Chips
v-model="amount"
:items="selectedMethod.interval.fixed.values"
:format-label="(val) => '$' + val"
/>
</template>
<template v-else-if="minWithdrawAmount == maxWithdrawAmount">
<div>
<p>
This method has a fixed transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong
>.
</p>
</div>
</template>
<template v-else>
<div>
<p>
This method has a minimum transfer amount of
<strong>{{ $formatMoney(minWithdrawAmount) }}</strong> and a maximum transfer amount of
<strong>{{ $formatMoney(maxWithdrawAmount) }}</strong
>.
</p>
<input
id="confirmation"
v-model="amount"
type="text"
pattern="^\d*(\.\d{0,2})?$"
autocomplete="off"
placeholder="Amount to transfer..."
/>
<p>
You have entered <strong>{{ $formatMoney(parsedAmount) }}</strong> to transfer.
</p>
</div>
</template>
</div>
<p v-if="willTriggerTaxForm" class="font-bold text-orange">
This withdrawal will exceed $600 for the year. You will be prompted to complete a tax form
before proceeding.
</p>
<p v-else-if="blockedByTax" class="font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form.
</p>
<div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
I acknowledge that an estimated
{{ formatMoney(fees) }} will be deducted from the amount I receive to cover
{{ formatWallet(selectedMethod.type) }} processing fees.
</Checkbox>
<Checkbox v-model="agreedTransfer" description="Confirm transfer">
<template v-if="selectedMethod.type === 'tremendous'">
I confirm that I am initiating a transfer and I will receive further instructions on how
to redeem this payment via email to:
{{ withdrawAccount }}
</template>
<template v-else>
I confirm that I am initiating a transfer to the following
{{ formatWallet(selectedMethod.type) }} account: {{ withdrawAccount }}
</template>
</Checkbox>
<Checkbox v-model="agreedTerms" class="rewards-checkbox">
I agree to the
<nuxt-link to="/legal/cmp" class="text-link">Rewards Program Terms</nuxt-link>
</Checkbox>
</template>
<template v-else>
<span v-for="(error, index) in knownErrors" :key="index" class="invalid">
{{ error }}
</span>
</template>
</div>
<div class="button-group">
<nuxt-link to="/dashboard/revenue" class="iconified-button">
<XIcon />
Cancel
</nuxt-link>
<button
:disabled="
knownErrors.length > 0 ||
!amount ||
!agreedTransfer ||
!agreedTerms ||
(fees > 0 && !agreedFees) ||
blockedByTax
"
class="iconified-button brand-button"
@click="withdraw"
>
<TransferIcon />
Withdraw
</button>
</div>
</section>
</template>
<script setup>
import {
PayPalIcon,
RadioButtonCheckedIcon,
RadioButtonIcon,
SearchIcon,
TransferIcon,
XIcon,
} from '@modrinth/assets'
import { Breadcrumbs, Checkbox, Chips, injectNotificationManager } from '@modrinth/ui'
import { formatMoney, formatWallet } from '@modrinth/utils'
import { all } from 'iso-3166-1'
import { Multiselect } from 'vue-multiselect'
import VenmoIcon from '~/assets/images/external/venmo.svg?component'
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
const { addNotification } = injectNotificationManager()
const auth = await useAuth()
const data = useNuxtApp()
const countries = computed(() =>
all().map((x) => ({
id: x.alpha2,
name: x.alpha2 === 'TW' ? 'Taiwan' : x.country,
})),
)
const search = ref('')
const amount = ref('')
const country = ref(
countries.value.find((x) => x.id === (auth.value.user.payout_data.paypal_region ?? 'US')),
)
const [{ data: userBalance }, { data: payoutMethods, refresh: refreshPayoutMethods }] =
await Promise.all([
useAsyncData(`payout/balance`, async () => {
const response = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
return {
...response,
available: parseFloat(response.available),
withdrawn_lifetime: parseFloat(response.withdrawn_lifetime),
withdrawn_ytd: parseFloat(response.withdrawn_ytd),
pending: parseFloat(response.pending),
dates: Object.fromEntries(
Object.entries(response.dates).map(([date, value]) => [date, parseFloat(value)]),
),
}
}),
useAsyncData(`payout/methods?country=${country.value.id}`, () =>
useBaseFetch(`payout/methods?country=${country.value.id}`, { apiVersion: 3 }),
),
])
const selectedMethodId = ref(payoutMethods.value[0].id)
const selectedMethod = computed(() =>
payoutMethods.value.find((x) => x.id === selectedMethodId.value),
)
const parsedAmount = computed(() => {
const regex = /^\$?(\d*(\.\d{2})?)$/gm
const matches = regex.exec(amount.value)
return matches && matches[1] ? parseFloat(matches[1]) : 0.0
})
const fees = computed(() => {
return Math.min(
Math.max(
selectedMethod.value.fee.min,
selectedMethod.value.fee.percentage * parsedAmount.value,
),
selectedMethod.value.fee.max ?? Number.MAX_VALUE,
)
})
const getIntervalRange = (intervalType) => {
if (!intervalType) {
return []
}
const { min, max, values } = intervalType
if (values) {
const first = values[0]
const last = values.slice(-1)[0]
return first === last ? [first] : [first, last]
}
return min === max ? [min] : [min, max]
}
const getRangeOfMethod = (method) => {
return getIntervalRange(method.interval?.fixed || method.interval?.standard)
}
const maxWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.max : (interval?.fixed?.values.slice(-1)[0] ?? 0)
})
const minWithdrawAmount = computed(() => {
const interval = selectedMethod.value.interval
return interval?.standard ? interval.standard.min : (interval?.fixed?.values?.[0] ?? fees.value)
})
const withdrawAccount = computed(() => {
if (selectedMethod.value.type === 'paypal') {
return auth.value.user.payout_data.paypal_address
} else if (selectedMethod.value.type === 'venmo') {
return auth.value.user.payout_data.venmo_handle
} else {
return auth.value.user.email
}
})
const knownErrors = computed(() => {
const errors = []
if (selectedMethod.value.type === 'paypal' && !auth.value.user.payout_data.paypal_address) {
errors.push('Please link your PayPal account in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'venmo' && !auth.value.user.payout_data.venmo_handle) {
errors.push('Please set your Venmo handle in the dashboard to proceed.')
}
if (selectedMethod.value.type === 'tremendous') {
if (!auth.value.user.email) {
errors.push('Please set your email address in your account settings to proceed.')
}
if (!auth.value.user.email_verified) {
errors.push('Please verify your email address to proceed.')
}
}
if (!parsedAmount.value && amount.value.length > 0) {
errors.push(`${amount.value} is not a valid amount`)
} else if (
parsedAmount.value > userBalance.value.available ||
parsedAmount.value > maxWithdrawAmount.value
) {
const maxAmount = Math.min(userBalance.value.available, maxWithdrawAmount.value)
errors.push(`The amount must be no more than ${data.$formatMoney(maxAmount)}`)
} else if (parsedAmount.value <= fees.value || parsedAmount.value < minWithdrawAmount.value) {
const minAmount = Math.max(fees.value + 0.01, minWithdrawAmount.value)
errors.push(`The amount must be at least ${data.$formatMoney(minAmount)}`)
}
return errors
})
const agreedTransfer = ref(false)
const agreedFees = ref(false)
const agreedTerms = ref(false)
const flags = useFeatureFlags()
const blockedByTax = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const willTriggerTaxForm = computed(() => {
if (flags.value.testTaxForm) return true
const status = userBalance.value?.form_completion_status ?? 'unknown'
const currentWithdrawn = userBalance.value?.withdrawn_ytd ?? 0
const wouldExceedThreshold = currentWithdrawn + parsedAmount.value >= 600
return wouldExceedThreshold && status !== 'complete' && !blockedByTax.value
})
watch(country, async () => {
await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) {
selectedMethodId.value = payoutMethods.value[0].id
}
})
watch(selectedMethod, () => {
if (selectedMethod.value.interval?.fixed) {
amount.value = selectedMethod.value.interval.fixed.values[0]
}
if (maxWithdrawAmount.value === minWithdrawAmount.value) {
amount.value = maxWithdrawAmount.value
}
agreedTransfer.value = false
agreedFees.value = false
agreedTerms.value = false
})
const taxFormModalRef = ref(null)
const taxFormCancelled = ref(false)
async function performWithdrawal() {
startLoading()
try {
const auth = await useAuth()
await useBaseFetch(`payout`, {
method: 'POST',
body: {
amount: parsedAmount.value,
method: selectedMethod.value.type,
method_id: selectedMethod.value.id,
},
apiVersion: 3,
})
await useAuth(auth.value.token)
await navigateTo('/dashboard/revenue')
addNotification({
title: 'Withdrawal complete',
text:
selectedMethod.value.type === 'tremendous'
? 'An email has been sent to your account with further instructions on how to redeem your payout!'
: `Payment has been sent to your ${formatWallet(selectedMethod.value.type)} account!`,
type: 'success',
})
} catch (err) {
addNotification({
title: 'An error occurred',
text: err.data ? err.data.description : err,
type: 'error',
})
}
stopLoading()
}
async function withdraw() {
if (willTriggerTaxForm.value) {
taxFormCancelled.value = false
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
await taxFormModalRef.value.startTaxForm(new MouseEvent('click'))
}
return
}
await performWithdrawal()
}
async function onTaxFormSuccess() {
// Skip balance check if testTaxForm flag is enabled
if (flags.value.testTaxForm) {
await performWithdrawal()
return
}
// Refresh user balance to get updated form completion status
const updatedBalance = await useBaseFetch(`payout/balance`, { apiVersion: 3 })
userBalance.value = updatedBalance
if (updatedBalance?.form_completion_status === 'complete') {
await performWithdrawal()
} else {
addNotification({
title: 'Tax form incomplete',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
}
function onTaxFormCancelled() {
taxFormCancelled.value = true
addNotification({
title: 'Withdrawal canceled',
text: 'You must complete a tax form for this withdrawal.',
type: 'error',
})
}
</script>
<style lang="scss" scoped>
.withdraw-options-scroll {
max-height: 460px;
overflow-y: auto;
&::-webkit-scrollbar {
width: var(--gap-md);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-track {
background: var(--color-bg);
border: 3px solid var(--color-bg);
}
&::-webkit-scrollbar-thumb {
background-color: var(--color-raised-bg);
border-radius: var(--radius-lg);
border: 3px solid var(--color-bg);
}
}
.withdraw-options {
display: grid;
grid-template-columns: repeat(1, 1fr);
gap: var(--gap-lg);
padding-right: 0.5rem;
@media screen and (min-width: 300px) {
grid-template-columns: repeat(2, 1fr);
}
@media screen and (min-width: 600px) {
grid-template-columns: repeat(3, 1fr);
}
}
.withdraw-option {
width: 100%;
border-radius: var(--radius-md);
padding: 0;
overflow: hidden;
border: 1px solid var(--color-divider);
background-color: var(--color-button-bg);
color: var(--color-text);
&.selected {
color: var(--color-contrast);
.label svg {
color: var(--color-brand);
}
}
.preview {
display: flex;
justify-content: center;
aspect-ratio: 30 / 19;
position: relative;
.preview-badges {
// These will float over the image in the bottom right corner
position: absolute;
bottom: 0;
right: 0;
padding: var(--gap-sm) var(--gap-xs);
.badge {
background-color: var(--color-button-bg);
border-radius: var(--radius-xs);
padding: var(--gap-xs) var(--gap-sm);
font-size: var(--font-size-xs);
}
}
&.show-bg {
background-color: var(--color-bg);
}
img {
-webkit-user-drag: none;
-khtml-user-drag: none;
-moz-user-drag: none;
-o-user-drag: none;
user-drag: none;
user-select: none;
width: 100%;
height: auto;
object-fit: cover;
}
.placeholder {
display: flex;
align-items: center;
gap: var(--gap-xs);
svg {
width: 2rem;
height: auto;
}
span {
font-weight: var(--font-weight-bold);
font-size: 2rem;
font-style: italic;
}
.enlarge {
width: auto;
height: 1.5rem;
}
}
}
.label {
display: flex;
align-items: center;
padding: var(--gap-md) var(--gap-lg);
svg {
min-height: 1rem;
min-width: 1rem;
margin-right: 0.5rem;
}
span {
text-overflow: ellipsis;
overflow: hidden;
white-space: nowrap;
}
}
}
.invalid {
color: var(--color-red);
}
.confirm-text {
margin: var(--spacing-card-md) 0;
display: flex;
flex-direction: column;
gap: var(--spacing-card-sm);
}
.iconified-input {
margin-bottom: var(--spacing-card-md);
}
.country-multiselect,
.iconified-input {
max-width: 16rem;
}
.rewards-checkbox {
a {
margin-left: 0.5ch;
}
}
</style>