Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
99 changes: 99 additions & 0 deletions app/marketplace/__tests__/marketplace-nav-chip.test.tsx
Original file line number Diff line number Diff line change
@@ -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 <QueryClientProvider client={queryClient}>{children}</QueryClientProvider>;
};
}

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(<MarketplacePage />, { 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(<MarketplacePage />, { wrapper: createWrapper() });

await screen.findByTestId("invoice-row-inv-1");
await vi.waitFor(() => {
expect(api.fetchInvoiceNav).toHaveBeenCalled();
});
expect(screen.queryByTestId("nav-chip")).not.toBeInTheDocument();
});
});
5 changes: 4 additions & 1 deletion app/marketplace/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
InvoiceTagPills,
InvestorDemandMetrics,
MarketplaceFilterBar,
NavChip,
SettlementCountdown,
SectorChips,
TaxonomyFilterPanel,
Expand Down Expand Up @@ -160,7 +161,9 @@ function InvoiceRow({ invoice }: { invoice: Invoice }) {
target={invoice.amount}
investorCount={invoice.investor_count}
/>
<div className="mt-3 flex justify-end">
<div className="mt-3 flex items-center justify-between gap-2">
{/* Fair value against the market price (#546). */}
<NavChip invoiceId={invoice.id} fractionPrice={invoice.fraction_price} />
<CompareToggleButton invoice={invoice} />
</div>
</div>
Expand Down
2 changes: 1 addition & 1 deletion components/invoices/FeeTierDisplay.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,7 @@ export function FeeTierDisplay({ amount, loyalty }: { amount?: number | null; lo
</PopoverContent>
</Popover>
</div>
{typeof amount === "number" && feeAmount !== null && total !== null && (
{typeof amount === "number" && feeAmount !== null && discountedFeeAmount !== null && total !== null && (
<div className="space-y-1 text-xs text-muted-foreground" data-testid="fee-cost-breakdown">
<div className="flex justify-between"><span>Investment</span><span>{amount.toLocaleString()} XLM</span></div>
<div className="flex justify-between"><span>{loyaltyDiscount > 0 ? `Standard fee (${data.fee_percentage}%)` : `Fee (${data.fee_percentage}%)`}</span><span>{feeAmount.toLocaleString(undefined, { maximumFractionDigits: 2 })} XLM</span></div>
Expand Down
92 changes: 47 additions & 45 deletions components/invoices/InvoiceDetailView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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,
Expand All @@ -134,53 +140,37 @@ function InvestCallToAction({
enabled: isConnected,
});

let body: ReactNode;

if (!isConnected) {
return (
<Card data-testid="invest-cta">
<CardContent className="pt-6">
<p className="text-sm text-muted-foreground">
Connect a wallet to invest in this invoice.
</p>
</CardContent>
</Card>
body = (
<p className="text-sm text-muted-foreground">
Connect a wallet to invest in this invoice.
</p>
);
}

if (isLoading) {
return (
<Card data-testid="invest-cta">
<CardContent className="pt-6">
<Skeleton className="h-9 w-32" data-testid="invest-cta-loading" />
</CardContent>
</Card>
} else if (isLoading) {
body = <Skeleton className="h-9 w-32" data-testid="invest-cta-loading" />;
} else if (!isApproved) {
body = (
<div>
<p
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid="invest-hidden-not-approved"
>
<ShieldAlert className="h-4 w-4" />
Investing is available once your KYC is approved.
</p>
<p className="mt-1 text-xs text-muted-foreground" data-testid="kyc-status">
Current KYC status: {status ?? "unknown"}
</p>
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href="/kyc/status">View KYC status</Link>
</Button>
</div>
);
}

if (!isApproved) {
return (
<Card data-testid="invest-cta">
<CardContent className="pt-6">
<p
className="flex items-center gap-2 text-sm text-muted-foreground"
data-testid="invest-hidden-not-approved"
>
<ShieldAlert className="h-4 w-4" />
Investing is available once your KYC is approved.
</p>
<p className="mt-1 text-xs text-muted-foreground" data-testid="kyc-status">
Current KYC status: {status ?? "unknown"}
</p>
<Button asChild variant="outline" size="sm" className="mt-3">
<Link href="/kyc/status">View KYC status</Link>
</Button>
</CardContent>
</Card>
);
}

return (
<Card data-testid="invest-cta">
<CardContent className="pt-6">
} else {
body = (
<>
<InvestmentModal
invoiceId={invoiceId}
minInvestment={minInvestment}
Expand All @@ -193,6 +183,15 @@ function InvestCallToAction({
maturityDate={maturityDate}
fractionPrice={fractionPrice}
/>
</>
);
}

return (
<Card data-testid="invest-cta">
<CardContent className="pt-6 space-y-4">
<InvoiceNavPanel invoiceId={invoiceId} fractionPrice={fractionPrice} />
{body}
</CardContent>
</Card>
);
Expand Down Expand Up @@ -438,7 +437,10 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {
/>
) : (
<Card data-testid="invest-cta">
<CardContent className="pt-6">
<CardContent className="pt-6 space-y-4">
{/* The NAV comparison still applies once the primary market has
closed: funded invoices trade on the secondary market (#546). */}
<InvoiceNavPanel invoiceId={invoice.id} fractionPrice={invoice.fraction_price} />
<p className="text-sm text-muted-foreground" data-testid="invest-unavailable">
{invoice.status === "settled"
? "This invoice has been settled and is no longer open for investment."
Expand Down
64 changes: 64 additions & 0 deletions components/invoices/InvoiceNavPanel.tsx
Original file line number Diff line number Diff line change
@@ -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 (
<div className="rounded-md border p-3" data-testid="invoice-nav-panel">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">NAV per fraction</span>
<span className="font-medium tabular-nums" data-testid="nav-panel-nav">
{formatNavPrice(nav)} XLM
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">Current price</span>
<span className="font-medium tabular-nums" data-testid="nav-panel-price">
{formatNavPrice(price)} XLM
</span>
</div>
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">vs NAV</span>
<span className="font-medium tabular-nums" data-testid="nav-panel-bps">
{premiumDiscountLabel(comparison.premiumBps)}
</span>
</div>
{stale && (
<p className="mt-2 text-xs text-amber-700" data-testid="nav-panel-stale">
NAV was computed over an hour ago and may be out of date.
</p>
)}
</div>
);
}
Loading
Loading