Skip to contentVibraUI

Upcoming payouts

What leaves the account next, soonest first — the payroll run, hosting, tools and the live ad accounts — and what that adds up to; reads upcomingPayouts().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-overview-upcoming-payouts

Needs the @vibra registry in your components.json — set it up once.

Source

app/finance/components/upcoming-payouts.tsx
"use client"

import { formatCurrency, formatDate } from "@/lib/format"
import { DataList, DataListItem } from "@/components/ui/data-list"
import { Widget } from "@/components/ui/widget"

import { type Payout } from "../costs"

const money = (value: number) => formatCurrency(value, "USD", { maximumFractionDigits: 0 })

/** How a due date reads on the list — fixed to UTC, so it is the same day for every reader. */
const dueLabel = (due: Date) => formatDate(due, "medium", { timeZone: "UTC" })

/** What leaves the account next, soonest first. */
export function UpcomingPayouts({ payouts }: { payouts: Payout[] }) {
  const total = payouts.reduce((sum, payout) => sum + payout.amount, 0)

  return (
    <Widget
      data-widget="widget-finance-overview-upcoming-payouts"
      title="Upcoming payouts"
      description="Standing runs and the live ad accounts, soonest first"
      className="h-full"
      footer={`${money(total)} leaving over the next month`}
    >
      <DataList divided>
        {payouts.map((payout) => (
          <DataListItem
            key={payout.id}
            title={payout.title}
            description={payout.description}
            meta={
              <span className="flex flex-col items-end gap-0.5">
                <span className="font-medium tabular-nums">{money(payout.amount)}</span>
                <span className="text-xs text-muted-foreground">{dueLabel(payout.due)}</span>
              </span>
            }
          />
        ))}
      </DataList>
    </Widget>
  )
}
app/finance/costs.ts
/**
 * What the month costs, and what it earns. Revenue is the book — the MRR on
 * `db.customers` that still bills. Every cost is a rule over rows rather than
 * an invented line: payroll is the active `db.members` priced by role,
 * marketing is `db.campaigns` spend spread over the days each one ran, hosting
 * is per active seat, and tooling is per head. The accounting month is the
 * last closed one, and `data.ts` builds the page's figures on top of this.
 */
import { db, REFERENCE_DATE, type Customer, type Member } from "@/lib/sample-data"

export const DAY_MS = 86_400_000

export const dollars = (cents: number): number => cents / 100

/** Midnight UTC on the first of the month `k` months before the current one. */
export function monthStart(k: number): Date {
  return new Date(Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - k, 1))
}

const THIS_MONTH = monthStart(0)
const LAST_MONTH = monthStart(1)

const MONTH_NAME = new Intl.DateTimeFormat("en-US", { month: "long", timeZone: "UTC" })

/** The month the P&L closed on. */
export const CLOSED_MONTH = `${MONTH_NAME.format(LAST_MONTH)} ${LAST_MONTH.getUTCFullYear()}`

// What a seat on the team costs a month, fully loaded — salary, tax and
// benefits. A pay band is a business decision, written down like the pricing
// tiers are, and payroll is that band applied to the members who are active.
const MONTHLY_COST: Record<Member["role"], number> = {
  owner: 18_000,
  admin: 15_000,
  member: 12_500,
  viewer: 7_500,
}

// The two unit costs behind the smaller lines: what a seat of product costs to
// host for a month, and what a desk costs in tools.
const HOSTING_PER_SEAT = 2.4
const TOOLING_PER_HEAD = 220

/**
 * A campaign's spend inside a window. Spend is a single figure for a run of
 * days, so it is spread evenly over them and only the overlap is counted — a
 * campaign that ran a fortnight into the month contributes a fortnight of it.
 */
export function marketingBetween(from: Date, to: Date): number {
  let total = 0
  for (const campaign of db.campaigns.all()) {
    const ran = campaign.endedAt ?? REFERENCE_DATE
    const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
    const overlapFrom = Math.max(from.getTime(), campaign.startedAt.getTime())
    const overlapTo = Math.min(to.getTime(), ran.getTime())
    if (overlapTo <= overlapFrom) continue
    total += dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
  }
  return total
}

/** The closed month's book and what it cost, as the rows stand. */
export type Ledger = {
  /** The book: what every account that still bills pays in a month. */
  revenue: number
  payroll: number
  marketing: number
  hosting: number
  tooling: number
  costs: { label: string; value: number }[]
  net: number
  /** The members on the payroll, and the accounts the book is made of. */
  team: Member[]
  billing: Customer[]
}

/**
 * The month's revenue and costs, read per call and never held at module
 * scope: an account imported or a teammate who leaves since the server
 * started moves the next render's figures.
 */
