Problem Statement
The tax engine (src/tax/) computes cost basis with exactly one method — FIFO — and prices exactly one asset class: stablecoins at a constant $1 assumption. A US user holding volatile assets (which the platform's rebalancing agent actively trades) cannot get a truthful cost basis or realized-gain figure, and a non-US user (or a US user wanting a better outcome) cannot choose a legally-available accounting method. prisma/schema.prisma's CostBasisLot comment explicitly leaves the door open: "FIFO consumption order lives in src/tax/fifo.ts so LIFO could be added later without a schema change." This issue walks through that door — a method-parameterized cost-basis engine with multi-currency FX-accurate pricing — without breaking the existing report, lot bookkeeping, or the idempotency guarantees the event listener depends on.
Current State
src/tax/fifo.ts: consumeLotsFifo is the only consumption order; src/tax/pricing.ts: priceForAsset returns a fixed stablecoin assumption for stablecoins and null for everything else ("unpriced, never silently zeroed").
src/tax/service.ts: createLotForDeposit and recordDisposalsForWithdrawal run inside the event listener's DB transaction and are idempotent under replay (unique constraint on transactionId / (transactionId, lotId)). CostBasisLot carries acquisitionPrice + priceSource; LotDisposal snapshots money fields at disposal time ("immutable ledger read").
scripts/backfill-cost-basis-lots.ts reconstructs lots deterministically from confirmed transactions — meaning any change to lot semantics must remain reconstructible and idempotent, or backfill breaks.
docs/TAX_REPORT.md documents the current report and its caveats.
Proposed Solution
1. Accounting-method abstraction (src/tax/methods/)
Replace the FIFO-only consumption with a method interface, keeping FIFO as the default and byte-identical current behavior:
interface CostBasisMethod {
readonly id: 'FIFO' | 'LIFO' | 'HIFO' | 'SPECIFIC_ID'
consumeLots(lots: OpenLot[], amount: Decimal, opts?: MethodOptions): ConsumptionResult
}
- LIFO: most-recently-acquired first (test: the mirror-image of FIFO on the same fixture lots — realized gain differs, remaining lots differ, totals reconcile).
- HIFO: highest
acquisitionPrice first, with documented tie-break (acquiredAt asc, then id) so it's deterministic.
- SPECIFIC_ID: disposal against explicitly selected lots — requires an input of which lots, so the withdrawal path needs an optional lot-selection parameter (validated: selected lots must cover the amount, selection cannot exceed remaining, no double-selection). This is where "the user says which lots they're selling" meets the real world.
- The invariants that must hold for every method:
remainingAmount never negative; consumption is all-or-nothing (shortfall → InsufficientLotsError with the same requested/available/shortfall shape); disposals are idempotent under replay.
- A per-user accounting method (
accountingMethod on User, default FIFO) and a per-method report: the same annual report must be producible under any method with a documented, legal-notice statement that the method choice changes tax outcomes and is the user's responsibility.
2. Multi-currency pricing (src/tax/pricing.ts)
- Extend
priceForAsset into a real pricing service with a source hierarchy and explicit provenance:
- Explicit user-declared basis (SPECIFIC_ID flows) / cost recorded at deposit time.
- A price feed for supported volatile assets (e.g. the same data source class as
ProtocolRate; rate sourcing must be documented and its fetch cadence stated) — priceSource gains a value for it.
- Stablecoin $1 assumption (unchanged).
null = genuinely unpriced, surfaced with a caveat — never a silent zero (unchanged contract, now with more sources).
- FX-accurate cost basis: for non-USD purchases, the acquisition price must be converted at the acquisition-date rate with
PriceSource capturing the FX origin. The report must state the currency of each figure (USD vs. foreign) and the FX assumption, so a multi-currency report is honest rather than silently "in USD".
- Ordering of price capture: acquisition price is snapshotted at deposit time (already the case); the disposal
disposalPrice snapshot convention stays — do not re-price history at report time.
3. Report v2 (src/tax/report.ts, src/routes/transactions.ts or a new tax route, docs/openapi.yaml)
GET /api/v1/tax/report?method=FIFO|LIFO|HIFO&from=&to= — same CSV/JSON shape as today for FIFO, plus method-selection; realized gains/losses per method on the same lot ledger. The report must include per-method totals and the method + price-source provenance in the export metadata.
- Annual statements: group by tax year with per-year summary (realized gain/loss, short vs long-term where relevant — flag long/short holding-period classification as an explicit, documented rule for this jurisdiction, not a global truth).
- Unpriced-asset handling in the report: rows with
null prices must appear with a caveat column and a count summary — never folded into totals as zero (preserve the existing contract).
4. Migration & backfill
- Add
accountingMethod to User; backfill path must remain deterministic under every method (the scripts/backfill-cost-basis-lots.ts consumer must be extended so re-running a method produces a consistent ledger — or document that method changes apply forward-only, with a clear policy).
- Policy decision required (document it): does changing a user's method re-compute historical lots/disposals (rewriting the "immutable ledger") or apply forward-only from the change date? Rewriting history breaks the immutability story and the audit-ledger ambitions; forward-only preserves idempotency. Recommend forward-only with a
methodEffectiveAt, and require a migration-safe rollback story.
Edge Cases & Failure Modes
- HIFO tie-breaks must be byte-deterministic (same lots, same order, same result — test).
- SPECIFIC_ID with insufficient selected lots: 400 with the shortfall; never partial.
- Method change mid-tax-year: the report must not silently mix methods — a year's figures are one method (documented policy above).
- Volatile asset priced on acquisition but no feed today:
priceForAsset returns null → unpriced with caveat (unchanged contract).
- FX rate lookup failure at deposit time: snapshot what is available; record
priceSource = null and caveat — never block the deposit (the lot recorder must not throw into the money path, matching today's never-throw design).
- Wash-sale-like churn (a method can create many small gains/losses): out of scope for v1 (see below), but the report should note the limitation.
Security & Privacy Considerations
- Tax data is the most sensitive user data this platform touches: the report endpoints are owner-scoped with
enforceUserAccess, and export URLs must not be guessable or cacheable across users (no per-user data in shared caches).
- The method-selection validation must reject non-whitelisted strings (no injection into a
ORDER BY/consumeLots switch).
- FX/price feed credentials stay in env/secrets (
src/config/secrets.ts); no new keys in the repo.
Out of Scope
- Wash-sale loss disallowance and other jurisdiction-specific adjustment rules (flag as a follow-up; the data model should not preclude it, but v1 does not compute it).
- Long-term/short-term holding-period classification as authoritative tax advice — the report states its own assumption instead.
- Auto-filing / regulator submission.
- Re-pricing historical lots (the snapshot-at-time contract is preserved).
Suggested Implementation Plan
src/tax/methods/ — method interface, LIFO, HIFO, SPECIFIC_ID, FIFO preserved byte-identically; exhaustive property tests (totals reconcile, invariants hold).
User.accountingMethod + migration; method plumbing through recordDisposalsForWithdrawal.
- Pricing service with source hierarchy + FX conversion + new
PriceSource value; provenance surfaced everywhere.
- Report v2 with method selection, annual grouping, unpriced caveats.
- Backfill extension + documented forward-only method-change policy;
docs/openapi.yaml; docs/TAX_REPORT.md update.
Acceptance Criteria
Problem Statement
The tax engine (
src/tax/) computes cost basis with exactly one method — FIFO — and prices exactly one asset class: stablecoins at a constant $1 assumption. A US user holding volatile assets (which the platform's rebalancing agent actively trades) cannot get a truthful cost basis or realized-gain figure, and a non-US user (or a US user wanting a better outcome) cannot choose a legally-available accounting method.prisma/schema.prisma'sCostBasisLotcomment explicitly leaves the door open: "FIFO consumption order lives insrc/tax/fifo.tsso LIFO could be added later without a schema change." This issue walks through that door — a method-parameterized cost-basis engine with multi-currency FX-accurate pricing — without breaking the existing report, lot bookkeeping, or the idempotency guarantees the event listener depends on.Current State
src/tax/fifo.ts:consumeLotsFifois the only consumption order;src/tax/pricing.ts:priceForAssetreturns a fixed stablecoin assumption for stablecoins andnullfor everything else ("unpriced, never silently zeroed").src/tax/service.ts:createLotForDepositandrecordDisposalsForWithdrawalrun inside the event listener's DB transaction and are idempotent under replay (unique constraint ontransactionId/(transactionId, lotId)).CostBasisLotcarriesacquisitionPrice+priceSource;LotDisposalsnapshots money fields at disposal time ("immutable ledger read").scripts/backfill-cost-basis-lots.tsreconstructs lots deterministically from confirmed transactions — meaning any change to lot semantics must remain reconstructible and idempotent, or backfill breaks.docs/TAX_REPORT.mddocuments the current report and its caveats.Proposed Solution
1. Accounting-method abstraction (
src/tax/methods/)Replace the FIFO-only consumption with a method interface, keeping FIFO as the default and byte-identical current behavior:
acquisitionPricefirst, with documented tie-break (acquiredAt asc, then id) so it's deterministic.remainingAmountnever negative; consumption is all-or-nothing (shortfall →InsufficientLotsErrorwith the samerequested/available/shortfallshape); disposals are idempotent under replay.accountingMethodonUser, default FIFO) and a per-method report: the same annual report must be producible under any method with a documented, legal-notice statement that the method choice changes tax outcomes and is the user's responsibility.2. Multi-currency pricing (
src/tax/pricing.ts)priceForAssetinto a real pricing service with a source hierarchy and explicit provenance:ProtocolRate; rate sourcing must be documented and its fetch cadence stated) —priceSourcegains a value for it.null= genuinely unpriced, surfaced with a caveat — never a silent zero (unchanged contract, now with more sources).PriceSourcecapturing the FX origin. The report must state the currency of each figure (USD vs. foreign) and the FX assumption, so a multi-currency report is honest rather than silently "in USD".disposalPricesnapshot convention stays — do not re-price history at report time.3. Report v2 (
src/tax/report.ts,src/routes/transactions.tsor a new tax route,docs/openapi.yaml)GET /api/v1/tax/report?method=FIFO|LIFO|HIFO&from=&to=— same CSV/JSON shape as today for FIFO, plus method-selection; realized gains/losses per method on the same lot ledger. The report must include per-method totals and the method + price-source provenance in the export metadata.nullprices must appear with a caveat column and a count summary — never folded into totals as zero (preserve the existing contract).4. Migration & backfill
accountingMethodtoUser; backfill path must remain deterministic under every method (thescripts/backfill-cost-basis-lots.tsconsumer must be extended so re-running a method produces a consistent ledger — or document that method changes apply forward-only, with a clear policy).methodEffectiveAt, and require a migration-safe rollback story.Edge Cases & Failure Modes
priceForAssetreturnsnull→ unpriced with caveat (unchanged contract).priceSource = nulland caveat — never block the deposit (the lot recorder must not throw into the money path, matching today's never-throw design).Security & Privacy Considerations
enforceUserAccess, and export URLs must not be guessable or cacheable across users (no per-user data in shared caches).ORDER BY/consumeLotsswitch).src/config/secrets.ts); no new keys in the repo.Out of Scope
Suggested Implementation Plan
src/tax/methods/— method interface, LIFO, HIFO, SPECIFIC_ID, FIFO preserved byte-identically; exhaustive property tests (totals reconcile, invariants hold).User.accountingMethod+ migration; method plumbing throughrecordDisposalsForWithdrawal.PriceSourcevalue; provenance surfaced everywhere.docs/openapi.yaml; docs/TAX_REPORT.md update.Acceptance Criteria
User.accountingMethodconfigurable; per-method report endpoint; unpriced rows caveated, never zeroedPriceSourceextendedscripts/backfill-cost-basis-lots.tsstill deterministic; method-change policy documented (recommend forward-only)docs/openapi.yamlupdated; unit + integration tests green