From fb911bb3af65a30f7d8d775c8cf6ac0944fa4478 Mon Sep 17 00:00:00 2001 From: neumattock <152253273+newmattock@users.noreply.github.com> Date: Tue, 26 May 2026 09:53:03 -0700 Subject: [PATCH] feat: add LP earnings history export --- app/governance/page.tsx | 1 + src/components/LPEarningsTable.tsx | 144 +++++++++++++++++++++++++ src/components/LPPortfolio.tsx | 16 ++- src/utils/__tests__/lpEarnings.test.ts | 68 ++++++++++++ src/utils/lpEarnings.ts | 104 ++++++++++++++++++ vitest.config.ts | 2 +- 6 files changed, 332 insertions(+), 3 deletions(-) create mode 100644 src/components/LPEarningsTable.tsx create mode 100644 src/utils/__tests__/lpEarnings.test.ts create mode 100644 src/utils/lpEarnings.ts diff --git a/app/governance/page.tsx b/app/governance/page.tsx index 87a40dd..fe8dd65 100644 --- a/app/governance/page.tsx +++ b/app/governance/page.tsx @@ -23,6 +23,7 @@ function StatusBadge({ status }: { status: ProposalStatus }) { Failed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "cancel" }, Executed: { color: "bg-purple-500/15 text-purple-500 border-purple-500/30", icon: "rocket_launch" }, Pending: { color: "bg-amber-500/15 text-amber-500 border-amber-500/30", icon: "schedule" }, + Vetoed: { color: "bg-red-500/15 text-red-500 border-red-500/30", icon: "block" }, }; const { color, icon } = config[status]; return ( diff --git a/src/components/LPEarningsTable.tsx b/src/components/LPEarningsTable.tsx new file mode 100644 index 0000000..d643364 --- /dev/null +++ b/src/components/LPEarningsTable.tsx @@ -0,0 +1,144 @@ +"use client"; + +import { useMemo, useState } from "react"; +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { Invoice } from "@/utils/soroban"; +import { + buildLPEarningsCsv, + buildLPEarningsRows, + formatEarningsPayer, + getLPEarningsExportFilename, +} from "@/utils/lpEarnings"; +import { downloadFile } from "@/utils/exportData"; + +interface LPEarningsTableProps { + invoices: Invoice[]; + tokenMap?: Map; + defaultToken?: ApprovedToken | null; +} + +const PAGE_SIZE = 20; + +export default function LPEarningsTable({ + invoices, + tokenMap = new Map(), + defaultToken = null, +}: LPEarningsTableProps) { + const [page, setPage] = useState(1); + const rows = useMemo( + () => buildLPEarningsRows(invoices, tokenMap, defaultToken), + [defaultToken, invoices, tokenMap], + ); + const pageCount = Math.max(1, Math.ceil(rows.length / PAGE_SIZE)); + const currentPage = Math.min(page, pageCount); + const visibleRows = rows.slice((currentPage - 1) * PAGE_SIZE, currentPage * PAGE_SIZE); + + const exportCsv = () => { + downloadFile( + buildLPEarningsCsv(rows), + getLPEarningsExportFilename(), + "text/csv;charset=utf-8;", + ); + }; + + return ( +
+
+
+

+ Earnings History +

+

+ Settled LP positions for accounting and tax export. +

+
+ +
+ +
+ + + + {[ + "Invoice ID", + "Payer", + "Settlement Date", + "Amount Funded", + "Payout Received", + "Earned", + "Token", + "Yield %", + ].map((heading) => ( + + ))} + + + + {visibleRows.length === 0 ? ( + + + + ) : ( + visibleRows.map((row) => ( + + + + + + + + + + + )) + )} + +
+ {heading} +
+ No settled LP earnings yet. +
#{row.invoiceId}{formatEarningsPayer(row.payer)}{row.settlementDate}{row.amountFunded}{row.payoutReceived}{row.earned}{row.token}{row.yieldPercent}
+
+ +
+ + Showing {visibleRows.length} of {rows.length} settled positions + +
+ + + Page {currentPage} / {pageCount} + + +
+
+
+ ); +} diff --git a/src/components/LPPortfolio.tsx b/src/components/LPPortfolio.tsx index dd38602..cac6371 100644 --- a/src/components/LPPortfolio.tsx +++ b/src/components/LPPortfolio.tsx @@ -1,6 +1,6 @@ "use client"; -import { useState, useMemo } from "react"; +import { useEffect, useState, useMemo } from "react"; import { formatAddress, formatDate, formatUSDC, calculateYield } from "@/utils/format"; import type { Invoice } from "@/utils/soroban"; import type { ApprovedToken } from "@/hooks/useApprovedTokens"; @@ -9,6 +9,7 @@ import InvoiceTable, { ColumnDefinition } from "./InvoiceTable"; import LPTokenMetricsCards from "./LPTokenMetricsCards"; import WeeklyYieldChart from "./WeeklyYieldChart"; import { calculatePerTokenMetrics } from "@/utils/per-token-yield"; +import LPEarningsTable from "./LPEarningsTable"; interface LPPortfolioProps { invoices: Invoice[]; @@ -28,7 +29,12 @@ export default function LPPortfolio({ defaultToken = null, }: LPPortfolioProps) { const [showUSDEquivalent, setShowUSDEquivalent] = useState(false); - const now = Date.now(); + const [now, setNow] = useState(() => Date.now()); + + useEffect(() => { + const interval = window.setInterval(() => setNow(Date.now()), 60 * 1000); + return () => window.clearInterval(interval); + }, []); // Calculate per-token metrics const perTokenMetrics = useMemo( @@ -152,6 +158,12 @@ export default function LPPortfolio({ /> )} + + {/* Portfolio Table */} ): Invoice { + return { + id: 1n, + status: "Paid", + freelancer: "freelancer", + payer: "payer", + amount: 1000_0000000n, + due_date: 1_800_000_000n, + discount_rate: 300, + funder: "lp", + funded_at: 1_801_000_000n, + token: "usdc-token", + ...overrides, + }; +} + +const tokenMap = new Map([ + ["usdc-token", { contractId: "usdc-token", symbol: "USDC", decimals: 7 }], + ["eurc-token", { contractId: "eurc-token", symbol: "EURC", decimals: 7 }], +]); + +describe("LP earnings history", () => { + it("builds newest-first rows for paid invoices only", () => { + const rows = buildLPEarningsRows( + [ + invoice({ id: 1n, funded_at: 1_701_000_000n }), + invoice({ id: 2n, status: "Funded", funded_at: 1_901_000_000n }), + invoice({ id: 3n, funded_at: 1_801_000_000n, token: "eurc-token" }), + ], + tokenMap, + null, + ); + + expect(rows.map((row) => row.invoiceId)).toEqual(["3", "1"]); + expect(rows[0]).toMatchObject({ + token: "EURC", + amountFunded: "970 EURC", + payoutReceived: "1,000 EURC", + earned: "30 EURC", + yieldPercent: "3.00%", + }); + }); + + it("exports all rows to CSV with the required headers", () => { + const rows = buildLPEarningsRows([invoice({ id: 7n })], tokenMap, null); + const csv = buildLPEarningsCsv(rows); + + expect(csv.split("\n")[0]).toBe( + "Invoice ID,Payer,Settlement Date,Amount Funded,Payout Received,Earned,Token,Yield %", + ); + expect(csv).toContain('"7","payer"'); + expect(csv).toContain('"30 USDC"'); + }); + + it("uses the requested ILN-LP-Earnings date filename", () => { + expect(getLPEarningsExportFilename(new Date("2026-05-26T09:00:00Z"))).toBe( + "ILN-LP-Earnings-2026-05-26.csv", + ); + }); +}); diff --git a/src/utils/lpEarnings.ts b/src/utils/lpEarnings.ts new file mode 100644 index 0000000..6a13ea0 --- /dev/null +++ b/src/utils/lpEarnings.ts @@ -0,0 +1,104 @@ +import type { ApprovedToken } from "@/hooks/useApprovedTokens"; +import type { Invoice } from "@/utils/soroban"; +import { + calculateYield, + formatAddress, + formatTokenAmount, + type TokenDisplayMeta, +} from "@/utils/format"; + +export interface LPEarningsRow { + invoiceId: string; + payer: string; + settlementTimestamp: bigint; + settlementDate: string; + amountFunded: string; + payoutReceived: string; + earned: string; + token: string; + yieldPercent: string; +} + +function getTokenForInvoice( + invoice: Invoice, + tokenMap: Map, + defaultToken: ApprovedToken | null, +): TokenDisplayMeta { + return ( + tokenMap.get(invoice.token ?? defaultToken?.contractId ?? "") ?? + defaultToken ?? { + symbol: "USDC", + decimals: 7, + } + ); +} + +function formatIsoDate(timestamp: bigint): string { + return new Date(Number(timestamp) * 1000).toISOString().slice(0, 10); +} + +export function buildLPEarningsRows( + invoices: Invoice[], + tokenMap = new Map(), + defaultToken: ApprovedToken | null = null, +): LPEarningsRow[] { + return invoices + .filter((invoice) => invoice.status === "Paid") + .map((invoice) => { + const token = getTokenForInvoice(invoice, tokenMap, defaultToken); + const earned = calculateYield(invoice.amount, invoice.discount_rate); + const payoutReceived = invoice.amount; + const amountFunded = invoice.amount - earned; + const settlementTimestamp = invoice.funded_at ?? invoice.due_date; + + return { + invoiceId: invoice.id.toString(), + payer: invoice.payer, + settlementTimestamp, + settlementDate: formatIsoDate(settlementTimestamp), + amountFunded: formatTokenAmount(amountFunded, token), + payoutReceived: formatTokenAmount(payoutReceived, token), + earned: formatTokenAmount(earned, token), + token: token.symbol, + yieldPercent: `${(invoice.discount_rate / 100).toFixed(2)}%`, + }; + }) + .sort((a, b) => Number(b.settlementTimestamp - a.settlementTimestamp)); +} + +export function buildLPEarningsCsv(rows: LPEarningsRow[]): string { + const headers = [ + "Invoice ID", + "Payer", + "Settlement Date", + "Amount Funded", + "Payout Received", + "Earned", + "Token", + "Yield %", + ]; + + const escapeCell = (value: string) => `"${value.replace(/"/g, '""')}"`; + const body = rows.map((row) => + [ + row.invoiceId, + row.payer, + row.settlementDate, + row.amountFunded, + row.payoutReceived, + row.earned, + row.token, + row.yieldPercent, + ].map(escapeCell).join(","), + ); + + return [headers.join(","), ...body].join("\n"); +} + +export function getLPEarningsExportFilename(date = new Date()): string { + return `ILN-LP-Earnings-${date.toISOString().slice(0, 10)}.csv`; +} + +export function formatEarningsPayer(address: string): string { + return formatAddress(address); +} diff --git a/vitest.config.ts b/vitest.config.ts index a222b59..f89dda0 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -6,7 +6,7 @@ export default defineConfig({ plugins: [react()], resolve: { alias: { - '@': path.resolve(__dirname, '.'), + '@': path.resolve(__dirname, 'src'), }, }, test: {