Skip to content

Feat/issue 284 tax cost basis tracking - #297

Merged
robertocarlous merged 12 commits into
Neurowealth:mainfrom
Lansa-18:feat/issue-284-tax-cost-basis-tracking
Jul 21, 2026
Merged

Feat/issue 284 tax cost basis tracking#297
robertocarlous merged 12 commits into
Neurowealth:mainfrom
Lansa-18:feat/issue-284-tax-cost-basis-tracking

Conversation

@Lansa-18

Copy link
Copy Markdown
Contributor

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 Transaction table 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

GET /api/v1/portfolio/:userId/tax-report?year=<yyyy>&format=json|csv
  • Guarded by requireAuth + enforceUserAccess (own report only), Zod-validated (year 2000–2100, format defaults json)
  • UTC calendar-year boundaries on disposedAt; a year with no activity returns a valid empty report
  • format=csv returns 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)

  • PriceSource enum, CostBasisLot (one per confirmed deposit, transactionId @unique), LotDisposal (@@unique([transactionId, lotId]), denormalized userId/assetSymbol for join-free reporting)
  • No accounting-method column — FIFO order lives in code, so LIFO/HIFO can be added later without a schema change

New Domain Module — src/tax/

File Description
fifo.ts Pure FIFO engine, no DB. Sorts acquiredAt asc with id tiebreak, skips empty lots, throws InsufficientLotsError before producing any instructions (all-or-nothing)
pricing.ts USDC priced 1.0 USD with explicit STABLECOIN_ASSUMPTION; all other assets null (flagged, never zeroed)
service.ts createLotForDeposit / recordDisposalsForWithdrawal: transactional, idempotent, never-throwing
report.ts Report assembly; totals computed only over fully priced disposals, with caveats (unpriced counts/assets, stablecoin assumption, rebalances excluded)

Event Listener Wiring — src/stellar/events.ts

handleDepositEvent / handleWithdrawEvent call the tax service on the same tx handle as the Transaction/Position writes. The event listener stays the single money authority — the HTTP controller intentionally does not create lots, matching existing Position behavior.

Other Files

File Description
src/routes/portfolio.ts New tax report endpoint
src/utils/csv.ts Dependency-free RFC 4180 writer
scripts/backfill-cost-basis-lots.ts Replays all CONFIRMED deposits/withdrawals through the same service functions; idempotent, safe to re-run
docs/TAX_REPORT.md Methodology, units caveat, reconciliation SQL, known limitations
docs/openapi.yaml New path + TaxReport/TaxReportDisposal schemas, first text/csv media type in the spec — passes validate:spec
docs/API_REFERENCE.md, docs/DOCUMENTATION_INDEX.md Updated

Key Technical Decisions

1. High-risk protocol invariants (money-adjacent change):

  • Lot/disposal writes join the deposit/withdrawal DB transaction → never inconsistent with the Transaction they derive from
  • One deposit ⇒ at most one lot; one withdrawal ⇒ at most one disposal set — idempotent under event replay and the non-transactional batch-fallback path (unique constraints + exists-check, P2002 treated as benign)
  • remainingAmount never goes negative; shortfall writes nothing, logs requested/available/shortfall, and fires a critical alert — partial rows would poison later repair, while a clean re-run after backfill produces the correct ledger
  • Tax bookkeeping failures never block deposit/withdrawal confirmation (repo invariant, mirrors referral activation), but are loud and reconcilable via documented queries
  • No network I/O inside DB transactions — alert emission is fire-and-forget

2. Path-param userId, not query-param (deviation from the issue text): enforceUserAccess only checks params.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

  • 43 new unit tests: 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 vectors
  • Integration: tests/integration/tax-report.integration.test.ts401 unauth, cross-user rejection, seeded happy path, CSV headers + injection-safe cells, empty year
  • Verified live against a fresh Postgres: migration apply → rollback → re-apply; end-to-end smoke (auth, FIFO totals, caveats, escaped =SUM(A1:A9) cell, empty year); backfill idempotency (re-run writes zero duplicates)
  • Re-verified after merging main (goal-based investing #296) — same-timestamp migrations coexist cleanly and all behavior is unchanged

Deploy Notes

  1. Apply the migration, then run once:
   npx ts-node scripts/backfill-cost-basis-lots.ts
  1. Before trusting priced totals on a real network, verify one live deposit event's persisted Transaction.amount is token-scaled (not stroops) — documented in docs/TAX_REPORT.md § Units

@robertocarlous
robertocarlous merged commit b3c6f38 into Neurowealth:main Jul 21, 2026
8 checks passed
@robertocarlous

Copy link
Copy Markdown
Contributor

LGTM❤️

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Tax Reporting & Cost-Basis Lot Tracking

2 participants