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>
|
</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:
|
||||||
|
'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()
|
const basePopoutId = useId()
|
||||||
async function handleResendEmailVerification() {
|
async function handleResendEmailVerification() {
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -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": "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": {
|
"layout.banner.verify-email.action": {
|
||||||
"message": "Re-send verification email"
|
"message": "Re-send verification email"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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'))) {
|
||||||
|
|||||||
@@ -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]) {
|
||||||
|
|||||||
Reference in New Issue
Block a user