Skip to contentVibraUI

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

Install

npx shadcn@latest add @vibra/widget-finance-payments-payments-stats

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

Source

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/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 }))
}

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