Compare commits

...
Author SHA1 Message Date
tdgao 0fdb25149e fix: ci 2026-06-12 10:08:56 -07:00
tdgao 7f6e40d239 pnpm prepr 2026-06-12 09:59:53 -07:00
tdgao 3f5eb626bd feat: implement tooltip on badges 2026-06-12 09:57:48 -07:00
tdgao 3f8890e1a2 fix: small button to expand table 2026-06-12 09:36:06 -07:00
Truman GaoandGitHub 0fd1824552 Merge branch 'main' into truman/creator-payouts-frontend
Signed-off-by: Truman Gao <106889354+tdgao@users.noreply.github.com>
2026-06-12 10:25:03 -06:00
tdgao 8ed8596632 feat-small: dash to solid 2026-06-12 09:20:32 -07:00
tdgao ab0a3a6045 feat: fix variance deduction line in distribution breakdown 2026-06-12 09:19:43 -07:00
tdgao b6e76f709c feat: polish styles 2026-06-12 09:09:04 -07:00
tdgao ad420a12be chore: update mock data dates 2026-06-12 09:05:08 -07:00
tdgao 0a9f4a2bc4 feat: add table tooltips 2026-06-12 08:42:47 -07:00
tdgao 76ff596c0a Merge branch 'main' into truman/creator-payouts-frontend 2026-06-12 08:37:29 -07:00
tdgao 91fe2b8458 feat: add confirm modal for deleting adjustments 2026-06-09 14:28:23 -07:00
tdgao 494df86ee7 fix: improve breakdown distribution card styles 2026-06-09 14:28:12 -07:00
tdgao 9e5de0f241 feat: add creator payouts page to admin pages dropdown 2026-06-09 14:09:07 -07:00
tdgao 6b111da84a feat: add payouts table expand/collapse transition 2026-06-09 13:32:09 -07:00
tdgao 0a500cad93 feat: implement expandable row for payouts table 2026-06-09 13:30:05 -07:00
tdgao 3fb087af88 fix: creator payout initiated card 2026-06-09 12:49:30 -07:00
tdgao 3cf489d64d fix: verify payout modal styles 2026-06-09 12:47:14 -07:00
tdgao d32619b112 feat: fix empty value using text contrast 2026-06-09 11:08:16 -07:00
tdgao 94815c857c fix: distribute month card styles 2026-06-09 10:51:05 -07:00
tdgao f7b056de8d fix: more style improvements 2026-06-08 20:18:10 -07:00
tdgao c047d2d348 fix: styles 2026-06-08 20:01:39 -07:00
tdgao 7297070f5c feat: mock route responses 2026-06-08 19:57:51 -07:00
tdgao 4610c71b45 feat: layout creator payouts page files 2026-06-08 19:04:51 -07:00
17 changed files with 2022 additions and 2 deletions
@@ -0,0 +1,121 @@
<template>
<ConfirmModal
ref="deleteAdjustmentModal"
title="Delete adjustment?"
:description="deleteAdjustmentDescription"
proceed-label="Delete"
:markdown="false"
width="36rem"
@proceed="confirmRemoveAdjustment"
/>
<div
class="flex flex-col gap-5 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6"
>
<h2 class="m-0 text-lg font-semibold text-contrast">Adjustments</h2>
<div v-if="adjustments.length > 0" class="flex flex-col gap-2.5">
<div
v-for="(adjustment, index) in adjustments"
:key="index"
class="grid grid-cols-[minmax(0,1fr)_6.5rem_auto] gap-1.5"
>
<StyledInput
:model-value="adjustment.description"
placeholder="Description"
autocomplete="off"
wrapper-class="w-full"
@update:model-value="updateAdjustment(index, 'description', String($event ?? ''))"
/>
<StyledInput
:model-value="adjustment.amount"
type="number"
inputmode="decimal"
placeholder="0.00"
:step="0.01"
wrapper-class="w-full"
@update:model-value="updateAdjustment(index, 'amount', Number($event ?? 0))"
/>
<ButtonStyled circular type="outlined" color="red">
<button
:aria-label="`Remove adjustment ${index + 1}`"
@click="openDeleteAdjustmentModal(index)"
>
<TrashIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
<ButtonStyled type="outlined">
<button class="w-full" @click="addAdjustment">
<PlusIcon aria-hidden="true" />
Add Adjustment
</button>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import { PlusIcon, TrashIcon } from '@modrinth/assets'
import { ButtonStyled, ConfirmModal, StyledInput } from '@modrinth/ui'
import { computed, ref } from 'vue'
import { type DistributionAdjustment,formatCurrency } from '../utils'
const adjustments = defineModel<DistributionAdjustment[]>({ required: true })
const pendingDeleteIndex = ref<number | null>(null)
const deleteAdjustmentModal = ref<InstanceType<typeof ConfirmModal> | null>(null)
const pendingDeleteAdjustment = computed(() =>
pendingDeleteIndex.value === null ? null : adjustments.value[pendingDeleteIndex.value],
)
const deleteAdjustmentDescription = computed(() => {
const adjustment = pendingDeleteAdjustment.value
if (!adjustment) {
return ''
}
if (!adjustment.description) {
return `Delete adjustment for ${formatAdjustmentAmount(adjustment.amount)}`
}
return `Delete adjustment "${adjustment.description}" for ${formatAdjustmentAmount(adjustment.amount)}`
})
function addAdjustment() {
adjustments.value = [...adjustments.value, { description: '', amount: 0 }]
}
function openDeleteAdjustmentModal(index: number) {
pendingDeleteIndex.value = index
deleteAdjustmentModal.value?.show()
}
function confirmRemoveAdjustment() {
if (pendingDeleteIndex.value === null) {
return
}
adjustments.value = adjustments.value.filter(
(_, adjustmentIndex) => adjustmentIndex !== pendingDeleteIndex.value,
)
pendingDeleteIndex.value = null
}
function updateAdjustment(
index: number,
field: keyof DistributionAdjustment,
value: string | number,
) {
adjustments.value = adjustments.value.map((adjustment, adjustmentIndex) =>
adjustmentIndex === index ? { ...adjustment, [field]: value } : adjustment,
)
}
function formatAdjustmentAmount(amount: number): string {
const formatted = formatCurrency(Math.abs(amount), { cents: true })
return amount < 0 ? `-${formatted}` : formatted
}
</script>
@@ -0,0 +1,160 @@
<template>
<div class="rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6">
<h2 class="m-0 text-lg font-semibold text-contrast">Distribution Breakdown</h2>
<div class="mt-5 border-0 border-t border-solid border-surface-4 pt-5">
<DistributeBreakdownRow
label="Estimated Revenue"
:value="formatCurrency(estimatedRevenue, { cents: true })"
/>
<DistributeBreakdownRow
label="Clean.io Fee"
:value="formatSignedCurrencyWithCents(-Math.abs(payout.fees_deducted_usd))"
:tone="getAmountTone(-Math.abs(payout.fees_deducted_usd))"
/>
<DistributeBreakdownRow
label="Variance Deduction"
:value="formatSignedCurrencyWithCents(payout.variance_adjustment_usd)"
:tone="getAmountTone(payout.variance_adjustment_usd)"
/>
</div>
<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow
label="Net Estimated Revenue"
:value="formatCurrency(payout.net_estimated_revenue_usd, { cents: true })"
strong
/>
</div>
<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Actual Revenue" :value="actualRevenueLabel" />
<DistributeBreakdownRow
label="Variance Resolution"
:value="varianceResolutionLabel"
:description="varianceResolutionDescription"
:tone="getAmountTone(varianceResolution)"
/>
<DistributeBreakdownRow
v-for="(adjustment, index) in adjustments"
:key="`${index}-${adjustment.description}`"
:label="adjustment.description"
:value="formatSignedCurrencyWithCents(adjustment.amount)"
:tone="getAmountTone(adjustment.amount)"
/>
</div>
<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Net Actual Revenue" :value="netActualLabel" strong />
</div>
<div class="mt-4 border-0 border-t border-solid border-surface-4 pt-4">
<DistributeBreakdownRow label="Creator Revenue (75%)" :value="creatorRevenueLabel" />
<DistributeBreakdownRow label="Modrinth Revenue (25%)" :value="modrinthRevenueLabel" />
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { computed } from 'vue'
import {
type DistributionAdjustment,
formatCurrency,
getCreatorShare,
getModrinthShare,
getNetActualRevenue,
roundCurrency,
} from '../utils'
import DistributeBreakdownRow from './DistributeBreakdownRow.vue'
const props = defineProps<{
payout: Labrinth.Payouts.Internal.HistoryItem
amountReceived: number | undefined
adjustments: DistributionAdjustment[]
}>()
const emptyValue = '—'
const estimatedRevenue = computed(() =>
props.payout.days.reduce((total, day) => total + (day.estimated_revenue_usd ?? 0), 0),
)
const hasActualAmount = computed(() => (props.amountReceived ?? 0) > 0)
const actualRevenue = computed(() => props.amountReceived ?? 0)
const netActualRevenue = computed(() => getNetActualRevenue(actualRevenue.value, props.adjustments))
const varianceResolution = computed(() =>
roundCurrency(actualRevenue.value - props.payout.net_estimated_revenue_usd),
)
const varianceDeduction = computed(() => Math.abs(props.payout.variance_adjustment_usd))
const returnedVariance = computed(() =>
Math.min(Math.max(varianceResolution.value, 0), varianceDeduction.value),
)
const actualRevenueLabel = computed(() =>
hasActualAmount.value ? formatCurrency(actualRevenue.value, { cents: true }) : emptyValue,
)
const varianceResolutionLabel = computed(() =>
hasActualAmount.value ? formatSignedCurrencyWithCents(varianceResolution.value) : emptyValue,
)
const varianceResolutionDescription = computed(() => {
if (!hasActualAmount.value) {
return undefined
}
if (varianceResolution.value < 0) {
return `Variance consumed + ${formatCurrency(Math.abs(varianceResolution.value), { cents: true })} additional shortfall`
}
if (varianceDeduction.value === 0) {
return undefined
}
if (varianceResolution.value >= varianceDeduction.value) {
return 'Variance deduction fully returned'
}
return `${formatCurrency(returnedVariance.value, { cents: true })} of ${formatCurrency(varianceDeduction.value, { cents: true })} returned`
})
const adjustments = computed(() =>
props.adjustments.filter((adjustment) => adjustment.description || adjustment.amount !== 0),
)
const netActualLabel = computed(() =>
hasActualAmount.value ? formatCurrency(netActualRevenue.value, { cents: true }) : emptyValue,
)
const creatorRevenueLabel = computed(() =>
hasActualAmount.value
? formatCurrency(getCreatorShare(netActualRevenue.value), { cents: true })
: emptyValue,
)
const modrinthRevenueLabel = computed(() =>
hasActualAmount.value
? formatCurrency(getModrinthShare(netActualRevenue.value), { cents: true })
: emptyValue,
)
function formatSignedCurrencyWithCents(amount: number): string {
const formatted = formatCurrency(Math.abs(amount), { cents: true })
if (amount < 0) {
return `-${formatted}`
}
if (amount > 0) {
return `+${formatted}`
}
return formatted
}
function getAmountTone(amount: number): 'positive' | 'negative' | 'neutral' {
if (amount > 0) {
return 'positive'
}
if (amount < 0) {
return 'negative'
}
return 'neutral'
}
</script>
@@ -0,0 +1,53 @@
<template>
<div class="mb-4 flex items-start justify-between gap-6 last:mb-0">
<div class="flex min-w-0 flex-col gap-1">
<span
class="font-base text-base"
:class="strong ? 'font-medium text-contrast' : 'text-primary'"
>
{{ label }}
</span>
<span v-if="description" class="text-sm text-secondary">
{{ description }}
</span>
</div>
<span
class="font-base shrink-0 text-right text-base"
:class="[
value === emptyValue
? 'text-primary opacity-60'
: strong
? 'text-lg font-medium text-contrast'
: valueToneClass,
]"
>
{{ value }}
</span>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
label: string
value: string
description?: string
negative?: boolean
strong?: boolean
tone?: 'positive' | 'negative' | 'neutral'
}>()
const emptyValue = '—'
const valueToneClass = computed(() => {
if (props.tone === 'positive') {
return 'text-green'
}
if (props.tone === 'negative' || props.negative) {
return 'text-red'
}
return 'text-primary'
})
</script>
@@ -0,0 +1,176 @@
<template>
<NewModal
ref="modal"
header="Verify Payout"
width="32rem"
max-width="calc(100vw - 2rem)"
:close-on-click-outside="!submitting"
:on-hide="reset"
>
<div class="flex flex-col gap-6">
<Admonition type="warning" header="You are about to initiate a payout">
This will distribute {{ formatCurrency(creatorAmount, { cents: true }) }} to creators for
{{ formatMonthYear(payout.payouts_date) }}.
</Admonition>
<label class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Enter
{{ creatorAmount }}
to confirm
</span>
<StyledInput
v-model="confirmedAmount"
type="number"
inputmode="decimal"
:step="0.01"
placeholder="0.00"
wrapper-class="w-full"
/>
</label>
<div class="flex flex-col gap-2">
<span class="font-semibold text-contrast">
Enter 6-digit code from your authenticator app
</span>
<label
class="flex w-fit flex-wrap gap-1.5"
@pointerdown.prevent="focusFirstUnfilledCodeInput"
>
<input
v-for="index in 6"
:key="index"
:ref="(element) => setCodeInput(element, index - 1)"
:value="totpDigits[index - 1]"
inputmode="numeric"
maxlength="1"
class="h-12 w-11 appearance-none rounded-xl border-none bg-surface-4 p-1 text-center text-base font-medium text-primary outline-none focus:text-primary focus:ring-4 focus:ring-brand-shadow"
@input="handleTotpInput($event, index - 1)"
@keydown="handleTotpKeydown($event, index - 1)"
@paste.prevent="handleTotpPaste"
/>
</label>
</div>
<ButtonStyled color="green">
<button class="w-full" :disabled="!canSubmit || submitting" @click="submit">
Verify & Run Payout
<ChevronRightIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</NewModal>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon } from '@modrinth/assets'
import { Admonition, ButtonStyled, NewModal, StyledInput } from '@modrinth/ui'
import { type ComponentPublicInstance,computed, nextTick, ref } from 'vue'
import {
type DistributionAdjustment,
formatCurrency,
formatMonthYear,
roundCurrency,
} from '../utils'
const props = defineProps<{
payout: Labrinth.Payouts.Internal.HistoryItem
creatorAmount: number
amountReceived: number
adjustments: DistributionAdjustment[]
submitting?: boolean
}>()
const emit = defineEmits<{
submit: [request: Labrinth.Payouts.Internal.StartDistributionRequest]
}>()
const modal = ref<InstanceType<typeof NewModal> | null>(null)
const confirmedAmount = ref<number | undefined>()
const totpDigits = ref<string[]>(['', '', '', '', '', ''])
const codeInputs = ref<HTMLInputElement[]>([])
const totpCode = computed(() => totpDigits.value.join(''))
const canSubmit = computed(
() =>
roundCurrency(Number(confirmedAmount.value ?? Number.NaN)) ===
roundCurrency(props.creatorAmount) && /^\d{6}$/.test(totpCode.value),
)
function show() {
modal.value?.show()
void nextTick(focusFirstUnfilledCodeInput)
}
function hide() {
modal.value?.hide()
}
function reset() {
confirmedAmount.value = undefined
totpDigits.value = ['', '', '', '', '', '']
codeInputs.value = []
}
function setCodeInput(element: Element | ComponentPublicInstance | null, index: number) {
if (element instanceof HTMLInputElement) {
codeInputs.value[index] = element
}
}
function focusFirstUnfilledCodeInput() {
const firstUnfilledIndex = totpDigits.value.findIndex((digit) => !digit)
const inputIndex = firstUnfilledIndex === -1 ? totpDigits.value.length - 1 : firstUnfilledIndex
const input = codeInputs.value[inputIndex]
input?.focus()
input?.setSelectionRange(input.value.length, input.value.length)
}
function handleTotpInput(event: Event, index: number) {
const input = event.target as HTMLInputElement
const digit = input.value.replace(/\D/g, '').slice(-1)
totpDigits.value[index] = digit
input.value = digit
if (digit && index < codeInputs.value.length - 1) {
codeInputs.value[index + 1]?.focus()
}
}
function handleTotpKeydown(event: KeyboardEvent, index: number) {
if (event.key === 'Backspace' && !totpDigits.value[index] && index > 0) {
codeInputs.value[index - 1]?.focus()
}
}
function handleTotpPaste(event: ClipboardEvent) {
const clipboardText = event.clipboardData?.getData('text') ?? ''
const pastedCode = clipboardText.replace(/\D/g, '').slice(0, 6)
if (!pastedCode) {
return
}
totpDigits.value = Array.from({ length: 6 }, (_, index) => pastedCode[index] ?? '')
void nextTick(focusFirstUnfilledCodeInput)
}
function submit() {
if (!canSubmit.value || props.submitting) {
return
}
emit('submit', {
payouts_date: props.payout.payouts_date,
totp_code: totpCode.value,
amount_received: props.amountReceived,
adjustments: props.adjustments,
})
}
defineExpose({
show,
hide,
})
</script>
@@ -0,0 +1,151 @@
<template>
<VerifyPayoutModal
ref="verifyModal"
:payout="payout"
:creator-amount="creatorAmount"
:amount-received="amountReceivedValue"
:adjustments="validAdjustments"
:submitting="submitting"
@submit="startDistribution"
/>
<div class="grid gap-6 xl:grid-cols-[minmax(0,1fr)_minmax(24rem,42rem)]">
<div class="flex flex-col gap-6">
<div
class="flex flex-col gap-4 rounded-2xl border border-solid border-surface-4 bg-surface-3 p-6"
>
<h2 class="m-0 text-lg font-semibold text-contrast">Aditude Payment</h2>
<div class="flex flex-col gap-2.5">
<span class="text-base font-semibold text-contrast">Amount Received</span>
<StyledInput
v-model="amountReceived"
type="number"
inputmode="decimal"
placeholder="0.00"
:step="0.01"
wrapper-class="w-full"
/>
</div>
</div>
<AdjustmentsCard v-model="adjustments" />
</div>
<div class="flex flex-col gap-6">
<DistributeBreakdownCard
:payout="payout"
:amount-received="amountReceived"
:adjustments="validAdjustments"
/>
<ButtonStyled color="green" size="large">
<button
class="w-full"
:disabled="!canOpenVerification || submitting"
@click="openVerifyModal"
>
Reconcile & Distribute Earnings
<ChevronRightIcon aria-hidden="true" />
</button>
</ButtonStyled>
</div>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon } from '@modrinth/assets'
import {
ButtonStyled,
injectModrinthClient,
injectNotificationManager,
StyledInput,
} from '@modrinth/ui'
import { useQueryClient } from '@tanstack/vue-query'
import { computed, ref } from 'vue'
import {
type DistributionAdjustment,
formatCurrency,
getCreatorShare,
getNetActualRevenue,
roundCurrency,
} from '../utils'
import AdjustmentsCard from './AdjustmentsCard.vue'
import DistributeBreakdownCard from './DistributeBreakdownCard.vue'
import VerifyPayoutModal from './VerifyPayoutModal.vue'
defineProps<{
payout: Labrinth.Payouts.Internal.HistoryItem
}>()
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const verifyModal = ref<InstanceType<typeof VerifyPayoutModal> | null>(null)
const amountReceived = ref<number | undefined>()
const adjustments = ref<DistributionAdjustment[]>([])
const submitting = ref(false)
const amountReceivedValue = computed(() => roundCurrency(Number(amountReceived.value ?? 0)))
const validAdjustments = computed(() =>
adjustments.value
.filter((adjustment) => adjustment.description.trim() || adjustment.amount !== 0)
.map((adjustment) => ({
description: adjustment.description.trim(),
amount: roundCurrency(Number(adjustment.amount)),
})),
)
const adjustmentsAreValid = computed(() =>
validAdjustments.value.every(
(adjustment) => adjustment.description.length > 0 && Number.isFinite(adjustment.amount),
),
)
const netActualRevenue = computed(() =>
getNetActualRevenue(amountReceivedValue.value, validAdjustments.value),
)
const creatorAmount = computed(() => getCreatorShare(netActualRevenue.value))
const canOpenVerification = computed(
() => amountReceivedValue.value > 0 && adjustmentsAreValid.value && creatorAmount.value > 0,
)
function openVerifyModal() {
if (!canOpenVerification.value) {
return
}
verifyModal.value?.show()
}
async function startDistribution(request: Labrinth.Payouts.Internal.StartDistributionRequest) {
if (submitting.value) {
return
}
submitting.value = true
try {
await client.labrinth.payouts_internal.startDistribution(request)
verifyModal.value?.hide()
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['creator-payouts-history'] }),
queryClient.invalidateQueries({ queryKey: ['creator-payouts-distribution'] }),
])
addNotification({
title: 'Payout initiated',
text: `${formatCurrency(creatorAmount.value, { cents: true })} will be distributed to creators.`,
type: 'success',
})
await navigateTo('/admin/creator-payouts')
} catch (error) {
addNotification({
title: 'Failed to initiate payout',
text: error instanceof Error ? error.message : String(error),
type: 'error',
})
} finally {
submitting.value = false
}
}
</script>
@@ -0,0 +1,87 @@
<template>
<div
class="flex flex-col gap-2.5 rounded-2xl border border-solid p-5"
:class="
payout.status === 'review'
? 'border-surface-4 bg-surface-2'
: 'border-dashed border-surface-4 bg-surface-1.5 opacity-75'
"
>
<div class="flex flex-wrap items-center gap-2 pb-1">
<div
class="inline-flex items-center gap-2 rounded-full border border-solid border-surface-4 bg-surface-2 px-3 py-1 text-sm font-semibold"
>
<CalendarIcon v-if="payout.status === 'review'" class="size-4" aria-hidden="true" />
<ClockIcon v-else class="size-4" aria-hidden="true" />
{{ availabilityLabel }}
</div>
<div
v-if="payout.status === 'review'"
class="flex items-center gap-1 text-sm font-semibold text-red"
>
<CircleAlertIcon class="size-4" aria-hidden="true" />
{{ remainingLabel }}
</div>
</div>
<h2 class="m-0 text-xl font-semibold text-contrast">
{{ title }}
</h2>
<p class="font-base m-0 text-base">
{{ description }}
</p>
<ButtonStyled v-if="payout.status === 'review'" type="outlined">
<NuxtLink
:to="`/admin/creator-payouts/distribute?payouts_date=${payout.payouts_date}`"
class="w-fit"
>
Reconcile earnings
<ChevronRightIcon aria-hidden="true" />
</NuxtLink>
</ButtonStyled>
</div>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { CalendarIcon, ChevronRightIcon, CircleAlertIcon, ClockIcon } from '@modrinth/assets'
import { ButtonStyled } from '@modrinth/ui'
import { computed } from 'vue'
import {
formatMonthYear,
formatShortDate,
getDaysRemaining,
getPendingAvailableDate,
getReviewDueDate,
} from '../utils'
const props = defineProps<{
payout: Labrinth.Payouts.Internal.HistoryItem
}>()
const title = computed(() =>
props.payout.status === 'review'
? `Distribute ${formatMonthYear(props.payout.payouts_date)} Earnings`
: `${formatMonthYear(props.payout.payouts_date)} Earnings`,
)
const availabilityLabel = computed(() => {
if (props.payout.status === 'review') {
return `Due ${formatShortDate(getReviewDueDate(props.payout.payouts_date))}`
}
return `Available ~${formatShortDate(getPendingAvailableDate(props.payout.payouts_date))}`
})
const remainingLabel = computed(() => {
const days = getDaysRemaining(getReviewDueDate(props.payout.payouts_date))
return days === 1 ? '1 day remaining' : `${days} days remaining`
})
const description = computed(() =>
props.payout.status === 'review'
? `Reconcile advertisement revenue and distribute ${formatMonthYear(
props.payout.payouts_date,
)} creator earnings.`
: 'Awaiting advertisement revenue from Aditude.',
)
</script>
@@ -0,0 +1,66 @@
<template>
<Admonition
type="info"
:header="`${formatMonthYear(distribution.payouts_date)} payout initiated`"
>
<div class="text-primary">
{{ formatCurrency(creatorAmount, { cents: true }) }} to creators. Processing in
<span class="text-contrast">{{ countdownLabel }}</span>
</div>
<template #top-right-actions>
<ButtonStyled type="outlined">
<button class="!border" type="button" :disabled="cancelling" @click="$emit('cancel')">
Cancel
</button>
</ButtonStyled>
</template>
</Admonition>
</template>
<script setup lang="ts">
import { Admonition, ButtonStyled } from '@modrinth/ui'
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import {
type DistributionRun,
formatCurrency,
formatMonthYear,
getDistributionCreatorAmount,
} from '../utils'
const props = defineProps<{
distribution: DistributionRun
cancelling?: boolean
}>()
defineEmits<{
cancel: []
}>()
const now = ref(Date.now())
let interval: ReturnType<typeof setInterval> | undefined
const creatorAmount = computed(() => getDistributionCreatorAmount(props.distribution))
const countdownLabel = computed(() => {
const remainingSeconds = Math.max(
0,
Math.floor((new Date(props.distribution.distributes_at).getTime() - now.value) / 1000),
)
const minutes = Math.floor(remainingSeconds / 60)
const seconds = remainingSeconds % 60
return `${minutes}:${seconds.toString().padStart(2, '0')}`
})
onMounted(() => {
interval = setInterval(() => {
now.value = Date.now()
}, 1000)
})
onBeforeUnmount(() => {
if (interval) {
clearInterval(interval)
}
})
</script>
@@ -0,0 +1,424 @@
<template>
<Table
:columns="columns"
:data="rows"
row-key="rowKey"
:body-cell-class="bodyCellClass"
row-transition-name="payout-day-row"
>
<template #header-period="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-status="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-estimated="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-fees="{ column }">
<span class="inline-flex items-center gap-1 text-sm font-normal">
{{ column.label }}
<span v-tooltip="feesTooltip" class="inline-flex" tabindex="0" :aria-label="feesTooltip">
<InfoIcon class="size-4" aria-hidden="true" />
</span>
</span>
</template>
<template #header-variance="{ column }">
<span class="inline-flex items-center gap-1 text-sm font-normal">
{{ column.label }}
<span
v-tooltip="varianceTooltip"
class="inline-flex"
tabindex="0"
:aria-label="varianceTooltip"
>
<InfoIcon class="size-4" aria-hidden="true" />
</span>
</span>
</template>
<template #header-netEstimated="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-actual="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-external="{ column }">
<span class="inline-flex items-center gap-1 text-sm font-normal">
{{ column.label }}
<span
v-tooltip="externalTooltip"
class="inline-flex"
tabindex="0"
:aria-label="externalTooltip"
>
<InfoIcon class="size-4" aria-hidden="true" />
</span>
</span>
</template>
<template #header-netActual="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-creator="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #header-modrinth="{ column }">
<span class="text-sm font-normal">{{ column.label }}</span>
</template>
<template #cell-period="{ row }">
<button
v-if="row.rowKind === 'period'"
type="button"
class="flex h-full w-full cursor-pointer items-center gap-3 border-0 bg-transparent p-0 text-left font-medium text-contrast"
:class="{ 'opacity-50': isDim(row) }"
:aria-expanded="row.isExpanded"
@click="toggleExpanded(row.payouts_date)"
>
<ChevronRightIcon
class="size-5 shrink-0 text-secondary transition-transform"
:class="{ 'rotate-90': row.isExpanded }"
aria-hidden="true"
/>
{{ row.period }}
</button>
<div v-else class="flex items-center gap-3 pl-8 text-sm text-secondary">
{{ row.period }}
</div>
</template>
<template #cell-status="{ row }">
<span
v-if="row.rowKind === 'period'"
v-tooltip="statusTooltip(row.status)"
class="inline-flex rounded-full border border-solid px-3 py-0.5 text-sm font-medium"
:class="statusClass(row.status)"
tabindex="0"
:aria-label="statusTooltip(row.status)"
>
{{ statusLabel(row.status) }}
</span>
<span v-else :class="emptyValueClass">{{ row.status }}</span>
</template>
<template #cell-fees="{ row }">
<span :class="row.fees === emptyValue ? emptyValueClass : 'text-red'">{{ row.fees }}</span>
</template>
<template #cell-variance="{ row }">
<span :class="row.variance === emptyValue ? emptyValueClass : 'text-red'">{{
row.variance
}}</span>
</template>
<template #cell-estimated="{ row }">
<span :class="valueClass(row.estimated)">{{ row.estimated }}</span>
</template>
<template #cell-netEstimated="{ row }">
<span :class="valueClass(row.netEstimated)">{{ row.netEstimated }}</span>
</template>
<template #cell-actual="{ row }">
<span :class="valueClass(row.actual)">{{ row.actual }}</span>
</template>
<template #cell-external="{ row }">
<span :class="valueClass(row.external)">{{ row.external }}</span>
</template>
<template #cell-netActual="{ row }">
<span :class="row.netActual === emptyValue ? emptyValueClass : 'font-medium text-contrast'">
{{ row.netActual }}
</span>
</template>
<template #cell-creator="{ row }">
<span
v-tooltip="row.creatorIsEstimated ? pendingEstimatedRevenueTooltip : undefined"
:class="valueClass(row.creator)"
:tabindex="row.creatorIsEstimated ? 0 : undefined"
:aria-label="row.creatorIsEstimated ? pendingEstimatedRevenueTooltip : undefined"
>
{{ row.creator }}
</span>
</template>
<template #cell-modrinth="{ row }">
<span
v-tooltip="row.modrinthIsEstimated ? pendingEstimatedRevenueTooltip : undefined"
:class="valueClass(row.modrinth)"
:tabindex="row.modrinthIsEstimated ? 0 : undefined"
:aria-label="row.modrinthIsEstimated ? pendingEstimatedRevenueTooltip : undefined"
>
{{ row.modrinth }}
</span>
</template>
</Table>
</template>
<script setup lang="ts">
import type { Labrinth } from '@modrinth/api-client'
import { ChevronRightIcon, InfoIcon } from '@modrinth/assets'
import { Table, type TableColumn } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
formatCurrency,
formatMonthYear,
formatSignedCurrency,
getCreatorShare,
getModrinthShare,
} from '../utils'
type PayoutColumnKey =
| 'period'
| 'status'
| 'estimated'
| 'fees'
| 'variance'
| 'netEstimated'
| 'actual'
| 'external'
| 'netActual'
| 'creator'
| 'modrinth'
type PayoutRowBase = Record<string, unknown> &
Record<PayoutColumnKey, string> & {
rowKey: string
rowKind: 'period' | 'day'
payouts_date: string
isDimmed: boolean
isExpanded: boolean
creatorIsEstimated: boolean
modrinthIsEstimated: boolean
}
type PeriodRow = PayoutRowBase & {
rowKind: 'period'
status: Labrinth.Payouts.Internal.PayoutStatus
}
type DayRow = PayoutRowBase & {
rowKind: 'day'
}
type PayoutRow = PeriodRow | DayRow
const props = defineProps<{
payouts: Labrinth.Payouts.Internal.HistoryItem[]
}>()
const emptyValue = '—'
const emptyValueClass = 'text-primary opacity-60'
const feesTooltip = 'Deduction to cover Clean.io fees'
const varianceTooltip = 'Deduction to account for variance between estimated and actual revenue.'
const externalTooltip =
'Manual adjustments for direct campaigns, overreported revenue, or other corrections.'
const pendingEstimatedRevenueTooltip = 'Pending estimated revenue'
const expandedPayoutDates = ref<Set<string>>(new Set())
const columns: TableColumn<PayoutColumnKey>[] = [
{ key: 'period', label: 'Period', width: '16%' },
{ key: 'status', label: 'Status', width: '8.75%' },
{ key: 'estimated', label: 'Est Rev', align: 'right', width: '10%' },
{ key: 'fees', label: 'Fees', align: 'right', width: '10%' },
{ key: 'variance', label: 'Variance Adj', align: 'right', width: '12%' },
{ key: 'netEstimated', label: 'Net Est Rev', align: 'right', width: '11%' },
{ key: 'actual', label: 'Actual Rev', align: 'right', width: '11%' },
{ key: 'external', label: 'External Adj', align: 'right', width: '12%' },
{ key: 'netActual', label: 'Net Actual Rev', align: 'right', width: '13%' },
{ key: 'creator', label: 'Creator (75%)', align: 'right', width: '12%' },
{ key: 'modrinth', label: 'Modrinth (25%)', align: 'right', width: '14%' },
]
const rows = computed<PayoutRow[]>(() => {
const tableRows: PayoutRow[] = []
for (const payout of [...props.payouts].sort((left, right) =>
right.payouts_date.localeCompare(left.payouts_date),
)) {
const isExpanded = expandedPayoutDates.value.has(payout.payouts_date)
tableRows.push({
rowKey: `period-${payout.payouts_date}`,
rowKind: 'period',
payouts_date: payout.payouts_date,
isDimmed: payout.status === 'paid',
isExpanded,
period: formatMonthYear(payout.payouts_date),
status: payout.status,
estimated: formatCurrency(getEstimatedRevenue(payout)),
fees: formatSignedCurrency(-Math.abs(payout.fees_deducted_usd)),
variance: formatSignedCurrency(payout.variance_adjustment_usd),
netEstimated: formatCurrency(payout.net_estimated_revenue_usd),
actual: formatCurrency(payout.actual_revenue_usd),
external: formatCurrency(payout.total_external_adjustment_usd),
netActual: formatCurrency(payout.net_actual_revenue_usd),
creator: formatPayoutSplitCurrency(
payout.creator_net_actual_revenue_usd,
payout.creator_net_estimated_revenue_usd,
),
modrinth: formatPayoutSplitCurrency(
payout.modrinth_net_actual_revenue_usd,
payout.modrinth_net_estimated_revenue_usd,
),
creatorIsEstimated: payout.creator_net_actual_revenue_usd === undefined,
modrinthIsEstimated: payout.modrinth_net_actual_revenue_usd === undefined,
})
if (isExpanded) {
payout.days.forEach((day, dayIndex) => {
const dailyEstimatedRevenue = day.estimated_revenue_usd
const dailyFeesDeducted = getDailyFeesDeducted(payout)
const dailyNetEstimatedRevenue = getDailyNetEstimatedRevenue(
dailyEstimatedRevenue,
dailyFeesDeducted,
)
const dailyCreatorRevenue = getDailyCreatorRevenue(dailyNetEstimatedRevenue)
const dailyModrinthRevenue = getDailyModrinthRevenue(dailyNetEstimatedRevenue)
tableRows.push({
rowKey: `day-${payout.payouts_date}-${dayIndex}`,
rowKind: 'day',
payouts_date: payout.payouts_date,
isDimmed: payout.status === 'paid',
isExpanded: false,
period: formatDayLabel(payout.payouts_date, dayIndex),
status: emptyValue,
estimated: formatCurrency(dailyEstimatedRevenue),
fees: formatSignedCurrency(-dailyFeesDeducted),
variance: emptyValue,
netEstimated: formatCurrency(dailyNetEstimatedRevenue),
actual: emptyValue,
external: emptyValue,
netActual: emptyValue,
creator: formatEstimatedCurrency(dailyCreatorRevenue),
modrinth: formatEstimatedCurrency(dailyModrinthRevenue),
creatorIsEstimated: dailyCreatorRevenue !== undefined,
modrinthIsEstimated: dailyModrinthRevenue !== undefined,
})
})
}
}
return tableRows
})
function toggleExpanded(payoutsDate: string) {
const nextExpandedPayoutDates = new Set(expandedPayoutDates.value)
if (nextExpandedPayoutDates.has(payoutsDate)) {
nextExpandedPayoutDates.delete(payoutsDate)
} else {
nextExpandedPayoutDates.add(payoutsDate)
}
expandedPayoutDates.value = nextExpandedPayoutDates
}
function formatDayLabel(payoutsDate: string, dayIndex: number): string {
const [year, month] = payoutsDate.split('-').map(Number)
return new Intl.DateTimeFormat(undefined, {
month: 'short',
day: 'numeric',
}).format(new Date(year, month - 1, dayIndex + 1, 12))
}
function getDailyFeesDeducted(payout: Labrinth.Payouts.Internal.HistoryItem): number {
return payout.days.length > 0 ? payout.fees_deducted_usd / payout.days.length : 0
}
function getDailyNetEstimatedRevenue(
estimatedRevenue: number | null,
feesDeducted: number,
): number | undefined {
if (estimatedRevenue === null) {
return undefined
}
return estimatedRevenue - feesDeducted
}
function getDailyCreatorRevenue(netEstimatedRevenue: number | undefined): number | undefined {
return netEstimatedRevenue === undefined ? undefined : getCreatorShare(netEstimatedRevenue)
}
function getDailyModrinthRevenue(netEstimatedRevenue: number | undefined): number | undefined {
return netEstimatedRevenue === undefined ? undefined : getModrinthShare(netEstimatedRevenue)
}
function formatPayoutSplitCurrency(
actualRevenue: number | undefined,
estimatedRevenue: number | undefined,
): string {
if (actualRevenue !== undefined) {
return formatCurrency(actualRevenue)
}
return formatEstimatedCurrency(estimatedRevenue)
}
function formatEstimatedCurrency(amount: number | undefined): string {
const formattedAmount = formatCurrency(amount)
return formattedAmount === emptyValue ? formattedAmount : `~${formattedAmount}`
}
function getEstimatedRevenue(payout: Labrinth.Payouts.Internal.HistoryItem): number | undefined {
const total = payout.days.reduce((sum, day) => sum + (day.estimated_revenue_usd ?? 0), 0)
if (total !== 0) {
return total
}
return (
payout.net_estimated_revenue_usd + payout.fees_deducted_usd - payout.variance_adjustment_usd
)
}
function statusLabel(status: Labrinth.Payouts.Internal.PayoutStatus): string {
return status[0].toUpperCase() + status.slice(1)
}
function statusClass(status: Labrinth.Payouts.Internal.PayoutStatus): string {
switch (status) {
case 'open':
return 'border-blue bg-blue-highlight text-blue'
case 'pending':
return 'border-orange bg-orange-highlight text-orange'
case 'review':
return 'border-green bg-green-highlight text-green'
case 'paid':
return 'border-surface-4 bg-surface-3 text-secondary'
}
}
function statusTooltip(status: Labrinth.Payouts.Internal.PayoutStatus): string {
switch (status) {
case 'open':
return 'Revenue is still being earned for this month.'
case 'pending':
return 'Month closed. Awaiting payout from ad provider on its NET 60 schedule (~60 days after month-end).'
case 'review':
return 'Distribution of this month is in review, awaiting finalization.'
case 'paid':
return 'Revenue paid and distributed.'
}
}
function isDim(row: PayoutRow): boolean {
return row.isDimmed
}
function bodyCellClass(row: PayoutRow): string {
return row.rowKind === 'day' ? 'h-8' : 'h-14'
}
function valueClass(value: string): string {
return value === emptyValue ? emptyValueClass : 'text-secondary'
}
</script>
<style scoped>
:deep(.payout-day-row-enter-active),
:deep(.payout-day-row-leave-active) {
transition:
opacity 150ms ease,
transform 150ms ease;
}
:deep(.payout-day-row-enter-from),
:deep(.payout-day-row-leave-to) {
opacity: 0;
transform: translateY(-4px);
}
</style>
@@ -0,0 +1,109 @@
import type { Labrinth } from '@modrinth/api-client'
export const CREATOR_PAYOUT_SHARE = 0.75
export const MODRINTH_PAYOUT_SHARE = 0.25
export type PayoutHistoryItem = Labrinth.Payouts.Internal.HistoryItem
export type DistributionAdjustment = Labrinth.Payouts.Internal.DistributionAdjustment
export type DistributionRun = Labrinth.Payouts.Internal.DistributionRun
export function isYearMonth(value: unknown): value is Labrinth.Payouts.Internal.YearMonth {
return typeof value === 'string' && /^\d{4}-\d{2}$/.test(value)
}
export function formatMonthYear(yearMonth: string): string {
const date = getYearMonthDate(yearMonth)
return new Intl.DateTimeFormat(undefined, {
month: 'long',
year: 'numeric',
}).format(date)
}
export function formatShortDate(date: Date): string {
return new Intl.DateTimeFormat(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
}).format(date)
}
export function formatCurrency(amount: number | null | undefined, options?: { cents?: boolean }) {
if (amount === null || amount === undefined || Number.isNaN(amount)) {
return '—'
}
return new Intl.NumberFormat(undefined, {
style: 'currency',
currency: 'USD',
minimumFractionDigits: options?.cents ? 2 : 0,
maximumFractionDigits: options?.cents ? 2 : 0,
}).format(amount)
}
export function formatSignedCurrency(amount: number | null | undefined): string {
if (amount === null || amount === undefined || Number.isNaN(amount)) {
return '—'
}
const formatted = formatCurrency(Math.abs(amount))
return amount < 0 ? `-${formatted}` : formatted
}
export function getReviewDueDate(yearMonth: string): Date {
return addDays(getLastDayOfMonth(yearMonth), 75)
}
export function getPendingAvailableDate(yearMonth: string): Date {
return addDays(getLastDayOfMonth(yearMonth), 60)
}
export function getDaysRemaining(date: Date): number {
const today = new Date()
today.setHours(0, 0, 0, 0)
const target = new Date(date)
target.setHours(0, 0, 0, 0)
return Math.ceil((target.getTime() - today.getTime()) / 86_400_000)
}
export function getNetActualRevenue(
amountReceived: number,
adjustments: DistributionAdjustment[],
): number {
return roundCurrency(amountReceived + getTotalAdjustments(adjustments))
}
export function getTotalAdjustments(adjustments: DistributionAdjustment[]): number {
return roundCurrency(adjustments.reduce((total, adjustment) => total + adjustment.amount, 0))
}
export function getCreatorShare(amount: number): number {
return roundCurrency(amount * CREATOR_PAYOUT_SHARE)
}
export function getModrinthShare(amount: number): number {
return roundCurrency(amount * MODRINTH_PAYOUT_SHARE)
}
export function getDistributionCreatorAmount(distribution: DistributionRun): number {
return getCreatorShare(getNetActualRevenue(distribution.amount_received, distribution.adjustments))
}
export function roundCurrency(amount: number): number {
return Math.round(amount * 100) / 100
}
export function getYearMonthDate(yearMonth: string): Date {
const [year, month] = yearMonth.split('-').map(Number)
return new Date(year, month - 1, 1, 12)
}
function getLastDayOfMonth(yearMonth: string): Date {
const [year, month] = yearMonth.split('-').map(Number)
return new Date(year, month, 0, 12)
}
function addDays(date: Date, days: number): Date {
const nextDate = new Date(date)
nextDate.setDate(nextDate.getDate() + days)
return nextDate
}
+10
View File
@@ -389,6 +389,12 @@
link: '/admin/analytics/events',
shown: isAdmin(auth.user),
},
{
id: 'creator-payouts',
color: 'primary',
link: '/admin/creator-payouts',
shown: isAdmin(auth.user),
},
]"
>
<ModrinthIcon aria-hidden="true" />
@@ -423,6 +429,9 @@
<template #servers-nodes>
<ServerIcon aria-hidden="true" /> Credit server nodes
</template>
<template #creator-payouts>
<BadgeDollarSignIcon aria-hidden="true" /> Creator payouts
</template>
<template #analytics-events>
<ChartIcon aria-hidden="true" /> {{ formatMessage(messages.analyticsEvents) }}
</template>
@@ -725,6 +734,7 @@
import {
AffiliateIcon,
ArrowBigUpDashIcon,
BadgeDollarSignIcon,
BellIcon,
BoxIcon,
BracesIcon,
@@ -0,0 +1,109 @@
<template>
<div v-if="selectedPayout" class="normal-page no-sidebar !mb-20">
<div class="normal-page__content flex flex-col gap-8">
<NuxtLink
to="/admin/creator-payouts"
class="mt-6 inline-flex w-fit items-center gap-2 text-base font-medium text-secondary hover:text-contrast"
>
<ArrowLeftIcon class="size-5" aria-hidden="true" />
Back to Overview
</NuxtLink>
<h1 class="m-0 text-3xl font-semibold text-contrast">
{{ formatMonthYear(selectedPayout.payouts_date) }} Earnings
</h1>
<DistributeEarnings :payout="selectedPayout" />
</div>
</div>
</template>
<script setup lang="ts">
import { ArrowLeftIcon } from '@modrinth/assets'
import { injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery } from '@tanstack/vue-query'
import { computed, watch } from 'vue'
import DistributeEarnings from '~/components/ui/creator-payouts/distribute-earnings/index.vue'
import { formatMonthYear, isYearMonth } from '~/components/ui/creator-payouts/utils'
definePageMeta({
middleware: [
'auth',
async () => {
const auth = await useAuth()
if (!auth.value.user || !isAdmin(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
],
})
const route = useRoute()
const client = injectModrinthClient()
const { addNotification } = injectNotificationManager()
const requestedPayoutDate = computed(() => route.query.payouts_date)
const { data: payoutHistory, error } = useQuery({
queryKey: ['creator-payouts-history'],
queryFn: () => client.labrinth.payouts_internal.getHistory(),
retry: false,
})
const requestedPayout = computed(() => {
if (!isYearMonth(requestedPayoutDate.value)) {
return null
}
return (
payoutHistory.value?.find((payout) => payout.payouts_date === requestedPayoutDate.value) ?? null
)
})
const selectedPayout = computed(() =>
requestedPayout.value?.status === 'review' ? requestedPayout.value : null,
)
watch(
[payoutHistory, error],
async () => {
if (error.value) {
addNotification({
title: 'Invalid month to distribute',
text: error.value.message,
type: 'error',
})
await navigateTo('/admin/creator-payouts')
return
}
if (!payoutHistory.value) {
return
}
if (!isYearMonth(requestedPayoutDate.value) || !requestedPayout.value) {
addNotification({
title: 'Invalid month to distribute',
type: 'error',
})
await navigateTo('/admin/creator-payouts')
return
}
if (requestedPayout.value.status !== 'review') {
addNotification({
title: 'Invalid month to distribute',
text: 'That payout period is not in review.',
type: 'error',
})
await navigateTo('/admin/creator-payouts')
}
},
{ immediate: true },
)
</script>
@@ -0,0 +1,135 @@
<template>
<div class="normal-page no-sidebar !mb-20">
<div class="normal-page__content flex flex-col gap-6">
<h1 class="m-0 mt-6 text-3xl font-semibold text-contrast">Creator Payouts</h1>
<PayoutInitiatedCard
v-if="activeDistribution"
:distribution="activeDistribution"
:cancelling="cancellingDistribution"
@cancel="cancelDistribution"
/>
<div class="grid gap-5 empty:hidden lg:grid-cols-3">
<DistributeMonthCard v-if="reviewPayout && !activeDistribution" :payout="reviewPayout" />
<DistributeMonthCard
v-for="payout in pendingPayouts"
:key="payout.payouts_date"
:payout="payout"
/>
</div>
<PayoutsTable :payouts="payoutHistory ?? []" />
</div>
</div>
</template>
<script setup lang="ts">
import { injectModrinthClient, injectNotificationManager } from '@modrinth/ui'
import { isAdmin } from '@modrinth/utils'
import { useQuery, useQueryClient } from '@tanstack/vue-query'
import { computed, ref, watch } from 'vue'
import DistributeMonthCard from '~/components/ui/creator-payouts/distribute-month-card/index.vue'
import PayoutInitiatedCard from '~/components/ui/creator-payouts/payout-initiated-card/index.vue'
import PayoutsTable from '~/components/ui/creator-payouts/payouts-table/index.vue'
definePageMeta({
middleware: [
'auth',
async () => {
const auth = await useAuth()
if (!auth.value.user || !isAdmin(auth.value.user)) {
throw createError({
fatal: true,
statusCode: 401,
statusMessage: 'Unauthorized',
})
}
},
],
})
const client = injectModrinthClient()
const queryClient = useQueryClient()
const { addNotification } = injectNotificationManager()
const cancellingDistribution = ref(false)
const { data: payoutHistory, error: historyError } = useQuery({
queryKey: ['creator-payouts-history'],
queryFn: () => client.labrinth.payouts_internal.getHistory(),
placeholderData: [],
retry: false,
})
const { data: activeDistribution, error: distributionError } = useQuery({
queryKey: ['creator-payouts-distribution'],
queryFn: () => client.labrinth.payouts_internal.getDistribution(),
retry: false,
})
const sortedPayouts = computed(() =>
[...(payoutHistory.value ?? [])].sort((left, right) =>
right.payouts_date.localeCompare(left.payouts_date),
),
)
const reviewPayout = computed(() =>
sortedPayouts.value.find((payout) => payout.status === 'review'),
)
const pendingPayouts = computed(() =>
sortedPayouts.value.filter((payout) => payout.status === 'pending').slice(0, 2),
)
watch(historyError, (error) => {
if (!error) {
return
}
addNotification({
title: 'Failed to load creator payouts',
text: error.message,
type: 'error',
})
})
watch(distributionError, (error) => {
if (!error) {
return
}
addNotification({
title: 'Failed to load active payout',
text: error.message,
type: 'error',
})
})
async function cancelDistribution() {
if (cancellingDistribution.value) {
return
}
cancellingDistribution.value = true
try {
await client.labrinth.payouts_internal.cancelDistribution()
await Promise.all([
queryClient.invalidateQueries({ queryKey: ['creator-payouts-history'] }),
queryClient.invalidateQueries({ queryKey: ['creator-payouts-distribution'] }),
])
addNotification({
title: 'Payout cancelled',
type: 'success',
})
} catch (error) {
addNotification({
title: 'Failed to cancel payout',
text: error instanceof Error ? error.message : String(error),
type: 'error',
})
} finally {
cancellingDistribution.value = false
}
}
</script>
+2
View File
@@ -35,6 +35,7 @@ import { LabrinthOAuthInternalModule } from './labrinth/oauth/internal'
import { LabrinthOrganizationsV3Module } from './labrinth/organizations/v3'
import { LabrinthPatsV2Module } from './labrinth/pats/v2'
import { LabrinthPayoutV3Module } from './labrinth/payout/v3'
import { LabrinthPayoutsInternalModule } from './labrinth/payouts/internal'
import { LabrinthPayoutsV3Module } from './labrinth/payouts/v3'
import { LabrinthProjectsV2Module } from './labrinth/projects/v2'
import { LabrinthProjectsV3Module } from './labrinth/projects/v3'
@@ -104,6 +105,7 @@ export const MODULE_REGISTRY = {
labrinth_pats_v2: LabrinthPatsV2Module,
labrinth_limits_v3: LabrinthLimitsV3Module,
labrinth_payout_v3: LabrinthPayoutV3Module,
labrinth_payouts_internal: LabrinthPayoutsInternalModule,
labrinth_payouts_v3: LabrinthPayoutsV3Module,
labrinth_projects_v2: LabrinthProjectsV2Module,
labrinth_projects_v3: LabrinthProjectsV3Module,
@@ -13,6 +13,7 @@ export * from './oauth/internal'
export * from './organizations/v3'
export * from './pats/v2'
export * from './payout/v3'
export * from './payouts/internal'
export * from './payouts/v3'
export * from './projects/v2'
export * from './projects/v3'
@@ -0,0 +1,344 @@
import { AbstractModule } from '../../../core/abstract-module'
import type { Labrinth } from '../types'
const mockHistory: Labrinth.Payouts.Internal.HistoryItem[] = [
{
payouts_date: '2026-06',
days: createMockRevenueDays('2026-06', 24_150),
status: 'open',
fees_deducted_usd: 1_440,
variance_adjustment_usd: -2_650,
net_estimated_revenue_usd: 20_060,
creator_net_estimated_revenue_usd: 15_045,
modrinth_net_estimated_revenue_usd: 5_015,
started_at: null,
started_by: null,
detailed_external_adjustments: null,
},
{
payouts_date: '2026-05',
days: createMockRevenueDays('2026-05', 48_200),
status: 'pending',
fees_deducted_usd: 1_440,
variance_adjustment_usd: -2_650,
net_estimated_revenue_usd: 44_110,
creator_net_estimated_revenue_usd: 33_083,
modrinth_net_estimated_revenue_usd: 11_028,
started_at: null,
started_by: null,
detailed_external_adjustments: null,
},
{
payouts_date: '2026-04',
days: createMockRevenueDays('2026-04', 45_500),
status: 'pending',
fees_deducted_usd: 1_312,
variance_adjustment_usd: -2_500,
net_estimated_revenue_usd: 41_688,
creator_net_estimated_revenue_usd: 31_266,
modrinth_net_estimated_revenue_usd: 10_422,
started_at: null,
started_by: null,
detailed_external_adjustments: null,
},
{
payouts_date: '2026-03',
days: createMockRevenueDays('2026-03', 42_000),
status: 'review',
fees_deducted_usd: 1_200,
variance_adjustment_usd: -2_100,
net_estimated_revenue_usd: 38_700,
creator_net_estimated_revenue_usd: 29_025,
modrinth_net_estimated_revenue_usd: 9_675,
started_at: null,
started_by: null,
detailed_external_adjustments: null,
},
{
payouts_date: '2026-02',
days: createMockRevenueDays('2026-02', 51_000),
status: 'paid',
fees_deducted_usd: 1_520,
variance_adjustment_usd: -2_800,
net_estimated_revenue_usd: 46_680,
creator_net_estimated_revenue_usd: 35_010,
modrinth_net_estimated_revenue_usd: 11_670,
actual_revenue_usd: 48_800,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 48_800,
creator_net_actual_revenue_usd: 36_600,
modrinth_net_actual_revenue_usd: 12_200,
started_at: '2026-05-16T21:10:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2026-01',
days: createMockRevenueDays('2026-01', 49_500),
status: 'paid',
fees_deducted_usd: 1_472,
variance_adjustment_usd: -2_700,
net_estimated_revenue_usd: 45_328,
creator_net_estimated_revenue_usd: 33_996,
modrinth_net_estimated_revenue_usd: 11_332,
actual_revenue_usd: 47_500,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 47_500,
creator_net_actual_revenue_usd: 35_625,
modrinth_net_actual_revenue_usd: 11_875,
started_at: '2026-04-14T19:45:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-12',
days: createMockRevenueDays('2025-12', 46_000),
status: 'paid',
fees_deducted_usd: 1_360,
variance_adjustment_usd: -2_500,
net_estimated_revenue_usd: 42_140,
creator_net_estimated_revenue_usd: 31_605,
modrinth_net_estimated_revenue_usd: 10_535,
actual_revenue_usd: 44_000,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 44_000,
creator_net_actual_revenue_usd: 33_000,
modrinth_net_actual_revenue_usd: 11_000,
started_at: '2026-03-15T20:25:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-11',
days: createMockRevenueDays('2025-11', 44_000),
status: 'paid',
fees_deducted_usd: 1_280,
variance_adjustment_usd: -2_400,
net_estimated_revenue_usd: 40_320,
creator_net_estimated_revenue_usd: 30_240,
modrinth_net_estimated_revenue_usd: 10_080,
actual_revenue_usd: 42_000,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 42_000,
creator_net_actual_revenue_usd: 31_500,
modrinth_net_actual_revenue_usd: 10_500,
started_at: '2026-02-14T18:20:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-10',
days: createMockRevenueDays('2025-10', 43_500),
status: 'paid',
fees_deducted_usd: 1_264,
variance_adjustment_usd: -2_350,
net_estimated_revenue_usd: 39_886,
creator_net_estimated_revenue_usd: 29_915,
modrinth_net_estimated_revenue_usd: 9_972,
actual_revenue_usd: 39_800,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 39_800,
creator_net_actual_revenue_usd: 29_850,
modrinth_net_actual_revenue_usd: 9_950,
started_at: '2026-01-15T17:30:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-09',
days: createMockRevenueDays('2025-09', 41_000),
status: 'paid',
fees_deducted_usd: 1_200,
variance_adjustment_usd: -2_200,
net_estimated_revenue_usd: 37_600,
creator_net_estimated_revenue_usd: 28_200,
modrinth_net_estimated_revenue_usd: 9_400,
actual_revenue_usd: 39_200,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 39_200,
creator_net_actual_revenue_usd: 29_400,
modrinth_net_actual_revenue_usd: 9_800,
started_at: '2025-12-14T17:30:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-08',
days: createMockRevenueDays('2025-08', 39_500),
status: 'paid',
fees_deducted_usd: 1_152,
variance_adjustment_usd: -2_100,
net_estimated_revenue_usd: 36_248,
creator_net_estimated_revenue_usd: 27_186,
modrinth_net_estimated_revenue_usd: 9_062,
actual_revenue_usd: 37_800,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 37_800,
creator_net_actual_revenue_usd: 28_350,
modrinth_net_actual_revenue_usd: 9_450,
started_at: '2025-11-14T17:30:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
{
payouts_date: '2025-07',
days: createMockRevenueDays('2025-07', 38_000),
status: 'paid',
fees_deducted_usd: 1_120,
variance_adjustment_usd: -2_000,
net_estimated_revenue_usd: 34_880,
creator_net_estimated_revenue_usd: 26_160,
modrinth_net_estimated_revenue_usd: 8_720,
actual_revenue_usd: 36_200,
total_external_adjustment_usd: 0,
net_actual_revenue_usd: 36_200,
creator_net_actual_revenue_usd: 27_150,
modrinth_net_actual_revenue_usd: 9_050,
started_at: '2025-10-15T17:30:00.000Z',
started_by: 'mock-admin-user',
detailed_external_adjustments: [],
},
]
let mockDistribution: Labrinth.Payouts.Internal.DistributionRun | null = null
function createMockRevenueDays(
payoutsDate: Labrinth.Payouts.Internal.YearMonth,
totalRevenue: number,
): Labrinth.Payouts.Internal.RevenueDay[] {
const daysInMonth = getMockRevenueDayCount(payoutsDate)
const weights = Array.from({ length: daysInMonth }, (_, index) => {
const weekdayLift = index % 7 === 4 || index % 7 === 5 ? 0.16 : 0
return 1 + ((index * 7) % 11) / 20 + weekdayLift
})
const totalWeight = weights.reduce((total, weight) => total + weight, 0)
let allocatedRevenue = 0
return weights.map((weight, index) => {
const estimatedRevenue =
index === weights.length - 1
? totalRevenue - allocatedRevenue
: Math.round((totalRevenue * weight) / totalWeight)
allocatedRevenue += estimatedRevenue
return { estimated_revenue_usd: estimatedRevenue }
})
}
function getMockRevenueDayCount(payoutsDate: Labrinth.Payouts.Internal.YearMonth): number {
const [year, month] = payoutsDate.split('-').map(Number)
const today = new Date()
if (today.getFullYear() === year && today.getMonth() === month - 1) {
return today.getDate()
}
return getDaysInMonth(payoutsDate)
}
function getDaysInMonth(payoutsDate: Labrinth.Payouts.Internal.YearMonth): number {
const [year, month] = payoutsDate.split('-').map(Number)
return new Date(year, month, 0).getDate()
}
export class LabrinthPayoutsInternalModule extends AbstractModule {
public getModuleID(): string {
return 'labrinth_payouts_internal'
}
/**
* Get creator payout history.
* GET /_internal/payouts/history
*/
public async getHistory(): Promise<Labrinth.Payouts.Internal.HistoryItem[]> {
return getMockHistory()
// return this.client.request<Labrinth.Payouts.Internal.HistoryItem[]>('/payouts/history', {
// api: 'labrinth',
// version: 'internal',
// method: 'GET',
// })
}
/**
* Get the active payout distribution run.
* GET /_internal/payouts/distribution
*/
public async getDistribution(): Promise<Labrinth.Payouts.Internal.DistributionRun | null> {
return mockDistribution
// return this.client.request<Labrinth.Payouts.Internal.DistributionRun | null>(
// '/payouts/distribution',
// {
// api: 'labrinth',
// version: 'internal',
// method: 'GET',
// },
// )
}
/**
* Start a payout distribution run.
* POST /_internal/payouts/distribution/start
*/
public async startDistribution(
data: Labrinth.Payouts.Internal.StartDistributionRequest,
): Promise<Labrinth.Payouts.Internal.DistributionRun> {
const startedAt = new Date()
mockDistribution = {
payouts_date: data.payouts_date,
amount_received: data.amount_received,
adjustments: data.adjustments,
started_at: startedAt.toISOString(),
started_by: 'mock-admin-user',
distributes_at: new Date(startedAt.getTime() + 2 * 60 * 1000).toISOString(),
}
return mockDistribution
// return this.client.request<Labrinth.Payouts.Internal.DistributionRun>(
// '/payouts/distribution/start',
// {
// api: 'labrinth',
// version: 'internal',
// method: 'POST',
// body: data,
// },
// )
}
/**
* Cancel the active payout distribution run.
* POST /_internal/payouts/distribution/cancel
*/
public async cancelDistribution(): Promise<void> {
mockDistribution = null
// return this.client.request<void>('/payouts/distribution/cancel', {
// api: 'labrinth',
// version: 'internal',
// method: 'POST',
// })
}
}
function getMockHistory(): Labrinth.Payouts.Internal.HistoryItem[] {
if (!mockDistribution) {
return mockHistory
}
const activeDistribution = mockDistribution
return mockHistory.map((payout) =>
payout.payouts_date === activeDistribution.payouts_date
? {
...payout,
started_at: activeDistribution.started_at,
started_by: activeDistribution.started_by,
detailed_external_adjustments: activeDistribution.adjustments.map((adjustment) => ({
description: adjustment.description,
amount_usd: adjustment.amount,
})),
}
: payout,
)
}
@@ -1694,6 +1694,61 @@ export namespace Labrinth {
}
export namespace Payouts {
export namespace Internal {
export type YearMonth = string
export type PayoutStatus = 'open' | 'pending' | 'review' | 'paid'
export type RevenueDay = {
estimated_revenue_usd: number | null
}
export type DetailedExternalAdjustment = {
description: string
amount_usd: number
}
export type HistoryItem = {
payouts_date: YearMonth
days: RevenueDay[]
status: PayoutStatus
fees_deducted_usd: number
variance_adjustment_usd: number
net_estimated_revenue_usd: number
creator_net_estimated_revenue_usd: number
modrinth_net_estimated_revenue_usd: number
actual_revenue_usd?: number
total_external_adjustment_usd?: number
net_actual_revenue_usd?: number
creator_net_actual_revenue_usd?: number
modrinth_net_actual_revenue_usd?: number
started_at: string | null
started_by: string | null
detailed_external_adjustments: DetailedExternalAdjustment[] | null
}
export type DistributionAdjustment = {
description: string
amount: number
}
export type StartDistributionRequest = {
payouts_date: YearMonth
totp_code: string
amount_received: number
adjustments: DistributionAdjustment[]
}
export type DistributionRun = {
payouts_date: YearMonth
amount_received: number
adjustments: DistributionAdjustment[]
started_at: string
started_by: string
distributes_at: string
}
}
export namespace v3 {
export type RevenueData = {
time: number
+19 -2
View File
@@ -85,6 +85,7 @@
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
:class="getBodyCellClass(row, getAbsoluteRowIndex(rowIndex))"
>
<Checkbox
:model-value="isSelected(row)"
@@ -96,7 +97,10 @@
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
:class="[
getBodyCellClass(row, getAbsoluteRowIndex(rowIndex)),
`text-${column.align ?? 'left'}`,
]"
>
<slot
:name="`cell-${column.key}`"
@@ -137,6 +141,7 @@
<td
v-if="showSelection"
class="w-12 border-solid border-0 border-t border-surface-4 focus:outline-none"
:class="getBodyCellClass(row, getAbsoluteRowIndex(rowIndex))"
>
<Checkbox
:model-value="isSelected(row)"
@@ -148,7 +153,10 @@
v-for="column in columns"
:key="column.key"
class="text-secondary h-14 overflow-hidden first:pl-4 last:pr-4 border-solid border-0 border-t border-surface-4"
:class="`text-${column.align ?? 'left'}`"
:class="[
getBodyCellClass(row, getAbsoluteRowIndex(rowIndex)),
`text-${column.align ?? 'left'}`,
]"
>
<slot
:name="`cell-${column.key}`"
@@ -219,6 +227,7 @@ const props = withDefaults(
virtualRowHeight?: number
virtualBufferSize?: number /* The number of extra rows rendered above and below the visible viewport */
rowTransitionName?: string
bodyCellClass?: string | ((row: T, rowIndex: number) => string)
/**
* Sets a minimum width for the table content, allowing horizontal overflow below that width.
*/
@@ -323,6 +332,14 @@ function getRowClass(rowIndex: number): string {
return rowIndex % 2 === 0 ? 'bg-surface-2' : 'bg-surface-1.5'
}
function getBodyCellClass(row: T, rowIndex: number): string {
if (typeof props.bodyCellClass === 'function') {
return props.bodyCellClass(row, rowIndex)
}
return props.bodyCellClass ?? 'h-14'
}
function isSelected(row: T): boolean {
return selectedIdSet.value.has(getSelectionId(row))
}