Skip to contentVibraUI
Part of the Finance dashboardinstalls at /finance/payments

Payments

Every payment the store took, sent back or failed to take, read off the order book: the month so far in three figures against the same days of the month before, a table with each customer's face and the card behind the charge, method and status facets, and a CSV export written by the server.

Open the live page

There is no payments entity: a payment is a db.orders row that reached the payment step. A paid or fulfilled order is a captured payment dated when it was placed; a refunded order is a refund of its whole total dated fulfilledAt ?? placedAt, the day it last moved; a cancelled order is a failed payment; a pending order has not reached the step and is left out. The card is the customer's default db.paymentMethods row. The three figures are this month so far — the first of REFERENCE_DATE's month to REFERENCE_DATE — against the same days of the month before, so a month to date is never measured against a whole month; refunds and failures carry pills where a rise is bad news. The table runs in the browser with the reference, customer and company searched together and facets on method and status; a refund is signed and a failed amount muted. Export CSV calls exportPayments, a server action that writes every payment as CSV (a refund's amount negative, a failed one at the amount it asked for) and returns it in a Result; the header saves it through the export lib as payments-<date>.csv and says so in an always-mounted status line, or says why not in a danger callout. The reference is plain text: the order it names lives in the store's dashboard, and a finance page links only inside its own. Composes AppShell, PageHeader, AsyncButton, Callout, StatCardGroup, StatCard, DataTable, DataTableColumnHeader, UserCell, StatusBadge and PaymentsHeader (block-local).

Preview

Install

npx shadcn@latest add @vibra/finance-payments

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

Source

app/finance/payments/page.tsx
import { REFERENCE_DATE } from "@/lib/sample-data"
import { AppShell } from "@/components/ui/app-shell"

import { signOut } from "./actions"
import { PaymentsHeader } from "./components/payments-header"
import { PaymentsStats } from "./components/payments-stats"
import { PaymentsTable } from "./components/payments-table"
import { currentUser, lastUpdated, paymentRows, paymentStats, shellNotifications } from "./data"
import { NAV, ROUTES } from "@/lib/dashboards/finance/nav"

/**
 * Every payment the store took, sent back or failed to take. A server
 * component: it reads the orders that reached the payment step, counts the
 * month so far, and hands the rows to the table and the export to the header.
 */
export default async function PaymentsPage() {
  return (
    <AppShell
      nav={NAV}
      activeHref={ROUTES.payments}
      user={currentUser()}
      notifications={shellNotifications()}
      now={REFERENCE_DATE}
      onSignOut={signOut}
    >
      <PaymentsHeader
        title="Payments"
        titleId="finance-payments-title"
        description="Every charge the store took, sent back, or failed to take."
        meta={lastUpdated()}
        asOf={REFERENCE_DATE}
      />

      <PaymentsStats stats={paymentStats()} />

      <PaymentsTable titleId="finance-payments-title" rows={paymentRows()} />
    </AppShell>
  )
}
app/finance/payments/data.ts
/**
 * What the payments page reads. There is no payments table in the store: a
 * payment is an order that reached the payment step, read off `db.orders`.
 * A paid or fulfilled order is a captured payment dated when it was placed; a
 * refunded one is a refund of its whole total, dated when it went back —
 * `fulfilledAt ?? placedAt`, the day the order last moved; a cancelled one is
 * a payment that failed. A pending order has not reached the step yet and is
 * not a payment. The card behind a card payment is the customer's default
 * `db.paymentMethods` row. "Now" is `REFERENCE_DATE`.
 */
import { formatCurrency, formatDate, formatNumber, getInitials } from "@/lib/format"
import { rowsToCsv } from "@/lib/export"
import { db, REFERENCE_DATE, type Member, type Order } from "@/lib/sample-data"

export type PaymentStatus = "captured" | "refunded" | "failed"

/** One row of the payments table: an order, read as the money it moved. */
export type PaymentRow = {
  /** The order's id — a payment has no id of its own. */
  id: string
  /** The order's number, which is what a customer quotes. */
  reference: string
  customer: { name: string; company: string; avatarUrl?: string }
  method: Order["paymentMethod"]
  /** "Visa •••• 4242", or the method's own name when no card is on file. */
  methodDetail: string
  /** The order's total in whole dollars, unsigned; the status says which way it went. */
  amount: number
  status: PaymentStatus
  at: Date
}

// The web store's three, and the till's cash and gift voucher: a sale rung up
// in the shop is a payment too.
const METHOD_NAMES: Record<Order["paymentMethod"], string> = {
  card: "Card",
  wallet: "Wallet",
  bank: "Bank transfer",
  cash: "Cash",
  voucher: "Voucher",
}
const BRANDS: Record<string, string> = { visa: "Visa", mastercard: "Mastercard", amex: "Amex" }

/** What an order's status means for the money: nothing yet, taken, sent back, or never taken. */
function statusOf(order: Order): PaymentStatus | undefined {
  if (order.status === "pending") return undefined
  if (order.status === "refunded") return "refunded"
  if (order.status === "cancelled") return "failed"
  return "captured"
}

/** Every payment, newest first. */
export function paymentRows(): PaymentRow[] {
  const customers = new Map(db.customers.all().map((customer) => [customer.id, customer]))
  const cards = new Map<string, { brand: string; last4: string }>()
  for (const method of db.paymentMethods.all()) {
    if (!cards.has(method.customerId) || method.default) cards.set(method.customerId, method)
  }

  const rows: PaymentRow[] = []
  for (const order of db.orders.all()) {
    const status = statusOf(order)
    if (!status) continue
    const customer = customers.get(order.customerId)
    const card = order.paymentMethod === "card" ? cards.get(order.customerId) : undefined
    rows.push({
      id: order.id,
      reference: order.number,
      customer: {
        // A sale the till rang up for nobody in particular has no customer.
        name: customer?.name ?? (order.customerId ? "Unknown customer" : "Walk-in"),
        company: customer?.company ?? "—",
        avatarUrl: customer?.avatarUrl,
      },
      method: order.paymentMethod,
      methodDetail: card ? `${BRANDS[card.brand] ?? card.brand} •••• ${card.last4}` : METHOD_NAMES[order.paymentMethod],
      amount: order.totalCents / 100,
      status,
      at: status === "refunded" ? (order.fulfilledAt ?? order.placedAt) : order.placedAt,
    })
  }
  return rows.sort((a, b) => b.at.getTime() - a.at.getTime())
}

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

/**
 * This month so far, and the same stretch of the month before: September 1
 * to "now", against August 1 to the same day of August — a month to date
 * compared with a whole month would always look like a collapse.
 */
function windows() {
  const start = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth(), 1)
  const elapsed = REFERENCE_DATE.getTime() - start
  const previous = Date.UTC(REFERENCE_DATE.getUTCFullYear(), REFERENCE_DATE.getUTCMonth() - 1, 1)
  const day = REFERENCE_DATE.getUTCDate()
  const range = `${SHORT.format(new Date(previous))} 1${day > 1 ? `–${day}` : ""}`
  return {
    now: (at: Date) => at.getTime() >= start && at.getTime() <= REFERENCE_DATE.getTime(),
    before: (at: Date) => at.getTime() >= previous && at.getTime() <= previous + elapsed,
    range,
  }
}

