Skip to content
Open
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
90 changes: 90 additions & 0 deletions frontend/__tests__/TaxReport.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
/**
* __tests__/TaxReport.test.tsx
* Unit tests for TaxReport (issue #605).
*/

import { render, screen, waitFor } from "@testing-library/react";
import TaxReport from "@/components/TaxReport";

jest.mock("react-i18next", () => ({
useTranslation: () => ({
t: (key: string) => key,
i18n: { language: "en" },
}),
}));

const mockPublicKey = { publicKey: null as string | null };
jest.mock("@/lib/useWallet", () => ({
useWallet: () => ({ publicKey: mockPublicKey.publicKey }),
}));

const mockGetPaymentHistory = jest.fn();
jest.mock("@/lib/stellar", () => ({
getPaymentHistory: (...args: unknown[]) => mockGetPaymentHistory(...args),
}));

jest.mock("@/lib/portfolio", () => {
const actual = jest.requireActual("@/lib/portfolio");
return {
...actual,
fetchTokenPricesCached: jest.fn().mockResolvedValue({ prices: {}, stale: false }),
fetchHistoricalPrices: jest.fn().mockResolvedValue({}),
};
});

jest.mock("@/lib/exportTransactions", () => ({
downloadCSV: jest.fn(),
}));

describe("TaxReport", () => {
beforeEach(() => {
jest.clearAllMocks();
mockPublicKey.publicKey = null;
mockGetPaymentHistory.mockReset();
});

it("prompts to connect a wallet when none is connected", () => {
render(<TaxReport fiatCurrency="USD" />);
expect(screen.getByText("dashboard.connectPrompt")).toBeInTheDocument();
});

it("shows an empty state when there is no transaction history", async () => {
mockPublicKey.publicKey = "GABC";
mockGetPaymentHistory.mockResolvedValue({ records: [], nextCursor: undefined });

render(<TaxReport fiatCurrency="USD" />);
await waitFor(() =>
expect(screen.getByText("taxReport.noData")).toBeInTheDocument()
);
});

it("renders a summary and per-asset rows when history exists", async () => {
mockPublicKey.publicKey = "GABC";
mockGetPaymentHistory.mockResolvedValue({
records: [
{
id: "1",
type: "received",
amount: "10",
asset: "XLM",
from: "GOTHER",
to: "GABC",
createdAt: "2026-01-01T00:00:00Z",
transactionHash: "abc",
hash: "abc",
},
],
nextCursor: undefined,
});

render(<TaxReport fiatCurrency="USD" />);

await waitFor(() =>
expect(screen.getByText("taxReport.summary")).toBeInTheDocument()
);
// Asset row present
expect(screen.getByText("XLM")).toBeInTheDocument();
// Export button present
expect(screen.getByText("taxReport.exportCSV")).toBeInTheDocument();
});
});
92 changes: 92 additions & 0 deletions frontend/__tests__/portfolio.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import {
recordPortfolioValueSnapshot,
loadPortfolioHistory,
calculatePnL,
computeFIFOTaxReport,
} from "@/lib/portfolio";

const mockGetBalances = stellarModule.getBalances as jest.Mock;
Expand Down Expand Up @@ -244,3 +245,94 @@ describe("portfolio value history and P&L", () => {
expect(pnl).toEqual({ absolute: 0, percent: null });
});
});

