Payments figures
The month so far in three figures — what was captured, what was refunded and what failed — each against the same days of the month before; reads paymentStats().
Preview
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>
)
}Install
$
npx shadcn@latest add @vibra/widget-finance-payments-payments-statsNeeds the @vibra registry in your components.json — set it up once.
Source
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>
)
}/**
* 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 }))
}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 Payments page