export function ledger(): Ledger {
  const team = db.members.all().filter((member) => member.status === "active")
  const billing = db.customers.all().filter((customer) => customer.mrrCents > 0)
  const revenue = dollars(billing.reduce((total, customer) => total + customer.mrrCents, 0))
  const payroll = team.reduce((total, member) => total + MONTHLY_COST[member.role], 0)
  const hosting = billing.reduce((total, customer) => total + customer.seats, 0) * HOSTING_PER_SEAT
  const tooling = team.length * TOOLING_PER_HEAD
  const marketing = marketingBetween(LAST_MONTH, THIS_MONTH)
  const costs = [
    { label: "Payroll", value: payroll },
    { label: "Marketing", value: marketing },
    { label: "Hosting", value: hosting },
    { label: "Tooling", value: tooling },
  ]
  const spend = costs.reduce((total, cost) => total + cost.value, 0)
  return { revenue, payroll, marketing, hosting, tooling, costs, net: revenue - spend, team, billing }
}

const ROLE_LABELS: Record<Member["role"], string> = {
  owner: "Founders",
  admin: "Leads",
  member: "Team",
  viewer: "Contractors",
}

const CHANNEL_LABELS: Record<string, string> = {
  search: "Search",
  social: "Social",
  email: "Email",
  referral: "Referral",
  organic: "Organic",
}

/** Where the month's money went, payroll and marketing broken out by their parts. */
export function expensesByCategory() {
  const { team, payroll, marketing, hosting, tooling } = ledger()
  const byRole = new Map<Member["role"], number>()
  for (const member of team) {
    byRole.set(member.role, (byRole.get(member.role) ?? 0) + MONTHLY_COST[member.role])
  }

  const byChannel = new Map<string, number>()
  for (const campaign of db.campaigns.all()) {
    const ran = campaign.endedAt ?? REFERENCE_DATE
    const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
    const overlapFrom = Math.max(LAST_MONTH.getTime(), campaign.startedAt.getTime())
    const overlapTo = Math.min(THIS_MONTH.getTime(), ran.getTime())
    if (overlapTo <= overlapFrom) continue
    const share = dollars(campaign.spendCents) * ((overlapTo - overlapFrom) / DAY_MS / days)
    byChannel.set(campaign.channel, (byChannel.get(campaign.channel) ?? 0) + share)
  }

  return [
    {
      name: "Payroll",
      value: Math.round(payroll),
      children: [...byRole]
        .map(([role, value]) => ({ name: ROLE_LABELS[role], value: Math.round(value) }))
        .sort((a, b) => b.value - a.value),
    },
    {
      name: "Marketing",
      value: Math.round(marketing),
      children: [...byChannel]
        .map(([channel, value]) => ({
          name: CHANNEL_LABELS[channel] ?? channel,
          value: Math.round(value),
        }))
        .sort((a, b) => b.value - a.value),
    },
    { name: "Hosting", value: Math.round(hosting) },
    { name: "Tooling", value: Math.round(tooling) },
  ]
}

export type Payout = {
  id: string
  title: string
  description: string
  amount: number
  due: Date
}

// Day 0 of the next month is the last day of this one.
const MONTH_END = new Date(
  Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() + 1, 0)
)
const NEXT_MONTH = monthStart(-1)

/** What leaves the account next: the standing runs, then the live ad accounts. */
export function upcomingPayouts(): Payout[] {
  const { team, billing, payroll, hosting, tooling } = ledger()
  const live = db.campaigns
    .all()
    .filter((campaign) => campaign.status === "active")
    .map((campaign) => {
      const ran = campaign.endedAt ?? REFERENCE_DATE
      const days = Math.max(1, (ran.getTime() - campaign.startedAt.getTime()) / DAY_MS)
      return {
        id: campaign.id,
        title: campaign.name,
        description: `${CHANNEL_LABELS[campaign.channel] ?? campaign.channel} · one month of spend`,
        amount: Math.round((dollars(campaign.spendCents) / days) * 30),
        due: new Date(Date.UTC(NEXT_MONTH.getUTCFullYear(), NEXT_MONTH.getUTCMonth(), 15)),
      }
    })
    .sort((a, b) => b.amount - a.amount)
    .slice(0, 3)

  return [
    {
      id: "payroll",
      title: "Payroll run",
      description: `${team.length} people on the books`,
      amount: Math.round(payroll),
      due: MONTH_END,
    },
    {
      id: "hosting",
      title: "Cloud hosting",
      description: `${billing.reduce((seats, customer) => seats + customer.seats, 0)} billed seats`,
      amount: Math.round(hosting),
      due: NEXT_MONTH,
    },
    {
      id: "tooling",
      title: "Software and tools",
      description: `${team.length} desks`,
      amount: Math.round(tooling),
      due: NEXT_MONTH,
    },
    ...live,
  ].sort((a, b) => a.due.getTime() - b.due.getTime())
}

Its page

On its page the card sits among the rest of the dashboard and shares its range and its data with them.

From the Finance dashboard page