describe("FIFO cost-basis tax reporting (issue #605)", () => {
it("builds a single lot for one received event and values it at acquisition price", () => {
const report = computeFIFOTaxReport(
[
{ type: "received", amount: "10", asset: "XLM", createdAt: "2026-01-01T00:00:00Z" },
],
(asset) => (asset === "XLM" ? 0.1 : null),
"USD"
);
expect(report.positions).toHaveLength(1);
const pos = report.positions[0];
expect(pos.asset).toBe("XLM");
expect(pos.remainingQty).toBe(10);
expect(pos.costBasis).toBeCloseTo(1.0, 5);
expect(pos.unrealizedGain).toBeCloseTo(0, 5);
expect(report.totalRealizedGain).toBeCloseTo(0, 5);
});

it("computes realized gain on a FIFO disposal", () => {
const report = computeFIFOTaxReport(
[
// Buy 10 XLM @ 1.00 on day 1
{ type: "received", amount: "10", asset: "XLM", createdAt: "2026-01-01T00:00:00Z" },
// Sell 4 XLM @ 2.00 on day 2 -> realized gain = 4*(2-1) = 4
{ type: "sent", amount: "4", asset: "XLM", createdAt: "2026-01-02T00:00:00Z" },
],
(asset, date) => (asset === "XLM" ? (date.startsWith("2026-01-01") ? 1 : 2) : null),
"USD"
);
expect(report.totalRealizedGain).toBeCloseTo(4, 5);
// Remaining 6 XLM @ cost basis 1.0 each = 6, valued @ 2.0 -> unrealized 6
expect(report.positions[0].remainingQty).toBe(6);
expect(report.positions[0].costBasis).toBeCloseTo(6, 5);
expect(report.totalUnrealizedGain).toBeCloseTo(6, 5);
});

it("consumes the oldest lot first (FIFO order)", () => {
const report = computeFIFOTaxReport(
[
// Buy 10 @ 1, then 10 @ 3
{ type: "received", amount: "10", asset: "XLM", createdAt: "2026-01-01T00:00:00Z" },
{ type: "received", amount: "10", asset: "XLM", createdAt: "2026-02-01T00:00:00Z" },
// Sell 15 -> consumes all 10 of lot1 (cost 10) + 5 of lot2 (cost 15) = 25
{ type: "sent", amount: "15", asset: "XLM", createdAt: "2026-03-01T00:00:00Z" },
],
(asset, date) => {
if (date.startsWith("2026-01")) return 1;
if (date.startsWith("2026-02")) return 3;
return 5; // sell price
},
"USD"
);
expect(report.totalRealizedGain).toBeCloseTo(15 * 5 - 25, 5); // 75-25=50
expect(report.positions[0].remainingQty).toBe(5); // 5 from lot2 remain
expect(report.positions[0].costBasis).toBeCloseTo(15, 5); // 5 * 3
});

it("flags a position as unpriced when historical price is unavailable", () => {
const report = computeFIFOTaxReport(
[
{ type: "received", amount: "10", asset: "CUSTOM", createdAt: "2026-01-01T00:00:00Z" },
],
() => null,
"USD"
);
expect(report.positions[0].unpriced).toBe(true);
expect(report.positions[0].costBasis).toBe(0);
});

it("is order-independent across the feed and handles swaps as paired events", () => {
// Swap 10 XLM -> 20 USDC on the same date: XLM sent, USDC received.
const feed = [
{ type: "received", amount: "10", asset: "XLM", createdAt: "2026-01-01T00:00:00Z" },
{ type: "sent", amount: "10", asset: "XLM", createdAt: "2026-01-05T00:00:00Z" },
{ type: "received", amount: "20", asset: "USDC", createdAt: "2026-01-05T00:00:00Z" },
];
const priceAt = (asset: string, date: string) => {
if (asset === "XLM") return 1; // constant
if (asset === "USDC") return date.startsWith("2026-01") ? 1 : 1;
return null;
};
const report = computeFIFOTaxReport([...feed].reverse(), priceAt, "USD");
// Sorting inside the engine makes order irrelevant
expect(report.totalRealizedGain).toBeCloseTo(0, 5);
const xlm = report.positions.find((p) => p.asset === "XLM");
const usdc = report.positions.find((p) => p.asset === "USDC");
expect(xlm?.remainingQty).toBe(0);
expect(usdc?.remainingQty).toBe(20);
});
});
Loading
Loading