Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
143 changes: 117 additions & 26 deletions docs/TAX_REPORT.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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)

Expand Down Expand Up @@ -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
Expand All @@ -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=<yyyy>&format=json|csv
GET /api/v1/portfolio/:userId/tax-report?year=<yyyy>&format=json|csv&method=FIFO|LIFO|HIFO|SPECIFIC_ID
```

- Auth: `requireAuth` + `enforceUserAccess` (own report only). The userId is a
Expand All @@ -86,12 +154,22 @@ GET /api/v1/portfolio/:userId/tax-report?year=<yyyy>&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-<year>.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

Expand Down Expand Up @@ -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.
Original file line number Diff line number Diff line change
@@ -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[];
Original file line number Diff line number Diff line change
@@ -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";
41 changes: 32 additions & 9 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -186,23 +198,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[]
Expand Down Expand Up @@ -433,6 +451,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])
Expand Down
17 changes: 16 additions & 1 deletion scripts/backfill-cost-basis-lots.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,9 +44,21 @@ async function main(): Promise<void> {
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,
Expand Down Expand Up @@ -78,7 +90,10 @@ async function main(): Promise<void> {
tx.id,
tx.assetSymbol,
tx.amount,
effectiveAt
effectiveAt,
db,
methodByUserId.get(tx.userId),
tx.selectedLotIds
)
}
processed++
Expand Down
9 changes: 8 additions & 1 deletion src/controllers/transaction-controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,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<Transaction> {
const pending = await db.$transaction(async (tx) => {
const transaction = await tx.transaction.create({
Expand All @@ -44,6 +48,7 @@ async function enqueueAndDispatch(params: {
network: params.network,
protocolName: params.protocolName,
memo: params.memo,
selectedLotIds: params.selectedLotIds ?? [],
},
})

Expand Down Expand Up @@ -185,7 +190,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)
Expand Down Expand Up @@ -228,6 +234,7 @@ export async function processOnChainTransaction(
protocolName,
memo,
actingAsUserId,
selectedLotIds,
})

logger.info('On-chain withdrawal completed', {
Expand Down
Loading
Loading