mirror of
https://github.com/modrinth/code.git
synced 2026-07-31 13:16:38 +00:00
Compare commits
24
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fdb25149e | ||
|
|
7f6e40d239 | ||
|
|
3f5eb626bd | ||
|
|
3f8890e1a2 | ||
|
|
0fd1824552 | ||
|
|
8ed8596632 | ||
|
|
ab0a3a6045 | ||
|
|
b6e76f709c | ||
|
|
ad420a12be | ||
|
|
0a9f4a2bc4 | ||
|
|
76ff596c0a | ||
|
|
91fe2b8458 | ||
|
|
494df86ee7 | ||
|
|
9e5de0f241 | ||
|
|
6b111da84a | ||
|
|
0a500cad93 | ||
|
|
3fb087af88 | ||
|
|
3cf489d64d | ||
|
|
d32619b112 | ||
|
|
94815c857c | ||
|
|
f7b056de8d | ||
|
|
c047d2d348 | ||
|
|
7297070f5c | ||
|
|
4610c71b45 |
+121
@@ -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>
|
||||
+160
@@ -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>
|
||||
+53
@@ -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>
|
||||
+176
@@ -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
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user