From af2cc076d17ac314781949c4241bbb10f6250df7 Mon Sep 17 00:00:00 2001 From: johnsmccain Date: Wed, 21 Oct 2026 14:13:21 +0100 Subject: [PATCH] feat(invoices): add NAV fair value indicator (#546) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds the computed NAV of an invoice against its current market price, so an investor can see whether the market is paying a premium or a discount. - lib/nav.ts: the pricing rules as pure helpers — where the price sits relative to NAV, the gap in basis points, and when a NAV is stale. - lib/api: fetchInvoiceNav reads GET /invoices/:id/nav and normalises the field spellings the endpoint has shipped. A 404 is "no NAV", not an error, so an unpriced invoice hides the chip instead of failing. - NavChip on the marketplace card and list row, and InvoiceNavPanel in the invest panel on the invoice detail page. - The chip and panel render nothing when an invoice has no NAV, and warn once the NAV is more than an hour old. Also fixes a pre-existing type error in FeeTierDisplay that was failing `tsc --noEmit` on dev, and with it the last merged CI run. Fixes #546 --- .../__tests__/marketplace-nav-chip.test.tsx | 99 +++++++++++++ app/marketplace/page.tsx | 5 +- components/invoices/FeeTierDisplay.tsx | 2 +- components/invoices/InvoiceDetailView.tsx | 92 ++++++------ components/invoices/InvoiceNavPanel.tsx | 64 +++++++++ .../__tests__/InvoiceNavPanel.test.tsx | 81 +++++++++++ components/marketplace/NavChip.tsx | 77 ++++++++++ .../marketplace/__tests__/NavChip.test.tsx | 136 ++++++++++++++++++ components/marketplace/index.ts | 1 + components/marketplace/invoice-card.tsx | 5 + hooks/useInvoiceNav.ts | 36 +++++ lib/__tests__/nav.test.ts | 101 +++++++++++++ lib/api/index.ts | 71 +++++++++ lib/nav.ts | 105 ++++++++++++++ 14 files changed, 828 insertions(+), 47 deletions(-) create mode 100644 app/marketplace/__tests__/marketplace-nav-chip.test.tsx create mode 100644 components/invoices/InvoiceNavPanel.tsx create mode 100644 components/invoices/__tests__/InvoiceNavPanel.test.tsx create mode 100644 components/marketplace/NavChip.tsx create mode 100644 components/marketplace/__tests__/NavChip.test.tsx create mode 100644 hooks/useInvoiceNav.ts create mode 100644 lib/__tests__/nav.test.ts create mode 100644 lib/nav.ts diff --git a/app/marketplace/__tests__/marketplace-nav-chip.test.tsx b/app/marketplace/__tests__/marketplace-nav-chip.test.tsx new file mode 100644 index 0000000..c855b16 --- /dev/null +++ b/app/marketplace/__tests__/marketplace-nav-chip.test.tsx @@ -0,0 +1,99 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { render, screen } from "@testing-library/react"; +import { QueryClient, QueryClientProvider, useInfiniteQuery } from "@tanstack/react-query"; +import React from "react"; +import MarketplacePage from "../page"; +import * as api from "@/lib/api"; + +vi.mock("@tanstack/react-query", async () => { + const actual = await vi.importActual("@tanstack/react-query"); + return { + ...actual, + useInfiniteQuery: vi.fn(), + }; +}); + +vi.mock("next/navigation", () => ({ + useSearchParams: () => new URLSearchParams(), + useRouter: () => ({ replace: vi.fn() }), + usePathname: () => "/marketplace", +})); + +const mockUseInfiniteQuery = vi.mocked(useInfiniteQuery); + +function createWrapper() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false } }, + }); + return function Wrapper({ children }: { children: React.ReactNode }) { + return {children}; + }; +} + +const invoice = { + id: "inv-1", + title: "Acme receivable", + seller: "seller", + amount: 10000, + raised: 5000, + investor_count: 3, + status: "open", + due_date: "2027-01-01T00:00:00.000Z", + fraction_price: 0.9, + has_more: false, + next_cursor: null, +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockUseInfiniteQuery.mockReturnValue({ + data: { pages: [{ invoices: [invoice], has_more: false, next_cursor: null }] }, + fetchNextPage: vi.fn(), + hasNextPage: false, + isFetchingNextPage: false, + isLoading: false, + isFetching: false, + } as any); + vi.stubGlobal( + "IntersectionObserver", + class { + observe() {} + unobserve() {} + disconnect() {} + } + ); +}); + +afterEach(() => { + vi.unstubAllGlobals(); +}); + +describe("Marketplace list view - NAV chip (#546)", () => { + it("shows the NAV position on the list row", async () => { + vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue({ + invoice_id: "inv-1", + nav_per_fraction: 1, + current_price: 0.9, + computed_at: new Date().toISOString(), + }); + + render(, { wrapper: createWrapper() }); + + expect(await screen.findByTestId("invoice-row-inv-1")).toBeInTheDocument(); + const chip = await screen.findByTestId("nav-chip"); + expect(chip).toHaveTextContent("Below NAV"); + expect(screen.getByTestId("invoice-row-inv-1")).toContainElement(chip); + }); + + it("hides the chip for an invoice with no NAV", async () => { + vi.spyOn(api, "fetchInvoiceNav").mockResolvedValue(null); + + render(, { wrapper: createWrapper() }); + + await screen.findByTestId("invoice-row-inv-1"); + await vi.waitFor(() => { + expect(api.fetchInvoiceNav).toHaveBeenCalled(); + }); + expect(screen.queryByTestId("nav-chip")).not.toBeInTheDocument(); + }); +}); diff --git a/app/marketplace/page.tsx b/app/marketplace/page.tsx index a3b4aff..dae7d67 100644 --- a/app/marketplace/page.tsx +++ b/app/marketplace/page.tsx @@ -17,6 +17,7 @@ import { InvoiceTagPills, InvestorDemandMetrics, MarketplaceFilterBar, + NavChip, SettlementCountdown, SectorChips, TaxonomyFilterPanel, @@ -160,7 +161,9 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) { target={invoice.amount} investorCount={invoice.investor_count} /> -
+
+ {/* Fair value against the market price (#546). */} +
diff --git a/components/invoices/FeeTierDisplay.tsx b/components/invoices/FeeTierDisplay.tsx index 3775e14..d703db0 100644 --- a/components/invoices/FeeTierDisplay.tsx +++ b/components/invoices/FeeTierDisplay.tsx @@ -52,7 +52,7 @@ export function FeeTierDisplay({ amount, loyalty }: { amount?: number | null; lo - {typeof amount === "number" && feeAmount !== null && total !== null && ( + {typeof amount === "number" && feeAmount !== null && discountedFeeAmount !== null && total !== null && (
Investment{amount.toLocaleString()} XLM
{loyaltyDiscount > 0 ? `Standard fee (${data.fee_percentage}%)` : `Fee (${data.fee_percentage}%)`}{feeAmount.toLocaleString(undefined, { maximumFractionDigits: 2 })} XLM
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