From 5b44454e18fe7715ad1da038d6ca144b510f0522 Mon Sep 17 00:00:00 2001 From: "Calum H." Date: Sun, 21 Sep 2025 23:23:07 +0100 Subject: [PATCH] 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> --- .../ui/dashboard/CreatorTaxFormModal.vue | 286 ++++++++++++++++++ apps/frontend/src/composables/avalara1099.ts | 187 ++++++++++++ apps/frontend/src/layouts/default.vue | 59 ++++ apps/frontend/src/locales/en-US/index.json | 48 +++ .../src/pages/dashboard/revenue/index.vue | 73 +++-- .../src/pages/dashboard/revenue/withdraw.vue | 13 +- 6 files changed, 640 insertions(+), 26 deletions(-) create mode 100644 apps/frontend/src/components/ui/dashboard/CreatorTaxFormModal.vue create mode 100644 apps/frontend/src/composables/avalara1099.ts diff --git a/apps/frontend/src/components/ui/dashboard/CreatorTaxFormModal.vue b/apps/frontend/src/components/ui/dashboard/CreatorTaxFormModal.vue new file mode 100644 index 00000000..cd430a24 --- /dev/null +++ b/apps/frontend/src/components/ui/dashboard/CreatorTaxFormModal.vue @@ -0,0 +1,286 @@ + + + + + diff --git a/apps/frontend/src/composables/avalara1099.ts b/apps/frontend/src/composables/avalara1099.ts new file mode 100644 index 00000000..c34ab33a --- /dev/null +++ b/apps/frontend/src/composables/avalara1099.ts @@ -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 + // 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 }, + ) => Promise | any + requestW8BEN: ( + formRequest: FormRequestResponse | FormRequestData, + opts?: { prefill?: Record }, + ) => Promise | any + requestW8BENE: ( + formRequest: FormRequestResponse | FormRequestData, + opts?: { prefill?: Record }, + ) => Promise | 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 { + 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 = ref(initial) + const loading = ref(false) + const error: Ref = 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 { + 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, + } +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 99cb7eb8..bd959ed9 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -27,6 +27,21 @@
+ + + + + + +
@@ -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 { diff --git a/apps/frontend/src/locales/en-US/index.json b/apps/frontend/src/locales/en-US/index.json index 57a69e83..0633ab28 100644 --- a/apps/frontend/src/locales/en-US/index.json +++ b/apps/frontend/src/locales/en-US/index.json @@ -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. Learn more here." + }, + "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" }, diff --git a/apps/frontend/src/pages/dashboard/revenue/index.vue b/apps/frontend/src/pages/dashboard/revenue/index.vue index 8c5c2e58..3deb63d1 100644 --- a/apps/frontend/src/pages/dashboard/revenue/index.vue +++ b/apps/frontend/src/pages/dashboard/revenue/index.vue @@ -68,23 +68,37 @@
- - - Withdraw - + + + + Withdraw + + - - - View transfer history - + + + + View transfer history + +
+

+ You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax + form. +

+

By uploading projects to Modrinth and withdrawing money from your account, you agree to the Rewards Program Terms. For more @@ -101,10 +115,12 @@ email {{ auth.user.payout_data.paypal_address }}

- + + + @@ -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'))) { diff --git a/apps/frontend/src/pages/dashboard/revenue/withdraw.vue b/apps/frontend/src/pages/dashboard/revenue/withdraw.vue index b7f86899..9401ad9d 100644 --- a/apps/frontend/src/pages/dashboard/revenue/withdraw.vue +++ b/apps/frontend/src/pages/dashboard/revenue/withdraw.vue @@ -135,6 +135,10 @@ +

+ You have withdrawn over $600 this year. To continue withdrawing, you must complete a tax form. +

+