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
105 changes: 105 additions & 0 deletions src/__tests__/InvoiceInsights.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
import React from "react";
import { render, screen } from "@testing-library/react";
import type { Invoice } from "@stellar-split/sdk";
import InvoiceSentiment, { analyzeInvoiceSentiment } from "@/components/invoice/InvoiceSentiment";
import AdvancedStatusTimeline, { buildStatusStages } from "@/components/invoice/AdvancedStatusTimeline";
import PayerWalletHealth, { computePayerHealth } from "@/components/invoice/PayerWalletHealth";
import ReconciliationPanel, { reconcileInvoice } from "@/components/invoice/ReconciliationPanel";

const NOW = 1_700_000_000;
const A = "GAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA";
const B = "GBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBB";
const C = "GCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCC";

function makeInvoice(overrides: Partial<Invoice> = {}): Invoice {
return {
id: "1",
creator: A,
recipients: [{ address: B, amount: 100n }],
token: "USDC",
deadline: NOW + 7 * 86_400,
funded: 0n,
status: "Pending",
payments: [],
...overrides,
};
}

describe("analyzeInvoiceSentiment", () => {
it("is positive when released", () => {
expect(analyzeInvoiceSentiment(makeInvoice({ status: "Released" }), NOW).label).toBe("positive");
});
it("is negative when refunded", () => {
expect(analyzeInvoiceSentiment(makeInvoice({ status: "Refunded" }), NOW).label).toBe("negative");
});
it("is negative with no payments past the deadline", () => {
expect(analyzeInvoiceSentiment(makeInvoice({ deadline: NOW - 1 }), NOW).label).toBe("negative");
});
it("is positive when well funded by many payers", () => {
const payments = [A, B, C].map((payer) => ({ payer, amount: 30n }));
expect(analyzeInvoiceSentiment(makeInvoice({ funded: 90n, payments }), NOW).label).toBe("positive");
});
it("renders the label", () => {
render(<InvoiceSentiment invoice={makeInvoice({ status: "Released" })} />);
expect(screen.getByTestId("sentiment-label")).toHaveTextContent("Positive");
});
});

describe("buildStatusStages", () => {
it("marks funding as current for a new invoice", () => {
const stages = buildStatusStages(makeInvoice(), NOW);
expect(stages.map((s) => s.state)).toEqual(["complete", "current", "upcoming", "upcoming"]);
});
it("marks release as current once fully funded", () => {
const stages = buildStatusStages(makeInvoice({ funded: 100n, payments: [{ payer: A, amount: 100n }] }), NOW);
expect(stages[3]).toMatchObject({ key: "released", state: "current" });
});
it("ends in refunded for refunded invoices", () => {
const stages = buildStatusStages(makeInvoice({ status: "Refunded" }), NOW);
expect(stages[stages.length - 1]).toMatchObject({ key: "refunded", state: "complete" });
expect(stages[1].state).toBe("skipped");
});
it("renders every stage", () => {
render(<AdvancedStatusTimeline invoice={makeInvoice()} />);
expect(screen.getAllByRole("listitem")).toHaveLength(4);
});
});

describe("computePayerHealth", () => {
it("aggregates and sorts by total, flagging concentration", () => {
const rows = computePayerHealth([
{ payer: A, amount: 70n },
{ payer: B, amount: 20n },
{ payer: A, amount: 5n },
{ payer: C, amount: 5n },
]);
expect(rows.map((r) => r.payer)).toEqual([A, B, C]);
expect(rows[0]).toMatchObject({ total: 75n, count: 2, share: 75, health: "concentrated" });
expect(rows[1].health).toBe("healthy");
});
it("does not flag a sole payer", () => {
expect(computePayerHealth([{ payer: A, amount: 10n }])[0].health).toBe("healthy");
});
it("renders an empty state", () => {
render(<PayerWalletHealth payments={[]} />);
expect(screen.getByText("No payer wallets yet.")).toBeInTheDocument();
});
});