export type PaymentStat = {
  key: PaymentStatus
  label: string
  value: string
  description: string
  /** Against the same days of the month before; absent when that stretch had none, since a rise from nothing is no percentage. */
  delta?: number
  /** False where a rise is bad news — refunds, failures. */
  positiveIsGood: boolean
}

/** A change as a ratio, or nothing when there is no earlier figure to divide by. */
const change = (now: number, before: number) => (before === 0 ? (now === 0 ? 0 : undefined) : now / before - 1)

const count = (n: number, noun: string) => `${formatNumber(n)} ${noun}${n === 1 ? "" : "s"}`

/** "vs Aug 1–4" beside a pill; "none Aug 1–4" where the earlier stretch was empty and there is no pill. */
const against = (before: number, range: string) => (before === 0 ? `none ${range}` : `vs ${range}`)

/** Captured, refunded and failed this month so far, each against the same days of the month before. */
export function paymentStats(): PaymentStat[] {
  const rows = paymentRows()
  const { now, before, range } = windows()
  const pick = (status: PaymentStatus, inWindow: (at: Date) => boolean) =>
    rows.filter((row) => row.status === status && inWindow(row.at))
  const sum = (list: PaymentRow[]) => list.reduce((total, row) => total + row.amount, 0)

  const captured = pick("captured", now)
  const refunded = pick("refunded", now)
  const failed = pick("failed", now)
  const was = { captured: pick("captured", before), refunded: pick("refunded", before), failed: pick("failed", before) }

  return [
    {
      key: "captured",
      label: "Captured",
      value: formatCurrency(sum(captured)),
      description: `${count(captured.length, "payment")} · ${against(was.captured.length, range)}`,
      delta: change(sum(captured), sum(was.captured)),
      positiveIsGood: true,
    },
    {
      key: "refunded",
      label: "Refunded",
      value: formatCurrency(sum(refunded)),
      description: `${count(refunded.length, "refund")} · ${against(was.refunded.length, range)}`,
      delta: change(sum(refunded), sum(was.refunded)),
      positiveIsGood: false,
    },
    {
      key: "failed",
      label: "Failed",
      value: formatNumber(failed.length),
      description: `${formatCurrency(sum(failed))} not taken · ${against(was.failed.length, range)}`,
      delta: change(failed.length, was.failed.length),
      positiveIsGood: false,
    },
  ]
}

