diff --git a/components/invoices/InvoiceDetailView.tsx b/components/invoices/InvoiceDetailView.tsx
index 88d6496..9e340e7 100644
--- a/components/invoices/InvoiceDetailView.tsx
+++ b/components/invoices/InvoiceDetailView.tsx
@@ -10,6 +10,7 @@
*/
import Link from "next/link";
+import type { ReactNode } from "react";
import { FileQuestion, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card, CardContent, CardHeader } from "@/components/ui/card";
@@ -50,6 +51,7 @@ import { SyndicatePanel } from "@/components/invoices/SyndicatePanel";
import { InvoiceFraudReport, InvoiceUnderReviewBanner } from "@/components/invoices/InvoiceFraudReport";
import { InvoiceRiskAlert } from "@/components/invoices/InvoiceRiskAlert";
import { InvoiceBenchmarkCard } from "@/components/invoices/InvoiceBenchmarkCard";
+import { InvoiceNavPanel } from "@/components/invoices/InvoiceNavPanel";
import { truncateAddress } from "@/lib/stellar";
import { DEFAULT_MIN_INVESTMENT } from "@/lib/invoiceDefaults";
import { Money } from "@/components/currency";
@@ -111,6 +113,10 @@ export function InvoiceNotFound({ invoiceId }: { invoiceId: string }) {
/**
* The invest CTA. Hidden outright for wallets that have not passed KYC —
* showing a button that always fails is worse than not offering it.
+ *
+ * The NAV panel sits inside this card for every branch (#546): fair value
+ * against the market price is public information, so it is shown whether or
+ * not this particular wallet is allowed to invest.
*/
function InvestCallToAction({
invoiceId,
@@ -134,53 +140,37 @@ function InvestCallToAction({
enabled: isConnected,
});
+ let body: ReactNode;
+
if (!isConnected) {
- return (
-
-
-
- Connect a wallet to invest in this invoice.
-
-
-
+ body = (
+
+ Connect a wallet to invest in this invoice.
+
);
- }
-
- if (isLoading) {
- return (
-
-
-
-
-
+ } else if (isLoading) {
+ body = ;
+ } else if (!isApproved) {
+ body = (
+
+
+
+ Investing is available once your KYC is approved.
+
+
+ Current KYC status: {status ?? "unknown"}
+
+
+
);
- }
-
- if (!isApproved) {
- return (
-
-
-
-
- Investing is available once your KYC is approved.
-
-
- Current KYC status: {status ?? "unknown"}
-
-
-
-
- );
- }
-
- return (
-
-
+ } else {
+ body = (
+ <>
+ >
+ );
+ }
+
+ return (
+
+
+
+ {body}
);
@@ -438,7 +437,10 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {
/>
) : (
-
+
+ {/* The NAV comparison still applies once the primary market has
+ closed: funded invoices trade on the secondary market (#546). */}
+
{invoice.status === "settled"
? "This invoice has been settled and is no longer open for investment."
diff --git a/components/invoices/InvoiceNavPanel.tsx b/components/invoices/InvoiceNavPanel.tsx
new file mode 100644
index 0000000..fd6fad6
--- /dev/null
+++ b/components/invoices/InvoiceNavPanel.tsx
@@ -0,0 +1,64 @@
+"use client";
+
+import { useInvoiceNav } from "@/hooks/useInvoiceNav";
+import {
+ compareToNav,
+ formatNavPrice,
+ isNavStale,
+ premiumDiscountLabel,
+} from "@/lib/nav";
+
+interface InvoiceNavPanelProps {
+ invoiceId: string;
+ /**
+ * Primary-market price of one fraction, used when the NAV endpoint reports
+ * no market price of its own.
+ */
+ fractionPrice?: number;
+}
+
+/**
+ * NAV, the current price and the premium or discount between them, for the
+ * invest panel on the invoice detail page (#546).
+ *
+ * Renders nothing when the invoice has no NAV — a panel with an empty NAV row
+ * on every invoice the backend has not priced yet would be noise.
+ */
+export function InvoiceNavPanel({ invoiceId, fractionPrice }: InvoiceNavPanelProps) {
+ const { data } = useInvoiceNav(invoiceId);
+
+ const nav = data?.nav_per_fraction ?? null;
+ const price = data?.current_price ?? fractionPrice ?? null;
+ const comparison = compareToNav(nav, price);
+ if (!comparison) return null;
+
+ const stale = isNavStale(data?.computed_at);
+
+ return (
+
+
+ NAV per fraction
+
+ {formatNavPrice(nav)} XLM
+
+
+
+ Current price
+
+ {formatNavPrice(price)} XLM
+
+
+
+ vs NAV
+
+ {premiumDiscountLabel(comparison.premiumBps)}
+
+
+ {stale && (
+
+ NAV was computed over an hour ago and may be out of date.
+
+ )}
+
+ );
+}
diff --git a/components/invoices/__tests__/InvoiceNavPanel.test.tsx b/components/invoices/__tests__/InvoiceNavPanel.test.tsx
new file mode 100644
index 0000000..52bccdf
--- /dev/null
+++ b/components/invoices/__tests__/InvoiceNavPanel.test.tsx
@@ -0,0 +1,81 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { InvoiceNavPanel } from "../InvoiceNavPanel";
+import * as api from "@/lib/api";
+
+function navPayload(overrides: Partial = {}): api.InvoiceNav {
+ return {
+ invoice_id: "inv-1",
+ nav_per_fraction: 1,
+ current_price: 1,
+ computed_at: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+function renderPanel(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render({ui});
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("InvoiceNavPanel (#546)", () => {
+ it("shows the NAV, the current price and the premium in basis points", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1.0234, current_price: 0.98 })
+ );
+ renderPanel();
+
+ expect(await screen.findByTestId("invoice-nav-panel")).toBeInTheDocument();
+ expect(screen.getByTestId("nav-panel-nav")).toHaveTextContent("1.0234 XLM");
+ expect(screen.getByTestId("nav-panel-price")).toHaveTextContent("0.98 XLM");
+ expect(screen.getByTestId("nav-panel-bps")).toHaveTextContent("-424 bps discount");
+ });
+
+ it("phrases a price above the NAV as a premium", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: 1.05 })
+ );
+ renderPanel();
+
+ expect(await screen.findByTestId("nav-panel-bps")).toHaveTextContent("+500 bps premium");
+ });
+
+ it("warns when the NAV is stale", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({
+ nav_per_fraction: 1,
+ current_price: 0.9,
+ computed_at: new Date(Date.now() - 3 * 60 * 60 * 1000).toISOString(),
+ })
+ );
+ renderPanel();
+
+ expect(await screen.findByTestId("nav-panel-stale")).toBeInTheDocument();
+ });
+
+ it("renders nothing when the invoice has no NAV", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(null);
+ renderPanel();
+
+ await vi.waitFor(() => {
+ expect(api.fetchInvoiceNav).toHaveBeenCalled();
+ });
+ expect(screen.queryByTestId("invoice-nav-panel")).not.toBeInTheDocument();
+ });
+
+ it("falls back to the invoice price when the API reports no market price", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: null })
+ );
+ renderPanel();
+
+ expect(await screen.findByTestId("nav-panel-price")).toHaveTextContent("0.95 XLM");
+ });
+});
diff --git a/components/marketplace/NavChip.tsx b/components/marketplace/NavChip.tsx
new file mode 100644
index 0000000..b6c6e7c
--- /dev/null
+++ b/components/marketplace/NavChip.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import {
+ Tooltip,
+ TooltipContent,
+ TooltipProvider,
+ TooltipTrigger,
+} from "@/components/ui/tooltip";
+import { useInvoiceNav } from "@/hooks/useInvoiceNav";
+import {
+ compareToNav,
+ formatNavPrice,
+ isNavStale,
+ NAV_POSITION_LABELS,
+ premiumDiscountLabel,
+ type NavPosition,
+} from "@/lib/nav";
+
+const POSITION_STYLES: Record = {
+ above: "bg-amber-100 text-amber-800 dark:bg-amber-900 dark:text-amber-100",
+ at: "bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-200",
+ below: "bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-100",
+};
+
+interface NavChipProps {
+ invoiceId: string;
+ /**
+ * Primary-market price of one fraction, used when the NAV endpoint reports
+ * no market price of its own.
+ */
+ fractionPrice?: number;
+}
+
+/**
+ * Shows where an invoice's current price sits against its computed NAV
+ * (#546): a premium when the price is above NAV, a discount when it is below.
+ *
+ * Renders nothing when the invoice has no NAV, or no price to compare it
+ * with — an empty chip on an unpriced invoice would be noise.
+ */
+export function NavChip({ invoiceId, fractionPrice }: NavChipProps) {
+ const { data } = useInvoiceNav(invoiceId);
+
+ const nav = data?.nav_per_fraction ?? null;
+ const price = data?.current_price ?? fractionPrice ?? null;
+ const comparison = compareToNav(nav, price);
+ if (!comparison) return null;
+
+ const stale = isNavStale(data?.computed_at);
+
+ return (
+
+
+
+
+ {NAV_POSITION_LABELS[comparison.position]}
+
+
+
+
NAV per fraction: {formatNavPrice(nav)} XLM
+
Current price: {formatNavPrice(price)} XLM
+
vs NAV: {premiumDiscountLabel(comparison.premiumBps)}
+ {stale && (
+
+ NAV was computed over an hour ago
+
+ )}
+
+
+
+ );
+}
diff --git a/components/marketplace/__tests__/NavChip.test.tsx b/components/marketplace/__tests__/NavChip.test.tsx
new file mode 100644
index 0000000..50dc3f7
--- /dev/null
+++ b/components/marketplace/__tests__/NavChip.test.tsx
@@ -0,0 +1,136 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { render, screen } from "@testing-library/react";
+import userEvent from "@testing-library/user-event";
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { NavChip } from "../NavChip";
+import { InvoiceCard } from "../invoice-card";
+import { renderWithCardProviders } from "./render-with-card-providers";
+import * as api from "@/lib/api";
+import type { Invoice } from "@/lib/api";
+
+function navPayload(overrides: Partial = {}): api.InvoiceNav {
+ return {
+ invoice_id: "inv-1",
+ nav_per_fraction: 1,
+ current_price: 1,
+ computed_at: new Date().toISOString(),
+ ...overrides,
+ };
+}
+
+function renderChip(ui: React.ReactElement) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false } },
+ });
+ return render({ui});
+}
+
+beforeEach(() => {
+ vi.restoreAllMocks();
+});
+
+describe("NavChip (#546)", () => {
+ it("rates a price below the NAV as below, with the discount colour", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: 0.9 })
+ );
+ renderChip();
+
+ const chip = await screen.findByTestId("nav-chip");
+ expect(chip).toHaveTextContent("Below NAV");
+ expect(chip).toHaveAttribute("data-position", "below");
+ expect(chip).toHaveClass("bg-green-100");
+ });
+
+ it("rates a price above the NAV as above, with the premium colour", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: 1.1 })
+ );
+ renderChip();
+
+ const chip = await screen.findByTestId("nav-chip");
+ expect(chip).toHaveTextContent("Above NAV");
+ expect(chip).toHaveAttribute("data-position", "above");
+ expect(chip).toHaveClass("bg-amber-100");
+ });
+
+ it("rates a price level with the NAV as at NAV", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: 1 })
+ );
+ renderChip();
+
+ const chip = await screen.findByTestId("nav-chip");
+ expect(chip).toHaveTextContent("At NAV");
+ expect(chip).toHaveAttribute("data-position", "at");
+ });
+
+ it("shows the NAV, the price and the gap in the tooltip", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1.0234, current_price: 0.98 })
+ );
+ renderChip();
+
+ await userEvent.hover(await screen.findByTestId("nav-chip"));
+ const tooltip = await screen.findByRole("tooltip");
+ expect(tooltip).toHaveTextContent("NAV per fraction: 1.0234 XLM");
+ expect(tooltip).toHaveTextContent("Current price: 0.98 XLM");
+ expect(tooltip).toHaveTextContent("vs NAV: -424 bps discount");
+ });
+
+ it("falls back to the invoice price when the API reports no market price", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: null })
+ );
+ renderChip();
+
+ const chip = await screen.findByTestId("nav-chip");
+ expect(chip).toHaveAttribute("data-position", "below");
+ });
+
+ it("warns when the NAV is more than an hour old", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({
+ nav_per_fraction: 1,
+ current_price: 0.9,
+ computed_at: new Date(Date.now() - 2 * 60 * 60 * 1000).toISOString(),
+ })
+ );
+ renderChip();
+
+ await userEvent.hover(await screen.findByTestId("nav-chip"));
+ expect(await screen.findByTestId("nav-chip-stale")).toBeInTheDocument();
+ });
+
+ it("renders nothing when the invoice has no NAV", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(null);
+ renderChip();
+
+ await vi.waitFor(() => {
+ expect(api.fetchInvoiceNav).toHaveBeenCalled();
+ });
+ expect(screen.queryByTestId("nav-chip")).not.toBeInTheDocument();
+ });
+
+ it("appears on the grid InvoiceCard", async () => {
+ vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(
+ navPayload({ nav_per_fraction: 1, current_price: 0.9 })
+ );
+ const invoice: Invoice = {
+ id: "inv-1",
+ title: "Acme",
+ seller: "G",
+ amount: 1000,
+ raised: 0,
+ investor_count: 0,
+ status: "open",
+ due_date: new Date("2030-01-01").toISOString(),
+ fraction_price: 0.9,
+ has_more: false,
+ next_cursor: null,
+ };
+ renderWithCardProviders();
+
+ expect(await screen.findByTestId("nav-chip")).toHaveTextContent("Below NAV");
+ });
+});
diff --git a/components/marketplace/index.ts b/components/marketplace/index.ts
index a6db821..b7056cf 100644
--- a/components/marketplace/index.ts
+++ b/components/marketplace/index.ts
@@ -30,3 +30,4 @@ export { StateChangeBadge } from "./StateChangeBadge";
export { WatchlistButton } from "./WatchlistButton";
export { EmptyWatchlist } from "./EmptyWatchlist";
export { BenchmarkChip } from "./BenchmarkChip";
+export { NavChip } from "./NavChip";
diff --git a/components/marketplace/invoice-card.tsx b/components/marketplace/invoice-card.tsx
index 21ace6a..af17bb7 100644
--- a/components/marketplace/invoice-card.tsx
+++ b/components/marketplace/invoice-card.tsx
@@ -14,6 +14,7 @@ import { ShareInvoiceButton } from "@/components/invoices/ShareInvoiceButton";
import { StateChangeBadge } from "./StateChangeBadge";
import { WatchlistButton } from "./WatchlistButton";
import { BenchmarkChip } from "./BenchmarkChip";
+import { NavChip } from "./NavChip";
import { Lock } from "lucide-react";
import type { Invoice } from "@/lib/api";
import type { WatchlistStateChange } from "@/lib/watchlist";
@@ -96,6 +97,10 @@ export function InvoiceCard({
)}
+ {/* Fair value against the market price (#546). The chip hides itself
+ when the invoice has no NAV, so it is safe to always render. */}
+
+
({
+ queryKey: ["invoice", invoiceId, "nav"],
+ queryFn: () => fetchInvoiceNav(invoiceId),
+ enabled: enabled && Boolean(invoiceId),
+ staleTime: NAV_POLL_INTERVAL_MS,
+ refetchInterval: (query) => {
+ if (query.state.data === null) return false;
+ if (typeof document !== "undefined" && document.hidden) return false;
+ return NAV_POLL_INTERVAL_MS;
+ },
+ // Keep the last good value through each refetch, so the chip does not
+ // unmount and remount every minute.
+ placeholderData: (previous) => previous,
+ });
+}
diff --git a/lib/__tests__/nav.test.ts b/lib/__tests__/nav.test.ts
new file mode 100644
index 0000000..895bd29
--- /dev/null
+++ b/lib/__tests__/nav.test.ts
@@ -0,0 +1,101 @@
+import { describe, it, expect } from "vitest";
+import {
+ compareToNav,
+ formatNavPrice,
+ isNavStale,
+ NAV_PARITY_BPS,
+ NAV_STALE_AFTER_MS,
+ premiumDiscountLabel,
+} from "../nav";
+
+describe("compareToNav (#546)", () => {
+ it("rates a price above the NAV as above", () => {
+ const result = compareToNav(1, 1.1);
+ expect(result?.position).toBe("above");
+ expect(result?.premiumBps).toBe(1000);
+ });
+
+ it("rates a price below the NAV as below", () => {
+ const result = compareToNav(1, 0.9);
+ expect(result?.position).toBe("below");
+ expect(result?.premiumBps).toBe(-1000);
+ });
+
+ it("rates a price within the parity band as at NAV", () => {
+ expect(compareToNav(1, 1)?.position).toBe("at");
+ expect(compareToNav(1, 1 + NAV_PARITY_BPS / 10_000)?.position).toBe("at");
+ expect(compareToNav(1, 1 - NAV_PARITY_BPS / 10_000)?.position).toBe("at");
+ });
+
+ it("steps outside the parity band just past the threshold", () => {
+ expect(compareToNav(1, 1 + (NAV_PARITY_BPS + 1) / 10_000)?.position).toBe("above");
+ expect(compareToNav(1, 1 - (NAV_PARITY_BPS + 1) / 10_000)?.position).toBe("below");
+ });
+
+ it("expresses the gap relative to the NAV, not the price", () => {
+ // Price 2.20 against a NAV of 2.00 is a 10% premium either way round, but
+ // the basis points must be measured against the NAV.
+ expect(compareToNav(2, 2.2)?.premiumBps).toBe(1000);
+ expect(compareToNav(0.5, 0.55)?.premiumBps).toBe(1000);
+ });
+
+ it("returns null when either side is missing", () => {
+ expect(compareToNav(null, 1)).toBeNull();
+ expect(compareToNav(1, null)).toBeNull();
+ expect(compareToNav(undefined, undefined)).toBeNull();
+ });
+
+ it("returns null for a NAV that cannot be divided into", () => {
+ expect(compareToNav(0, 1)).toBeNull();
+ expect(compareToNav(-1, 1)).toBeNull();
+ expect(compareToNav(Number.NaN, 1)).toBeNull();
+ });
+});
+
+describe("premiumDiscountLabel", () => {
+ it("signs a premium", () => {
+ expect(premiumDiscountLabel(120)).toBe("+120 bps premium");
+ });
+
+ it("signs a discount", () => {
+ expect(premiumDiscountLabel(-120)).toBe("-120 bps discount");
+ });
+
+ it("reads a gap inside the parity band as zero", () => {
+ expect(premiumDiscountLabel(0)).toBe("0 bps");
+ });
+});
+
+describe("isNavStale (#546)", () => {
+ const now = new Date("2026-10-21T12:00:00.000Z").getTime();
+
+ it("flags a NAV computed more than an hour ago", () => {
+ const computedAt = new Date(now - NAV_STALE_AFTER_MS - 1000).toISOString();
+ expect(isNavStale(computedAt, now)).toBe(true);
+ });
+
+ it("does not flag a NAV computed within the hour", () => {
+ const computedAt = new Date(now - NAV_STALE_AFTER_MS + 60_000).toISOString();
+ expect(isNavStale(computedAt, now)).toBe(false);
+ });
+
+ it("does not flag a NAV with no timestamp, because there is nothing to compare", () => {
+ expect(isNavStale(null, now)).toBe(false);
+ expect(isNavStale(undefined, now)).toBe(false);
+ expect(isNavStale("not-a-date", now)).toBe(false);
+ });
+});
+
+describe("formatNavPrice", () => {
+ it("keeps up to four decimals and trims trailing zeros", () => {
+ expect(formatNavPrice(1.0234)).toBe("1.0234");
+ expect(formatNavPrice(1.5)).toBe("1.5");
+ expect(formatNavPrice(1)).toBe("1");
+ });
+
+ it("falls back to a dash when there is no price", () => {
+ expect(formatNavPrice(null)).toBe("—");
+ expect(formatNavPrice(undefined)).toBe("—");
+ expect(formatNavPrice(Number.NaN)).toBe("—");
+ });
+});
diff --git a/lib/api/index.ts b/lib/api/index.ts
index 039ceef..99d71aa 100644
--- a/lib/api/index.ts
+++ b/lib/api/index.ts
@@ -2435,6 +2435,36 @@ export async function fetchInvoiceBenchmark(invoiceId: string): Promise {
+ const res = await fetch(`${API_BASE}/invoices/${encodeURIComponent(invoiceId)}/nav`);
+ if (res.status === 404) return null;
+ if (!res.ok) throw new Error("Failed to fetch invoice NAV");
+ return normalizeInvoiceNav(await res.json(), invoiceId);
+}
+
export const INVOICE_REPORT_REASONS = [
"misrepresentation",
"false_documents",
@@ -4098,6 +4128,47 @@ function normalizeInvoiceDemandMetrics(raw: any, invoiceId: string): InvoiceDema
};
}
+/** Coerces an API value to a finite number, or null when it is not one. */
+function toFiniteNumberOrNull(value: unknown): number | null {
+ if (value === null || value === undefined || value === "") return null;
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+}
+
+/**
+ * Normalises the NAV payload (#546).
+ *
+ * The endpoint has shipped under a few field spellings, so each value is read
+ * from the names it could plausibly carry. A payload with neither a NAV nor a
+ * price is treated as "no NAV" so the UI stays hidden rather than rendering
+ * empty figures.
+ */
+function normalizeInvoiceNav(raw: any, invoiceId: string): InvoiceNav | null {
+ if (!raw || typeof raw !== "object") return null;
+
+ const nav = toFiniteNumberOrNull(
+ raw.nav_per_fraction ?? raw.navPerFraction ?? raw.nav ?? raw.fair_value ?? raw.fairValue
+ );
+ const price = toFiniteNumberOrNull(
+ raw.current_price ??
+ raw.currentPrice ??
+ raw.price ??
+ raw.fraction_price ??
+ raw.fractionPrice
+ );
+ if (nav === null && price === null) return null;
+
+ const computedAt =
+ raw.computed_at ?? raw.computedAt ?? raw.as_of ?? raw.asOf ?? raw.updated_at ?? raw.updatedAt;
+
+ return {
+ invoice_id: raw.invoice_id ?? raw.invoiceId ?? invoiceId,
+ nav_per_fraction: nav,
+ current_price: price,
+ computed_at: computedAt ? String(computedAt) : null,
+ };
+}
+
function normalizeInvoiceFundingProgress(raw: any, invoiceId: string): InvoiceFundingProgress {
const target = Number(raw.target ?? raw.amount ?? raw.face_value ?? 0);
const raised = Number(raw.raised ?? raw.funded ?? raw.raised_amount ?? 0);
diff --git a/lib/nav.ts b/lib/nav.ts
new file mode 100644
index 0000000..958d419
--- /dev/null
+++ b/lib/nav.ts
@@ -0,0 +1,105 @@
+/**
+ * NAV (net asset value) helpers (issue #546)
+ *
+ * NAV is the computed fair value of one fraction of an invoice. Comparing it
+ * with the price actually being asked tells an investor whether the market is
+ * paying a premium (the price sits above NAV) or a discount (the price sits
+ * below NAV).
+ *
+ * These helpers are deliberately free of React so the pricing rules — which
+ * decide what a user sees — can be tested directly.
+ */
+
+/** Where the current price sits relative to NAV. */
+export type NavPosition = "above" | "at" | "below";
+
+/** Prices within this many bps of NAV count as trading at NAV. */
+export const NAV_PARITY_BPS = 25;
+
+/** NAV computed longer ago than this is flagged as stale (#546). */
+export const NAV_STALE_AFTER_MS = 60 * 60 * 1000;
+
+export const NAV_POSITION_LABELS: Record = {
+ above: "Above NAV",
+ at: "At NAV",
+ below: "Below NAV",
+};
+
+export interface NavComparison {
+ position: NavPosition;
+ /** Price minus NAV, as a percentage of NAV, in basis points. */
+ premiumBps: number;
+}
+
+/** Coerces a value to a finite number, or null when it is not one. */
+function usableNumber(value: number | null | undefined): number | null {
+ if (value === null || value === undefined) return null;
+ const num = Number(value);
+ return Number.isFinite(num) ? num : null;
+}
+
+/**
+ * Compares the current price with the NAV.
+ *
+ * Returns null when either side is missing, or when the NAV is not a usable
+ * number, so a chip with nothing to say is not rendered rather than claiming
+ * the invoice is trading "at NAV".
+ */
+export function compareToNav(
+ nav: number | null | undefined,
+ price: number | null | undefined
+): NavComparison | null {
+ const navValue = usableNumber(nav);
+ const priceValue = usableNumber(price);
+ if (navValue === null || priceValue === null) return null;
+ if (navValue <= 0) return null;
+
+ const premiumBps = Math.round(((priceValue - navValue) / navValue) * 10_000);
+ const position: NavPosition =
+ premiumBps > NAV_PARITY_BPS ? "above" : premiumBps < -NAV_PARITY_BPS ? "below" : "at";
+
+ return { position, premiumBps };
+}
+
+/**
+ * Phrases the gap between price and NAV as a premium or a discount, e.g.
+ * "-424 bps discount". A gap inside the parity band reads as "0 bps".
+ */
+export function premiumDiscountLabel(premiumBps: number): string {
+ if (premiumBps === 0) return "0 bps";
+ const sign = premiumBps > 0 ? "+" : "-";
+ const side = premiumBps > 0 ? "premium" : "discount";
+ return `${sign}${Math.abs(premiumBps)} bps ${side}`;
+}
+
+/**
+ * True when the NAV was computed more than an hour ago.
+ *
+ * A missing timestamp is not treated as stale: there is nothing to compare it
+ * against, and warning on every invoice whose payload omits the field would be
+ * noise rather than a safety net.
+ */
+export function isNavStale(
+ computedAt: string | null | undefined,
+ now: number = Date.now()
+): boolean {
+ if (!computedAt) return false;
+ const timestamp = new Date(computedAt).getTime();
+ if (Number.isNaN(timestamp)) return false;
+ return now - timestamp > NAV_STALE_AFTER_MS;
+}
+
+/**
+ * Formats a per-fraction price in XLM.
+ *
+ * Fraction prices are small numbers with more precision than a currency
+ * display needs, so trailing zeros are trimmed but four decimals are kept.
+ */
+export function formatNavPrice(value: number | null | undefined): string {
+ const num = usableNumber(value);
+ if (num === null) return "—";
+ return num.toLocaleString("en-US", {
+ minimumFractionDigits: 0,
+ maximumFractionDigits: 4,
+ });
+}