Skip to contentVibraUI

Balances by currency

A card per currency the store holds — the balance, what of it is available and what is pending, and its move on thirty days ago; reads balanceCards().

Preview

Install

npx shadcn@latest add @vibra/widget-finance-balances-balance-cards

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

Source

app/finance/balances/components/balance-cards.tsx
import { StatCard } from "@/components/ui/stat-card"
import { StatCardGroup } from "@/components/ui/stat-card-group"

import { type BalanceCard } from "../data"
import { CURRENCY_NAMES, money, signed } from "./balances-vocabulary"

/**
 * One card per currency the store holds: the balance, what of it is available
 * and what is still pending under it, and a pill against where it stood
 * thirty days ago. The currency's code is set in the mono face, as every code.
 */
export function BalanceCards({ cards }: { cards: BalanceCard[] }) {
  return (
    <StatCardGroup data-widget="widget-finance-balances-balance-cards" columns={4}>
      {cards.map((card) => (
        <StatCard
          key={card.currency}
          data-currency={card.currency}
          label={
            <span className="flex items-center gap-2">
              <span className="font-mono text-xs text-muted-foreground">{card.currency}</span>
              {CURRENCY_NAMES[card.currency]}
            </span>
          }
          value={money(card.balance, card.currency)}
          delta={card.delta}
          description={`${money(card.available, card.currency)} available · ${signed(card.pending, card.currency)} pending`}
        />
      ))}
    </StatCardGroup>
  )
}
app/finance/balances/data.ts
/**
 * What the balances page reads. A balance is never typed in: it is the
 * ledger's opening entry for the currency plus every completed row of
 * `db.transactions` in it. Pending rows have not settled, so they are not in
 * the balance — they are said beside it — and what is available is the
 * balance less the money already on its way out. Rates are `db.fxRates`; a
 * conversion between two currencies goes through the dollar, so the page's
 * figures and its converter can never disagree. "Now" is `REFERENCE_DATE`.
 */
import { formatCurrency, formatDate, getInitials } from "@/lib/format"
import {
  db,
  REFERENCE_DATE,
  type Currency,
  type FxPair,
  type FxPoint,
  type Member,
  type Transaction,
} from "@/lib/sample-data"

const DAY_MS = 86_400_000

/**
 * The ledger's opening entry per currency, in minor units — cents, pence and
 * whole yen — as it stood before the first row `db.transactions` holds. The
 * one figure on the page that is not a row: every balance is this plus what
 * has completed since.
 */
export const OPENING_BALANCES: Record<Currency, number> = {
  USD: 14_260_000,
  EUR: 5_840_000,
  GBP: 3_675_000,
  JPY: 6_400_000,
}

/** The currencies in the order the cards stand. */
const CURRENCIES: Currency[] = ["USD", "EUR", "GBP", "JPY"]

/** Minor units in a major one: a hundred cents, or one yen. */
export const MINOR: Record<Currency, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1 }

export type BalanceCard = {
  currency: Currency
  /** Opening plus every completed row, in minor units. */
  balance: number
  /** The balance less the payouts, refunds and fees already pending — what can still be spent. */
  available: number
  /** Every pending row, signed: money that has not landed yet, or has not left yet. */
  pending: number
  /** The change on thirty days ago, as a ratio; absent when there was nothing then to measure against. */
  delta?: number
}

const completed = (row: Transaction) => row.status === "completed"
const sum = (rows: Transaction[]) => rows.reduce((total, row) => total + row.amountCents, 0)

/** One card per currency, each computed from the ledger. */
export function balanceCards(): BalanceCard[] {
  const ledger = db.transactions.all()
  const monthAgo = REFERENCE_DATE.getTime() - 30 * DAY_MS
  return CURRENCIES.map((currency) => {
    const rows = ledger.filter((row) => row.currency === currency)
    const balance = OPENING_BALANCES[currency] + sum(rows.filter(completed))
    const then = OPENING_BALANCES[currency] + sum(rows.filter((row) => completed(row) && row.at.getTime() <= monthAgo))
    const pending = rows.filter((row) => row.status === "pending")
    return {
      currency,
      balance,
      available: balance + sum(pending.filter((row) => row.amountCents < 0)),
      pending: sum(pending),
      ...(then === 0 ? {} : { delta: balance / then - 1 }),
    }
  })
}