const STATUS_WORDS: Record<PaymentStatus, string> = { captured: "Captured", refunded: "Refunded", failed: "Failed" }

/**
 * Every payment as CSV, newest first. A refund's amount is negative — money
 * that left; a failed payment keeps the amount it asked for, and its status
 * says it was never taken.
 */
export function paymentsCsv(): { csv: string; count: number } {
  const rows = paymentRows()
  const csv = rowsToCsv(
    rows.map((row) => ({
      reference: row.reference,
      customer: row.customer.name,
      company: row.customer.company,
      method: row.methodDetail,
      amount: (row.status === "refunded" ? -row.amount : row.amount).toFixed(2),
      status: STATUS_WORDS[row.status],
      date: row.at.toISOString().slice(0, 10),
    })),
    [
      { key: "reference", label: "Reference" },
      { key: "customer", label: "Customer" },
      { key: "company", label: "Company" },
      { key: "method", label: "Method" },
      { key: "amount", label: "Amount (USD)" },
      { key: "status", label: "Status" },
      { key: "date", label: "Date" },
    ]
  )
  return { csv, count: rows.length }
}

/** The freshness line under the title, measured against REFERENCE_DATE. */
export function lastUpdated(): string {
  return `Synced ${formatDate(REFERENCE_DATE, "medium", { timeZone: "UTC" })}`
}

function ownerRow(): Member {
  return db.members.all().find((member) => member.role === "owner") ?? db.members.all()[0]
}

/** The person looking at the page: whoever owns this workspace. */
export function currentUser() {
  const owner = ownerRow()
  return { name: owner.name, email: owner.email, initials: getInitials(owner.name), avatarUrl: owner.avatarUrl }
}

/** The bell's contents: the newest notifications, unread first in the panel. */
export function shellNotifications() {
  return db.notifications
    .all()
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 6)
    .map(({ id, title, description, at, read, href }) => ({ id, title, description, at, read, href }))
}
app/finance/payments/actions.ts
"use server"

import { mockAuthAdapter } from "@/lib/auth-adapter"
import { type Result } from "@/lib/sample-data"

import { paymentsCsv } from "./data"

/**
 * What the payments page asks the server for. The export is written here, from
 * the rows the store holds when it is asked — not from whatever page of the
 * table the browser happens to have — and handed back as text for the island
 * to save.
 */

export async function signOut(): Promise<Result<{ signedOut: true }>> {
  await mockAuthAdapter.signOut()
  return { ok: true, data: { signedOut: true } }
}

/** Every payment as CSV, and how many rows it holds. */
export async function exportPayments(): Promise<Result<{ csv: string; count: number }>> {
  const { csv, count } = paymentsCsv()
  if (count === 0) return { ok: false, error: { code: "empty", message: "There are no payments to export yet." } }
  return { ok: true, data: { csv, count } }
}
app/finance/payments/components/payment-columns.tsx
"use client"

import { cn } from "@/lib/utils"
import { formatCurrency, formatDate } from "@/lib/format"
import { DataTableColumnHeader, type DataTableColumnDef } from "@/components/ui/data-table"
import { StatusBadge } from "@/components/ui/status-badge"
import { UserCell } from "@/components/ui/user-cell"