describe("reconcileInvoice", () => {
it("is balanced when funded matches payments", () => {
const r = reconcileInvoice(makeInvoice({ funded: 40n, payments: [{ payer: A, amount: 40n }] }));
expect(r).toMatchObject({ balanced: true, outstanding: 60n, ledgerDiscrepancy: 0n });
});
it("reports ledger discrepancies and overpayment", () => {
const r = reconcileInvoice(makeInvoice({ funded: 120n, payments: [{ payer: A, amount: 100n }] }));
expect(r.balanced).toBe(false);
expect(r.ledgerDiscrepancy).toBe(20n);
expect(r.overpaid).toBe(20n);
expect(r.issues).toHaveLength(2);
});
it("renders issue count", () => {
render(<ReconciliationPanel invoice={makeInvoice({ funded: 10n })} />);
expect(screen.getByTestId("reconciliation-status")).toHaveTextContent("1 issue");
});
});
14 changes: 14 additions & 0 deletions src/app/invoice/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,10 @@ import CommentThread from "@/components/invoice/CommentThread";
import { loadPermissions } from "@/components/CoCreatorPanel";
import InvoiceTimeline from "@/components/InvoiceTimeline";
import HistoryLog from "@/components/invoice/HistoryLog";
import AdvancedStatusTimeline from "@/components/invoice/AdvancedStatusTimeline";
import InvoiceSentiment from "@/components/invoice/InvoiceSentiment";
import PayerWalletHealth from "@/components/invoice/PayerWalletHealth";
import ReconciliationPanel from "@/components/invoice/ReconciliationPanel";
import InvoiceExportButton from "@/components/InvoiceExportButton";
import ReleaseBanner from "@/components/ReleaseBanner";
import { cancelReminder, setReminder } from "@/lib/reminders";
Expand Down Expand Up @@ -1140,6 +1144,16 @@ export default function InvoiceDetailPage({ params }: Props) {
/>
)}

{/* Invoice insights: status timeline, sentiment, payer health, reconciliation */}
<div className="mb-8 grid grid-cols-1 gap-4">
<AdvancedStatusTimeline invoice={invoice} />
<div className="grid grid-cols-1 gap-4 md:grid-cols-2">
<InvoiceSentiment invoice={invoice} />
<ReconciliationPanel invoice={invoice} />
</div>
<PayerWalletHealth payments={invoice.payments} />
</div>

{/* Activity Timeline */}
<section className="mb-8" aria-labelledby="activity-timeline-heading">
<HistoryLog invoiceId={id} />
Expand Down
89 changes: 89 additions & 0 deletions src/components/invoice/AdvancedStatusTimeline.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
"use client";

import type { Invoice } from "@stellar-split/sdk";

export type StageState = "complete" | "current" | "upcoming" | "skipped";

export interface TimelineStage {
key: string;
label: string;
state: StageState;
detail?: string;
}

/** Builds the ordered lifecycle stages of an invoice with their current state. */
export function buildStatusStages(invoice: Invoice, nowSec = Math.floor(Date.now() / 1000)): TimelineStage[] {
const total = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n);
const hasPayments = invoice.payments.length > 0;
const fullyFunded = total > 0n && invoice.funded >= total;
const expired = invoice.deadline <= nowSec;
const deadline = new Date(invoice.deadline * 1000).toLocaleDateString();
const settled = invoice.status !== "Pending";

const stages: TimelineStage[] = [
{ key: "created", label: "Created", state: "complete" },
{
key: "funding",
label: "Funding",
state: hasPayments ? "complete" : settled ? "skipped" : "current",
detail: `${invoice.payments.length} payment${invoice.payments.length === 1 ? "" : "s"}`,
},
{
key: "funded",
label: "Fully Funded",
state: fullyFunded ? "complete" : settled ? "skipped" : hasPayments ? "current" : "upcoming",
detail: expired && !fullyFunded ? `Deadline passed ${deadline}` : `Deadline ${deadline}`,
},
];

