feat: temporary tax compliance impl (#4393)

* feat: temporary tax compliance impl

* fix: lint & intl

* Update banner, reload page on submit, and fix withdraw button disabled state

---------

Co-authored-by: Prospector <6166773+Prospector@users.noreply.github.com>
This commit is contained in:
Calum H.
2025-09-21 23:23:07 +01:00
committed by GitHub
parent b425c66832
commit 5b44454e18
6 changed files with 640 additions and 26 deletions

View File

@@ -0,0 +1,286 @@
<template>
<NewModal ref="taxFormModal" :header="formatMessage(messages.taxFormHeader)">
<div class="w-full sm:w-[540px]">
<Admonition type="info" :header="formatMessage(messages.securityHeader)">
<IntlFormatted :message-id="messages.securityDescription">
<template #security-link="{ children }">
<a
href="https://www.track1099.com/info/security"
class="flex w-fit flex-row gap-1 align-middle text-link"
target="_blank"
rel="noopener noreferrer"
>
<component :is="() => normalizeChildren(children)" />
<ExternalIcon class="my-auto" />
</a>
</template>
</IntlFormatted>
</Admonition>
<div class="mt-4 flex flex-col gap-2">
<label>
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.usCitizenQuestion) }}
<span class="text-brand-red">*</span>
</span>
</label>
<Chips
v-model="isUSCitizen"
:items="['yes', 'no']"
:format-label="
(item) => (item === 'yes' ? formatMessage(messages.yes) : formatMessage(messages.no))
"
:never-empty="false"
:capitalize="true"
/>
</div>
<Transition
enter-active-class="transition-all duration-300 ease-in-out"
enter-from-class="h-0 overflow-hidden opacity-0"
enter-to-class="h-auto overflow-visible opacity-100"
leave-active-class="transition-all duration-300 ease-in-out"
leave-from-class="h-auto overflow-visible opacity-100"
leave-to-class="h-0 overflow-hidden opacity-0"
>
<div v-if="isUSCitizen === 'no'" class="flex flex-col gap-1">
<label class="mt-4">
<span class="text-lg font-semibold text-contrast">
{{ formatMessage(messages.entityQuestion) }}
<span class="text-brand-red">*</span>
</span>
</label>
<Chips
v-model="entityType"
:items="['private-individual', 'foreign-entity']"
:format-label="
(item) =>
item === 'private-individual'
? formatMessage(messages.privateIndividual)
: formatMessage(messages.foreignEntity)
"
:never-empty="false"
:capitalize="false"
class="mt-2"
/>
<span class="text-md mt-2 leading-tight">
{{ formatMessage(messages.entityDescription) }}
</span>
</div>
</Transition>
<div class="mt-4 flex justify-end gap-3">
<ButtonStyled @click="handleCancel">
<button><XIcon /> {{ formatMessage(messages.cancel) }}</button>
</ButtonStyled>
<ButtonStyled>
<button :disabled="!canContinue || loading" @click="continueForm">
{{ formatMessage(messages.continue) }}
<RightArrowIcon v-if="!loading" /> <SpinnerIcon v-else class="animate-spin" />
</button>
</ButtonStyled>
</div>
</div>
</NewModal>
</template>
<script setup lang="ts">
import { ExternalIcon, RightArrowIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, Chips, injectNotificationManager, NewModal } from '@modrinth/ui'
import { defineMessages, useVIntl } from '@vintl/vintl'
import { IntlFormatted } from '@vintl/vintl/components'
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
import { normalizeChildren } from '@/utils/vue-children.ts'
const { addNotification } = injectNotificationManager()
const taxFormModal = ref<InstanceType<typeof NewModal> | null>(null)
async function startTaxForm(e: MouseEvent) {
taxFormModal.value?.show(e)
}
defineExpose({
startTaxForm,
})
const auth = await useAuth()
const { formatMessage } = useVIntl()
const messages = defineMessages({
taxFormHeader: {
id: 'dashboard.creator-tax-form-modal.header',
defaultMessage: 'Tax form',
},
securityHeader: {
id: 'dashboard.creator-tax-form-modal.security.header',
defaultMessage: 'Security practices',
},
securityDescription: {
id: 'dashboard.creator-tax-form-modal.security.description',
defaultMessage:
'Modrinth uses third-party provider Track1099 to securely collect and store your tax forms. <security-link>Learn more here.</security-link>',
},
usCitizenQuestion: {
id: 'dashboard.creator-tax-form-modal.us-citizen.question',
defaultMessage: 'Are you a US citizen?',
},
yes: { id: 'common.yes', defaultMessage: 'Yes' },
no: { id: 'common.no', defaultMessage: 'No' },
entityQuestion: {
id: 'dashboard.creator-tax-form-modal.entity.question',
defaultMessage: 'Are you a private individual or part of a foreign entity?',
},
entityDescription: {
id: 'dashboard.creator-tax-form-modal.entity.description',
defaultMessage:
'A foreign entity means a business entity organized outside the United States (such as a non-US corporation, partnership, or LLC).',
},
privateIndividual: {
id: 'dashboard.creator-tax-form-modal.entity.private-individual',
defaultMessage: 'Private individual',
},
foreignEntity: {
id: 'dashboard.creator-tax-form-modal.entity.foreign-entity',
defaultMessage: 'Foreign entity',
},
cancel: { id: 'action.cancel', defaultMessage: 'Cancel' },
continue: { id: 'action.continue', defaultMessage: 'Continue' },
})
const isUSCitizen = ref<'yes' | 'no' | null>(null)
const entityType = ref<'private-individual' | 'foreign-entity' | null>(null)
function hideModal() {
manualLoading.value = false
taxFormModal.value?.hide()
}
function handleCancel() {
emit('cancelled')
hideModal()
}
const determinedFormType = computed(() => {
if (isUSCitizen.value === 'yes') {
return 'W-9'
} else if (isUSCitizen.value === 'no' && entityType.value === 'private-individual') {
return 'W-8BEN'
} else if (isUSCitizen.value === 'no' && entityType.value === 'foreign-entity') {
return 'W-8BEN-E'
}
return null
})
const canContinue = computed(() => {
if (isUSCitizen.value === 'yes') {
return true
} else if (isUSCitizen.value === 'no' && entityType.value) {
return true
}
return false
})
const emit = defineEmits<{
(event: 'success' | 'cancelled'): void
}>()
const avalaraState = ref<ReturnType<typeof useAvalara1099> | null>(null)
const manualLoading = ref(false)
const loading = computed(
() =>
manualLoading.value ||
(avalaraState.value ? ((avalaraState.value as any).loading?.value ?? false) : false),
)
async function continueForm() {
if (!import.meta.client) return
if (!determinedFormType.value) return
manualLoading.value = true
const response = (await useBaseFetch('payout/compliance', {
apiVersion: 3,
method: 'POST',
body: {
form_type: determinedFormType.value,
},
})) as FormRequestResponse
if (!avalaraState.value) {
avalaraState.value = useAvalara1099(response, {
prefill: {
email: (auth.value.user as any)?.email ?? '',
account_number: (auth.value.user as any)?.id ?? '',
reference_number: (auth.value.user as any)?.id ?? '',
},
})
}
try {
if (avalaraState.value) {
await avalaraState.value.start()
if (avalaraState.value.status === 'signed') {
addNotification({
title: 'Tax form submitted',
text: 'You can now withdraw your full balance.',
type: 'success',
})
emit('success')
hideModal()
return
}
addNotification({
title: 'Tax form incomplete',
text: 'You have not completed the tax form. Please try again.',
type: 'warning',
})
}
} catch (error) {
console.error('Error occurred while continuing tax form:', error)
} finally {
manualLoading.value = false
}
}
watch(isUSCitizen, (newValue) => {
if (newValue === 'yes') {
entityType.value = null
}
})
</script>
<style>
dialog[open]:has(> iframe[src*='form_embed']) {
width: min(960px, calc(100vw - 2rem)) !important;
max-width: 100% !important;
height: min(95vh, max(640px, 75vh)) !important;
background: var(--color-raised-bg) !important;
border: 1px solid var(--color-button-border) !important;
border-radius: var(--radius-lg) !important;
box-shadow: var(--shadow-floating) !important;
padding: 0 !important;
}
dialog[open] > iframe[src*='form_embed'] {
position: absolute !important;
inset: 0 !important;
width: 100% !important;
height: 100% !important;
display: block !important;
border: none !important;
border-radius: var(--radius-lg) !important;
}
@media (max-width: 640px) {
dialog[open]:has(> iframe[src*='form_embed']) {
width: calc(100vw - 1rem) !important;
height: 95vh !important;
border-radius: var(--radius-md) !important;
}
dialog[open] > iframe[src*='form_embed'] {
border-radius: var(--radius-md) !important;
}
}
</style>

