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..f016dfb853 --- /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..667a2f6291 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownCard.vue @@ -0,0 +1,95 @@ + + + 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..7595068c8b --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/distribute-earnings/DistributeBreakdownRow.vue @@ -0,0 +1,34 @@ + + + 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..8c4181a7df --- /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..c55ddce51b --- /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..df09b8f0a5 --- /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..e4cc1f06a7 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/payouts-table/index.vue @@ -0,0 +1,347 @@ + + + + + diff --git a/apps/frontend/src/components/ui/creator-payouts/routes.md b/apps/frontend/src/components/ui/creator-payouts/routes.md new file mode 100644 index 0000000000..dd4d286d67 --- /dev/null +++ b/apps/frontend/src/components/ui/creator-payouts/routes.md @@ -0,0 +1,90 @@ +## **Roughly the routes needed** + +- New type `YearMonth` which is represented as eg `2026-05` + - So it’s an ISO 8601 year and month, but not day +- GET /\_internal/payouts/history + + ```json + // response + Array<{ + payouts_date: string // YearMonth + days: Array<{ + estimated_revenue_usd: number | null + }> + status: open | pending | review | paid + 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 + + // if status is paid + 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 + + // admin authed fields + started_at: string | null // DateTime + started_by: string | null // user id + detailed_external_adjustments: Array<{ + description: string + amount_usd: number + }> | null + }> + ``` + +- POST /\_internal/payouts/distribution/start + - Only allowed to run on payout dates in review - not pending + + ```json + // body + { + payouts_date: string // YearMonth + totp_code: string // make sure the backend checks that this user has 2FA set up + amount_received: number + adjustments: Array<{ + description: string + amount: number + }> + } + + // response + // (same as GET /_internal/payouts/distribution) + ``` + + - We’re going to need to handle processing time + cancel time. Better to not be just in frontend, so maybe some new routes? + - Only one distribution at a time + - POST /\_internal/payouts/distribution/cancel + - GET /\_internal/payouts/distribution + - gets distribution that’s currently awaiting to go out, and details on it + + ```jsx + // response + { + payouts_date: string // YearMonth + amount_received: number + adjustments: Array<{ + description: string + amount: number + }> + started_at: string // DateTime + started_by: string // user ID + distributes_at: string // DateTime + } + ``` + +- Audit logs notes + - Payout distribution runs will store who started it and when + - We don’t store an explicit audit log of, this user attempted to start a run / this user cancelled a run / etc. +- Backend notes + - When we make `payouts_values` rows, we set `available_at` to null + - When we return when a payout value is estimated to be available, backend computes it on the fly + - Take the `created` time, set to end of month, add net 70/75/etc, return that + - When we do a distribution run, take all the payout values with dates in that year and month, and set their `available_at` to now + - On the payouts routes (routes which fetch from `payouts_values`), we will leave the `available_at` fields as-is, but add a new `estimated_available_at` which is computed on the fly + - Frontend will use the following logic for copy: + - `available_at` is present → this is exactly when it will be (or in our case, was made to be) available. this is not an estimate (also, `estimated_available_at` will not be computed) + - else, `estimated_available_at` is present → say that this actually is an estimate 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 67119adf82..3ba81a9106 100644 --- a/apps/frontend/src/layouts/default.vue +++ b/apps/frontend/src/layouts/default.vue @@ -383,6 +383,12 @@ action: (event) => $refs.modal_batch_credit.show(event), shown: isAdmin(auth.user), }, + { + id: 'creator-payouts', + color: 'primary', + link: '/admin/creator-payouts', + shown: isAdmin(auth.user), + }, ]" >