Problem Statement
A user closing out positions across multiple protocols over a tax year has no way to answer "what's my realized gain/loss this year?" without manually reconstructing it from raw transaction history. Every deposit establishes a cost basis; every withdrawal or agent-driven rebalance realizes a gain or loss against that basis. This is a per-user tax document, distinct in purpose from the compliance audit-log work in #271 (which is for regulators/admins, not individual tax reporting).
Current State
There is no cost-basis tracking anywhere in the codebase today — Transaction records deposits/withdrawals but does not track lot-level acquisition price or FIFO consumption. There is also no CSV or PDF export capability anywhere in the codebase currently (confirmed by search across src/ — no relevant library is even a dependency). Both need to be built from scratch as part of this issue; this should be scoped and estimated accordingly rather than treated as "add an export button to an existing exporter."
Proposed Solution
Data Model
model CostBasisLot {
id String @id @default(uuid())
userId String
transactionId String // the deposit Transaction that created this lot
assetSymbol String
amount Decimal
remainingAmount Decimal
acquiredAt DateTime
acquiredPriceUsd Decimal
createdAt DateTime @default(now())
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
transaction Transaction @relation(fields: [transactionId], references: [id])
disposals LotDisposal[]
@@index([userId, assetSymbol])
}
model LotDisposal {
id String @id @default(uuid())
lotId String
transactionId String // the withdrawal/rebalance Transaction that triggered this disposal
amountDisposed Decimal
disposedPriceUsd Decimal
realizedGainUsd Decimal
disposedAt DateTime @default(now())
lot CostBasisLot @relation(fields: [lotId], references: [id])
transaction Transaction @relation(fields: [transactionId], references: [id])
}
API Surface
GET /api/portfolio/tax-report?userId=&year=2026&format=json|csv — behind requireAuth + enforceUserAccess (matching the existing :userId-scoped pattern in src/routes/portfolio.ts), returns the disposal ledger and totals for the given tax year, or a CSV stream when format=csv.
Integration Points
- A lot is created whenever a deposit's
Transaction is finalized — hook into the same point in src/routes/deposit.ts / underlying service where the Transaction and Position update already happen, so lot creation is transactionally consistent with the deposit itself (not a separate, potentially-missed step).
- On withdrawal/rebalance, consume lots FIFO: walk
CostBasisLot rows for the asset ordered by acquiredAt, decrementing remainingAmount and writing LotDisposal rows with the realized gain/loss, until the withdrawn amount is fully accounted for. Structure this as a pure function taking lots + disposal amount + disposal price, returning the disposals to make, so it's unit-testable without a database.
- Historical USD price at acquisition/disposal time should reuse whatever price data the platform already has (
ProtocolRate/YieldSnapshot context) where available. Where it's genuinely not available for a given point in time, the report must say so explicitly for that lot/disposal rather than fabricating a price — this is a real limitation of the current data model and should be documented, not hidden.
- CSV generation: since no export library exists yet, pick one (e.g. a minimal streaming CSV writer) and establish the pattern here — this will likely be the first export feature other future export needs (e.g. transaction history export) can follow.
Edge Cases & Failure Modes
- A withdrawal amount exceeds the sum of all remaining lots for that asset (shouldn't happen if
Position accounting is correct, but the disposal function must fail loudly rather than silently under-disposing if it does).
- Assets with no available historical price at the required date — flagged per-lot in the report output, and excluded from the totals with a visible caveat rather than silently treated as zero gain.
- A tax year with zero activity — return an empty-but-valid report, not an error.
- Partial-lot disposal spanning the request's date boundary (a lot acquired in one year, partially disposed in the next) — each
LotDisposal is dated by its own disposedAt, so year filtering is straightforward, but this should be covered explicitly in tests.
Security & Privacy Considerations
- Tax data is sensitive financial information; this endpoint must follow the exact same
requireAuth + enforceUserAccess pattern as other user-scoped financial endpoints — no new/weaker auth path.
- CSV output must not be vulnerable to CSV injection (formula injection via leading
=, +, -, @ in any user-influenced field) — sanitize/escape accordingly since this is a genuinely exportable file a user might open in Excel.
Out of Scope
- LIFO or specific-lot-identification accounting methods — FIFO only for v1 (the model should make LIFO addable later without a schema change, but only FIFO needs to be implemented now).
- Jurisdiction-specific tax form generation (e.g. a literal Form 8949) — this produces the underlying ledger and totals, not a filled tax form.
Suggested Implementation Plan
CostBasisLot/LotDisposal models and migration; lot creation wired into the deposit path.
- Pure FIFO consumption function with thorough unit tests, then wired into the withdrawal/rebalance path.
- Tax report endpoint (JSON) with year filtering and gap-flagging.
- CSV export, including injection-safe encoding.
Acceptance Criteria
Problem Statement
A user closing out positions across multiple protocols over a tax year has no way to answer "what's my realized gain/loss this year?" without manually reconstructing it from raw transaction history. Every deposit establishes a cost basis; every withdrawal or agent-driven rebalance realizes a gain or loss against that basis. This is a per-user tax document, distinct in purpose from the compliance audit-log work in #271 (which is for regulators/admins, not individual tax reporting).
Current State
There is no cost-basis tracking anywhere in the codebase today —
Transactionrecords deposits/withdrawals but does not track lot-level acquisition price or FIFO consumption. There is also no CSV or PDF export capability anywhere in the codebase currently (confirmed by search acrosssrc/— no relevant library is even a dependency). Both need to be built from scratch as part of this issue; this should be scoped and estimated accordingly rather than treated as "add an export button to an existing exporter."Proposed Solution
Data Model
API Surface
GET /api/portfolio/tax-report?userId=&year=2026&format=json|csv— behindrequireAuth+enforceUserAccess(matching the existing:userId-scoped pattern insrc/routes/portfolio.ts), returns the disposal ledger and totals for the given tax year, or a CSV stream whenformat=csv.Integration Points
Transactionis finalized — hook into the same point insrc/routes/deposit.ts/ underlying service where theTransactionandPositionupdate already happen, so lot creation is transactionally consistent with the deposit itself (not a separate, potentially-missed step).CostBasisLotrows for the asset ordered byacquiredAt, decrementingremainingAmountand writingLotDisposalrows with the realized gain/loss, until the withdrawn amount is fully accounted for. Structure this as a pure function taking lots + disposal amount + disposal price, returning the disposals to make, so it's unit-testable without a database.ProtocolRate/YieldSnapshotcontext) where available. Where it's genuinely not available for a given point in time, the report must say so explicitly for that lot/disposal rather than fabricating a price — this is a real limitation of the current data model and should be documented, not hidden.Edge Cases & Failure Modes
Positionaccounting is correct, but the disposal function must fail loudly rather than silently under-disposing if it does).LotDisposalis dated by its owndisposedAt, so year filtering is straightforward, but this should be covered explicitly in tests.Security & Privacy Considerations
requireAuth+enforceUserAccesspattern as other user-scoped financial endpoints — no new/weaker auth path.=,+,-,@in any user-influenced field) — sanitize/escape accordingly since this is a genuinely exportable file a user might open in Excel.Out of Scope
Suggested Implementation Plan
CostBasisLot/LotDisposalmodels and migration; lot creation wired into the deposit path.Acceptance Criteria
CostBasisLot/LotDisposalmodels + migration; lots created transactionally alongside every successful depositrequireAuth/enforceUserAccesspatterndocs/docs/openapi.yamlupdated