diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue new file mode 100644 index 0000000000..faf7384143 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/AdjustmentsCard.vue @@ -0,0 +1,121 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue new file mode 100644 index 0000000000..a23ec7bd3a --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue @@ -0,0 +1,160 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue new file mode 100644 index 0000000000..8d72423062 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue @@ -0,0 +1,53 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue new file mode 100644 index 0000000000..1ae7b058f3 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/VerifyPayoutModal.vue @@ -0,0 +1,176 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue new file mode 100644 index 0000000000..5d738a266c --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/index.vue @@ -0,0 +1,151 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue b/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue new file mode 100644 index 0000000000..dfe71d7ce0 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-month-card/index.vue @@ -0,0 +1,87 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue b/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue new file mode 100644 index 0000000000..aebca79c4d --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/payout-initiated-card/index.vue @@ -0,0 +1,66 @@ + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue b/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue new file mode 100644 index 0000000000..8c0cb726c7 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue @@ -0,0 +1,424 @@ + + + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/utils.ts b/apps/frontend/src/components/ui/creator-payouts/utils.ts new file mode 100644 index 0000000000..aa89ebd7d8 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/utils.ts @@ -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 +} diff --git a/apps/frontend/src/layouts/default.vue b/apps/frontend/src/layouts/default.vue index 271666927c..099d5ca1e4 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -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), + }, ]" >