import { type PaymentRow } from "../data"
import { STATUS_LABELS, STATUS_MAP } from "./payments-vocabulary"

/**
 * Columns step in as the table's own frame allows — a container query, not
 * the viewport, because at 768 and 1024 the sidebar leaves the table less
 * room than the window suggests. Below 30rem: the customer, with the
 * reference under the name, the amount and the status; then the date, the
 * reference in its own column, and the method.
 */
const FROM = {
  date: "hidden @min-[30rem]/payments:table-cell",
  reference: "hidden @min-[37rem]/payments:table-cell",
  method: "hidden @min-[47rem]/payments:table-cell",
} as const

/**
 * The payments table's columns. The reference is the order's number, in the
 * mono face every id takes. A refund is signed — it is money that left — and
 * a failed payment's amount is muted, because it was never taken.
 */
export const PAYMENT_COLUMNS: DataTableColumnDef<PaymentRow>[] = [
  {
    id: "reference",
    accessorFn: (row) => `${row.reference} ${row.customer.name} ${row.customer.company}`,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Reference" />,
    cell: ({ row }) => <span className="font-mono text-xs">{row.original.reference}</span>,
    meta: { label: "Reference", className: FROM.reference },
  },
  {
    id: "customer",
    accessorFn: (row) => row.customer.name,
    header: ({ column }) => <DataTableColumnHeader column={column} title="Customer" />,
    cell: ({ row }) => (
      <UserCell
        size="sm"
        name={row.original.customer.name}
        description={
          // While the Reference column is away, the reference rides under the name instead of the company.
          <>
            <span className="font-mono @min-[37rem]/payments:hidden">{row.original.reference}</span>
            <span className="hidden @min-[37rem]/payments:inline">{row.original.customer.company}</span>
          </>
        }
        src={row.original.customer.avatarUrl}
      />
    ),
    meta: { label: "Customer" },
  },
  {
    accessorKey: "method",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Method" />,
    cell: ({ row }) => <span className="whitespace-nowrap text-muted-foreground">{row.original.methodDetail}</span>,
    meta: { label: "Method", className: FROM.method },
  },
  {
    accessorKey: "amount",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Amount" />,
    cell: ({ row }) => {
      const { amount, status } = row.original
      return (
        <span className={cn("block text-right tabular-nums", status === "failed" && "text-muted-foreground")}>
          {status === "refunded" ? `−${formatCurrency(amount)}` : formatCurrency(amount)}
        </span>
      )
    },
    meta: { align: "right", label: "Amount" },
  },
  {
    accessorKey: "status",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Status" />,
    cell: ({ row }) => (
      <StatusBadge status={row.original.status} map={STATUS_MAP} label={STATUS_LABELS[row.original.status]} />
    ),
    meta: { label: "Status" },
  },
  {
    accessorKey: "at",
    header: ({ column }) => <DataTableColumnHeader column={column} title="Date" />,
    cell: ({ row }) => (
      <time
        dateTime={new Date(row.original.at).toISOString()}
        className="block text-right whitespace-nowrap text-muted-foreground tabular-nums"
      >
        {formatDate(row.original.at, "medium", { timeZone: "UTC" })}
      </time>
    ),
    meta: { align: "right", label: "Date", className: FROM.date },
  },
]
app/finance/payments/components/payments-header.tsx
"use client"

import * as React from "react"
import { DownloadIcon } from "lucide-react"

import { cn } from "@/lib/utils"
import { downloadText, exportFilename } from "@/lib/export"
import { formatNumber } from "@/lib/format"
import { AsyncButton } from "@/components/ui/async-button"
import { Callout } from "@/components/ui/callout"
import { PageHeader } from "@/components/ui/page-header"

import { exportPayments } from "../actions"

export type PaymentsHeaderProps = {
  title: string
  /** The h1's id; the payments table is named by it. */
  titleId?: string
  description: string
  meta: string
  /** The day the file is named for — "now", so the name and the numbers in it agree. */
  asOf: Date
}

/**
 * The page's title and its one action. The export is written by the server
 * from every payment the store holds, then saved here through the export
 * lib; what happened lands right under the button — a refusal in an alert,
 * a success in the status line — never in the same slot.
 */
