You've already forked AstralRinth
forked from didirus/AstralRinth
feat: tax form download stage (#4513)
* feat: start on fix * fix: withdraw btn * fix: lint issues * feat: start on download stage for tax form modal * fix: use button rather than span * fix: lint * fix: lint issues * feat: tax form notification email for users who didnt get chance to download * feat: finish download stage for tax modal * fix: lint & i18n * fix: lint + svg cleanup --------- Signed-off-by: Calum H. <contact@cal.engineer> Co-authored-by: --global <--global>
This commit is contained in:
@@ -1,89 +1,161 @@
|
||||
<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">
|
||||
<NewModal
|
||||
ref="taxFormModal"
|
||||
:header="formatMessage(messages.taxFormHeader)"
|
||||
:hide-header="currentStage === 'download-confirmation'"
|
||||
:close-on-click-outside="currentStage !== 'download-confirmation'"
|
||||
:close-on-esc="currentStage !== 'download-confirmation'"
|
||||
>
|
||||
<div
|
||||
class="w-full"
|
||||
:class="[currentStage === 'form-selection' ? 'sm:w-[540px]' : 'sm:w-[400px]']"
|
||||
>
|
||||
<div v-if="currentStage === 'form-selection'">
|
||||
<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.entityQuestion) }}
|
||||
{{ formatMessage(messages.usCitizenQuestion) }}
|
||||
<span class="text-brand-red">*</span>
|
||||
</span>
|
||||
</label>
|
||||
<Chips
|
||||
v-model="entityType"
|
||||
:items="['private-individual', 'foreign-entity']"
|
||||
v-model="isUSCitizen"
|
||||
:items="['yes', 'no']"
|
||||
:format-label="
|
||||
(item) =>
|
||||
item === 'private-individual'
|
||||
? formatMessage(messages.privateIndividual)
|
||||
: formatMessage(messages.foreignEntity)
|
||||
(item) => (item === 'yes' ? formatMessage(messages.yes) : formatMessage(messages.no))
|
||||
"
|
||||
:never-empty="false"
|
||||
:capitalize="false"
|
||||
class="mt-2"
|
||||
:capitalize="true"
|
||||
/>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<div v-else-if="currentStage === 'download-confirmation'" class="flex flex-col gap-6">
|
||||
<div class="relative block h-[180px] w-[400px] overflow-hidden rounded-xl rounded-b-none">
|
||||
<div
|
||||
class="absolute inset-0 rounded-xl rounded-b-none bg-gradient-to-r from-brand-green to-brand-blue"
|
||||
></div>
|
||||
<div
|
||||
class="absolute inset-0 rounded-xl rounded-b-none"
|
||||
style="
|
||||
background: linear-gradient(
|
||||
180deg,
|
||||
rgba(39, 41, 46, 0.15) 0%,
|
||||
var(--color-raised-bg) 100%
|
||||
);
|
||||
"
|
||||
></div>
|
||||
<BrowserWindowSuccessIllustration
|
||||
class="absolute left-[90px] top-[48px] h-[140px] w-[220px]"
|
||||
/>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2">
|
||||
<span class="text-2xl font-semibold text-contrast">{{
|
||||
formatMessage(messages.confirmationTitle)
|
||||
}}</span>
|
||||
<span>{{
|
||||
formatMessage(messages.confirmationSuccess, { formType: determinedFormType })
|
||||
}}</span>
|
||||
<IntlFormatted :message-id="messages.confirmationSupportText">
|
||||
<template #support-link="{ children }">
|
||||
<nuxt-link
|
||||
to="https://support.modrinth.com"
|
||||
class="text-link"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<component :is="() => normalizeChildren(children)" />
|
||||
</nuxt-link>
|
||||
</template>
|
||||
</IntlFormatted>
|
||||
</div>
|
||||
<div class="flex w-full flex-row justify-stretch gap-2">
|
||||
<ButtonStyled>
|
||||
<button class="w-full text-contrast" @click="handleClose">{{ closeButtonText }}</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled color="green">
|
||||
<button class="w-full text-contrast" @click="downloadTaxForm">
|
||||
<DownloadIcon />{{
|
||||
formatMessage(messages.downloadButton, { formType: determinedFormType })
|
||||
}}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</NewModal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ExternalIcon, RightArrowIcon, SpinnerIcon, XIcon } from '@modrinth/assets'
|
||||
import {
|
||||
BrowserWindowSuccessIllustration,
|
||||
DownloadIcon,
|
||||
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'
|
||||
@@ -91,19 +163,41 @@ import { IntlFormatted } from '@vintl/vintl/components'
|
||||
import { type FormRequestResponse, useAvalara1099 } from '@/composables/avalara1099'
|
||||
import { normalizeChildren } from '@/utils/vue-children.ts'
|
||||
|
||||
const props = withDefaults(
|
||||
defineProps<{
|
||||
closeButtonText?: string
|
||||
emitSuccessOnClose?: boolean
|
||||
}>(),
|
||||
{
|
||||
closeButtonText: 'Close',
|
||||
emitSuccessOnClose: true,
|
||||
},
|
||||
)
|
||||
|
||||
const { addNotification } = injectNotificationManager()
|
||||
|
||||
const taxFormModal = ref<InstanceType<typeof NewModal> | null>(null)
|
||||
|
||||
type ModalStage = 'form-selection' | 'download-confirmation'
|
||||
const currentStage = ref<ModalStage>('form-selection')
|
||||
|
||||
async function startTaxForm(e: MouseEvent) {
|
||||
currentStage.value = 'form-selection'
|
||||
taxFormModal.value?.show(e)
|
||||
}
|
||||
|
||||
async function showDownloadConfirmation(e: MouseEvent) {
|
||||
currentStage.value = 'download-confirmation'
|
||||
taxFormModal.value?.show(e)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
startTaxForm,
|
||||
showDownloadConfirmation,
|
||||
})
|
||||
|
||||
const auth = await useAuth()
|
||||
const flags = useFeatureFlags()
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
@@ -146,6 +240,23 @@ const messages = defineMessages({
|
||||
},
|
||||
cancel: { id: 'action.cancel', defaultMessage: 'Cancel' },
|
||||
continue: { id: 'action.continue', defaultMessage: 'Continue' },
|
||||
confirmationTitle: {
|
||||
id: 'dashboard.creator-tax-form-modal.confirmation.title',
|
||||
defaultMessage: "You're all set! 🎉",
|
||||
},
|
||||
confirmationSuccess: {
|
||||
id: 'dashboard.creator-tax-form-modal.confirmation.success',
|
||||
defaultMessage: 'Your {formType} tax form has been submitted successfully!',
|
||||
},
|
||||
confirmationSupportText: {
|
||||
id: 'dashboard.creator-tax-form-modal.confirmation.support-text',
|
||||
defaultMessage:
|
||||
'You can freely withdraw now. If you have questions or need to update your details <support-link>contact support</support-link>.',
|
||||
},
|
||||
downloadButton: {
|
||||
id: 'dashboard.creator-tax-form-modal.confirmation.download-button',
|
||||
defaultMessage: 'Download {formType}',
|
||||
},
|
||||
})
|
||||
|
||||
const isUSCitizen = ref<'yes' | 'no' | null>(null)
|
||||
@@ -159,6 +270,19 @@ function hideModal() {
|
||||
function handleCancel() {
|
||||
emit('cancelled')
|
||||
hideModal()
|
||||
setTimeout(() => {
|
||||
currentStage.value = 'form-selection'
|
||||
}, 300)
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (currentStage.value === 'download-confirmation' && props.emitSuccessOnClose) {
|
||||
emit('success')
|
||||
}
|
||||
hideModal()
|
||||
setTimeout(() => {
|
||||
currentStage.value = 'form-selection'
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const determinedFormType = computed(() => {
|
||||
@@ -186,6 +310,7 @@ const emit = defineEmits<{
|
||||
}>()
|
||||
|
||||
const avalaraState = ref<ReturnType<typeof useAvalara1099> | null>(null)
|
||||
const formResponse = ref<any>(null)
|
||||
const manualLoading = ref(false)
|
||||
const loading = computed(
|
||||
() =>
|
||||
@@ -199,6 +324,13 @@ async function continueForm() {
|
||||
|
||||
manualLoading.value = true
|
||||
|
||||
// Skip Avalara if testTaxForm flag is enabled
|
||||
if (flags.value.testTaxForm) {
|
||||
currentStage.value = 'download-confirmation'
|
||||
manualLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
const response = (await useBaseFetch('payout/compliance', {
|
||||
apiVersion: 3,
|
||||
method: 'POST',
|
||||
@@ -219,15 +351,11 @@ async function continueForm() {
|
||||
|
||||
try {
|
||||
if (avalaraState.value) {
|
||||
await avalaraState.value.start()
|
||||
const response = await avalaraState.value.start()
|
||||
formResponse.value = response
|
||||
if (avalaraState.value.status === 'signed') {
|
||||
addNotification({
|
||||
title: 'Tax form submitted',
|
||||
text: 'You can now withdraw your full balance.',
|
||||
type: 'success',
|
||||
})
|
||||
emit('success')
|
||||
hideModal()
|
||||
currentStage.value = 'download-confirmation'
|
||||
manualLoading.value = false
|
||||
return
|
||||
}
|
||||
|
||||
@@ -245,6 +373,15 @@ async function continueForm() {
|
||||
}
|
||||
}
|
||||
|
||||
function downloadTaxForm() {
|
||||
if (!formResponse.value) return
|
||||
|
||||
const signedPdfUrl = formResponse.value.links?.signed_pdf
|
||||
if (signedPdfUrl) {
|
||||
window.open(signedPdfUrl, '_blank')
|
||||
}
|
||||
}
|
||||
|
||||
watch(isUSCitizen, (newValue) => {
|
||||
if (newValue === 'yes') {
|
||||
entityType.value = null
|
||||
@@ -280,6 +417,7 @@ dialog[open] > iframe[src*='form_embed'] {
|
||||
height: 95vh !important;
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
dialog[open] > iframe[src*='form_embed'] {
|
||||
border-radius: var(--radius-md) !important;
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ export const DEFAULT_FEATURE_FLAGS = validateValues({
|
||||
showVersionFilesInTable: false,
|
||||
showAdsWithPlus: false,
|
||||
alwaysShowChecklistAsPopup: true,
|
||||
testTaxForm: false,
|
||||
|
||||
// Feature toggles
|
||||
projectTypesPrimaryNav: false,
|
||||
|
||||
@@ -189,7 +189,8 @@
|
||||
|
||||
<CreatorTaxFormModal
|
||||
ref="taxFormModalRef"
|
||||
@success="() => navigateTo('/dashboard/revenue', { external: true })"
|
||||
close-button-text="Close"
|
||||
:emit-success-on-close="false"
|
||||
/>
|
||||
<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]"
|
||||
@@ -911,6 +912,7 @@ const { data: payoutBalance } = await useAsyncData('payout/balance', () =>
|
||||
)
|
||||
|
||||
const showTaxComplianceBanner = computed(() => {
|
||||
if (flags.value.testTaxForm && auth.value.user) return true
|
||||
const bal = payoutBalance.value
|
||||
if (!bal) return false
|
||||
const thresholdMet = (bal.withdrawn_ytd ?? 0) >= 600
|
||||
|
||||
@@ -578,6 +578,18 @@
|
||||
"dashboard.collections.long-title": {
|
||||
"message": "Your collections"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.download-button": {
|
||||
"message": "Download {formType}"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.success": {
|
||||
"message": "Your {formType} tax form has been submitted successfully!"
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.support-text": {
|
||||
"message": "You can freely withdraw now. If you have questions or need to update your details <support-link>contact support</support-link>."
|
||||
},
|
||||
"dashboard.creator-tax-form-modal.confirmation.title": {
|
||||
"message": "You're all set! 🎉"
|
||||
},
|
||||
"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)."
|
||||
},
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
<template>
|
||||
<CreatorTaxFormModal
|
||||
ref="taxFormModalRef"
|
||||
close-button-text="Continue"
|
||||
@success="onTaxFormSuccess"
|
||||
@cancelled="onTaxFormCancelled"
|
||||
/>
|
||||
@@ -351,13 +352,17 @@ 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
|
||||
@@ -386,15 +391,7 @@ watch(selectedMethod, () => {
|
||||
const taxFormModalRef = ref(null)
|
||||
const taxFormCancelled = ref(false)
|
||||
|
||||
async function withdraw() {
|
||||
if (willTriggerTaxForm.value) {
|
||||
taxFormCancelled.value = false
|
||||
if (taxFormModalRef.value && taxFormModalRef.value.startTaxForm) {
|
||||
await taxFormModalRef.value.startTaxForm(new MouseEvent('click'))
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
async function performWithdrawal() {
|
||||
startLoading()
|
||||
try {
|
||||
const auth = await useAuth()
|
||||
@@ -428,13 +425,31 @@ async function withdraw() {
|
||||
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 withdraw()
|
||||
await performWithdrawal()
|
||||
} else {
|
||||
addNotification({
|
||||
title: 'Tax form incomplete',
|
||||
|
||||
@@ -31,6 +31,6 @@ export default {
|
||||
'project-invited': () => import('./project/ProjectInvited.vue'),
|
||||
'project-transferred': () => import('./project/ProjectTransferred.vue'),
|
||||
|
||||
// Organization
|
||||
// Organizations
|
||||
'organization-invited': () => import('./organization/OrganizationInvited.vue'),
|
||||
} as Record<string, () => Promise<{ default: Component }>>
|
||||
|
||||
Reference in New Issue
Block a user