Skip to content
Closed
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
20 changes: 20 additions & 0 deletions components/invoices/ImpactReport.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"use client";

import { useEffect, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { fetchIpfsText } from "@/lib/ipfs";
import type { Invoice } from "@/lib/api";

export function ImpactReport({ report }: { report?: Invoice["impact_report"] }) {
const [content, setContent] = useState<string | null>(null);
const [failed, setFailed] = useState(false);
useEffect(() => {
let active = true;
if (report?.metrics_hash) fetchIpfsText(report.metrics_hash).then((value) => { if (active) setContent(value); }).catch(() => { if (active) setFailed(true); });
return () => { active = false; };
}, [report?.metrics_hash]);
if (!report) return null;
let formatted: string;
try { formatted = JSON.stringify(JSON.parse(content ?? ""), null, 2); } catch { formatted = content ?? ""; }
return <section className="space-y-3 rounded-lg border p-4" aria-label="Impact report" data-testid="impact-report"><div className="flex items-center gap-2"><h2 className="text-lg font-semibold">Impact report</h2><Badge variant="secondary">{report.type}</Badge>{report.verified && <Badge>Admin verified</Badge>}</div>{failed ? <p className="text-sm text-muted-foreground">The report could not be loaded from IPFS.</p> : content === null ? <p className="text-sm text-muted-foreground">Loading report…</p> : <pre className="whitespace-pre-wrap break-words text-sm">{formatted}</pre>}</section>;
}
3 changes: 3 additions & 0 deletions components/invoices/InvestmentModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ interface InvestmentModalProps {
/** Total funding cap used for price-impact calculation (issue #344). */
fundingCap?: number;
onSuccess?: () => void;
trancheType?: 1 | 2;
}

export function InvestmentModal({
Expand All @@ -44,6 +45,7 @@ export function InvestmentModal({
maxInvestment,
fundingCap,
onSuccess,
trancheType,
}: InvestmentModalProps) {
const [isOpen, setIsOpen] = useState(false);
const [validAmount, setValidAmount] = useState<number | null>(null);
Expand Down Expand Up @@ -77,6 +79,7 @@ export function InvestmentModal({
await investMutation.mutateAsync({
invoiceId,
amount: validAmount,
...(trancheType ? { trancheType } : {}),
...(isBaseCurrency ? {} : { currency }),
});
setIsOpen(false);
Expand Down
15 changes: 14 additions & 1 deletion 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 { useState } from "react";
import type { ReactNode } from "react";
import { FileQuestion, ShieldAlert } from "lucide-react";
import { Button } from "@/components/ui/button";
Expand Down Expand Up @@ -56,6 +57,8 @@ import { truncateAddress } from "@/lib/stellar";
import { DEFAULT_MIN_INVESTMENT } from "@/lib/invoiceDefaults";
import { Money } from "@/components/currency";
import { CurrencyToggle } from "@/components/layout/CurrencyToggle";
import { InvoiceGreylistStatus } from "@/components/invoices/InvoiceGreylistStatus";
import { ImpactReport } from "@/components/invoices/ImpactReport";

function InvoiceDetailSkeleton() {
return (
Expand Down Expand Up @@ -126,6 +129,7 @@ function InvestCallToAction({
yieldPercentage,
maturityDate,
fractionPrice,
trancheStatus,
}: {
invoiceId: string;
minInvestment: number;
Expand All @@ -134,7 +138,9 @@ function InvestCallToAction({
yieldPercentage?: number | null;
maturityDate: string;
fractionPrice?: number;
trancheStatus?: NonNullable<import("@/lib/api").Invoice["tranche_status"]>;
}) {
const [selectedTranche, setSelectedTranche] = useState<1 | 2>(1);
const { isConnected } = useWallet();
const { isApproved, isLoading, status } = useIsKycApproved({
enabled: isConnected,
Expand Down Expand Up @@ -176,6 +182,7 @@ function InvestCallToAction({
minInvestment={minInvestment}
maxInvestment={maxInvestment}
fundingCap={fundingCap}
trancheType={trancheStatus ? selectedTranche : undefined}
/>
<ReturnsCalculator
faceValue={fundingCap}
Expand All @@ -191,6 +198,7 @@ function InvestCallToAction({
<Card data-testid="invest-cta">
<CardContent className="pt-6 space-y-4">
<InvoiceNavPanel invoiceId={invoiceId} fractionPrice={fractionPrice} />
{trancheStatus && <div className="grid grid-cols-2 gap-2" role="group" aria-label="Investment tranche" data-testid="tranche-selector">{([1, 2] as const).map((type) => { const senior = type === 1; const fill = senior ? trancheStatus.senior_filled : trancheStatus.junior_filled; const pct = senior ? trancheStatus.senior_pct : trancheStatus.junior_pct; const cap = fundingCap * pct / 100; const rate = (yieldPercentage ?? 0) + (senior ? 0 : trancheStatus.junior_yield_premium_bps / 100); const progress = cap > 0 ? Math.min(100, fill / cap * 100) : 0; return <button key={type} type="button" onClick={() => setSelectedTranche(type)} aria-pressed={selectedTranche === type} className={`rounded-md border p-3 text-left ${selectedTranche === type ? "border-primary bg-primary/5" : ""}`}><span className="font-semibold">{senior ? "Senior" : "Junior"}</span><span className="block text-sm">{rate}% yield · {senior ? "Priority repayment" : "Subordinate"}</span><span className="mt-2 block text-xs">{progress.toFixed(1)}% filled</span><span className="mt-1 block h-1.5 overflow-hidden rounded bg-muted"><span className="block h-full bg-primary" style={{ width: `${progress}%` }} /></span></button>; })}</div>}
{body}
</CardContent>
</Card>
Expand Down Expand Up @@ -219,7 +227,8 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {

const expired = isExpired(invoice.due_date);
const remaining = Math.max(0, invoice.amount - invoice.raised);
const isOpenForInvestment = invoice.status === "open" && !expired && remaining > 0;
const isGreylisted = Boolean(invoice.greylist && (!invoice.greylist.clears_at || new Date(invoice.greylist.clears_at).getTime() > Date.now()));
const isOpenForInvestment = invoice.status === "open" && !expired && remaining > 0 && !isGreylisted;
// Qualifying investor: connected wallet appears in the investor list (#348).
const isQualifyingInvestor = Boolean(
address && invoice.investors.some((inv) => inv.address === address)
Expand Down Expand Up @@ -308,13 +317,15 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {

<InvoiceRiskAlert invoiceId={invoice.id} />
<InvoiceUnderReviewBanner invoiceId={invoice.id} />
<InvoiceGreylistStatus greylist={invoice.greylist} />

<InvoiceFinancials
faceValue={invoice.amount}
yieldPercentage={invoice.yield_percentage}
maturityDate={invoice.due_date}
minInvestment={minInvestment}
/>
{invoice.status === "settled" && <ImpactReport report={invoice.impact_report} />}

{/* Repayment ledger (#478): interim instalments and the final settlement,
shown once the invoice has been funded. */}
Expand Down Expand Up @@ -363,6 +374,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {
/>
</div>
<InvoiceClassificationBadges invoice={invoice} />
{invoice.tranche_status && <div className="rounded border p-3" data-testid="tranche-status"><p className="font-medium">Tranche status</p><p className="text-sm">Senior {invoice.tranche_status.senior_filled.toLocaleString()} / {invoice.amount * invoice.tranche_status.senior_pct / 100} XLM · Junior {invoice.tranche_status.junior_filled.toLocaleString()} / {invoice.amount * invoice.tranche_status.junior_pct / 100} XLM</p></div>}
<InvoiceTagPills invoice={invoice} />
</CardContent>
</Card>
Expand Down Expand Up @@ -434,6 +446,7 @@ export function InvoiceDetailView({ invoiceId }: InvoiceDetailViewProps) {
yieldPercentage={invoice.yield_percentage}
maturityDate={invoice.maturity_date ?? invoice.due_date}
fractionPrice={invoice.fraction_price}
trancheStatus={invoice.tranche_status ?? undefined}
/>
) : (
<Card data-testid="invest-cta">
Expand Down
19 changes: 19 additions & 0 deletions components/invoices/InvoiceGreylistStatus.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
"use client";

import { useEffect, useState } from "react";
import { AlertTriangle } from "lucide-react";
import type { Invoice } from "@/lib/api";

function remaining(until?: string | null, now = Date.now()) {
if (!until) return null;
const seconds = Math.max(0, Math.floor((new Date(until).getTime() - now) / 1000));
return `${Math.floor(seconds / 86400)}d ${Math.floor((seconds % 86400) / 3600)}h ${Math.floor((seconds % 3600) / 60)}m`;
}

export function InvoiceGreylistStatus({ greylist, compact = false }: { greylist?: Invoice["greylist"]; compact?: boolean }) {
const [now, setNow] = useState(Date.now());
useEffect(() => { const timer = window.setInterval(() => setNow(Date.now()), 30_000); return () => window.clearInterval(timer); }, []);
if (!greylist || (greylist.clears_at && new Date(greylist.clears_at).getTime() <= now)) return null;
if (compact) return <span className="rounded-full bg-amber-100 px-2 py-0.5 text-xs font-medium text-amber-900" data-testid="greylist-chip">Compliance hold</span>;
return <div role="status" className="flex gap-3 rounded-lg border border-amber-300 bg-amber-50 p-4 text-amber-950" data-testid="greylist-banner"><AlertTriangle className="mt-0.5 size-5 shrink-0"/><div><p className="font-semibold">Compliance hold — new investments are paused</p>{greylist.reason && <p className="mt-1 text-sm">{greylist.reason}</p>}{greylist.clears_at && <p className="mt-1 text-sm">Auto-clear in {remaining(greylist.clears_at, now)}</p>}<p className="mt-1 text-xs">Existing holders can still exit their positions.</p></div></div>;
}
17 changes: 17 additions & 0 deletions components/issuers/IssuerCreditTierBadge.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
"use client";

import { useQuery } from "@tanstack/react-query";
import { fetchIssuerCreditScore } from "@/lib/api";
import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from "@/components/ui/tooltip";

const colors = {
C: "bg-red-100 text-red-800", B: "bg-amber-100 text-amber-800",
A: "bg-slate-100 text-slate-800", AA: "bg-blue-100 text-blue-800",
AAA: "bg-yellow-100 text-yellow-900",
} as const;

export function IssuerCreditTierBadge({ wallet }: { wallet: string }) {
const { data } = useQuery({ queryKey: ["issuer-credit-score", wallet], queryFn: () => fetchIssuerCreditScore(wallet), staleTime: 60_000 });
if (!data) return null;
return <TooltipProvider><Tooltip><TooltipTrigger asChild><span className={`inline-flex rounded-full px-2 py-0.5 text-xs font-semibold ${colors[data.tier]}`} aria-label={`Issuer credit tier ${data.tier}, score ${data.score}`} data-testid="issuer-credit-tier">{data.tier} · {data.score}</span></TooltipTrigger><TooltipContent><p className="font-medium">Credit score factors</p>{Object.entries(data.factors).map(([name, score]) => <p key={name}>{name.replaceAll("_", " ")}: {score}</p>)}</TooltipContent></Tooltip></TooltipProvider>;
}
2 changes: 2 additions & 0 deletions components/issuers/IssuerProfile.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ import { IssuerScoreCard, TotalFunded } from "./IssuerScoreCard";
import { InvoiceHistoryTable } from "./InvoiceHistoryTable";
import { ActiveInvoices } from "./ActiveInvoices";
import { IssuerReputationHeaderBadge, IssuerReputationPanel } from "./IssuerReputationPanel";
import { IssuerCreditTierBadge } from "./IssuerCreditTierBadge";

function IssuerProfileSkeleton() {
return (
Expand Down Expand Up @@ -80,6 +81,7 @@ export function IssuerProfile({ issuerId, now }: IssuerProfileProps) {
{issuer.name}
</h1>
<VerifiedBadge issuer={issuer} />
<IssuerCreditTierBadge wallet={issuer.id} />
<KycStatusNotice issuer={issuer} />
<IssuerReputationHeaderBadge wallet={issuer.id} />
</div>
Expand Down
6 changes: 5 additions & 1 deletion components/marketplace/invoice-card.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,8 @@ import type { WatchlistStateChange } from "@/lib/watchlist";
import { Money } from "@/components/currency";
import { useSuitabilityTier } from "@/hooks/useSuitabilityTier";
import { REQUIRED_TIER, TIER_LABELS, canInvestInGrade } from "@/lib/suitability";
import { InvoiceGreylistStatus } from "@/components/invoices/InvoiceGreylistStatus";
import { IssuerCreditTierBadge } from "@/components/issuers/IssuerCreditTierBadge";

const statusVariant: Record<string, "default" | "secondary" | "outline" | "destructive"> = {
open: "default",
Expand Down Expand Up @@ -66,6 +68,7 @@ export function InvoiceCard({
<CardTitle className="line-clamp-1 text-base">
{invoice.title}
</CardTitle>
<div className="flex items-center gap-2 text-xs text-muted-foreground"><span>Issuer {invoice.seller.slice(0, 6)}…{invoice.seller.slice(-4)}</span><IssuerCreditTierBadge wallet={invoice.seller} /></div>
{stateChange && <StateChangeBadge change={stateChange} />}
</div>
<div className="flex shrink-0 items-center gap-2">
Expand All @@ -76,6 +79,7 @@ export function InvoiceCard({
</div>
</div>
<InvoiceClassificationBadges invoice={invoice} />
<InvoiceGreylistStatus greylist={invoice.greylist} compact />
<InvoiceTagPills invoice={invoice} />
</CardHeader>

Expand Down Expand Up @@ -148,7 +152,7 @@ export function InvoiceCard({
<div className="flex gap-2 mt-auto">
<Button
className="flex-1"
disabled={!published || expired || locked}
disabled={!published || expired || locked || Boolean(invoice.greylist && (!invoice.greylist.clears_at || new Date(invoice.greylist.clears_at).getTime() > Date.now()))}
onClick={() => onInvest?.(invoice.id)}
>
{expired ? "Expired" : locked ? "Locked" : "Invest"}
Expand Down
7 changes: 4 additions & 3 deletions hooks/useInvestments.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ interface InvestMutationVars {
amount: number;
/** Payment currency (issue #497); omitted for the default base-currency flow. */
currency?: InvestCurrency;
trancheType?: 1 | 2;
}

/** One toast id per invoice so the pending state morphs into the resolved
Expand All @@ -32,10 +33,10 @@ export function useInvestMutation() {
const mutateRef = useRef<(vars: InvestMutationVars) => void>(() => {});

const mutation = useMutation({
mutationFn: ({ invoiceId, amount, currency }: InvestMutationVars) =>
mutationFn: ({ invoiceId, amount, currency, trancheType }: InvestMutationVars) =>
currency
? investInInvoice(invoiceId, amount, currency)
: investInInvoice(invoiceId, amount),
? investInInvoice(invoiceId, amount, currency, trancheType)
: investInInvoice(invoiceId, amount, undefined, trancheType),

onMutate: async ({ invoiceId, amount }) => {
// Transaction status toast, shown synchronously and immediately on
Expand Down
24 changes: 23 additions & 1 deletion lib/api/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,14 @@ export interface Invoice {
late_penalty_accrued?: number | null;
/** Sector yield benchmark returned alongside the list, for the card chip (#530). */
sector_benchmark?: SectorBenchmark | null;
/** Optional compliance hold returned by the invoice API. */
greylist?: { reason?: string | null; clears_at?: string | null } | null;
/** Optional tranche state returned for invoices using the tranche contract. */
tranche_status?: {
senior_pct: number; junior_pct: number; senior_filled: number;
junior_filled: number; junior_yield_premium_bps: number; is_settled: boolean;
} | null;
impact_report?: { type: "environmental" | "social" | "governance" | "combined"; metrics_hash: string; verified: boolean; submitted_at?: string } | null;
}

/** Sector average yield and how many invoices it was computed from (#530). */
Expand Down Expand Up @@ -146,6 +154,19 @@ export interface InvoiceDetail extends Invoice {
};
}

export interface IssuerCreditScore {
tier: "C" | "B" | "A" | "AA" | "AAA";
score: number;
factors: Record<string, number>;
}

export async function fetchIssuerCreditScore(wallet: string): Promise<IssuerCreditScore | null> {
const res = await fetch(`${API_BASE}/issuers/${encodeURIComponent(wallet)}/credit-score`);
if (res.status === 404) return null;
if (!res.ok) throw new Error("Failed to fetch issuer credit score");
return res.json();
}

export interface InvoicesResponse {
invoices: Invoice[];
has_more: boolean;
Expand Down Expand Up @@ -377,11 +398,12 @@ export async function investInInvoice(
invoiceId: string,
amount: number,
currency?: string,
trancheType?: 1 | 2,
): Promise<InvestInInvoiceResult> {
const res = await fetch(`${API_BASE}/invoices/${invoiceId}/invest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(currency ? { amount, currency } : { amount }),
body: JSON.stringify({ amount, ...(currency ? { currency } : {}), ...(trancheType ? { tranche_type: trancheType } : {}) }),
});
if (!res.ok) {
if (currency) {
Expand Down