export function PaymentsHeader({ title, titleId, description, meta, asOf }: PaymentsHeaderProps) {
  const [notice, setNotice] = React.useState("")
  const [refusal, setRefusal] = React.useState<string | null>(null)

  async function run() {
    setNotice("")
    setRefusal(null)
    const result = await exportPayments()
    if (!result.ok) return setRefusal(result.error.message)
    const saved = downloadText(exportFilename("payments", "csv", asOf), result.data.csv)
    if (!saved) return setRefusal("This browser cannot save the file.")
    setNotice(`Exported ${formatNumber(result.data.count)} payments.`)
  }

  return (
    <div className="flex flex-col gap-3">
      <PageHeader
        title={title}
        titleId={titleId}
        description={description}
        meta={meta}
        actions={
          <AsyncButton variant="outline" onClick={run}>
            <DownloadIcon data-icon="inline-start" aria-hidden="true" />
            Export CSV
          </AsyncButton>
        }
      />
      {refusal ? (
        <Callout variant="danger" role="alert" title="Not exported">
          {refusal}
        </Callout>
      ) : null}
      <p
        data-slot="payments-status"
        role="status"
        aria-live="polite"
        className={cn("text-sm text-muted-foreground", !notice && "sr-only")}
      >
        {notice}
      </p>
    </div>
  )
}
app/finance/payments/components/payments-stats.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type PaymentStat } from "../data"

/**
 * The month so far in three figures — what was taken, what went back, what
 * failed — each against the same days of the month before. Refunds and
 * failures are figures where a rise is bad news, and their pills say so.
 */
export function PaymentsStats({ stats }: { stats: PaymentStat[] }) {
  return (
    <StatCardGroup data-widget="widget-finance-payments-payments-stats" columns={3}>
      {stats.map((stat) => (
        <StatCard
          key={stat.key}
          label={stat.label}
          value={stat.value}
          delta={stat.delta}
          positiveIsGood={stat.positiveIsGood}
          description={stat.description}
        />
      ))}
    </StatCardGroup>
  )
}
app/finance/payments/components/payments-table.tsx
"use client"

import { DataTable, type DataTableFacet } from "@/components/ui/data-table"

import { type PaymentRow } from "../data"
import { PAYMENT_COLUMNS } from "./payment-columns"
import { METHOD_LABELS, STATUS_LABELS } from "./payments-vocabulary"

const FACETS: DataTableFacet<PaymentRow>[] = [
  {
    columnId: "method",
    title: "Method",
    options: Object.entries(METHOD_LABELS).map(([value, label]) => ({ label, value })),
  },
  {
    columnId: "status",
    title: "Status",
    options: Object.entries(STATUS_LABELS).map(([value, label]) => ({ label, value })),
  },
]

/**
 * Every payment, newest first, in the browser: a few hundred rows are one
 * page of JSON, so search, the facets and paging need no second trip. The
 * search box reads the reference, the customer and the company together.
 */
export function PaymentsTable({ titleId, rows }: { titleId: string; rows: PaymentRow[] }) {
  return (
    // The container the columns measure themselves against: see payment-columns.
    <div className="@container/payments">
      <DataTable
        aria-labelledby={titleId}
        columns={PAYMENT_COLUMNS}
        data={rows}
        facets={FACETS}
        searchKey="reference"
        searchPlaceholder="Search payments"
        getRowId={(row) => row.id}
        enableRowSelection={false}
        emptyMessage="No payments match these filters."
        pageSizeOptions={[10, 25, 50]}
      />
    </div>
  )
}
app/finance/payments/components/payments-vocabulary.ts
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words this page puts on a payment. Vocabulary, not data: it lives beside
 * the islands that print it, because `data.ts` reads `db` and must never reach
 * the browser.
 */

export const METHOD_LABELS: Record<string, string> = {
  card: "Card",
  wallet: "Wallet",
  bank: "Bank transfer",
  cash: "Cash",
  voucher: "Voucher",
}

export const STATUS_LABELS: Record<string, string> = { captured: "Captured", refunded: "Refunded", failed: "Failed" }

export const STATUS_MAP: Record<string, StatusVariant> = { captured: "success", refunded: "warning", failed: "danger" }