forked from didirus/AstralRinth
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:
@@ -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>
|
||||
187
apps/frontend/src/composables/avalara1099.ts
Normal file
187
apps/frontend/src/composables/avalara1099.ts
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -27,6 +27,21 @@
|
||||
</div>
|
||||
</div>
|
||||
<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
|
||||
v-if="auth.user && !auth.user.email_verified && route.path !== '/auth/verify-email'"
|
||||
variant="warning"
|
||||
@@ -116,6 +131,11 @@
|
||||
}}
|
||||
</template>
|
||||
</PagewideBanner>
|
||||
|
||||
<CreatorTaxFormModal
|
||||
ref="taxFormModalRef"
|
||||
@success="() => navigateTo('/dashboard/revenue', { external: true })"
|
||||
/>
|
||||
<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]"
|
||||
>
|
||||
@@ -764,6 +784,7 @@ import {
|
||||
DownloadIcon,
|
||||
DropdownIcon,
|
||||
FileIcon,
|
||||
FileTextIcon,
|
||||
GithubIcon,
|
||||
GlassesIcon,
|
||||
HamburgerIcon,
|
||||
@@ -805,6 +826,7 @@ import { IntlFormatted } from '@vintl/vintl/components'
|
||||
|
||||
import TextLogo from '~/components/brand/TextLogo.vue'
|
||||
import CollectionCreateModal from '~/components/ui/CollectionCreateModal.vue'
|
||||
import CreatorTaxFormModal from '~/components/ui/dashboard/CreatorTaxFormModal.vue'
|
||||
import ModalCreation from '~/components/ui/ModalCreation.vue'
|
||||
import OrganizationCreateModal from '~/components/ui/OrganizationCreateModal.vue'
|
||||
import TeleportOverflowMenu from '~/components/ui/servers/TeleportOverflowMenu.vue'
|
||||
@@ -826,6 +848,43 @@ const route = useNativeRoute()
|
||||
const router = useNativeRouter()
|
||||
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:
|
||||
'You’ve 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()
|
||||
async function handleResendEmailVerification() {
|
||||
try {
|
||||
|
||||
@@ -1,4 +1,10 @@
|
||||
{
|
||||
"action.cancel": {
|
||||
"message": "Cancel"
|
||||
},
|
||||
"action.continue": {
|
||||
"message": "Continue"
|
||||
},
|
||||
"admin.billing.error.not-found": {
|
||||
"message": "User not found"
|
||||
},
|
||||
@@ -413,6 +419,15 @@
|
||||
"collection.title": {
|
||||
"message": "{name} - Collection"
|
||||
},
|
||||
"common.close": {
|
||||
"message": "Close"
|
||||
},
|
||||
"common.no": {
|
||||
"message": "No"
|
||||
},
|
||||
"common.yes": {
|
||||
"message": "Yes"
|
||||
},
|
||||
"dashboard.collections.button.create-new": {
|
||||
"message": "Create new"
|
||||
},
|
||||
@@ -425,6 +440,30 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"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": {
|
||||
"message": "You may have mistyped the collection's URL."
|
||||
},
|
||||
@@ -728,6 +767,15 @@
|
||||
"layout.banner.subscription-payment-failed.title": {
|
||||
"message": "Billing action required."
|
||||
},
|
||||
"layout.banner.tax.action": {
|
||||
"message": "Complete tax form"
|
||||
},
|
||||
"layout.banner.tax.description": {
|
||||
"message": "You’ve 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": {
|
||||
"message": "Re-send verification email"
|
||||
},
|
||||
|
||||
@@ -68,23 +68,37 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="input-group mt-4">
|
||||
<span :class="{ 'disabled-cursor-wrapper': userBalance.available < minWithdraw }">
|
||||
<nuxt-link
|
||||
:aria-disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:class="{ 'disabled-link': userBalance.available < minWithdraw }"
|
||||
:disabled="userBalance.available < minWithdraw ? 'true' : 'false'"
|
||||
:tabindex="userBalance.available < minWithdraw ? -1 : undefined"
|
||||
class="iconified-button brand-button"
|
||||
to="/dashboard/revenue/withdraw"
|
||||
>
|
||||
<TransferIcon /> Withdraw
|
||||
</nuxt-link>
|
||||
<span
|
||||
:class="{
|
||||
'disabled-cursor-wrapper': userBalance.available < minWithdraw || blockedByTax,
|
||||
}"
|
||||
>
|
||||
<ButtonStyled color="brand">
|
||||
<nuxt-link
|
||||
:aria-disabled="
|
||||
userBalance.available < minWithdraw || blockedByTax ? 'true' : 'false'
|
||||
"
|
||||
: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>
|
||||
<NuxtLink class="iconified-button" to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
<ButtonStyled>
|
||||
<NuxtLink to="/dashboard/revenue/transfers">
|
||||
<HistoryIcon />
|
||||
View transfer history
|
||||
</NuxtLink>
|
||||
</ButtonStyled>
|
||||
</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">
|
||||
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
|
||||
@@ -101,10 +115,12 @@
|
||||
email
|
||||
{{ auth.user.payout_data.paypal_address }}
|
||||
</p>
|
||||
<button class="btn mt-4" @click="handleRemoveAuthProvider('paypal')">
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
<ButtonStyled>
|
||||
<button class="mt-4" @click="handleRemoveAuthProvider('paypal')">
|
||||
<XIcon />
|
||||
Disconnect account
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</template>
|
||||
<template v-else>
|
||||
<p>Connect your PayPal account to enable withdrawing to your PayPal balance.</p>
|
||||
@@ -126,15 +142,16 @@
|
||||
id="venmo"
|
||||
v-model="auth.user.payout_data.venmo_handle"
|
||||
autocomplete="off"
|
||||
class="mt-4"
|
||||
name="search"
|
||||
placeholder="@example"
|
||||
type="search"
|
||||
/>
|
||||
<button class="btn btn-secondary" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
<ButtonStyled color="brand">
|
||||
<button class="mt-4" @click="updateVenmo">
|
||||
<SaveIcon />
|
||||
Save information
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
@@ -148,7 +165,7 @@ import {
|
||||
UnknownIcon,
|
||||
XIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { injectNotificationManager } from '@modrinth/ui'
|
||||
import { ButtonStyled, injectNotificationManager } from '@modrinth/ui'
|
||||
import { formatDate } from '@modrinth/utils'
|
||||
import dayjs from 'dayjs'
|
||||
import { computed } from 'vue'
|
||||
@@ -163,6 +180,12 @@ const { data: userBalance } = await useAsyncData(`payout/balance`, () =>
|
||||
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(() => {
|
||||
let deadline = dayjs().subtract(2, 'month').endOf('month').add(60, 'days')
|
||||
if (deadline.isBefore(dayjs().startOf('day'))) {
|
||||
|
||||
@@ -135,6 +135,10 @@
|
||||
</template>
|
||||
</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">
|
||||
<template v-if="knownErrors.length === 0 && amount">
|
||||
<Checkbox v-if="fees > 0" v-model="agreedFees" description="Consent to fee">
|
||||
@@ -175,7 +179,8 @@
|
||||
!amount ||
|
||||
!agreedTransfer ||
|
||||
!agreedTerms ||
|
||||
(fees > 0 && !agreedFees)
|
||||
(fees > 0 && !agreedFees) ||
|
||||
blockedByTax
|
||||
"
|
||||
class="iconified-button brand-button"
|
||||
@click="withdraw"
|
||||
@@ -323,6 +328,12 @@ const agreedTransfer = ref(false)
|
||||
const agreedFees = 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 () => {
|
||||
await refreshPayoutMethods()
|
||||
if (payoutMethods.value && payoutMethods.value[0]) {
|
||||
|
||||
Reference in New Issue
Block a user