Feat/issue 284 tax cost basis tracking - #297
Merged
robertocarlous merged 12 commits intoJul 21, 2026
Merged
Conversation
…ue-284-tax-cost-basis-tracking
7 tasks
Contributor
|
LGTM❤️ |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Closes #284
feat: Tax Reporting & Cost-Basis Lot Tracking (#284)
Summary
Users closing positions across protocols had no way to answer "what's my realized gain/loss this year?" The
Transactiontable recorded deposits/withdrawals but tracked no acquisition price and no lot consumption, and no export capability existed anywhere in the codebase.This PR adds full cost-basis accounting from scratch: every confirmed on-chain deposit creates a cost-basis lot, every confirmed on-chain withdrawal consumes open lots FIFO and records immutable disposal rows (cost basis, proceeds, realized gain snapshotted at disposal time), and a new authenticated endpoint serves a per-user, per-year tax report as JSON or injection-safe CSV.
Endpoint
requireAuth+enforceUserAccess(own report only), Zod-validated (year2000–2100, format defaultsjson)disposedAt; a year with no activity returns a valid empty reportformat=csvreturns an RFC 4180 attachment (tax-report-<year>.csv) with spreadsheet formula-injection guarding (leading=+-@tab/CR prefixed with')Changes
Schema & Migration —
prisma/schema.prisma,prisma/migrations/20260721000000_add_tax_lot_tracking/(+rollback.sql)PriceSourceenum,CostBasisLot(one per confirmed deposit,transactionId @unique),LotDisposal(@@unique([transactionId, lotId]), denormalizeduserId/assetSymbolfor join-free reporting)New Domain Module —
src/tax/fifo.tsacquiredAtasc withidtiebreak, skips empty lots, throwsInsufficientLotsErrorbefore producing any instructions (all-or-nothing)pricing.ts1.0USD with explicitSTABLECOIN_ASSUMPTION; all other assetsnull(flagged, never zeroed)service.tscreateLotForDeposit/recordDisposalsForWithdrawal: transactional, idempotent, never-throwingreport.tsEvent Listener Wiring —
src/stellar/events.tshandleDepositEvent/handleWithdrawEventcall the tax service on the same tx handle as theTransaction/Positionwrites. The event listener stays the single money authority — the HTTP controller intentionally does not create lots, matching existingPositionbehavior.Other Files
src/routes/portfolio.tssrc/utils/csv.tsscripts/backfill-cost-basis-lots.tsCONFIRMEDdeposits/withdrawals through the same service functions; idempotent, safe to re-rundocs/TAX_REPORT.mddocs/openapi.yamlTaxReport/TaxReportDisposalschemas, firsttext/csvmedia type in the spec — passesvalidate:specdocs/API_REFERENCE.md,docs/DOCUMENTATION_INDEX.mdKey Technical Decisions
1. High-risk protocol invariants (money-adjacent change):
Transactionthey derive fromP2002treated as benign)remainingAmountnever goes negative; shortfall writes nothing, logsrequested/available/shortfall, and fires a critical alert — partial rows would poison later repair, while a clean re-run after backfill produces the correct ledger2. Path-param
userId, not query-param (deviation from the issue text):enforceUserAccessonly checksparams.userId/body.userId— a?userId=design would silently bypass authorization.3. Rebalances are not disposals (v1): rebalance events carry no per-user amounts and are same-asset protocol moves. Documented limitation.
4. Null pricing, never zero: unpriced assets are excluded from totals with visible caveats rather than silently distorting them.
Testing
tests/unit/tax/{fifo,service,report}.test.ts,tests/unit/utils/csv.test.ts— FIFO ordering/tiebreak/boundaries, all-or-nothing shortfall, idempotent replay, null propagation, totals exclusion, UTC year bounds, RFC 4180 + injection vectorstests/integration/tax-report.integration.test.ts—401unauth, cross-user rejection, seeded happy path, CSV headers + injection-safe cells, empty year=SUM(A1:A9)cell, empty year); backfill idempotency (re-run writes zero duplicates)main(goal-based investing #296) — same-timestamp migrations coexist cleanly and all behavior is unchangedDeploy Notes
Transaction.amountis token-scaled (not stroops) — documented indocs/TAX_REPORT.md § Units