Skip to content

Multi-Method Tax Engine (LIFO / HIFO / Specific ID) with Multi-Currency Cost Basis #317

Description

@robertocarlous

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:
    1. Explicit user-declared basis (SPECIFIC_ID flows) / cost recorded at deposit time.
    2. 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.
    3. Stablecoin $1 assumption (unchanged).
    4. 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

  1. src/tax/methods/ — method interface, LIFO, HIFO, SPECIFIC_ID, FIFO preserved byte-identically; exhaustive property tests (totals reconcile, invariants hold).
  2. User.accountingMethod + migration; method plumbing through recordDisposalsForWithdrawal.
  3. Pricing service with source hierarchy + FX conversion + new PriceSource value; provenance surfaced everywhere.
  4. Report v2 with method selection, annual grouping, unpriced caveats.
  5. Backfill extension + documented forward-only method-change policy; docs/openapi.yaml; docs/TAX_REPORT.md update.

Acceptance Criteria

  • FIFO path byte-identical to today (regression tests); LIFO/HIFO/SPECIFIC_ID implemented with deterministic tie-breaks
  • All methods preserve the never-negative-remaining and all-or-nothing invariants; idempotency under replay holds for every method
  • User.accountingMethod configurable; per-method report endpoint; unpriced rows caveated, never zeroed
  • Multi-currency cost basis via documented FX provenance; PriceSource extended
  • scripts/backfill-cost-basis-lots.ts still deterministic; method-change policy documented (recommend forward-only)
  • Report export is owner-scoped and non-cacheable across users
  • docs/openapi.yaml updated; unit + integration tests green

Metadata

Metadata

Assignees

Labels

GrantFox OSSIssue tracked in GrantFox OSSMaybe RewardedIssue may be eligible for a GrantFox rewardOfficial Campaign | FWC26Campaign: Official Campaign | FWC26Third CampaignCampaign: Third CampaignenhancementNew feature or request

Type

No type

Projects

No projects

Milestone

No milestone

Relationships

None yet

Development

No branches or pull requests

Issue actions