if (invoice.status === "Refunded") {
stages.push({ key: "refunded", label: "Refunded", state: "complete" });
} else {
stages.push({
key: "released",
label: "Released",
state: invoice.status === "Released" ? "complete" : fullyFunded ? "current" : "upcoming",
});
}
return stages;
}

const DOT: Record<StageState, string> = {
complete: "bg-green-500 border-green-500",
current: "bg-blue-500 border-blue-400 animate-pulse",
upcoming: "bg-transparent border-gray-500",
skipped: "bg-gray-600 border-gray-600",
};

export default function AdvancedStatusTimeline({ invoice }: { invoice: Invoice }) {
const stages = buildStatusStages(invoice);

return (
<section className="rounded-xl border border-gray-700 bg-gray-800/50 p-4 sm:p-6" aria-labelledby="status-timeline-heading">
<h2 id="status-timeline-heading" className="text-lg font-semibold text-white mb-4">
Status Timeline
</h2>
<ol className="flex flex-col gap-4 sm:flex-row sm:gap-0">
{stages.map((stage, i) => (
<li
key={stage.key}
className="flex items-start gap-3 sm:flex-1 sm:flex-col sm:items-center sm:text-center"
aria-current={stage.state === "current" ? "step" : undefined}
data-state={stage.state}
>
<div className="flex items-center sm:w-full">
<span className={`h-4 w-4 shrink-0 rounded-full border-2 ${DOT[stage.state]}`} aria-hidden="true" />
{i < stages.length - 1 && <span className="hidden h-0.5 flex-1 bg-gray-600 sm:block" aria-hidden="true" />}
</div>
<div className="sm:mt-2">
<p className={`text-sm font-medium ${stage.state === "upcoming" ? "text-gray-400" : "text-white"}`}>
{stage.label}
</p>
{stage.detail && <p className="text-xs text-gray-400">{stage.detail}</p>}
</div>
</li>
))}
</ol>
</section>
);
}
83 changes: 83 additions & 0 deletions src/components/invoice/InvoiceSentiment.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
"use client";

import type { Invoice } from "@stellar-split/sdk";

export type SentimentLabel = "positive" | "neutral" | "negative";

export interface SentimentResult {
label: SentimentLabel;
/** Score from -100 (very negative) to 100 (very positive). */
score: number;
signals: string[];
}

/**
* Derives a funding sentiment for an invoice from its progress, payer
* participation and time remaining until the deadline.
*/
export function analyzeInvoiceSentiment(invoice: Invoice, nowSec = Math.floor(Date.now() / 1000)): SentimentResult {
if (invoice.status === "Released") return { label: "positive", score: 100, signals: ["Funds released"] };
if (invoice.status === "Refunded") return { label: "negative", score: -100, signals: ["Invoice refunded"] };

const total = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n);
const progress = total > 0n ? Number((invoice.funded * 100n) / total) : 0;
const payers = new Set(invoice.payments.map((p) => p.payer)).size;
const secondsLeft = invoice.deadline - nowSec;
const signals: string[] = [];
let score = progress - 50;

signals.push(`${Math.min(progress, 100)}% funded`);
if (payers >= 3) {
score += 20;
signals.push(`${payers} unique payers`);
} else if (payers === 0) {
score -= 20;
signals.push("No payers yet");
}
if (secondsLeft <= 0) {
score -= 40;
signals.push("Deadline passed");
} else if (secondsLeft < 86_400 && progress < 100) {
score -= 20;
signals.push("Less than 24h left");
}

score = Math.max(-100, Math.min(100, score));
const label: SentimentLabel = score >= 20 ? "positive" : score <= -20 ? "negative" : "neutral";
return { label, score, signals };
}

const STYLES: Record<SentimentLabel, { text: string; emoji: string; name: string }> = {
positive: { text: "text-green-400", emoji: "😊", name: "Positive" },
neutral: { text: "text-yellow-400", emoji: "😐", name: "Neutral" },
negative: { text: "text-red-400", emoji: "😟", name: "Negative" },
};