View File

@@ -0,0 +1,187 @@
import type { Ref } from 'vue'
import { computed, ref } from 'vue'
export interface FormRequestAttributes {
form_type: 'W-9' | 'W-8BEN' | 'W-8BEN-E' | string
company_id: number
company_name: string
company_email: string
reference_id: string | null
form_id: string | null
signed_at: string | null
tin_match_status: string | null
expires_at: string | null
}
export interface FormRequestLinks {
action_validate?: string
action_complete?: string
[key: string]: unknown
}
export interface FormRequestData {
id: string
type: 'form_request' | string
attributes: FormRequestAttributes
links?: FormRequestLinks
[key: string]: unknown
}
export interface FormRequestResponse {
data: FormRequestData
[key: string]: unknown
}
export interface UseAvalara1099Options {
prefill?: Record<string, unknown>
// Optional override for the origin (defaults to vendor CDN domain)
origin?: string
// Optional hook to further style the injected dialog/iframe
styleDialog?: (dialog: HTMLDialogElement, iframe: HTMLIFrameElement | null) => void
// Poll interval while waiting for global to appear
pollIntervalMs?: number
// Max time to wait for script before rejecting (ms); 0/undefined => no timeout
timeoutMs?: number
}
interface AvalaraGlobal {
requestW9: (
formRequest: FormRequestResponse | FormRequestData,
opts?: { prefill?: Record<string, unknown> },
) => Promise<FormRequestResponse> | any
requestW8BEN: (
formRequest: FormRequestResponse | FormRequestData,
opts?: { prefill?: Record<string, unknown> },
) => Promise<FormRequestResponse> | any
requestW8BENE: (
formRequest: FormRequestResponse | FormRequestData,
opts?: { prefill?: Record<string, unknown> },
) => Promise<FormRequestResponse> | any
origin?: string
[key: string]: unknown
}
declare global {
interface Window {
Avalara1099?: AvalaraGlobal
}
}
const injectedKey = '__avalara1099_script_injected__'
function ensureScriptInjected(origin: string) {
if (import.meta.server) return
const w = window as any
if (w[injectedKey]) return
w[injectedKey] = true
useHead({
script: [
{
src: `${origin.replace(/\/$/, '')}/api/request_form.js`,
crossorigin: 'anonymous',
type: 'module',
},
],
})
}
async function waitForAvalara(opts: {
pollIntervalMs: number
timeoutMs?: number
origin: string
}): Promise<AvalaraGlobal> {
if (import.meta.server) throw new Error('Avalara 1099 is client-side only')
ensureScriptInjected(opts.origin)
const start = Date.now()
return await new Promise((resolve, reject) => {
const poll = () => {
const g = window.Avalara1099
if (g) return resolve(g)
if (opts.timeoutMs && opts.timeoutMs > 0 && Date.now() - start > opts.timeoutMs) {
return reject(new Error('Timed out waiting for Avalara1099 script to load'))
}
setTimeout(poll, opts.pollIntervalMs)
}
poll()
})
}
export function useAvalara1099(
initial: FormRequestResponse | FormRequestData,
options: UseAvalara1099Options = {},
) {
const origin = options.origin || 'https://www.track1099.com'
const pollIntervalMs = options.pollIntervalMs ?? 250
const timeoutMs = options.timeoutMs
const request: Ref<FormRequestResponse | FormRequestData> = ref(initial)
const loading = ref(false)
const error: Ref<unknown> = ref(null)
const signedAt = computed(() => {
const data = (request.value as FormRequestResponse).data || request.value
return data.attributes?.signed_at ? new Date(data.attributes.signed_at) : null
})
const status = computed(() => (signedAt.value ? 'signed' : 'incomplete'))
async function start(): Promise<FormRequestResponse | FormRequestData> {
loading.value = true
error.value = null
try {
const g = await waitForAvalara({ pollIntervalMs, timeoutMs, origin })
const data = (request.value as FormRequestResponse).data || request.value
const formType = data.attributes?.form_type
// Defensive deep clone to strip proxies / non-cloneable refs before postMessage
// (DataCloneError guard)
let safeRequest: any
try {
safeRequest = JSON.parse(JSON.stringify(request.value))
} catch {
// Fallback shallow copy
safeRequest = Array.isArray(request.value)
? [...(request.value as any)]
: { ...(request.value as any) }
}
let safePrefill: any = undefined
if (options.prefill) {
try {
safePrefill = JSON.parse(JSON.stringify(options.prefill))
} catch {
safePrefill = { ...options.prefill }
}
}
let promise: any
if (formType === 'W-8BEN') {
promise = g.requestW8BEN(safeRequest, { prefill: safePrefill })
} else if (formType === 'W-9') {
promise = g.requestW9(safeRequest, { prefill: safePrefill })
} else if (formType === 'W-8BEN-E' || formType === 'W-8BEN E') {
promise = g.requestW8BENE(safeRequest, { prefill: safePrefill })
} else {
throw new Error(`Unsupported form_type: ${formType}`)
}
// The vendor promise resolves with an updated form request (signed state)
const newReq = await promise
request.value = newReq
return newReq
} catch (e) {
error.value = e
throw e
} finally {
loading.value = false
}
}
return {
start,
request,
signedAt,
status, // 'signed' | 'incomplete'
loading,
error,
}
}

