diff --git a/src/__tests__/InvoiceInsights.test.tsx b/src/__tests__/InvoiceInsights.test.tsx new file mode 100644 index 0000000..5a99b4b --- /dev/null +++ b/src/__tests__/InvoiceInsights.test.tsx @@ -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 { + 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(); + 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(); + 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(); + 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(); + expect(screen.getByTestId("reconciliation-status")).toHaveTextContent("1 issue"); + }); +}); diff --git a/src/app/invoice/[id]/page.tsx b/src/app/invoice/[id]/page.tsx index 560f625..cf98b5d 100644 --- a/src/app/invoice/[id]/page.tsx +++ b/src/app/invoice/[id]/page.tsx @@ -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"; @@ -1140,6 +1144,16 @@ export default function InvoiceDetailPage({ params }: Props) { /> )} + {/* Invoice insights: status timeline, sentiment, payer health, reconciliation */} +
+ +
+ + +
+ +
+ {/* Activity Timeline */}
diff --git a/src/components/invoice/AdvancedStatusTimeline.tsx b/src/components/invoice/AdvancedStatusTimeline.tsx new file mode 100644 index 0000000..9bebe95 --- /dev/null +++ b/src/components/invoice/AdvancedStatusTimeline.tsx @@ -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 = { + 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 ( +
+

+ Status Timeline +

+
    + {stages.map((stage, i) => ( +
  1. +
    +
    +
    +

    + {stage.label} +

    + {stage.detail &&

    {stage.detail}

    } +
    +
  2. + ))} +
+
+ ); +} diff --git a/src/components/invoice/InvoiceSentiment.tsx b/src/components/invoice/InvoiceSentiment.tsx new file mode 100644 index 0000000..f7ae631 --- /dev/null +++ b/src/components/invoice/InvoiceSentiment.tsx @@ -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 = { + 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 ( +
+

+ Sentiment +

+
+ + + {style.name} + + score {score} +
+
    + {signals.map((s) => ( +
  • + {s} +
  • + ))} +
+
+ ); +} diff --git a/src/components/invoice/PayerWalletHealth.tsx b/src/components/invoice/PayerWalletHealth.tsx new file mode 100644 index 0000000..2cb43ab --- /dev/null +++ b/src/components/invoice/PayerWalletHealth.tsx @@ -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(); + 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 ( +
+

+ Payer Wallet Health +

+ {rows.length === 0 ? ( +

No payer wallets yet.

+ ) : ( +
+ + + + + + + + + + + + {rows.map((r) => ( + + + + + + + + ))} + +
WalletPaymentsTotalShareHealth
+ {truncateAddress(r.payer)} + {r.count}{formatAmount(r.total)}{r.share.toFixed(1)}% + + {r.health === "healthy" ? "Healthy" : "Concentrated"} + +
+
+ )} +
+ ); +} diff --git a/src/components/invoice/ReconciliationPanel.tsx b/src/components/invoice/ReconciliationPanel.tsx new file mode 100644 index 0000000..f9a976d --- /dev/null +++ b/src/components/invoice/ReconciliationPanel.tsx @@ -0,0 +1,86 @@ +"use client"; + +import { formatAmount, type Invoice } from "@stellar-split/sdk"; + +export interface ReconciliationReport { + target: bigint; + funded: bigint; + paymentsTotal: bigint; + /** Difference between on-chain `funded` and the sum of recorded payments. */ + ledgerDiscrepancy: bigint; + outstanding: bigint; + overpaid: bigint; + balanced: boolean; + issues: string[]; +} + +/** Cross-checks invoice totals: recipient targets, funded amount and recorded payments. */ +export function reconcileInvoice(invoice: Invoice): ReconciliationReport { + const target = invoice.recipients.reduce((sum, r) => sum + r.amount, 0n); + const paymentsTotal = invoice.payments.reduce((sum, p) => sum + p.amount, 0n); + const funded = invoice.funded; + const ledgerDiscrepancy = funded - paymentsTotal; + const outstanding = target > funded ? target - funded : 0n; + const overpaid = funded > target ? funded - target : 0n; + const issues: string[] = []; + + if (ledgerDiscrepancy !== 0n) issues.push("Funded amount does not match the sum of recorded payments"); + if (overpaid > 0n) issues.push("Invoice is overpaid"); + if (invoice.status === "Released" && outstanding > 0n) issues.push("Released with an outstanding balance"); + if (invoice.payments.some((p) => p.amount <= 0n)) issues.push("Contains zero or negative payments"); + + return { + target, + funded, + paymentsTotal, + ledgerDiscrepancy, + outstanding, + overpaid, + balanced: issues.length === 0, + issues, + }; +} + +export default function ReconciliationPanel({ invoice }: { invoice: Invoice }) { + const r = reconcileInvoice(invoice); + const rows: [string, bigint][] = [ + ["Target", r.target], + ["Funded (on-chain)", r.funded], + ["Recorded payments", r.paymentsTotal], + ["Outstanding", r.outstanding], + ["Ledger discrepancy", r.ledgerDiscrepancy], + ]; + + return ( +
+
+

+ Reconciliation +

+ + {r.balanced ? "Balanced" : `${r.issues.length} issue${r.issues.length === 1 ? "" : "s"}`} + +
+
+ {rows.map(([label, value]) => ( +
+
{label}
+
{formatAmount(value)}
+
+ ))} +
+ {r.issues.length > 0 && ( +
    + {r.issues.map((issue) => ( +
  • {issue}
  • + ))} +
+ )} +
+ ); +}