export default function InvoiceSentiment({ invoice }: { invoice: Invoice }) {
const { label, score, signals } = analyzeInvoiceSentiment(invoice);
const style = STYLES[label];

return (
<section className="rounded-xl border border-gray-700 bg-gray-800/50 p-4 sm:p-6" aria-labelledby="sentiment-heading">
<h2 id="sentiment-heading" className="text-lg font-semibold text-white mb-3">
Sentiment
</h2>
<div className="flex flex-wrap items-center gap-3">
<span className="text-3xl" aria-hidden="true">
{style.emoji}
</span>
<span className={`text-xl font-bold ${style.text}`} data-testid="sentiment-label">
{style.name}
</span>
<span className="text-sm text-gray-400">score {score}</span>
</div>
<ul className="mt-3 flex flex-wrap gap-2">
{signals.map((s) => (
<li key={s} className="rounded-full bg-gray-700 px-3 py-1 text-xs text-gray-200">
{s}
</li>
))}
</ul>
</section>
);
}
90 changes: 90 additions & 0 deletions src/components/invoice/PayerWalletHealth.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
"use client";

import { formatAmount, truncateAddress, type Payment } from "@stellar-split/sdk";

export type PayerHealth = "healthy" | "concentrated";

export interface PayerHealthRow {
payer: string;
total: bigint;
count: number;
/** Share of all paid funds, 0-100. */
share: number;
health: PayerHealth;
}

/** Share (percent) above which a single payer is flagged as a concentration risk. */
export const CONCENTRATION_THRESHOLD = 50;

/** Aggregates payments per payer wallet, sorted by total paid (descending). */
export function computePayerHealth(payments: Payment[]): PayerHealthRow[] {
const byPayer = new Map<string, { total: bigint; count: number }>();
let grand = 0n;
for (const p of payments) {
const row = byPayer.get(p.payer) ?? { total: 0n, count: 0 };
row.total += p.amount;
row.count += 1;
byPayer.set(p.payer, row);
grand += p.amount;
}
return Array.from(byPayer, ([payer, { total, count }]) => {
const share = grand > 0n ? Number((total * 10_000n) / grand) / 100 : 0;
return {
payer,
total,
count,
share,
health: byPayer.size > 1 && share > CONCENTRATION_THRESHOLD ? "concentrated" : "healthy",
} satisfies PayerHealthRow;
}).sort((a, b) => (b.total > a.total ? 1 : b.total < a.total ? -1 : 0));
}

export default function PayerWalletHealth({ payments }: { payments: Payment[] }) {
const rows = computePayerHealth(payments);

return (
<section className="rounded-xl border border-gray-700 bg-gray-800/50 p-4 sm:p-6" aria-labelledby="payer-health-heading">
<h2 id="payer-health-heading" className="text-lg font-semibold text-white mb-3">
Payer Wallet Health
</h2>
{rows.length === 0 ? (
<p className="text-sm text-gray-400">No payer wallets yet.</p>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[28rem] text-left text-sm">
<thead className="text-gray-400">
<tr>
<th className="py-2 pr-3 font-medium">Wallet</th>
<th className="py-2 pr-3 font-medium">Payments</th>
<th className="py-2 pr-3 font-medium">Total</th>
<th className="py-2 pr-3 font-medium">Share</th>
<th className="py-2 font-medium">Health</th>
</tr>
</thead>
<tbody className="text-gray-200">
{rows.map((r) => (
<tr key={r.payer} className="border-t border-gray-700">
<td className="py-2 pr-3 font-mono" title={r.payer}>
{truncateAddress(r.payer)}
</td>
<td className="py-2 pr-3">{r.count}</td>
<td className="py-2 pr-3">{formatAmount(r.total)}</td>
<td className="py-2 pr-3">{r.share.toFixed(1)}%</td>
<td className="py-2">
<span
className={`rounded-full px-2 py-0.5 text-xs ${
r.health === "healthy" ? "bg-green-900/50 text-green-300" : "bg-yellow-900/50 text-yellow-300"
}`}
>
{r.health === "healthy" ? "Healthy" : "Concentrated"}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
);
}
Loading
Loading