{
  "$schema": "https://ui.shadcn.com/schema/registry-item.json",
  "author": "Lopifi (https://lopifi.com)",
  "categories": [
    "receivables-ar",
    "finance",
    "accounting"
  ],
  "dependencies": [],
  "description": "The highest-value screen in AR. Every case, not just the happy one.",
  "files": [
    {
      "content": "\"use client\";\n\nimport * as React from \"react\";\nimport { useReducedMotion } from \"@/hooks/ledger/use-media-query\";\nimport { cn } from \"@/lib/ledger/cn\";\nimport { formatDate } from \"@/lib/ledger/fiscal-period\";\nimport type { MoneyValue } from \"@/lib/ledger/money.types\";\nimport { Button } from \"@/components/ledger/button\";\nimport { CheckIcon, ExclamationTriangleIcon } from \"@/components/ledger/icons\";\nimport { Money } from \"@/components/ledger/money\";\n\nexport type UnappliedPayment = {\n  id: string;\n  customer: string;\n  receivedAt: Date;\n  amount: MoneyValue;\n  reference: string;\n};\n\nexport type OpenInvoice = {\n  id: string;\n  number: string;\n  dueDate: Date;\n  balance: MoneyValue;\n};\n\nexport type Disposition =\n  | { kind: \"write-off\"; reason: string; account: string }\n  | { kind: \"leave-unapplied\" }\n  | { kind: \"dispute\"; reason: string };\n\nexport interface CashApplicationProps {\n  className?: string;\n  invoices: OpenInvoice[];\n  onApply?: (invoiceIds: string[], disposition: Disposition | null) => void;\n  payment: UnappliedPayment;\n  /** Write-offs above this need approval. */\n  writeOffThreshold: MoneyValue;\n}\n\nconst WRITE_OFF_REASONS = [\n  \"Bank fee deducted in transit\",\n  \"FX settlement difference\",\n  \"Short pay — disputed line\",\n  \"Early-payment discount taken\",\n  \"Immaterial residual\",\n];\n\n/**\n * The highest-value screen in AR, and the one most tools handle worst.\n *\n * Every case is a first-class path, not just the happy one: exact, partial,\n * overpayment, lump-sum-to-many, short pay, bank fee in transit, FX\n * settlement difference.\n *\n * Unapplied cash is never revenue. A remaining difference\n * always requires a disposition.\n */\nexport function CashApplication({\n  payment,\n  invoices,\n  writeOffThreshold,\n  onApply,\n  className,\n}: CashApplicationProps) {\n  const [selected, setSelected] = React.useState<string[]>([]);\n  const [dispositionKind, setDispositionKind] = React.useState<Disposition[\"kind\"] | \"\">(\"\");\n  const [reason, setReason] = React.useState(WRITE_OFF_REASONS[0]);\n  const [matching, setMatching] = React.useState(false);\n  const reduced = useReducedMotion();\n\n  const currency = payment.amount.currency;\n  const applied = invoices\n    .filter((i) => selected.includes(i.id))\n    .reduce((s, i) => s + Number(i.balance.amount), 0);\n  const paymentAmount = Number(payment.amount.amount);\n  const remaining = paymentAmount - applied;\n\n  const exact = remaining === 0;\n  const overpayment = remaining > 0 && selected.length > 0;\n  const shortfall = remaining < 0;\n  const needsDisposition = !exact && selected.length > 0;\n  const exceedsThreshold =\n    Math.abs(remaining) > Number(writeOffThreshold.amount) && dispositionKind === \"write-off\";\n\n  const caseLabel = exact\n    ? \"Exact application\"\n    : shortfall\n      ? \"Payment does not cover the selected invoices\"\n      : selected.length > 1\n        ? \"Lump sum applied to several invoices, with a residual\"\n        : selected.length === 1\n          ? \"Partial payment or short pay\"\n          : \"No invoices selected\";\n\n  const apply = () => {\n    if (reduced) {\n      onApply?.(selected, buildDisposition());\n      return;\n    }\n    setMatching(true);\n    setTimeout(() => {\n      setMatching(false);\n      onApply?.(selected, buildDisposition());\n    }, 520);\n  };\n\n  const buildDisposition = (): Disposition | null => {\n    if (exact) return null;\n    if (dispositionKind === \"write-off\")\n      return { account: \"6900 · Bad debt expense\", kind: \"write-off\", reason };\n    if (dispositionKind === \"dispute\") return { kind: \"dispute\", reason };\n    if (dispositionKind === \"leave-unapplied\") return { kind: \"leave-unapplied\" };\n    return null;\n  };\n\n  return (\n    <div className={cn(\"space-y-4\", className)}>\n      <div\n        className={cn(\n          \"sticky top-0 z-sticky flex flex-wrap items-center justify-between gap-4 rounded-md border px-4 py-3\",\n          exact ? \"border-positive bg-positive-subtle\" : \"border-border bg-surface\",\n        )}\n      >\n        <span className=\"flex flex-wrap items-baseline gap-3\">\n          <span className=\"text-caption text-text-secondary\">\n            Payment <Money size=\"xs\" tone=\"inherit\" value={payment.amount} />\n          </span>\n          <span className=\"text-caption text-text-secondary\">\n            Applied <Money size=\"xs\" tone=\"inherit\" value={{ amount: applied, currency }} />\n          </span>\n        </span>\n        <span className=\"flex items-baseline gap-2\">\n          <span className=\"text-label font-medium\">Remaining</span>\n          <Money\n            accounting\n            className={exact ? \"text-positive\" : \"text-negative\"}\n            size=\"md\"\n            value={{ amount: remaining, currency }}\n          />\n        </span>\n      </div>\n\n      <p className=\"text-caption text-text-secondary\">{caseLabel}</p>\n\n      <div className=\"grid gap-4 lg:grid-cols-2\">\n        <section className=\"overflow-hidden rounded-xl border border-border bg-surface\">\n          <h3 className=\"border-b border-border-subtle px-4 py-3 text-label font-medium text-text-secondary\">\n            Unapplied payment\n          </h3>\n          <div className=\"px-4 py-3\">\n            <p className=\"text-body font-medium\">{payment.customer}</p>\n            <p className=\"text-caption text-text-secondary\">\n              Received {formatDate(payment.receivedAt)} · ref{\" \"}\n              <span className=\"reference-numeric\">{payment.reference}</span>\n            </p>\n            <Money className=\"mt-2\" size=\"lg\" value={payment.amount} />\n            <p className=\"mt-2 rounded-sm bg-surface-sunken px-2 py-1 text-caption text-text-secondary\">\n              Held as a liability until applied. It does not reduce accounts receivable.\n            </p>\n          </div>\n        </section>\n\n        <section className=\"overflow-hidden rounded-xl border border-border bg-surface\">\n          <h3 className=\"border-b border-border-subtle px-4 py-3 text-label font-medium text-text-secondary\">\n            Open invoices — {payment.customer}\n          </h3>\n          <ul>\n            {invoices.map((i, idx) => (\n              <li\n                className={cn(\n                  \"flex items-center gap-3 border-b border-border-subtle px-4 py-3 last:border-b-0\",\n                  selected.includes(i.id) && \"bg-surface-selected\",\n                )}\n                key={i.id}\n                style={\n                  matching && selected.includes(i.id)\n                    ? {\n                        animation: `ledger-match 320ms var(--ease-out) ${Math.min(idx * 40, 240)}ms both`,\n                      }\n                    : undefined\n                }\n              >\n                <input\n                  aria-label={`Apply to invoice ${i.number}`}\n                  checked={selected.includes(i.id)}\n                  className=\"size-4 accent-[var(--accent)]\"\n                  onChange={() =>\n                    setSelected((s) => (s.includes(i.id) ? s.filter((x) => x !== i.id) : [...s, i.id]))\n                  }\n                  type=\"checkbox\"\n                />\n                <span className=\"min-w-0 flex-1\">\n                  <span className=\"reference-numeric block text-body\">{i.number}</span>\n                  <span className=\"block text-caption text-text-secondary\">\n                    Due <span className=\"numeric\">{formatDate(i.dueDate)}</span>\n                  </span>\n                </span>\n                <Money accounting value={i.balance} />\n              </li>\n            ))}\n          </ul>\n        </section>\n      </div>\n\n      {needsDisposition ? (\n        <div className=\"rounded-md border border-[var(--warning)] bg-warning-subtle p-4\">\n          <p className=\"flex items-center gap-2 text-body\">\n            <ExclamationTriangleIcon aria-hidden=\"true\" className=\"size-4 shrink-0 text-warning\" />A\n            difference of <Money accounting tone=\"inherit\" value={{ amount: remaining, currency }} /> remains.\n            It needs a disposition before this can be applied.\n          </p>\n          <div className=\"mt-3 flex flex-wrap items-center gap-3\">\n            <select\n              aria-label=\"Disposition\"\n              className=\"h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n              onChange={(e) => setDispositionKind(e.target.value as Disposition[\"kind\"] | \"\")}\n              value={dispositionKind}\n            >\n              <option value=\"\">Choose a disposition…</option>\n              <option value=\"write-off\">Write off</option>\n              <option value=\"leave-unapplied\">Leave unapplied</option>\n              <option value=\"dispute\">Raise a dispute</option>\n            </select>\n            {dispositionKind === \"write-off\" || dispositionKind === \"dispute\" ? (\n              <select\n                aria-label=\"Reason\"\n                className=\"h-8 rounded-sm border border-border bg-surface px-2 text-label outline-none focus-visible:border-accent focus-visible:ring-2 focus-visible:ring-accent-subtle\"\n                onChange={(e) => setReason(e.target.value)}\n                value={reason}\n              >\n                {WRITE_OFF_REASONS.map((r) => (\n                  <option key={r}>{r}</option>\n                ))}\n              </select>\n            ) : null}\n          </div>\n          {exceedsThreshold ? (\n            <p className=\"mt-2 text-caption text-negative\">\n              This write-off exceeds the <Money size=\"xs\" tone=\"inherit\" value={writeOffThreshold} />{\" \"}\n              threshold and needs approval.\n            </p>\n          ) : null}\n          {overpayment && dispositionKind === \"leave-unapplied\" ? (\n            <p className=\"mt-2 text-caption text-text-secondary\">\n              The residual stays as unapplied cash — a liability, not a reduction of AR.\n            </p>\n          ) : null}\n        </div>\n      ) : null}\n\n      <div className=\"flex flex-wrap items-center gap-3\">\n        <Button\n          disabled={selected.length === 0 || (needsDisposition && !dispositionKind)}\n          onClick={apply}\n          variant=\"primary\"\n        >\n          {exact ? (\n            <>\n              <CheckIcon aria-hidden=\"true\" className=\"size-4\" />\n              Apply exactly\n            </>\n          ) : (\n            `Apply to ${selected.length} invoice${selected.length === 1 ? \"\" : \"s\"}`\n          )}\n        </Button>\n        <p className=\"text-caption text-text-tertiary\">Applying is reversible and audited.</p>\n      </div>\n    </div>\n  );\n}\n",
      "path": "components/ledger/cash-application.tsx",
      "target": "components/ledger/cash-application.tsx",
      "type": "registry:component"
    }
  ],
  "name": "cash-application",
  "registryDependencies": [
    "@lopifi/theme",
    "@lopifi/button",
    "@lopifi/cn",
    "@lopifi/fiscal-period",
    "@lopifi/icons",
    "@lopifi/money",
    "@lopifi/money.types",
    "@lopifi/use-media-query"
  ],
  "title": "CashApplication",
  "type": "registry:component"
}