View File

@@ -27,6 +27,21 @@
</div> </div>
</div> </div>
<div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }"> <div ref="main_page" class="layout" :class="{ 'expanded-mobile-nav': isBrowseMenuOpen }">
<PagewideBanner v-if="showTaxComplianceBanner" variant="warning">
<template #title>
<span>{{ formatMessage(taxBannerMessages.title) }}</span>
</template>
<template #description>
<span>{{ formatMessage(taxBannerMessages.description) }}</span>
</template>
<template #actions>
<ButtonStyled color="orange">
<button @click="openTaxForm">
<FileTextIcon /> {{ formatMessage(taxBannerMessages.action) }}
</button>
</ButtonStyled>
</template>
</PagewideBanner>
<PagewideBanner <PagewideBanner
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'" v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
variant="warning" variant="warning"
@@ -116,6 +131,11 @@
}} }}
</template> </template>
</PagewideBanner> </PagewideBanner>
<CreatorTaxFormModal
ref="taxFormModalRef"
@success="() => navigateTo('/dashboard/revenue', { external: true })"
/>
<header <header
class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]" class="experimental-styles-within desktop-only relative z-[5] mx-auto grid max-w-[1280px] grid-cols-[1fr_auto] items-center gap-2 px-6 py-4 lg:grid-cols-[auto_1fr_auto]"
> >
@@ -764,6 +784,7 @@ import {
DownloadIcon, DownloadIcon,
DropdownIcon, DropdownIcon,
FileIcon, FileIcon,
FileTextIcon,
GithubIcon, GithubIcon,
GlassesIcon, GlassesIcon,
HamburgerIcon, HamburgerIcon,
@@ -805,6 +826,7 @@ import { IntlFormatted } from '@vintl/vintl/components'
import TextLogo from '~/components/brand/TextLogo.vue' import TextLogo from '~/components/brand/TextLogo.vue'
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue' import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
import ModalCreation from '~/components/ui/ModalCreation.vue' import ModalCreation from '~/components/ui/ModalCreation.vue'
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue' import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue' import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
@@ -826,6 +848,43 @@ const route = useNativeRoute()
const router = useNativeRouter() const router = useNativeRouter()
const link = config.public.siteUrl + route.path.replace(/\/+$/, '') const link = config.public.siteUrl + route.path.replace(/\/+$/, '')
const { data: payoutBalance } = await useAsyncData('payout/balance', () =>
useBaseFetch('payout/balance', { apiVersion: 3 }),
)
const showTaxComplianceBanner = computed(() => {
const bal = payoutBalance.value
if (!bal) return false
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
const status = bal.form_completion_status ?? 'unknown'
const isComplete = status === 'complete'
return !!auth.value.user && thresholdMet && !isComplete
})
const taxBannerMessages = defineMessages({
title: {
id: 'layout.banner.tax.title',
defaultMessage: 'Tax form required',
},
description: {
id: 'layout.banner.tax.description',
defaultMessage:
'Youve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted.',
},
action: {
id: 'layout.banner.tax.action',
defaultMessage: 'Complete tax form',
},
close: { id: 'common.close', defaultMessage: 'Close' },
})
const taxFormModalRef = ref(null)
function openTaxForm(e) {
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
taxFormModalRef.value.startTaxForm(e)
}
}
const basePopoutId = useId() const basePopoutId = useId()
async function handleResendEmailVerification() { async function handleResendEmailVerification() {
try { try {

View File

@@ -1,4 +1,10 @@
{ {
"action.cancel": {
"message": "Cancel"
},
"action.continue": {
"message": "Continue"
},
"admin.billing.error.not-found": { "admin.billing.error.not-found": {
"message": "User not found" "message": "User not found"
}, },
@@ -413,6 +419,15 @@
"collection.title": { "collection.title": {
"message": "{name} - Collection" "message": "{name} - Collection"
}, },
"common.close": {
"message": "Close"
},
"common.no": {
"message": "No"
},
"common.yes": {
"message": "Yes"
},
"dashboard.collections.button.create-new": { "dashboard.collections.button.create-new": {
"message": "Create new" "message": "Create new"
}, },
@@ -425,6 +440,30 @@
"dashboard.collections.long-title": { "dashboard.collections.long-title": {
"message": "Your collections" "message": "Your collections"
}, },
"dashboard.creator-tax-form-modal.entity.description": {
"message": "A foreign entity means a business entity organized outside the United States (such as a non-US corporation, partnership, or LLC)."
},
"dashboard.creator-tax-form-modal.entity.foreign-entity": {
"message": "Foreign entity"
},
"dashboard.creator-tax-form-modal.entity.private-individual": {
"message": "Private individual"
},
"dashboard.creator-tax-form-modal.entity.question": {
"message": "Are you a private individual or part of a foreign entity?"
},
"dashboard.creator-tax-form-modal.header": {
"message": "Tax form"
},
"dashboard.creator-tax-form-modal.security.description": {
"message": "Modrinth uses third-party provider Track1099 to securely collect and store your tax forms. <security-link>Learn more here.</security-link>"
},
"dashboard.creator-tax-form-modal.security.header": {
"message": "Security practices"
},
"dashboard.creator-tax-form-modal.us-citizen.question": {
"message": "Are you a US citizen?"
},
"error.collection.404.list_item.1": { "error.collection.404.list_item.1": {
"message": "You may have mistyped the collection's URL." "message": "You may have mistyped the collection's URL."
}, },
@@ -728,6 +767,15 @@
"layout.banner.subscription-payment-failed.title": { "layout.banner.subscription-payment-failed.title": {
"message": "Billing action required." "message": "Billing action required."
}, },
"layout.banner.tax.action": {
"message": "Complete tax form"
},
"layout.banner.tax.description": {
"message": "Youve already withdrawn over $600 from Modrinth this year. To comply with tax regulations, you need to complete a tax form. Your withdrawals are paused until this form is submitted."
},
"layout.banner.tax.title": {
"message": "Tax form required"
},
"layout.banner.verify-email.action": { "layout.banner.verify-email.action": {
"message": "Re-send verification email" "message": "Re-send verification email"
}, },

View File

@@ -68,23 +68,37 @@
</div> </div>
</div> </div>
<div class="input-group mt-4"> <div class="input-group mt-4">
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }"> <span
<nuxt-link :class="{
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'" 'disabled-cursor-wrapper': userBalance.available < minWithdraw || blockedByTax,
:class="{ 'disabled-link': userBalance.available < minWithdraw }" }"
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'" >
:tabindex="userBalance.available < minWithdraw ? -1 : undefined" <ButtonStyled color="brand">
class="iconified-button brand-button" <nuxt-link
to="/dashboard/revenue/withdraw" :aria-disabled="
> userBalance.available < minWithdraw || blockedByTax ? 'true' : 'false'
<TransferIcon /> Withdraw "
</nuxt-link> :class="{ 'disabled-link': userBalance.available < minWithdraw || blockedByTax }"
:disabled="userBalance.available < minWithdraw || blockedByTax ? 'true' : 'false'"
:tabindex="userBalance.available < minWithdraw || blockedByTax ? -1 : undefined"
to="/dashboard/revenue/withdraw"
>
<TransferIcon /> Withdraw
</nuxt-link>
</ButtonStyled>
</span> </span>
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers"> <ButtonStyled>
<HistoryIcon /> <NuxtLink to="/dashboard/revenue/transfers">
View transfer history <HistoryIcon />
</NuxtLink> View transfer history
</NuxtLink>
</ButtonStyled>
</div> </div>
<p v-if="blockedByTax" class="text-sm font-bold text-orange">
You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax
form.
</p>
<p class="text-sm text-secondary"> <p class="text-sm text-secondary">
By uploading projects to Modrinth and withdrawing money from your account, you agree to the By uploading projects to Modrinth and withdrawing money from your account, you agree to the
<nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more <nuxt-link class="text-link" to="/legal/cmp">Rewards Program Terms</nuxt-link>. For more
@@ -101,10 +115,12 @@
email email
{{ auth.user.payout_data.paypal_address }} {{ auth.user.payout_data.paypal_address }}
</p> </p>
<button class="btn mt-4" @click="handleRemoveAuthProvider('paypal')"> <ButtonStyled>
<XIcon /> <button class="mt-4" @click="handleRemoveAuthProvider('paypal')">
Disconnect account <XIcon />
</button> Disconnect account
</button>
</ButtonStyled>
</template> </template>
<template v-else> <template v-else>
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p> <p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
@@ -126,15 +142,16 @@
id="venmo" id="venmo"
v-model="auth.user.payout_data.venmo_handle" v-model="auth.user.payout_data.venmo_handle"
autocomplete="off" autocomplete="off"
class="mt-4"
name="search" name="search"
placeholder="@example" placeholder="@example"
type="search" type="search"
/> />
<button class="btn btn-secondary" @click="updateVenmo"> <ButtonStyled color="brand">
<SaveIcon /> <button class="mt-4" @click="updateVenmo">
Save information <SaveIcon />
</button> Save information
</button>
</ButtonStyled>
</section> </section>
</div> </div>
</template> </template>
@@ -148,7 +165,7 @@ import {
UnknownIcon, UnknownIcon,
XIcon, XIcon,
} from '@modrinth/assets' } from '@modrinth/assets'
import { injectNotificationManager } from '@modrinth/ui' import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
import { formatDate } from '@modrinth/utils' import { formatDate } from '@modrinth/utils'
import dayjs from 'dayjs' import dayjs from 'dayjs'
import { computed } from 'vue' import { computed } from 'vue'
@@ -163,6 +180,12 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
useBaseFetch(`payout/balance`, { apiVersion: 3 }), useBaseFetch(`payout/balance`, { apiVersion: 3 }),
) )
const blockedByTax = computed(() => {
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
const deadlineEnding = computed(() => { const deadlineEnding = computed(() => {
let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days') let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days')
if (deadline.isBefore(dayjs().startOf('day'))) { if (deadline.isBefore(dayjs().startOf('day'))) {

View File

@@ -135,6 +135,10 @@
</template> </template>
</div> </div>
<p v-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"> <div class="confirm-text">
<template v-if="knownErrors.length === 0 && amount"> <template v-if="knownErrors.length === 0 && amount">
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee"> <Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
@@ -175,7 +179,8 @@
!amount || !amount ||
!agreedTransfer || !agreedTransfer ||
!agreedTerms || !agreedTerms ||
(fees > 0 && !agreedFees) (fees > 0 && !agreedFees) ||
blockedByTax
" "
class="iconified-button brand-button" class="iconified-button brand-button"
@click="withdraw" @click="withdraw"
@@ -323,6 +328,12 @@ const agreedTransfer = ref(false)
const agreedFees = ref(false) const agreedFees = ref(false)
const agreedTerms = ref(false) const agreedTerms = ref(false)
const blockedByTax = computed(() => {
const status = userBalance.value?.form_completion_status ?? 'unknown'
const thresholdMet = (userBalance.value?.withdrawn_ytd ?? 0) >= 600
return thresholdMet && status !== 'complete'
})
watch(country, async () => { watch(country, async () => {
await refreshPayoutMethods() await refreshPayoutMethods()
if (payoutMethods.value && payoutMethods.value[0]) { if (payoutMethods.value && payoutMethods.value[0]) {