From 95841b69ad8e326fb8ed9bec529e031081cba829 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Wed, 26 Aug 2026 16:56:05 +0100 Subject: [PATCH 1/2] Add multi-method tax engine with FX-ready pricing hierarchy (#317) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cost-basis consumption is now method-parameterized (FIFO/LIFO/HIFO/ SPECIFIC_ID) via a whitelisted registry in src/tax/methods/, resolved per user from the new User.accountingMethod (default FIFO, byte- identical to prior behavior). SPECIFIC_ID withdrawals carry their lot selection on Transaction.selectedLotIds so the event listener has it when disposals are recorded on confirmation. Method changes are forward-only (User.methodEffectiveAt) — disposals already recorded are never rewritten. The tax report's `method` query param is a confirmation gate against the account's real setting, not a what-if recompute switch, since disposals are an immutable ledger of what actually happened. A mid-year method change is flagged in the report's caveats rather than silently mixed. Pricing gains a real source hierarchy (user-declared -> market feed -> stablecoin assumption -> unpriced) with a documented, always-null feed stub as the integration point for a future price source. See docs/TAX_REPORT.md. --- docs/TAX_REPORT.md | 143 ++++++++++++++---- .../migration.sql | 13 ++ prisma/schema.prisma | 83 ++++++---- scripts/backfill-cost-basis-lots.ts | 17 ++- src/controllers/transaction-controller.ts | 9 +- src/routes/portfolio.ts | 18 ++- src/routes/withdraw.ts | 4 + src/stellar/events.ts | 4 +- src/tax/fifo.ts | 125 +++------------ src/tax/methods/fifo.ts | 33 ++++ src/tax/methods/hifo.ts | 47 ++++++ src/tax/methods/index.ts | 29 ++++ src/tax/methods/lifo.ts | 32 ++++ src/tax/methods/specificId.ts | 71 +++++++++ src/tax/methods/types.ts | 130 ++++++++++++++++ src/tax/pricing.ts | 53 ++++++- src/tax/report.ts | 69 ++++++++- src/tax/service.ts | 40 +++-- tests/unit/tax/methods/hifo.test.ts | 102 +++++++++++++ tests/unit/tax/methods/index.test.ts | 101 +++++++++++++ tests/unit/tax/methods/lifo.test.ts | 67 ++++++++ tests/unit/tax/methods/specificId.test.ts | 100 ++++++++++++ tests/unit/tax/report.test.ts | 73 ++++++++- tests/unit/tax/service.test.ts | 136 +++++++++++++++++ 24 files changed, 1306 insertions(+), 193 deletions(-) create mode 100644 prisma/migrations/20260824215727_add_multi_method_tax_engine/migration.sql create mode 100644 src/tax/methods/fifo.ts create mode 100644 src/tax/methods/hifo.ts create mode 100644 src/tax/methods/index.ts create mode 100644 src/tax/methods/lifo.ts create mode 100644 src/tax/methods/specificId.ts create mode 100644 src/tax/methods/types.ts create mode 100644 tests/unit/tax/methods/hifo.test.ts create mode 100644 tests/unit/tax/methods/index.test.ts create mode 100644 tests/unit/tax/methods/lifo.test.ts create mode 100644 tests/unit/tax/methods/specificId.test.ts diff --git a/docs/TAX_REPORT.md b/docs/TAX_REPORT.md index 6af8cc6..0d63a17 100644 --- a/docs/TAX_REPORT.md +++ b/docs/TAX_REPORT.md @@ -2,9 +2,10 @@ Answers "what's my realized gain/loss this year?" (#284). Every confirmed on-chain deposit creates a **cost-basis lot**; every confirmed on-chain -withdrawal consumes open lots **FIFO** and records immutable **disposal** rows -snapshotting cost basis, proceeds, and realized gain at disposal time. The -report endpoint is a pure read over that ledger. +withdrawal consumes open lots under the account's configured **accounting +method** (FIFO/LIFO/HIFO/SPECIFIC_ID — #317) and records immutable +**disposal** rows snapshotting cost basis, proceeds, and realized gain at +disposal time. The report endpoint is a pure read over that ledger. The design principle throughout: **tax bookkeeping is derived data**. It is written transactionally alongside the deposit/withdrawal it derives from, but a @@ -14,14 +15,81 @@ idempotent backfill, never silent. ## Data model -| Model | Meaning | -| --- | --- | -| `CostBasisLot` | One per confirmed DEPOSIT Transaction (`transactionId` unique). Carries `originalAmount`, `remainingAmount`, nullable `acquisitionPrice` + `priceSource`, `acquiredAt`. | -| `LotDisposal` | One lot's share of a withdrawal. A withdrawal may span many lots (`@@unique([transactionId, lotId])`). Snapshots `disposalPrice`, `costBasis`, `proceeds`, `realizedGain` — nullable, where null means **unpriced, never zero**. | - -The schema carries no accounting-method column: FIFO ordering (acquiredAt asc, -id tiebreak) lives in `src/tax/fifo.ts`, so LIFO/HIFO could be added later -without a schema change. +| Model | Meaning | +| -------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `CostBasisLot` | One per confirmed DEPOSIT Transaction (`transactionId` unique). Carries `originalAmount`, `remainingAmount`, nullable `acquisitionPrice` + `priceSource`, `acquiredAt`. | +| `LotDisposal` | One lot's share of a withdrawal. A withdrawal may span many lots (`@@unique([transactionId, lotId])`). Snapshots `disposalPrice`, `costBasis`, `proceeds`, `realizedGain` — nullable, where null means **unpriced, never zero**. | + +`User.accountingMethod` (default `FIFO`) selects the consumption order; +`User.methodEffectiveAt` stamps when it last changed (see "Accounting +methods" below). The lot/disposal schema itself is unchanged from #284 — the +method only decides _which_ open lots a withdrawal consumes, never the shape +of what gets written. + +## Accounting methods (#317) + +`src/tax/methods/` is a small interface (`CostBasisMethod.consumeLots`) with +four implementations, resolved through a whitelist (`resolveMethod`) so a raw +method string never reaches a switch/ORDER BY: + +| Method | Consumption order | +| ------------- | ------------------------------------------------------------------------------------------ | +| `FIFO` | Oldest lot first (`acquiredAt` asc, id tiebreak). **Default**; byte-identical to pre-#317. | +| `LIFO` | Newest lot first (mirror of FIFO's ordering). | +| `HIFO` | Highest `acquisitionPrice` first; unpriced lots sort last; tiebreak `acquiredAt` asc, id. | +| `SPECIFIC_ID` | Consumes only the caller-selected lots, in the given order — see below. | + +All four share one consumption loop (`consumeOrderedLots` in +`src/tax/methods/types.ts`): all-or-nothing shortfall (`InsufficientLotsError` +before any instruction is produced), `remainingAmount` never negative, and the +same costBasis/proceeds/realizedGain math. `src/tax/fifo.ts` re-exports the +original `consumeLotsFifo` name unchanged for backward compatibility. + +### SPECIFIC_ID plumbing + +Choosing which lots to sell has to happen at withdrawal _request_ time (the +user says which lots), but disposal recording happens later, when the Stellar +event listener confirms the on-chain withdrawal. `Transaction.selectedLotIds` +(a plain string array, default empty) bridges the gap: `POST /api/v1/withdraw` +accepts an optional `selectedLotIds` array, `executeWithdraw`/ +`enqueueAndDispatch` persist it on the `Transaction` row, and +`handleWithdrawEvent` reads it back off that same row (matched by `txHash`) +when it calls `recordDisposalsForWithdrawal`. + +**Important**: an invalid or missing selection is only discovered at that +point — _after_ the withdrawal has already executed on-chain, since disposal +recording always happens on the confirmation path (the same timing every +other method already uses). It is treated exactly like an +`InsufficientLotsError` shortfall: a critical alert, nothing written, the +withdrawal itself unaffected. There is no synchronous pre-flight validation in +the withdraw route — the controller has no tax-module awareness, and adding +one would break that separation. Validate the selection client-side before +submitting a SPECIFIC_ID withdrawal. + +### Method changes are forward-only + +Changing `accountingMethod` stamps `methodEffectiveAt = now()` and never +rewrites history: disposals already recorded keep whatever numbers they were +given under the method active at the time. `buildTaxReport` surfaces a +`methodChangeNote` caveat when `methodEffectiveAt` falls inside the requested +report year, so a year that mixes two methods is flagged, never silently +presented as one. Only the most recent method change is tracked — a second +change does not retroactively re-attribute the window before the first one. + +### Pricing source hierarchy (#317) + +`src/tax/pricing.ts`'s `priceForAsset` now checks, in order: + +1. An explicit `userDeclaredPrice` passed by the caller → `USER_DECLARED`. +2. `lookupFeedPrice` — a real, callable integration point for a future + volatile-asset market-data feed (`MARKET_FEED` source) — **stubbed to + always return `null` in this release**; no feed/credentials exist yet. +3. The USDC 1:1 USD assumption → `STABLECOIN_ASSUMPTION` (unchanged). +4. `null` — genuinely unpriced (unchanged contract, never a silent zero). + +So volatile, non-stablecoin assets remain honestly unpriced today, exactly as +before #317, just reached through a documented hierarchy instead of a +two-branch `if`. ## Write path (who creates lots) @@ -55,10 +123,10 @@ fallback path: lot creation relies on the `transactionId` unique constraint ## Pricing -| Asset | Price | Source | -| --- | --- | --- | -| USDC | `1.0` USD per token | `STABLECOIN_ASSUMPTION` (surfaced in report caveats) | -| anything else | `null` | — | +| Asset | Price | Source | +| ------------- | ------------------- | ---------------------------------------------------- | +| USDC | `1.0` USD per token | `STABLECOIN_ASSUMPTION` (surfaced in report caveats) | +| anything else | `null` | — | Unpriced lots/disposals keep null money fields, are flagged `priced: false`, and are **excluded from report totals** with a visible caveat @@ -77,7 +145,7 @@ wallet-visible token amount before trusting priced totals on a new network.** ## Endpoint ``` -GET /api/v1/portfolio/:userId/tax-report?year=&format=json|csv +GET /api/v1/portfolio/:userId/tax-report?year=&format=json|csv&method=FIFO|LIFO|HIFO|SPECIFIC_ID ``` - Auth: `requireAuth` + `enforceUserAccess` (own report only). The userId is a @@ -86,12 +154,22 @@ GET /api/v1/portfolio/:userId/tax-report?year=&format=json|csv - `year` is bounded 2000–2100; boundaries are **UTC** (`disposedAt` in `[Jan 1 00:00 UTC, next Jan 1)`). A disposal belongs to the year it was disposed in, regardless of when the lot was acquired. +- `method` is **optional and a confirmation gate, not a recompute switch**: + if passed, it must equal the account's current `accountingMethod` or the + request is rejected with 400 (`MethodMismatchError`). This report shows + disposals that actually happened under whichever method was active at each + withdrawal — it cannot hypothetically re-simulate a year under a different + method, since that would produce numbers that don't match what the real + withdrawals did lot-for-lot. Change the account's method (forward-only, see + above) to affect future reports. - A year with no activity returns a valid empty report (200). - `format=csv` returns an RFC 4180 attachment (`tax-report-.csv`). Cells starting with `=` `+` `-` `@` tab or CR are prefixed with `'` (spreadsheet formula-injection guard, `src/utils/csv.ts`). Money values are decimal strings. `totals` sums only fully priced disposals. +`method` in the response is the account's `accountingMethod`, not a +per-request-computed value. ## Backfill @@ -132,18 +210,31 @@ indicate an insufficient-lots condition (see the paired critical alert). ## Known limitations (v1) -1. **FIFO only.** No LIFO/HIFO/specific-identification election. -2. **Rebalances are not disposals.** Rebalance events carry no per-user +1. **Rebalances are not disposals.** Rebalance events carry no per-user amounts (protocol/APY only) and are same-asset protocol moves; some tax regimes may treat them differently — not modeled. -3. **Non-USDC assets are unpriced** and excluded from totals (flagged in - caveats). No market price feed is integrated. -4. **USDC 1:1 USD assumption** — actual market price may deviate slightly. -5. **HTTP-controller-only transactions** never re-seen by the event listener +2. **Volatile (non-stablecoin) assets are unpriced** and excluded from + totals (flagged in caveats). The market-feed pricing hierarchy level is a + real, tested integration point but has no feed wired up yet (see + "Pricing source hierarchy"). +3. **USDC 1:1 USD assumption** — actual market price may deviate slightly. +4. **HTTP-controller-only transactions** never re-seen by the event listener get no lots/disposals (consistent with Position behavior). -6. **UTC year boundaries** — users in other timezones may expect local-time +5. **UTC year boundaries** — users in other timezones may expect local-time year edges. -7. **Forward-only unless the backfill script is run** at deploy. -8. Yield claims, referral rewards, and swaps do not create or consume lots; +6. **Forward-only unless the backfill script is run** at deploy. +7. Yield claims, referral rewards, and swaps do not create or consume lots; only DEPOSIT/WITHDRAWAL Transactions participate. -9. This is bookkeeping output, **not tax advice**; jurisdictions differ. +8. This is bookkeeping output, **not tax advice**; jurisdictions differ. +9. **The report never recomputes history under a hypothetical method** — + `?method=` is a confirmation gate against the account's real setting, not + a what-if simulator (see "Endpoint"). +10. **SPECIFIC_ID selection is validated only after the withdrawal has + already executed on-chain** (event-listener confirmation timing); an + invalid selection alerts critically and writes nothing rather than + blocking the withdrawal. +11. **Only the most recent method change is tracked** (`methodEffectiveAt` + is a single timestamp) — a second change does not retroactively + re-attribute the window before the first one. +12. Wash-sale-like adjustment rules and long/short-term holding-period + classification are not computed — out of scope, jurisdiction-specific. diff --git a/prisma/migrations/20260824215727_add_multi_method_tax_engine/migration.sql b/prisma/migrations/20260824215727_add_multi_method_tax_engine/migration.sql new file mode 100644 index 0000000..8c7d800 --- /dev/null +++ b/prisma/migrations/20260824215727_add_multi_method_tax_engine/migration.sql @@ -0,0 +1,13 @@ +-- CreateEnum +CREATE TYPE "AccountingMethod" AS ENUM ('FIFO', 'LIFO', 'HIFO', 'SPECIFIC_ID'); + +-- AlterEnum +ALTER TYPE "PriceSource" ADD VALUE 'USER_DECLARED'; +ALTER TYPE "PriceSource" ADD VALUE 'MARKET_FEED'; + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "accountingMethod" "AccountingMethod" NOT NULL DEFAULT 'FIFO', +ADD COLUMN "methodEffectiveAt" TIMESTAMP(3); + +-- AlterTable +ALTER TABLE "transactions" ADD COLUMN "selectedLotIds" TEXT[] DEFAULT ARRAY[]::TEXT[]; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 3691dc5..d6d4a55 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -137,6 +137,18 @@ enum RecurringDepositPlanStatus { // unpriced in the tax report — never silently zeroed. enum PriceSource { STABLECOIN_ASSUMPTION + // #317 — see src/tax/pricing.ts's source hierarchy. + USER_DECLARED // an explicit price the user supplied (e.g. at deposit time) + MARKET_FEED // a volatile-asset price feed snapshot — stubbed (always null) in v1 +} + +// #317 — cost-basis consumption order. FIFO is the default and preserves +// every pre-existing user's realized-gain figures unchanged. +enum AccountingMethod { + FIFO + LIFO + HIFO + SPECIFIC_ID } enum GoalStatus { @@ -181,23 +193,29 @@ enum SubAccountStatus { } model User { - id String @id @default(uuid()) - walletAddress String @unique - network Network @default(MAINNET) + id String @id @default(uuid()) + walletAddress String @unique + network Network @default(MAINNET) displayName String? - email String? @unique + email String? @unique avatarUrl String? // E.164 WhatsApp number, when known. Nullable because most users onboard via // wallet auth and never link a number. Used as the WhatsApp delivery // destination for alert rules (#289); alerts on the WHATSAPP/BOTH channel are // skipped (logged, not errored) for users without a number on file. - phone String? @unique - riskTolerance Int @default(5) + phone String? @unique + riskTolerance Int @default(5) rebalanceStrategy String? // 'MAX_YIELD' | 'TARGET_ALLOCATION' | null (defaults to MAX_YIELD) strategyConfig Json? // e.g. { "targetAllocations": { "Blend": 50, "Stellar DEX": 30, "Luma": 20 } } - isActive Boolean @default(true) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + isActive Boolean @default(true) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + // #317 — cost-basis consumption method for the tax report. Changing this + // is forward-only: methodEffectiveAt stamps when a change took effect, so + // disposals recorded before it are never retroactively recomputed (see + // docs/TAX_REPORT.md). + accountingMethod AccountingMethod @default(FIFO) + methodEffectiveAt DateTime? sessions Session[] positions Position[] @@ -340,6 +358,11 @@ model Transaction { confirmedAt DateTime? createdAt DateTime @default(now()) updatedAt DateTime @updatedAt + // #317 — SPECIFIC_ID lot selection for a WITHDRAWAL, captured at request + // time so the Stellar event listener (the sole writer of disposal rows, + // see docs/TAX_REPORT.md) has it once the on-chain event confirms. Empty + // for every other accounting method and for all non-withdrawal types. + selectedLotIds String[] @default([]) user User @relation(fields: [userId], references: [id], onDelete: Cascade) position Position? @relation(fields: [positionId], references: [id]) @@ -714,35 +737,35 @@ model SubAccount { /// provider's webhook alone. No plaintext bank/card details are ever stored /// here — the provider's hosted checkout captures all PII/payment details. model FiatOrder { - id String @id @default(uuid()) - userId String - provider String // e.g. "moonpay" — provider key, no provider-specific logic elsewhere - providerOrderId String - direction FiatDirection - fiatAmount Decimal @db.Decimal(36, 18) - fiatCurrency String - cryptoAmount Decimal? @db.Decimal(36, 18) - assetSymbol String - status FiatOrderStatus @default(PENDING) - transactionId String? // linked once the resulting on-chain Transaction is matched - checkoutUrl String? // provider hosted checkout URL (on-ramp) - kycUrl String? // provider KYC next-step link, surfaced to the user when required - failureReason String? // populated on FAILED/REFUNDED for operator/user visibility - settledAt DateTime? + id String @id @default(uuid()) + userId String + provider String // e.g. "moonpay" — provider key, no provider-specific logic elsewhere + providerOrderId String + direction FiatDirection + fiatAmount Decimal @db.Decimal(36, 18) + fiatCurrency String + cryptoAmount Decimal? @db.Decimal(36, 18) + assetSymbol String + status FiatOrderStatus @default(PENDING) + transactionId String? // linked once the resulting on-chain Transaction is matched + checkoutUrl String? // provider hosted checkout URL (on-ramp) + kycUrl String? // provider KYC next-step link, surfaced to the user when required + failureReason String? // populated on FAILED/REFUNDED for operator/user visibility + settledAt DateTime? // Best-execution / rate-drift protection (#313). quoteRate/quotedCryptoAmount // and fees are captured from the locked quote at order-creation time so // cryptoAmount is never an unexplained number; settledRate/settledCryptoAmount // are populated at on-chain settlement so the quoted-vs-settled delta is // always inspectable on the order itself. - quoteRate Decimal? @db.Decimal(36, 18) - quotedCryptoAmount Decimal? @db.Decimal(36, 18) + quoteRate Decimal? @db.Decimal(36, 18) + quotedCryptoAmount Decimal? @db.Decimal(36, 18) fees Json? // structured FeeBreakdown; null means the provider could not price it (unpriced, never assumed 0) providerQuoteId String? rateLockExpiresAt DateTime? - settledRate Decimal? @db.Decimal(36, 18) - settledCryptoAmount Decimal? @db.Decimal(36, 18) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + settledRate Decimal? @db.Decimal(36, 18) + settledCryptoAmount Decimal? @db.Decimal(36, 18) + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt user User @relation(fields: [userId], references: [id], onDelete: Cascade) transaction Transaction? @relation(fields: [transactionId], references: [id]) diff --git a/scripts/backfill-cost-basis-lots.ts b/scripts/backfill-cost-basis-lots.ts index cda7e2b..368b364 100644 --- a/scripts/backfill-cost-basis-lots.ts +++ b/scripts/backfill-cost-basis-lots.ts @@ -44,9 +44,21 @@ async function main(): Promise { amount: true, confirmedAt: true, createdAt: true, + selectedLotIds: true, }, }) + // #317 — each transaction records disposals under its owner's CURRENT + // accounting method (methods are forward-only; there is no historical + // per-transaction method to recover). Loaded once per distinct user + // rather than per transaction. + const userIds = [...new Set(transactions.map((t) => t.userId))] + const users = await db.user.findMany({ + where: { id: { in: userIds } }, + select: { id: true, accountingMethod: true }, + }) + const methodByUserId = new Map(users.map((u) => [u.id, u.accountingMethod])) + logger.info('[Tax Backfill] Starting', { transactions: transactions.length, dryRun: DRY_RUN, @@ -78,7 +90,10 @@ async function main(): Promise { tx.id, tx.assetSymbol, tx.amount, - effectiveAt + effectiveAt, + db, + methodByUserId.get(tx.userId), + tx.selectedLotIds ) } processed++ diff --git a/src/controllers/transaction-controller.ts b/src/controllers/transaction-controller.ts index 5e5c0ee..00dab0c 100644 --- a/src/controllers/transaction-controller.ts +++ b/src/controllers/transaction-controller.ts @@ -30,6 +30,10 @@ async function enqueueAndDispatch(params: { protocolName?: string memo?: string actingAsUserId?: string | null + // #317 — SPECIFIC_ID lot selection, WITHDRAWAL only. Captured here so the + // Stellar event listener has it once the on-chain withdrawal confirms + // (see src/tax/service.ts's recordDisposalsForWithdrawal). + selectedLotIds?: string[] }): Promise { const pending = await db.$transaction(async (tx) => { const transaction = await tx.transaction.create({ @@ -43,6 +47,7 @@ async function enqueueAndDispatch(params: { network: params.network, protocolName: params.protocolName, memo: params.memo, + selectedLotIds: params.selectedLotIds ?? [], }, }) @@ -179,7 +184,8 @@ export async function processOnChainTransaction( res: Response, type: 'DEPOSIT' | 'WITHDRAWAL' ) { - const { userId, amount, assetSymbol, protocolName, memo } = req.body + const { userId, amount, assetSymbol, protocolName, memo, selectedLotIds } = + req.body if (!req.auth) { return sendUnauthorized(res) @@ -222,6 +228,7 @@ export async function processOnChainTransaction( protocolName, memo, actingAsUserId, + selectedLotIds, }) logger.info('On-chain withdrawal completed', { diff --git a/src/routes/portfolio.ts b/src/routes/portfolio.ts index bf00d22..bb2fe88 100644 --- a/src/routes/portfolio.ts +++ b/src/routes/portfolio.ts @@ -30,7 +30,9 @@ import { buildTaxReport, taxReportToCsvRows, TAX_REPORT_CSV_HEADERS, + MethodMismatchError, } from '../tax/report' +import { AccountingMethod } from '@prisma/client' import { toCsv } from '../utils/csv' import goalsRouter from './goals' @@ -53,6 +55,10 @@ const taxReportSchema = z.object({ query: z.object({ year: z.coerce.number().int().min(2000).max(2100), format: z.enum(['json', 'csv']).default('json'), + // #317 — whitelisted against the AccountingMethod enum (never a raw + // string into a switch/ORDER BY); optional confirmation gate, not a + // recompute switch — see src/tax/report.ts's buildTaxReport. + method: z.nativeEnum(AccountingMethod).optional(), }), }) @@ -343,7 +349,17 @@ router.get( } const year = req.query.year as unknown as number - const report = await buildTaxReport(userId, year) + const method = req.query.method as AccountingMethod | undefined + + let report + try { + report = await buildTaxReport(userId, year, method) + } catch (err) { + if (err instanceof MethodMismatchError) { + return res.status(400).json({ error: err.message }) + } + throw err + } if (req.query.format === 'csv') { res.setHeader('Content-Type', 'text/csv; charset=utf-8') diff --git a/src/routes/withdraw.ts b/src/routes/withdraw.ts index f2d0f17..8cc88ed 100644 --- a/src/routes/withdraw.ts +++ b/src/routes/withdraw.ts @@ -13,6 +13,10 @@ const withdrawSchema = z.object({ assetSymbol: z.string().min(1), protocolName: z.string().min(1).optional(), memo: z.string().max(280).optional(), + // #317 — required only when the user's accountingMethod is SPECIFIC_ID; + // ignored otherwise. Enforced in src/tax/service.ts at disposal-recording + // time, not here — this route has no tax-module awareness. + selectedLotIds: z.array(z.string().uuid()).optional(), }) router.post( diff --git a/src/stellar/events.ts b/src/stellar/events.ts index ba85486..35ec1ad 100644 --- a/src/stellar/events.ts +++ b/src/stellar/events.ts @@ -424,7 +424,9 @@ async function handleWithdrawEvent( withdrawData.assetSymbol, withdrawData.amount, transaction.confirmedAt ?? new Date(), - tx + tx, + user.accountingMethod, + transaction.selectedLotIds ) } diff --git a/src/tax/fifo.ts b/src/tax/fifo.ts index 4e7a08d..a7b16fd 100644 --- a/src/tax/fifo.ts +++ b/src/tax/fifo.ts @@ -1,115 +1,32 @@ /** - * Pure FIFO lot-consumption engine for tax cost-basis tracking (#284). + * Backward-compatible FIFO entry point (#284, extended by #317). * - * No database access — callers load open lots, run this, then persist the - * returned instructions transactionally (see src/tax/service.ts). Keeping the - * accounting method here (not in the schema) means LIFO/HIFO could be added - * later as sibling functions without a schema change. + * The actual method implementations now live in src/tax/methods/ (a + * method-parameterized engine — LIFO/HIFO/SPECIFIC_ID added alongside + * FIFO). This module re-exports the original names byte-identically so + * every existing caller/import of `consumeLotsFifo` keeps working + * unchanged; src/tax/service.ts and scripts/backfill-cost-basis-lots.ts + * behave exactly as before when no method is specified (FIFO is the + * default `AccountingMethod`). */ import { Decimal } from '@prisma/client/runtime/library' +import { fifoMethod } from './methods/fifo' +import { + OpenLot, + DisposalInstruction as MethodDisposalInstruction, + ConsumptionResult, + InsufficientLotsError, +} from './methods/types' + +export type { OpenLot } +export type DisposalInstruction = MethodDisposalInstruction +export type FifoResult = ConsumptionResult +export { InsufficientLotsError } -export interface OpenLot { - id: string - remainingAmount: Decimal - acquisitionPrice: Decimal | null - acquiredAt: Date -} - -export interface DisposalInstruction { - lotId: string - amount: Decimal - disposalPrice: Decimal | null - // Null when the lot's acquisition price is unknown — never zero, so - // unpriced disposals are visibly excluded from report totals. - costBasis: Decimal | null - proceeds: Decimal | null - realizedGain: Decimal | null -} - -export interface FifoResult { - disposals: DisposalInstruction[] - updatedLots: { id: string; remainingAmount: Decimal }[] -} - -export class InsufficientLotsError extends Error { - readonly requested: Decimal - readonly available: Decimal - readonly shortfall: Decimal - - constructor(requested: Decimal, available: Decimal) { - super( - `Insufficient lot balance: requested ${requested.toString()}, available ${available.toString()}` - ) - this.name = 'InsufficientLotsError' - this.requested = requested - this.available = available - this.shortfall = requested.minus(available) - } -} - -/** - * Consume `amount` from `lots` in FIFO order (acquiredAt asc, id as a stable - * tiebreak). All-or-nothing: throws InsufficientLotsError before producing any - * instructions if the open lots cannot cover the full amount — partial - * disposal rows written under an error path would poison later repair. - */ export function consumeLotsFifo( lots: OpenLot[], amount: Decimal, disposalPrice: Decimal | null ): FifoResult { - if (amount.isZero()) { - return { disposals: [], updatedLots: [] } - } - - const openLots = lots - .filter((lot) => lot.remainingAmount.greaterThan(0)) - .sort((a, b) => { - const byTime = a.acquiredAt.getTime() - b.acquiredAt.getTime() - if (byTime !== 0) return byTime - return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 - }) - - const available = openLots.reduce( - (sum, lot) => sum.plus(lot.remainingAmount), - new Decimal(0) - ) - if (available.lessThan(amount)) { - throw new InsufficientLotsError(amount, available) - } - - const disposals: DisposalInstruction[] = [] - const updatedLots: FifoResult['updatedLots'] = [] - let remaining = amount - - for (const lot of openLots) { - if (remaining.isZero()) break - - const consumed = Decimal.min(lot.remainingAmount, remaining) - remaining = remaining.minus(consumed) - updatedLots.push({ - id: lot.id, - remainingAmount: lot.remainingAmount.minus(consumed), - }) - - const costBasis = - lot.acquisitionPrice !== null - ? consumed.times(lot.acquisitionPrice) - : null - const proceeds = - disposalPrice !== null ? consumed.times(disposalPrice) : null - const realizedGain = - costBasis !== null && proceeds !== null ? proceeds.minus(costBasis) : null - - disposals.push({ - lotId: lot.id, - amount: consumed, - disposalPrice, - costBasis, - proceeds, - realizedGain, - }) - } - - return { disposals, updatedLots } + return fifoMethod.consumeLots(lots, amount, disposalPrice) } diff --git a/src/tax/methods/fifo.ts b/src/tax/methods/fifo.ts new file mode 100644 index 0000000..f2fb42a --- /dev/null +++ b/src/tax/methods/fifo.ts @@ -0,0 +1,33 @@ +/** + * FIFO: oldest lot first. Byte-identical ordering to the original + * src/tax/fifo.ts (acquiredAt asc, id tiebreak) — this is the default + * method and must not change existing users' realized-gain figures. + */ +import { Decimal } from '@prisma/client/runtime/library' +import { + CostBasisMethod, + OpenLot, + ConsumptionResult, + consumeOrderedLots, +} from './types' + +function order(lots: OpenLot[]): OpenLot[] { + return lots + .filter((lot) => lot.remainingAmount.greaterThan(0)) + .sort((a, b) => { + const byTime = a.acquiredAt.getTime() - b.acquiredAt.getTime() + if (byTime !== 0) return byTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 + }) +} + +export const fifoMethod: CostBasisMethod = { + id: 'FIFO', + consumeLots( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null + ): ConsumptionResult { + return consumeOrderedLots(order(lots), amount, disposalPrice) + }, +} diff --git a/src/tax/methods/hifo.ts b/src/tax/methods/hifo.ts new file mode 100644 index 0000000..a3c0497 --- /dev/null +++ b/src/tax/methods/hifo.ts @@ -0,0 +1,47 @@ +/** + * HIFO: highest acquisitionPrice first (minimizes realized gain / maximizes + * loss-harvesting). Unpriced lots (acquisitionPrice === null) sort last — + * they cannot be "highest" — with a documented, deterministic tiebreak + * (acquiredAt asc, then id) so two runs on the same lots always produce the + * same disposal order. + */ +import { Decimal } from '@prisma/client/runtime/library' +import { + CostBasisMethod, + OpenLot, + ConsumptionResult, + consumeOrderedLots, +} from './types' + +function order(lots: OpenLot[]): OpenLot[] { + return lots + .filter((lot) => lot.remainingAmount.greaterThan(0)) + .sort((a, b) => { + if (a.acquisitionPrice === null && b.acquisitionPrice === null) { + return tiebreak(a, b) + } + if (a.acquisitionPrice === null) return 1 + if (b.acquisitionPrice === null) return -1 + + const byPrice = b.acquisitionPrice.comparedTo(a.acquisitionPrice) + if (byPrice !== 0) return byPrice + return tiebreak(a, b) + }) +} + +function tiebreak(a: OpenLot, b: OpenLot): number { + const byTime = a.acquiredAt.getTime() - b.acquiredAt.getTime() + if (byTime !== 0) return byTime + return a.id < b.id ? -1 : a.id > b.id ? 1 : 0 +} + +export const hifoMethod: CostBasisMethod = { + id: 'HIFO', + consumeLots( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null + ): ConsumptionResult { + return consumeOrderedLots(order(lots), amount, disposalPrice) + }, +} diff --git a/src/tax/methods/index.ts b/src/tax/methods/index.ts new file mode 100644 index 0000000..72932c6 --- /dev/null +++ b/src/tax/methods/index.ts @@ -0,0 +1,29 @@ +/** + * Method registry (#317) — a whitelist lookup, so the report/service layer + * never string-switches on a raw user-supplied method value (closes the + * injection concern the issue calls out). + */ +import { AccountingMethod } from '@prisma/client' +import { CostBasisMethod } from './types' +import { fifoMethod } from './fifo' +import { lifoMethod } from './lifo' +import { hifoMethod } from './hifo' +import { specificIdMethod } from './specificId' + +const METHODS: Record = { + FIFO: fifoMethod, + LIFO: lifoMethod, + HIFO: hifoMethod, + SPECIFIC_ID: specificIdMethod, +} + +export function resolveMethod(method: AccountingMethod): CostBasisMethod { + const resolved = METHODS[method] + if (!resolved) { + throw new Error(`Unknown accounting method: ${method}`) + } + return resolved +} + +export * from './types' +export { SpecificIdSelectionError } from './specificId' diff --git a/src/tax/methods/lifo.ts b/src/tax/methods/lifo.ts new file mode 100644 index 0000000..5c7bf4a --- /dev/null +++ b/src/tax/methods/lifo.ts @@ -0,0 +1,32 @@ +/** + * LIFO: most-recently-acquired lot first. Mirror image of FIFO's ordering + * (acquiredAt desc, id desc tiebreak) on the same fixture lots. + */ +import { Decimal } from '@prisma/client/runtime/library' +import { + CostBasisMethod, + OpenLot, + ConsumptionResult, + consumeOrderedLots, +} from './types' + +function order(lots: OpenLot[]): OpenLot[] { + return lots + .filter((lot) => lot.remainingAmount.greaterThan(0)) + .sort((a, b) => { + const byTime = b.acquiredAt.getTime() - a.acquiredAt.getTime() + if (byTime !== 0) return byTime + return a.id < b.id ? 1 : a.id > b.id ? -1 : 0 + }) +} + +export const lifoMethod: CostBasisMethod = { + id: 'LIFO', + consumeLots( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null + ): ConsumptionResult { + return consumeOrderedLots(order(lots), amount, disposalPrice) + }, +} diff --git a/src/tax/methods/specificId.ts b/src/tax/methods/specificId.ts new file mode 100644 index 0000000..3751610 --- /dev/null +++ b/src/tax/methods/specificId.ts @@ -0,0 +1,71 @@ +/** + * SPECIFIC_ID: disposal against explicitly selected lots, in the given + * order. Unlike FIFO/LIFO/HIFO (which derive an order from all open lots), + * this method only ever sees the lots the caller selected — so "selection + * cannot exceed remaining" falls out of the same all-or-nothing + * `InsufficientLotsError` shortfall check every other method uses, just + * scoped to a smaller lot set. + */ +import { Decimal } from '@prisma/client/runtime/library' +import { + CostBasisMethod, + OpenLot, + ConsumptionResult, + MethodOptions, + consumeOrderedLots, +} from './types' + +export class SpecificIdSelectionError extends Error { + constructor(message: string) { + super(message) + this.name = 'SpecificIdSelectionError' + } +} + +function order( + lots: OpenLot[], + selectedLotIds: string[] | undefined +): OpenLot[] { + if (!selectedLotIds || selectedLotIds.length === 0) { + throw new SpecificIdSelectionError( + 'selectedLotIds is required for the SPECIFIC_ID method' + ) + } + + const seen = new Set() + for (const id of selectedLotIds) { + if (seen.has(id)) { + throw new SpecificIdSelectionError( + `Lot ${id} was selected more than once` + ) + } + seen.add(id) + } + + const byId = new Map(lots.map((lot) => [lot.id, lot])) + return selectedLotIds.map((id) => { + const lot = byId.get(id) + if (!lot || !lot.remainingAmount.greaterThan(0)) { + throw new SpecificIdSelectionError( + `Lot ${id} is not an open lot for this user/asset` + ) + } + return lot + }) +} + +export const specificIdMethod: CostBasisMethod = { + id: 'SPECIFIC_ID', + consumeLots( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null, + options?: MethodOptions + ): ConsumptionResult { + return consumeOrderedLots( + order(lots, options?.selectedLotIds), + amount, + disposalPrice + ) + }, +} diff --git a/src/tax/methods/types.ts b/src/tax/methods/types.ts new file mode 100644 index 0000000..e7d103b --- /dev/null +++ b/src/tax/methods/types.ts @@ -0,0 +1,130 @@ +/** + * Accounting-method abstraction (#317). + * + * No database access — same contract as the original src/tax/fifo.ts: + * callers load open lots, run one of these, then persist the returned + * instructions transactionally (see src/tax/service.ts). Every method must + * uphold the same invariants: remainingAmount never negative, consumption + * is all-or-nothing (InsufficientLotsError before any instructions are + * produced), and — because these are pure functions over the caller-supplied + * `lots` array — replaying the same inputs always produces the same + * disposals (the actual idempotency-under-replay guarantee lives in + * src/tax/service.ts's exists-check, unaffected by which method ran). + */ +import { Decimal } from '@prisma/client/runtime/library' + +export interface OpenLot { + id: string + remainingAmount: Decimal + acquisitionPrice: Decimal | null + acquiredAt: Date +} + +export interface DisposalInstruction { + lotId: string + amount: Decimal + disposalPrice: Decimal | null + // Null when the lot's acquisition price is unknown — never zero, so + // unpriced disposals are visibly excluded from report totals. + costBasis: Decimal | null + proceeds: Decimal | null + realizedGain: Decimal | null +} + +export interface ConsumptionResult { + disposals: DisposalInstruction[] + updatedLots: { id: string; remainingAmount: Decimal }[] +} + +export interface MethodOptions { + // SPECIFIC_ID only: the lots to consume from, in the given order. Ignored + // by every other method. + selectedLotIds?: string[] +} + +export class InsufficientLotsError extends Error { + readonly requested: Decimal + readonly available: Decimal + readonly shortfall: Decimal + + constructor(requested: Decimal, available: Decimal) { + super( + `Insufficient lot balance: requested ${requested.toString()}, available ${available.toString()}` + ) + this.name = 'InsufficientLotsError' + this.requested = requested + this.available = available + this.shortfall = requested.minus(available) + } +} + +export interface CostBasisMethod { + readonly id: 'FIFO' | 'LIFO' | 'HIFO' | 'SPECIFIC_ID' + consumeLots( + lots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null, + options?: MethodOptions + ): ConsumptionResult +} + +/** + * Shared consumption loop: given `lots` already in the method's intended + * order, walk them front-to-back consuming `amount`. Every method (FIFO, + * LIFO, HIFO, SPECIFIC_ID) sorts/selects differently but reduces to this + * same walk, so the money-math (costBasis/proceeds/realizedGain, the + * all-or-nothing shortfall check) lives in exactly one place. + */ +export function consumeOrderedLots( + orderedOpenLots: OpenLot[], + amount: Decimal, + disposalPrice: Decimal | null +): ConsumptionResult { + if (amount.isZero()) { + return { disposals: [], updatedLots: [] } + } + + const available = orderedOpenLots.reduce( + (sum, lot) => sum.plus(lot.remainingAmount), + new Decimal(0) + ) + if (available.lessThan(amount)) { + throw new InsufficientLotsError(amount, available) + } + + const disposals: DisposalInstruction[] = [] + const updatedLots: ConsumptionResult['updatedLots'] = [] + let remaining = amount + + for (const lot of orderedOpenLots) { + if (remaining.isZero()) break + if (lot.remainingAmount.isZero()) continue + + const consumed = Decimal.min(lot.remainingAmount, remaining) + remaining = remaining.minus(consumed) + updatedLots.push({ + id: lot.id, + remainingAmount: lot.remainingAmount.minus(consumed), + }) + + const costBasis = + lot.acquisitionPrice !== null + ? consumed.times(lot.acquisitionPrice) + : null + const proceeds = + disposalPrice !== null ? consumed.times(disposalPrice) : null + const realizedGain = + costBasis !== null && proceeds !== null ? proceeds.minus(costBasis) : null + + disposals.push({ + lotId: lot.id, + amount: consumed, + disposalPrice, + costBasis, + proceeds, + realizedGain, + }) + } + + return { disposals, updatedLots } +} diff --git a/src/tax/pricing.ts b/src/tax/pricing.ts index ae9ed8e..e5c48ae 100644 --- a/src/tax/pricing.ts +++ b/src/tax/pricing.ts @@ -1,10 +1,19 @@ /** - * USD pricing for tax lots (#284). v1 prices stablecoins only: USDC is - * assumed 1:1 USD with an explicit STABLECOIN_ASSUMPTION source surfaced in - * the report. Any other asset returns a null price, is flagged per-lot and - * per-disposal, and is excluded from report totals with a visible caveat — - * never silently zeroed. Prices are per token; amounts must be token units - * (see docs/TAX_REPORT.md "Units"). + * USD pricing for tax lots (#284, extended by #317). + * + * Source hierarchy, checked in order: + * 1. An explicit user-declared price (e.g. supplied at deposit time for an + * asset the platform doesn't otherwise price) — USER_DECLARED. + * 2. A market-data feed lookup for volatile assets — STUBBED (see + * lookupFeedPrice below). No feed is wired up in this v1; the function + * always returns null so this hierarchy level is a real, tested + * integration point rather than a TODO comment. + * 3. The USDC 1:1 USD assumption — STABLECOIN_ASSUMPTION (unchanged). + * 4. null = genuinely unpriced, surfaced with a caveat — never a silent + * zero (unchanged contract). + * + * Prices are per token; amounts must be token units (see + * docs/TAX_REPORT.md "Units"). */ import { PriceSource } from '@prisma/client' import { Decimal } from '@prisma/client/runtime/library' @@ -14,9 +23,39 @@ export interface AssetPrice { source: PriceSource | null } -export function priceForAsset(assetSymbol: string): AssetPrice { +export interface PriceForAssetOptions { + userDeclaredPrice?: Decimal | string | number +} + +/** + * Integration point for a future market-data source (the same "fetched and + * stored" shape as ProtocolRate — see prisma/schema.prisma). Always returns + * null today: no feed/credentials exist yet, and priceForAsset's contract + * requires unpriced assets to stay honestly null, never a fabricated value. + */ +function lookupFeedPrice(_assetSymbol: string): Decimal | null { + return null +} + +export function priceForAsset( + assetSymbol: string, + options?: PriceForAssetOptions +): AssetPrice { + if (options?.userDeclaredPrice !== undefined) { + return { + price: new Decimal(options.userDeclaredPrice), + source: PriceSource.USER_DECLARED, + } + } + + const feedPrice = lookupFeedPrice(assetSymbol) + if (feedPrice !== null) { + return { price: feedPrice, source: PriceSource.MARKET_FEED } + } + if (assetSymbol === 'USDC') { return { price: new Decimal(1), source: PriceSource.STABLECOIN_ASSUMPTION } } + return { price: null, source: null } } diff --git a/src/tax/report.ts b/src/tax/report.ts index b4382ab..e2adf12 100644 --- a/src/tax/report.ts +++ b/src/tax/report.ts @@ -1,17 +1,44 @@ /** - * Tax report assembly (#284). A pure read over the LotDisposal ledger — - * disposal rows snapshot cost basis / proceeds / gain at disposal time, so the - * report never recomputes money from mutable state. Totals include only fully - * priced disposals; unpriced ones are flagged and counted in caveats, never - * zeroed into the sums. Year boundaries are UTC. + * Tax report assembly (#284, extended by #317). A pure read over the + * LotDisposal ledger — disposal rows snapshot cost basis / proceeds / gain + * at disposal time, so the report never recomputes money from mutable + * state. Totals include only fully priced disposals; unpriced ones are + * flagged and counted in caveats, never zeroed into the sums. Year + * boundaries are UTC. + * + * Method selection (#317): this report shows the disposals that actually + * happened, recorded under whichever method was active on the account at + * each withdrawal — it does not hypothetically re-simulate history under a + * different method (that would produce numbers that don't match what the + * real withdrawals actually did, lot-for-lot). `method`, if passed, is + * therefore a confirmation gate: it must match the account's current + * `accountingMethod` or the call is rejected (MethodMismatchError) — never + * a silent recompute switch. */ -import { Prisma } from '@prisma/client' +import { AccountingMethod, Prisma } from '@prisma/client' import { Decimal } from '@prisma/client/runtime/library' import db from '../db' import { CsvValue } from '../utils/csv' type Db = typeof db | Prisma.TransactionClient +export class MethodMismatchError extends Error { + readonly requestedMethod: AccountingMethod + readonly actualMethod: AccountingMethod + + constructor( + requestedMethod: AccountingMethod, + actualMethod: AccountingMethod + ) { + super( + `Requested report method '${requestedMethod}' does not match the account's configured method '${actualMethod}'` + ) + this.name = 'MethodMismatchError' + this.requestedMethod = requestedMethod + this.actualMethod = actualMethod + } +} + export interface TaxReportDisposal { disposedAt: string assetSymbol: string @@ -30,7 +57,7 @@ export interface TaxReportDisposal { export interface TaxReport { userId: string year: number - method: 'FIFO' + method: AccountingMethod disposals: TaxReportDisposal[] totals: { proceeds: string @@ -43,6 +70,10 @@ export interface TaxReport { unpricedAssets: string[] stablecoinAssumption: string rebalancesNotIncluded: string + // #317 — set only when the account's method changed mid-year (see + // methodEffectiveAt): explains that this year's disposals are not all + // one method, per the issue's "must not silently mix methods" rule. + methodChangeNote: string | null } } @@ -52,8 +83,20 @@ const str = (value: Decimal | null): string | null => export async function buildTaxReport( userId: string, year: number, + method?: AccountingMethod, database: Db = db ): Promise { + const user = await (database as any).user.findUnique({ + where: { id: userId }, + select: { accountingMethod: true, methodEffectiveAt: true }, + }) + if (!user) { + throw new Error(`User ${userId} not found`) + } + if (method && method !== user.accountingMethod) { + throw new MethodMismatchError(method, user.accountingMethod) + } + const rows = await (database as any).lotDisposal.findMany({ where: { userId, @@ -101,10 +144,17 @@ export async function buildTaxReport( } } + const yearStart = new Date(Date.UTC(year, 0, 1)) + const yearEnd = new Date(Date.UTC(year + 1, 0, 1)) + const methodChangedDuringYear = + user.methodEffectiveAt !== null && + user.methodEffectiveAt >= yearStart && + user.methodEffectiveAt < yearEnd + return { userId, year, - method: 'FIFO', + method: user.accountingMethod, disposals, totals: { proceeds: proceeds.toString(), @@ -119,6 +169,9 @@ export async function buildTaxReport( 'USDC is priced at 1.00 USD by assumption (STABLECOIN_ASSUMPTION); no market price feed is used.', rebalancesNotIncluded: 'Protocol rebalances are same-asset transfers and are not treated as taxable disposals in this report.', + methodChangeNote: methodChangedDuringYear + ? `The accounting method changed to ${user.accountingMethod} on ${user.methodEffectiveAt!.toISOString()}. Disposals before that date were recorded under the previously configured method; this report does not retroactively recompute them.` + : null, }, } } diff --git a/src/tax/service.ts b/src/tax/service.ts index eb9d7bd..4de6551 100644 --- a/src/tax/service.ts +++ b/src/tax/service.ts @@ -14,12 +14,13 @@ * (no awaited network I/O inside the DB transaction), reconcilable via the * queries in docs/TAX_REPORT.md. */ -import { Prisma } from '@prisma/client' +import { Prisma, AccountingMethod } from '@prisma/client' import { Decimal } from '@prisma/client/runtime/library' import db from '../db' import { logger } from '../utils/logger' import { alertingService } from '../services/alerting' -import { consumeLotsFifo, InsufficientLotsError } from './fifo' +import { InsufficientLotsError } from './fifo' +import { resolveMethod, SpecificIdSelectionError } from './methods' import { priceForAsset } from './pricing' type Db = typeof db | Prisma.TransactionClient @@ -110,12 +111,22 @@ export async function createLotForDeposit( } /** - * Record FIFO disposals for a confirmed withdrawal Transaction. Idempotent: - * if any disposal already exists for this transactionId the call is a no-op - * (event replay). All-or-nothing: when open lots cannot cover the withdrawal, - * nothing is written — partial rows written under an error path would poison - * later repair, while an idempotent re-run after backfill produces the correct + * Record cost-basis disposals for a confirmed withdrawal Transaction, under + * the given accounting `method` (default FIFO — unchanged behavior for + * every existing caller). Idempotent: if any disposal already exists for + * this transactionId the call is a no-op (event replay). All-or-nothing: + * when the relevant lots (all open lots for FIFO/LIFO/HIFO, or just the + * `selectedLotIds` for SPECIFIC_ID) cannot cover the withdrawal, nothing is + * written — partial rows written under an error path would poison later + * repair, while an idempotent re-run after backfill produces the correct * ledger. That case alerts critically but never blocks the withdrawal. + * + * SPECIFIC_ID note: an invalid/missing selection is only ever discovered + * here — after the withdrawal has already been submitted on-chain, since + * disposal recording happens on the event-listener's confirmation path + * (see docs/TAX_REPORT.md), the same timing every other method already + * uses. It is treated exactly like an InsufficientLotsError: a critical + * alert, nothing written, the withdrawal itself unaffected. */ export async function recordDisposalsForWithdrawal( userId: string, @@ -123,7 +134,9 @@ export async function recordDisposalsForWithdrawal( assetSymbol: string, amount: Decimal | string | number, disposedAt: Date, - database: Db = db + database: Db = db, + method: AccountingMethod = AccountingMethod.FIFO, + selectedLotIds?: string[] ): Promise { try { const existing = await (database as any).lotDisposal.findFirst({ @@ -146,7 +159,7 @@ export async function recordDisposalsForWithdrawal( }) const { price } = priceForAsset(assetSymbol) - const { disposals, updatedLots } = consumeLotsFifo( + const { disposals, updatedLots } = resolveMethod(method).consumeLots( openLots.map((lot: any) => ({ id: lot.id, remainingAmount: new Decimal(lot.remainingAmount), @@ -157,7 +170,8 @@ export async function recordDisposalsForWithdrawal( acquiredAt: lot.acquiredAt, })), new Decimal(amount), - price + price, + { selectedLotIds } ) for (const lot of updatedLots) { @@ -203,10 +217,12 @@ export async function recordDisposalsForWithdrawal( } const message = err instanceof Error ? err.message : String(err) const isShortfall = err instanceof InsufficientLotsError + const isBadSelection = err instanceof SpecificIdSelectionError logger.error('[Tax] Disposal recording failed (withdrawal unaffected)', { userId, transactionId, assetSymbol, + method, error: message, ...(isShortfall && { requested: (err as InsufficientLotsError).requested.toString(), @@ -218,7 +234,9 @@ export async function recordDisposalsForWithdrawal( { title: isShortfall ? 'Withdrawal exceeds tracked cost-basis lots' - : 'Disposal recording failed', + : isBadSelection + ? 'Invalid SPECIFIC_ID lot selection' + : 'Disposal recording failed', description: `Recording disposals for withdrawal transaction ${transactionId} (user ${userId}) failed: ${message}. Nothing was written; the withdrawal is unaffected. Backfill/repair lots (scripts/backfill-cost-basis-lots.ts) — the recorder is idempotent and safe to re-run.`, severity: 'critical', component: 'tax-lot-tracking', diff --git a/tests/unit/tax/methods/hifo.test.ts b/tests/unit/tax/methods/hifo.test.ts new file mode 100644 index 0000000..d44453e --- /dev/null +++ b/tests/unit/tax/methods/hifo.test.ts @@ -0,0 +1,102 @@ +// HIFO engine tests (#317): highest acquisitionPrice first, unpriced lots +// sort last (they can't be "highest"), deterministic tiebreak. +import { Decimal } from '@prisma/client/runtime/library' +import { hifoMethod } from '../../../../src/tax/methods/hifo' +import { + InsufficientLotsError, + OpenLot, +} from '../../../../src/tax/methods/types' + +const d = (v: string | number) => new Decimal(v) + +function lot( + id: string, + remaining: string | number, + acquiredAt: string, + price: string | number | null = 1 +): OpenLot { + return { + id, + remainingAmount: d(remaining), + acquisitionPrice: price === null ? null : d(price), + acquiredAt: new Date(acquiredAt), + } +} + +describe('hifoMethod', () => { + it('consumes the highest-acquisitionPrice lot first', () => { + const result = hifoMethod.consumeLots( + [ + lot('low', 40, '2026-01-01', '1'), + lot('high', 30, '2026-02-01', '5'), + lot('mid', 50, '2026-03-01', '2'), + ], + d(60), + d(10) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['high', 'mid']) + expect(result.disposals[0].amount.toString()).toBe('30') + expect(result.disposals[1].amount.toString()).toBe('30') + }) + + it('sorts unpriced lots last regardless of acquiredAt', () => { + const result = hifoMethod.consumeLots( + [ + lot('unpriced', 100, '2026-01-01', null), + lot('priced', 50, '2026-03-01', '3'), + ], + d(60), + d(10) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['priced', 'unpriced']) + }) + + it('breaks a price tie by acquiredAt asc, then id (deterministic)', () => { + const result = hifoMethod.consumeLots( + [ + lot('b', 10, '2026-02-01', '2'), + lot('a', 10, '2026-01-01', '2'), + lot('c', 10, '2026-01-01', '2'), + ], + d(30), + d(10) + ) + + // Same price everywhere -> acquiredAt asc first, id asc tiebreak within + // the same acquiredAt. + expect(result.disposals.map((x) => x.lotId)).toEqual(['a', 'c', 'b']) + }) + + it('breaks a tie between two unpriced lots the same way (acquiredAt asc, id)', () => { + const result = hifoMethod.consumeLots( + [lot('z', 10, '2026-01-01', null), lot('y', 10, '2026-01-01', null)], + d(15), + d(10) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['y', 'z']) + }) + + it('is deterministic across repeated runs on the same input', () => { + const lots = [ + lot('b', 10, '2026-02-01', '3'), + lot('a', 10, '2026-01-01', '5'), + lot('c', 10, '2026-03-01', '5'), + ] + + const first = hifoMethod.consumeLots(lots, d(20), d(10)) + const second = hifoMethod.consumeLots(lots, d(20), d(10)) + + expect(second.disposals.map((x) => x.lotId)).toEqual( + first.disposals.map((x) => x.lotId) + ) + }) + + it('throws InsufficientLotsError before producing any instructions', () => { + expect(() => + hifoMethod.consumeLots([lot('a', 40, '2026-01-01', '1')], d(100), d(1)) + ).toThrow(InsufficientLotsError) + }) +}) diff --git a/tests/unit/tax/methods/index.test.ts b/tests/unit/tax/methods/index.test.ts new file mode 100644 index 0000000..26b3ce1 --- /dev/null +++ b/tests/unit/tax/methods/index.test.ts @@ -0,0 +1,101 @@ +// Method registry (#317): a whitelist lookup (closes the injection concern +// — never a raw string into a switch), plus the cross-method invariant the +// issue asks for: on the SAME fixture lots, every method's disposals +// reconcile back to the same totals (all lots + all disposed amounts sum +// to the same grand total) even though which lots get consumed, and so the +// realized gain, differs per method. +import { Decimal } from '@prisma/client/runtime/library' +import { resolveMethod } from '../../../../src/tax/methods' +import { OpenLot } from '../../../../src/tax/methods/types' + +const d = (v: string | number) => new Decimal(v) + +function lot( + id: string, + remaining: string | number, + acquiredAt: string, + price: string | number +): OpenLot { + return { + id, + remainingAmount: d(remaining), + acquisitionPrice: d(price), + acquiredAt: new Date(acquiredAt), + } +} + +const fixtureLots: OpenLot[] = [ + lot('a', 40, '2026-01-01', '1'), // oldest, cheapest-ish + lot('b', 30, '2026-02-01', '2'), // most expensive + lot('c', 50, '2026-03-01', '0.5'), // newest, cheapest +] + +describe('resolveMethod', () => { + it('resolves FIFO/LIFO/HIFO/SPECIFIC_ID to their implementations', () => { + expect(resolveMethod('FIFO').id).toBe('FIFO') + expect(resolveMethod('LIFO').id).toBe('LIFO') + expect(resolveMethod('HIFO').id).toBe('HIFO') + expect(resolveMethod('SPECIFIC_ID').id).toBe('SPECIFIC_ID') + }) + + it('rejects an unrecognized method rather than falling through to a default', () => { + expect(() => resolveMethod('WHATEVER' as any)).toThrow() + }) +}) + +describe('cross-method reconciliation on identical fixture lots', () => { + const amount = d(60) + const disposalPrice = d(3) + + it('FIFO consumes oldest first (a then part of b)', () => { + const { disposals } = resolveMethod('FIFO').consumeLots( + fixtureLots, + amount, + disposalPrice + ) + expect(disposals.map((x) => x.lotId)).toEqual(['a', 'b']) + }) + + it('LIFO consumes newest first (c then part of b)', () => { + const { disposals } = resolveMethod('LIFO').consumeLots( + fixtureLots, + amount, + disposalPrice + ) + expect(disposals.map((x) => x.lotId)).toEqual(['c', 'b']) + }) + + it('HIFO consumes highest-price first (b then part of a)', () => { + const { disposals } = resolveMethod('HIFO').consumeLots( + fixtureLots, + amount, + disposalPrice + ) + expect(disposals.map((x) => x.lotId)).toEqual(['b', 'a']) + }) + + it('every method disposes the exact requested amount, and realized gain differs by method', () => { + const results = (['FIFO', 'LIFO', 'HIFO'] as const).map((id) => ({ + id, + ...resolveMethod(id).consumeLots(fixtureLots, amount, disposalPrice), + })) + + for (const result of results) { + const totalDisposed = result.disposals.reduce( + (sum, d2) => sum.plus(d2.amount), + d(0) + ) + expect(totalDisposed.toString()).toBe('60') + } + + const gains = results.map((r) => + r.disposals + .reduce((sum, d2) => sum.plus(d2.realizedGain!), d(0)) + .toString() + ) + // Different lots consumed -> different cost basis -> different realized + // gain, even though every method disposed the same 60 units at the same + // disposalPrice. + expect(new Set(gains).size).toBeGreaterThan(1) + }) +}) diff --git a/tests/unit/tax/methods/lifo.test.ts b/tests/unit/tax/methods/lifo.test.ts new file mode 100644 index 0000000..148612f --- /dev/null +++ b/tests/unit/tax/methods/lifo.test.ts @@ -0,0 +1,67 @@ +// LIFO engine tests (#317). Mirror image of FIFO's ordering on the same +// fixture lots — same invariants: all-or-nothing shortfall, remainingAmount +// never negative, unpriced lots propagate null (never zero). +import { Decimal } from '@prisma/client/runtime/library' +import { lifoMethod } from '../../../../src/tax/methods/lifo' +import { + InsufficientLotsError, + OpenLot, +} from '../../../../src/tax/methods/types' + +const d = (v: string | number) => new Decimal(v) + +function lot( + id: string, + remaining: string | number, + acquiredAt: string, + price: string | number | null = 1 +): OpenLot { + return { + id, + remainingAmount: d(remaining), + acquisitionPrice: price === null ? null : d(price), + acquiredAt: new Date(acquiredAt), + } +} + +describe('lifoMethod', () => { + it('consumes the most-recently-acquired lot first', () => { + const result = lifoMethod.consumeLots( + [lot('older', 40, '2026-01-01'), lot('newer', 50, '2026-02-01')], + d(60), + d(1) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['newer', 'older']) + expect(result.disposals[0].amount.toString()).toBe('50') + expect(result.disposals[1].amount.toString()).toBe('10') + }) + + it('breaks acquiredAt ties by id descending (mirror of FIFO ascending)', () => { + const result = lifoMethod.consumeLots( + [lot('a', 10, '2026-01-01'), lot('b', 10, '2026-01-01')], + d(15), + d(1) + ) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['b', 'a']) + }) + + it('throws InsufficientLotsError before producing any instructions', () => { + expect(() => + lifoMethod.consumeLots([lot('a', 40, '2026-01-01')], d(100), d(1)) + ).toThrow(InsufficientLotsError) + }) + + it('never leaves remainingAmount negative', () => { + const result = lifoMethod.consumeLots( + [lot('a', 25, '2026-01-01'), lot('b', 75, '2026-02-01')], + d(100), + d(1) + ) + + expect(result.updatedLots.every((l) => l.remainingAmount.isZero())).toBe( + true + ) + }) +}) diff --git a/tests/unit/tax/methods/specificId.test.ts b/tests/unit/tax/methods/specificId.test.ts new file mode 100644 index 0000000..1aabec3 --- /dev/null +++ b/tests/unit/tax/methods/specificId.test.ts @@ -0,0 +1,100 @@ +// SPECIFIC_ID engine tests (#317): disposal against explicitly selected +// lots only, in the given order — validated (no double-selection, unknown +// lot ids, shortfall) before any instruction is produced. +import { Decimal } from '@prisma/client/runtime/library' +import { + specificIdMethod, + SpecificIdSelectionError, +} from '../../../../src/tax/methods/specificId' +import { + InsufficientLotsError, + OpenLot, +} from '../../../../src/tax/methods/types' + +const d = (v: string | number) => new Decimal(v) + +function lot( + id: string, + remaining: string | number, + acquiredAt: string, + price: string | number | null = 1 +): OpenLot { + return { + id, + remainingAmount: d(remaining), + acquisitionPrice: price === null ? null : d(price), + acquiredAt: new Date(acquiredAt), + } +} + +const lots = [ + lot('a', 40, '2026-01-01', '1'), + lot('b', 30, '2026-02-01', '2'), + lot('c', 50, '2026-03-01', '0.5'), +] + +describe('specificIdMethod', () => { + it('consumes only the selected lots, in the given order', () => { + const result = specificIdMethod.consumeLots(lots, d(60), d(10), { + selectedLotIds: ['c', 'a'], + }) + + expect(result.disposals.map((x) => x.lotId)).toEqual(['c', 'a']) + expect(result.disposals[0].amount.toString()).toBe('50') + expect(result.disposals[1].amount.toString()).toBe('10') + }) + + it('requires selectedLotIds to be provided', () => { + expect(() => specificIdMethod.consumeLots(lots, d(60), d(10))).toThrow( + SpecificIdSelectionError + ) + }) + + it('rejects a duplicate lot id in the selection', () => { + expect(() => + specificIdMethod.consumeLots(lots, d(60), d(10), { + selectedLotIds: ['a', 'a'], + }) + ).toThrow(SpecificIdSelectionError) + }) + + it('rejects an unknown lot id', () => { + expect(() => + specificIdMethod.consumeLots(lots, d(60), d(10), { + selectedLotIds: ['does-not-exist'], + }) + ).toThrow(SpecificIdSelectionError) + }) + + it('rejects a lot id with zero remaining amount', () => { + const exhausted = [...lots, lot('empty', 0, '2026-04-01', '1')] + + expect(() => + specificIdMethod.consumeLots(exhausted, d(10), d(10), { + selectedLotIds: ['empty'], + }) + ).toThrow(SpecificIdSelectionError) + }) + + it('throws InsufficientLotsError (all-or-nothing) when the selection cannot cover the amount', () => { + expect(() => + specificIdMethod.consumeLots(lots, d(60), d(10), { + selectedLotIds: ['a'], // only 40 available + }) + ).toThrow(InsufficientLotsError) + }) + + it('never partially selects a lot not in selectedLotIds, even if it would cover the shortfall', () => { + let caught: InsufficientLotsError | undefined + try { + specificIdMethod.consumeLots(lots, d(50), d(10), { + selectedLotIds: ['a'], // 40 available; 'b'/'c' exist but weren't selected + }) + } catch (err) { + caught = err as InsufficientLotsError + } + + expect(caught).toBeInstanceOf(InsufficientLotsError) + expect(caught!.available.toString()).toBe('40') + }) +}) diff --git a/tests/unit/tax/report.test.ts b/tests/unit/tax/report.test.ts index 37583ae..82b3b75 100644 --- a/tests/unit/tax/report.test.ts +++ b/tests/unit/tax/report.test.ts @@ -1,11 +1,13 @@ -// Tax report assembly tests (#284): totals include only fully priced -// disposals (unpriced flagged in caveats, never zeroed), UTC year bounds on -// disposedAt, and an empty year is still a valid report. +// Tax report assembly tests (#284, extended by #317): totals include only +// fully priced disposals (unpriced flagged in caveats, never zeroed), UTC +// year bounds on disposedAt, an empty year is still a valid report, and +// method selection is a confirmation gate (not a recompute switch). import db from '../../../src/db' import { buildTaxReport, taxReportToCsvRows, TAX_REPORT_CSV_HEADERS, + MethodMismatchError, } from '../../../src/tax/report' jest.mock('../../../src/db', () => ({ __esModule: true, default: {} })) @@ -42,6 +44,11 @@ function disposalRow(overrides: Record = {}) { beforeEach(() => { jest.clearAllMocks() mockDb.lotDisposal = { findMany: jest.fn() } + mockDb.user = { + findUnique: jest + .fn() + .mockResolvedValue({ accountingMethod: 'FIFO', methodEffectiveAt: null }), + } }) describe('buildTaxReport', () => { @@ -126,6 +133,66 @@ describe('buildTaxReport', () => { expect(report.disposals[0].acquisitionTxHash).toBe('deposit-hash') expect(report.disposals[0].withdrawalTxHash).toBe('withdraw-hash') }) + + it('reports the method column from the account, not a hardcoded literal', async () => { + mockDb.user.findUnique.mockResolvedValue({ + accountingMethod: 'HIFO', + methodEffectiveAt: null, + }) + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + const report = await buildTaxReport('user-1', 2026) + + expect(report.method).toBe('HIFO') + }) + + it('accepts a requested method that matches the account setting', async () => { + mockDb.user.findUnique.mockResolvedValue({ + accountingMethod: 'LIFO', + methodEffectiveAt: null, + }) + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + const report = await buildTaxReport('user-1', 2026, 'LIFO' as any) + + expect(report.method).toBe('LIFO') + }) + + it('rejects a requested method that does not match the account setting', async () => { + mockDb.user.findUnique.mockResolvedValue({ + accountingMethod: 'FIFO', + methodEffectiveAt: null, + }) + + await expect(buildTaxReport('user-1', 2026, 'LIFO' as any)).rejects.toThrow( + MethodMismatchError + ) + expect(mockDb.lotDisposal.findMany).not.toHaveBeenCalled() + }) + + it('flags a mid-year method change instead of silently mixing methods', async () => { + mockDb.user.findUnique.mockResolvedValue({ + accountingMethod: 'HIFO', + methodEffectiveAt: new Date('2026-06-01T00:00:00Z'), + }) + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + const report = await buildTaxReport('user-1', 2026) + + expect(report.caveats.methodChangeNote).toMatch(/HIFO/) + }) + + it('does not flag a method change outside the report year', async () => { + mockDb.user.findUnique.mockResolvedValue({ + accountingMethod: 'HIFO', + methodEffectiveAt: new Date('2025-06-01T00:00:00Z'), + }) + mockDb.lotDisposal.findMany.mockResolvedValue([]) + + const report = await buildTaxReport('user-1', 2026) + + expect(report.caveats.methodChangeNote).toBeNull() + }) }) describe('taxReportToCsvRows', () => { diff --git a/tests/unit/tax/service.test.ts b/tests/unit/tax/service.test.ts index 21048a6..e88a8f5 100644 --- a/tests/unit/tax/service.test.ts +++ b/tests/unit/tax/service.test.ts @@ -251,4 +251,140 @@ describe('recordDisposalsForWithdrawal', () => { expect(data.disposalPrice).toBeNull() expect(data.proceeds).toBeNull() }) + + // #317 — method-parameterized consumption + it('records LIFO disposals (newest lot first) when method=LIFO', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-old', + remainingAmount: '40', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: 'lot-new', + remainingAmount: '100', + acquisitionPrice: '1', + acquiredAt: new Date('2026-02-01T00:00:00Z'), + }, + ]) + mockDb.costBasisLot.update.mockResolvedValue({}) + mockDb.lotDisposal.create.mockResolvedValue({}) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '60', + disposedAt, + undefined, + 'LIFO' as any + ) + + const first = mockDb.lotDisposal.create.mock.calls[0][0].data + expect(first.lotId).toBe('lot-new') + expect(first.amount.toString()).toBe('60') + }) + + it('records SPECIFIC_ID disposals against only the selected lots', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-a', + remainingAmount: '40', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: 'lot-b', + remainingAmount: '100', + acquisitionPrice: '2', + acquiredAt: new Date('2026-02-01T00:00:00Z'), + }, + ]) + mockDb.costBasisLot.update.mockResolvedValue({}) + mockDb.lotDisposal.create.mockResolvedValue({}) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '30', + disposedAt, + undefined, + 'SPECIFIC_ID' as any, + ['lot-b'] + ) + + expect(mockDb.lotDisposal.create).toHaveBeenCalledTimes(1) + const data = mockDb.lotDisposal.create.mock.calls[0][0].data + expect(data.lotId).toBe('lot-b') + }) + + it('treats an invalid SPECIFIC_ID selection like a shortfall: writes nothing, alerts critically, does not throw', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-a', + remainingAmount: '40', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + ]) + + await expect( + recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '30', + disposedAt, + undefined, + 'SPECIFIC_ID' as any + // no selectedLotIds provided + ) + ).resolves.toBeUndefined() + + expect(mockDb.costBasisLot.update).not.toHaveBeenCalled() + expect(mockDb.lotDisposal.create).not.toHaveBeenCalled() + expect(mockError).toHaveBeenCalled() + expect(mockEmit).toHaveBeenCalledTimes(1) + expect(mockEmit.mock.calls[0][0].severity).toBe('critical') + expect(mockEmit.mock.calls[0][0].title).toBe( + 'Invalid SPECIFIC_ID lot selection' + ) + }) + + it('defaults to FIFO when no method is passed (unchanged behavior)', async () => { + mockDb.lotDisposal.findFirst.mockResolvedValue(null) + mockDb.costBasisLot.findMany.mockResolvedValue([ + { + id: 'lot-old', + remainingAmount: '40', + acquisitionPrice: '1', + acquiredAt: new Date('2026-01-01T00:00:00Z'), + }, + { + id: 'lot-new', + remainingAmount: '100', + acquisitionPrice: '1', + acquiredAt: new Date('2026-02-01T00:00:00Z'), + }, + ]) + mockDb.costBasisLot.update.mockResolvedValue({}) + mockDb.lotDisposal.create.mockResolvedValue({}) + + await recordDisposalsForWithdrawal( + 'user-1', + 'wtx-1', + 'USDC', + '60', + disposedAt + ) + + expect(mockDb.lotDisposal.create.mock.calls[0][0].data.lotId).toBe( + 'lot-old' + ) + }) }) From 1f87ee497e983ab72d3f37748c8f8474ce836d97 Mon Sep 17 00:00:00 2001 From: Anuoluwapo25 Date: Thu, 27 Aug 2026 22:29:25 +0100 Subject: [PATCH 2/2] Merge upstream/main and add the migration rollback.sql MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Syncs with 40+ commits of upstream history (websocket streaming, user API keys, session hardening, the user-event bridge replacing the old webhook dispatcher, etc.) — no real conflicts, git merged cleanly. Adds the rollback.sql the migration-rollback-check CI job requires for 20260824215727_add_multi_method_tax_engine. --- .../rollback.sql | 28 +++++++++++++++++++ 1 file changed, 28 insertions(+) create mode 100644 prisma/migrations/20260824215727_add_multi_method_tax_engine/rollback.sql diff --git a/prisma/migrations/20260824215727_add_multi_method_tax_engine/rollback.sql b/prisma/migrations/20260824215727_add_multi_method_tax_engine/rollback.sql new file mode 100644 index 0000000..1fae39f --- /dev/null +++ b/prisma/migrations/20260824215727_add_multi_method_tax_engine/rollback.sql @@ -0,0 +1,28 @@ +-- Rollback for 20260824215727_add_multi_method_tax_engine +-- Drops the multi-method tax engine's schema additions (#317). +-- +-- WARNING: Dropping "accountingMethod" loses each user's configured +-- consumption method (they revert to FIFO on re-add). Dropping +-- "selectedLotIds" loses any in-flight SPECIFIC_ID withdrawal's lot +-- selection that hasn't been consumed by the event listener yet — deploy +-- the reverted application code BEFORE running this, since the live event +-- listener reads Transaction.selectedLotIds on every withdrawal +-- confirmation (src/tax/service.ts's recordDisposalsForWithdrawal). +-- +-- IRREVERSIBLE STEP: PostgreSQL cannot drop a single enum value +-- (`ALTER TYPE ... DROP VALUE` does not exist). USER_DECLARED and +-- MARKET_FEED are left on the "PriceSource" enum — harmless (no row can +-- reference them once nothing writes them), but they will linger in the +-- type's value list. Rebuild the enum manually if that matters: +-- CREATE TYPE "PriceSource_new" AS ENUM ('STABLECOIN_ASSUMPTION'); +-- ALTER TABLE "cost_basis_lots" ALTER COLUMN "priceSource" TYPE "PriceSource_new" USING ("priceSource"::text::"PriceSource_new"); +-- ALTER TABLE "lot_disposals" ALTER COLUMN ... -- if priced elsewhere +-- DROP TYPE "PriceSource"; +-- ALTER TYPE "PriceSource_new" RENAME TO "PriceSource"; + +ALTER TABLE "transactions" DROP COLUMN IF EXISTS "selectedLotIds"; + +ALTER TABLE "users" DROP COLUMN IF EXISTS "methodEffectiveAt"; +ALTER TABLE "users" DROP COLUMN IF EXISTS "accountingMethod"; + +DROP TYPE IF EXISTS "AccountingMethod";