/** The last eight rows that settled or failed, newest first. */
export function latestTransactions(): Transaction[] {
  return db.transactions
    .all()
    .filter((row) => row.status === "completed" || row.status === "failed")
    .sort((a, b) => b.at.getTime() - a.at.getTime())
    .slice(0, 8)
}

/** A scheduled row still ahead, and how many days off it is. */
export type UpcomingRow = Transaction & { daysAway: number }

/** Every scheduled row after "now", soonest first. */
export function upcomingTransactions(): UpcomingRow[] {
  const now = REFERENCE_DATE.getTime()
  return db.transactions
    .all()
    .filter((row) => row.status === "scheduled" && row.at.getTime() > now)
    .sort((a, b) => a.at.getTime() - b.at.getTime())
    .map((row) => ({ ...row, daysAway: Math.ceil((row.at.getTime() - now) / DAY_MS) }))
}

/** Every pair's year of daily rates, for the chart. */
export function rateSeries(): Record<FxPair, FxPoint[]> {
  return db.fxRates
}

/** Dollars per unit of each currency the converter offers, from the latest rates. */
export function dollarRates(): Record<string, number> {
  const latest = (pair: FxPair) => db.fxRates[pair][db.fxRates[pair].length - 1].rate
  return {
    USD: 1,
    EUR: latest("EUR/USD"),
    GBP: latest("GBP/USD"),
    JPY: 1 / latest("USD/JPY"),
    CHF: 1 / latest("USD/CHF"),
  }
}

/** Every balance in dollars at the latest rates, for the line under the title. */
export function totalInDollars(): string {
  const rates = dollarRates()
  const total = balanceCards().reduce(
    (sum, card) => sum + (card.balance / MINOR[card.currency]) * rates[card.currency],
    0
  )
  return formatCurrency(total, "USD", { maximumFractionDigits: 0 })
}

/** 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/balances/components/balances-vocabulary.ts
import { ArrowLeftRightIcon, LandmarkIcon, ReceiptIcon, type LucideIcon } from "lucide-react"

import { formatCurrency } from "@/lib/format"
import { type StatusVariant } from "@/components/ui/status-badge"

/**
 * The words, marks and money formatting this page uses. Vocabulary, not
 * data: it lives beside the islands that print it, because `data.ts` reads
 * `db` and must never reach the browser.
 */

export const CURRENCY_NAMES: Record<string, string> = {
  USD: "US dollar",
  EUR: "Euro",
  GBP: "British pound",
  JPY: "Japanese yen",
  CHF: "Swiss franc",
}

/** Minor units in a major one: a hundred cents, or one yen — which has no minor unit. */
const MINOR: Record<string, number> = { USD: 100, EUR: 100, GBP: 100, JPY: 1, CHF: 100 }

/** An amount held in minor units, in its own currency: 1234567 USD cents → "$12,345.67"; 480000 yen → "¥480,000". */
export function money(minor: number, currency: string): string {
  return formatCurrency(minor / (MINOR[currency] ?? 100), currency)
}

/** A movement with its direction: "+$1.00" in, "−$1.00" out (a true minus), "$0.00" for none. */
export function signed(minor: number, currency: string): string {
  if (minor === 0) return money(0, currency)
  return `${minor < 0 ? "−" : "+"}${money(Math.abs(minor), currency)}`
}

/**
 * A rate as a desk quotes it: to four places, or to two when it is a price
 * in yen — 144.21 yen to the dollar — and to four figures when a single yen
 * is the unit, which two places would round away to nothing.
 */
export function quoteRate(rate: number, base: string, quote: string): string {
  if (quote === "JPY") return rate.toFixed(2)
  if (base === "JPY") return rate.toPrecision(4)
  return rate.toFixed(4)
}

export const KIND_LABELS: Record<string, string> = {
  charge: "Charge",
  payout: "Payout",
  refund: "Refund",
  transfer: "Transfer",
  fee: "Fee",
}

/** The glyph for a counterparty that is not a person: a bank for a payout, a receipt for a fee, arrows for a transfer. */
export const KIND_ICONS: Record<string, LucideIcon> = {
  payout: LandmarkIcon,
  fee: ReceiptIcon,
  transfer: ArrowLeftRightIcon,
}

export const STATUS_LABELS: Record<string, string> = {
  completed: "Completed",
  failed: "Failed",
  pending: "Pending",
  scheduled: "Scheduled",
}

export const STATUS_MAP: Record<string, StatusVariant> = {
  completed: "success",
  failed: "danger",
  pending: "warning",
  scheduled: "primary",
}

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 Balances page