/finance/paymentsPayments
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.
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
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>
)
}Install
npx shadcn@latest add @vibra/finance-paymentsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}"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 } }
}"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 },
},
]"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>
)
}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>
)
}"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>
)
}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" }