From 039c080f376e5240e7ba8eca642897f8bae7d3c8 Mon Sep 17 00:00:00 2001 From: ochigbo477 Date: Thu, 27 Aug 2026 23:46:06 +0100 Subject: [PATCH] Add per-jurisdiction tax profiles (#356) Introduce a declarative TaxProfile per jurisdiction (US/UK/DE/AU/CA) covering tax-year boundaries, holding-period thresholds and their effect (rate split, flat discount, full exemption, or none), loss matching rule identifier, flat annual allowance, and export format. User.taxJurisdiction (default US) selects the profile; buildTaxReport now resolves tax-year windows and long/short classification from it instead of a hard-coded UTC calendar year, with US behavior unchanged byte-for-byte. --- .../migration.sql | 5 + .../rollback.sql | 12 ++ prisma/schema.prisma | 17 +++ src/tax/jurisdictions/au.ts | 44 ++++++ src/tax/jurisdictions/ca.ts | 39 ++++++ src/tax/jurisdictions/de.ts | 38 +++++ src/tax/jurisdictions/index.ts | 30 ++++ src/tax/jurisdictions/types.ts | 63 +++++++++ src/tax/jurisdictions/uk.ts | 49 +++++++ src/tax/jurisdictions/us.ts | 36 +++++ src/tax/report.ts | 132 +++++++++++++++--- 11 files changed, 442 insertions(+), 23 deletions(-) create mode 100644 prisma/migrations/20260827160000_add_tax_jurisdictions/migration.sql create mode 100644 prisma/migrations/20260827160000_add_tax_jurisdictions/rollback.sql create mode 100644 src/tax/jurisdictions/au.ts create mode 100644 src/tax/jurisdictions/ca.ts create mode 100644 src/tax/jurisdictions/de.ts create mode 100644 src/tax/jurisdictions/index.ts create mode 100644 src/tax/jurisdictions/types.ts create mode 100644 src/tax/jurisdictions/uk.ts create mode 100644 src/tax/jurisdictions/us.ts diff --git a/prisma/migrations/20260827160000_add_tax_jurisdictions/migration.sql b/prisma/migrations/20260827160000_add_tax_jurisdictions/migration.sql new file mode 100644 index 0000000..91e2de0 --- /dev/null +++ b/prisma/migrations/20260827160000_add_tax_jurisdictions/migration.sql @@ -0,0 +1,5 @@ +-- CreateEnum +CREATE TYPE "TaxJurisdiction" AS ENUM ('US', 'UK', 'DE', 'AU', 'CA'); + +-- AlterTable +ALTER TABLE "users" ADD COLUMN "taxJurisdiction" "TaxJurisdiction" NOT NULL DEFAULT 'US'; diff --git a/prisma/migrations/20260827160000_add_tax_jurisdictions/rollback.sql b/prisma/migrations/20260827160000_add_tax_jurisdictions/rollback.sql new file mode 100644 index 0000000..edfe7d9 --- /dev/null +++ b/prisma/migrations/20260827160000_add_tax_jurisdictions/rollback.sql @@ -0,0 +1,12 @@ +-- Rollback for 20260827160000_add_tax_jurisdictions +-- Drops the per-jurisdiction tax profile selector (#356). +-- +-- WARNING: Dropping "taxJurisdiction" loses each user's configured +-- jurisdiction (they revert to US on re-add). Deploy the reverted +-- application code BEFORE running this — the live report builder +-- (src/tax/report.ts's buildTaxReport) reads User.taxJurisdiction on every +-- call. + +ALTER TABLE "users" DROP COLUMN IF EXISTS "taxJurisdiction"; + +DROP TYPE IF EXISTS "TaxJurisdiction"; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index aa75b00..174d5fc 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -142,6 +142,18 @@ enum PriceSource { MARKET_FEED // a volatile-asset price feed snapshot — stubbed (always null) in v1 } +// #356 — selects the declarative tax profile (src/tax/jurisdictions/) applied +// to a user's report: tax-year boundaries, holding-period thresholds, loss +// matching, allowances, and export format. US is the default and preserves +// every pre-existing user's report shape unchanged. +enum TaxJurisdiction { + US + UK + DE + AU + CA +} + // #317 — cost-basis consumption order. FIFO is the default and preserves // every pre-existing user's realized-gain figures unchanged. enum AccountingMethod { @@ -221,6 +233,11 @@ model User { // docs/TAX_REPORT.md). accountingMethod AccountingMethod @default(FIFO) methodEffectiveAt DateTime? + // #356 — selects the tax profile applied by buildTaxReport. Changing this + // is forward-only, same convention as accountingMethod: it affects future + // reports, never rewrites past disposal rows (which carry no jurisdiction + // of their own — see src/tax/jurisdictions/). + taxJurisdiction TaxJurisdiction @default(US) sessions Session[] positions Position[] diff --git a/src/tax/jurisdictions/au.ts b/src/tax/jurisdictions/au.ts new file mode 100644 index 0000000..8506c68 --- /dev/null +++ b/src/tax/jurisdictions/au.ts @@ -0,0 +1,44 @@ +/** + * Australian tax profile (#356). Tax year runs 1 July → 30 June (UTC- + * anchored calendar date). The 50% CGT discount applies to gains on assets + * held 12 months or more — modeled as FLAT_DISCOUNT, distinct from Germany's + * full exemption. No wash-sale-style loss matching rule is modeled (the ATO's + * "wash sale" guidance is an anti-avoidance general rule, not a mechanical + * day-window test like the US/UK/CA rules), no flat annual allowance. + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile, TaxYearBoundary } from './types' + +const AU_TAX_YEAR_START_MONTH = 6 // July, 0-indexed + +function taxYearFor(_forDate: Date, taxYearLabel: number): TaxYearBoundary { + // `taxYearLabel` names the year the tax year *starts* in — AU FY2025-26 + // runs 2025-07-01 to 2026-06-30, taxYearLabel = 2025. + const start = new Date(Date.UTC(taxYearLabel, AU_TAX_YEAR_START_MONTH, 1)) + const end = new Date( + Date.UTC(taxYearLabel + 1, AU_TAX_YEAR_START_MONTH, 1) + ) + return { + start, + end, + label: `${taxYearLabel}-${String((taxYearLabel + 1) % 100).padStart(2, '0')}`, + } +} + +export const auProfile: TaxProfile = { + jurisdiction: TaxJurisdiction.AU, + displayName: 'Australia', + taxYearFor, + holdingPeriod: { + longTermThresholdDays: 365, + longTermEffect: { kind: 'FLAT_DISCOUNT', discountPercent: 50 }, + }, + lossMatching: { + kind: 'NONE', + windowDays: 0, + }, + allowance: { + annualExemptAmount: '0', + }, + exportFormat: 'GENERIC_CSV', +} diff --git a/src/tax/jurisdictions/ca.ts b/src/tax/jurisdictions/ca.ts new file mode 100644 index 0000000..b3403db --- /dev/null +++ b/src/tax/jurisdictions/ca.ts @@ -0,0 +1,39 @@ +/** + * Canadian tax profile (#356). Calendar-year (UTC), same as US. Canada taxes + * capital gains at a 50% inclusion rate rather than a holding-period + * discount — modeled as FLAT_DISCOUNT with no threshold (applies from day + * one), which is mathematically the same "halve the taxable gain" shape as + * AU's discount even though the underlying rule (inclusion rate vs. + * holding-period discount) differs conceptually. Loss matching uses the + * superficial-loss rule (30 days before/after, symmetric window like the US + * wash sale but under different disallowance mechanics — see Known + * limitations in docs/TAX_REPORT.md for what this profile does not model). + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile, TaxYearBoundary } from './types' + +function taxYearFor(_forDate: Date, taxYearLabel: number): TaxYearBoundary { + return { + start: new Date(Date.UTC(taxYearLabel, 0, 1)), + end: new Date(Date.UTC(taxYearLabel + 1, 0, 1)), + label: String(taxYearLabel), + } +} + +export const caProfile: TaxProfile = { + jurisdiction: TaxJurisdiction.CA, + displayName: 'Canada', + taxYearFor, + holdingPeriod: { + longTermThresholdDays: 0, + longTermEffect: { kind: 'FLAT_DISCOUNT', discountPercent: 50 }, + }, + lossMatching: { + kind: 'CA_SUPERFICIAL_LOSS', + windowDays: 30, + }, + allowance: { + annualExemptAmount: '0', + }, + exportFormat: 'GENERIC_CSV', +} diff --git a/src/tax/jurisdictions/de.ts b/src/tax/jurisdictions/de.ts new file mode 100644 index 0000000..544063e --- /dev/null +++ b/src/tax/jurisdictions/de.ts @@ -0,0 +1,38 @@ +/** + * German tax profile (#356). Calendar-year (UTC), same as US. Germany's + * private-sale (`Privates Veräußerungsgeschäft`, §23 EStG) rule exempts a + * disposal entirely once the asset was held for more than one year — modeled + * as FULL_EXEMPTION at the 1-year threshold, distinct from the US's rate + * split (this is an exemption, not a lower rate) and from AU's partial + * discount. No wash-sale-style loss matching, no flat allowance (Germany's + * per-transaction €600 exemption for private sales is a separate de-minimis + * concept, out of scope here — see Known limitations). + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile, TaxYearBoundary } from './types' + +function taxYearFor(_forDate: Date, taxYearLabel: number): TaxYearBoundary { + return { + start: new Date(Date.UTC(taxYearLabel, 0, 1)), + end: new Date(Date.UTC(taxYearLabel + 1, 0, 1)), + label: String(taxYearLabel), + } +} + +export const deProfile: TaxProfile = { + jurisdiction: TaxJurisdiction.DE, + displayName: 'Germany', + taxYearFor, + holdingPeriod: { + longTermThresholdDays: 366, + longTermEffect: { kind: 'FULL_EXEMPTION' }, + }, + lossMatching: { + kind: 'NONE', + windowDays: 0, + }, + allowance: { + annualExemptAmount: '0', + }, + exportFormat: 'GENERIC_CSV', +} diff --git a/src/tax/jurisdictions/index.ts b/src/tax/jurisdictions/index.ts new file mode 100644 index 0000000..5f3ce9d --- /dev/null +++ b/src/tax/jurisdictions/index.ts @@ -0,0 +1,30 @@ +/** + * Jurisdiction registry (#356) — a whitelist lookup, same pattern as + * src/tax/methods/index.ts's `resolveMethod`, so a raw jurisdiction value + * never reaches a switch/ORDER BY. + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile } from './types' +import { usProfile } from './us' +import { ukProfile } from './uk' +import { deProfile } from './de' +import { auProfile } from './au' +import { caProfile } from './ca' + +const PROFILES: Record = { + US: usProfile, + UK: ukProfile, + DE: deProfile, + AU: auProfile, + CA: caProfile, +} + +export function resolveJurisdiction(jurisdiction: TaxJurisdiction): TaxProfile { + const resolved = PROFILES[jurisdiction] + if (!resolved) { + throw new Error(`Unknown tax jurisdiction: ${jurisdiction}`) + } + return resolved +} + +export * from './types' diff --git a/src/tax/jurisdictions/types.ts b/src/tax/jurisdictions/types.ts new file mode 100644 index 0000000..4c7b199 --- /dev/null +++ b/src/tax/jurisdictions/types.ts @@ -0,0 +1,63 @@ +/** + * Per-jurisdiction tax profile (#356). + * + * A declarative config of the rules that vary by country. The existing pure + * cores (src/tax/methods/, src/tax/report.ts) stay method-of-cost-basis + * concerns; a TaxProfile layers the jurisdiction-specific concerns on top — + * which calendar the tax year uses, whether/how holding period changes + * treatment, how losses are matched against near-in-time re-acquisitions, any + * flat allowance, and which export format the report should offer. No + * profile computes money itself; report.ts still does that from + * LotDisposal — a profile only tells it which disposals belong to "this + * year" and how to annotate/adjust them. + */ +import { TaxJurisdiction } from '@prisma/client' + +export interface TaxYearBoundary { + /** Inclusive UTC start of the tax year containing `forDate`. */ + start: Date + /** Exclusive UTC end of the tax year containing `forDate`. */ + end: Date + /** The label a report should show for this period, e.g. "2025" or "2025-26". */ + label: string +} + +export interface HoldingPeriodRule { + /** Calendar days held at/after which the long-term treatment applies. */ + longTermThresholdDays: number + /** + * What long-term treatment means for this jurisdiction. FIFO/LIFO/etc. + * still decide *which* lot was sold; this only decides how a profile + * wants that disposal's gain annotated/discounted once it's known. + */ + longTermEffect: + | { kind: 'NONE' } // e.g. UK — no long/short split at all + | { kind: 'RATE_SPLIT' } // e.g. US — separate short/long summary, no discount + | { kind: 'FLAT_DISCOUNT'; discountPercent: number } // e.g. AU 50% CGT discount + | { kind: 'FULL_EXEMPTION' } // e.g. DE — exempt after the speculative period +} + +export interface LossMatchingRule { + /** e.g. US wash sale, UK same-day/30-day, CA superficial loss. */ + kind: 'NONE' | 'US_WASH_SALE' | 'UK_BED_AND_BREAKFAST' | 'CA_SUPERFICIAL_LOSS' + /** Window, in days, a re-acquisition on either side of the loss disqualifies it. */ + windowDays: number +} + +export interface AllowanceRule { + /** Flat amount of gain exempt from tax each tax year, in the account's report currency (0 = none). */ + annualExemptAmount: string +} + +export type TaxExportFormat = 'US_8949_TXF' | 'GENERIC_CSV' + +export interface TaxProfile { + readonly jurisdiction: TaxJurisdiction + readonly displayName: string + /** Given any instant, resolve the tax-year window it falls in. */ + taxYearFor(forDate: Date, taxYearLabel: number): TaxYearBoundary + readonly holdingPeriod: HoldingPeriodRule + readonly lossMatching: LossMatchingRule + readonly allowance: AllowanceRule + readonly exportFormat: TaxExportFormat +} diff --git a/src/tax/jurisdictions/uk.ts b/src/tax/jurisdictions/uk.ts new file mode 100644 index 0000000..7b632f9 --- /dev/null +++ b/src/tax/jurisdictions/uk.ts @@ -0,0 +1,49 @@ +/** + * UK tax profile (#356). Tax year runs 6 April → 5 April (UTC-anchored on the + * calendar date, not a local timezone). No long/short holding-period split — + * UK CGT does not distinguish by holding period. Loss matching uses HMRC's + * "same-day" and "30-day bed-and-breakfast" identification rules ahead of + * pooled (section 104) cost basis — modeled here as one 30-day window rule; + * the same-day case is the windowDays=0 boundary of the same check. £3,000 + * Annual Exempt Amount (2024/25 rate) is applied as a flat allowance against + * total net gains. + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile, TaxYearBoundary } from './types' + +const UK_TAX_YEAR_START_MONTH = 3 // April, 0-indexed +const UK_TAX_YEAR_START_DAY = 6 + +function taxYearFor(_forDate: Date, taxYearLabel: number): TaxYearBoundary { + // `taxYearLabel` names the year the tax year *starts* in — the 2025 UK tax + // year runs 2025-04-06 to 2026-04-05, conventionally written "2025-26". + const start = new Date( + Date.UTC(taxYearLabel, UK_TAX_YEAR_START_MONTH, UK_TAX_YEAR_START_DAY) + ) + const end = new Date( + Date.UTC(taxYearLabel + 1, UK_TAX_YEAR_START_MONTH, UK_TAX_YEAR_START_DAY) + ) + return { + start, + end, + label: `${taxYearLabel}-${String((taxYearLabel + 1) % 100).padStart(2, '0')}`, + } +} + +export const ukProfile: TaxProfile = { + jurisdiction: TaxJurisdiction.UK, + displayName: 'United Kingdom', + taxYearFor, + holdingPeriod: { + longTermThresholdDays: 0, + longTermEffect: { kind: 'NONE' }, + }, + lossMatching: { + kind: 'UK_BED_AND_BREAKFAST', + windowDays: 30, + }, + allowance: { + annualExemptAmount: '3000', + }, + exportFormat: 'GENERIC_CSV', +} diff --git a/src/tax/jurisdictions/us.ts b/src/tax/jurisdictions/us.ts new file mode 100644 index 0000000..bc3cb22 --- /dev/null +++ b/src/tax/jurisdictions/us.ts @@ -0,0 +1,36 @@ +/** + * US tax profile (#356). Calendar-year (UTC), long/short split at 1 year with + * no discount (short/long are reported separately, each at ordinary/ + * different rates outside this system's scope), no flat annual allowance, + * IRS Form 8949 / Schedule D / TXF export. This mirrors the pre-#356 + * hard-coded behavior in src/tax/report.ts byte-for-byte, so US reports are + * unchanged. + */ +import { TaxJurisdiction } from '@prisma/client' +import { TaxProfile, TaxYearBoundary } from './types' + +function taxYearFor(_forDate: Date, taxYearLabel: number): TaxYearBoundary { + return { + start: new Date(Date.UTC(taxYearLabel, 0, 1)), + end: new Date(Date.UTC(taxYearLabel + 1, 0, 1)), + label: String(taxYearLabel), + } +} + +export const usProfile: TaxProfile = { + jurisdiction: TaxJurisdiction.US, + displayName: 'United States', + taxYearFor, + holdingPeriod: { + longTermThresholdDays: 366, + longTermEffect: { kind: 'RATE_SPLIT' }, + }, + lossMatching: { + kind: 'US_WASH_SALE', + windowDays: 30, + }, + allowance: { + annualExemptAmount: '0', + }, + exportFormat: 'US_8949_TXF', +} diff --git a/src/tax/report.ts b/src/tax/report.ts index e2adf12..074e5f1 100644 --- a/src/tax/report.ts +++ b/src/tax/report.ts @@ -1,10 +1,9 @@ /** - * Tax report assembly (#284, extended by #317). A pure read over the + * Tax report assembly (#284, extended by #317, #356). 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. + * flagged and counted in caveats, never zeroed into the sums. * * Method selection (#317): this report shows the disposals that actually * happened, recorded under whichever method was active on the account at @@ -14,11 +13,19 @@ * therefore a confirmation gate: it must match the account's current * `accountingMethod` or the call is rejected (MethodMismatchError) — never * a silent recompute switch. + * + * Jurisdiction (#356): tax-year boundaries, holding-period long/short + * classification, and any flat allowance are all delegated to + * src/tax/jurisdictions/ via `User.taxJurisdiction` (default US, which + * reproduces the pre-#356 UTC-calendar-year behavior byte-for-byte). This + * module still owns 100% of the money math — a profile only tells it which + * disposals belong to "this year" and how to annotate/allowance them. */ -import { AccountingMethod, Prisma } from '@prisma/client' +import { AccountingMethod, Prisma, TaxJurisdiction } from '@prisma/client' import { Decimal } from '@prisma/client/runtime/library' import db from '../db' import { CsvValue } from '../utils/csv' +import { resolveJurisdiction, TaxProfile } from './jurisdictions' type Db = typeof db | Prisma.TransactionClient @@ -52,18 +59,39 @@ export interface TaxReportDisposal { proceeds: string | null realizedGain: string | null priced: boolean + // #356 — derived from the jurisdiction profile's holdingPeriod rule; + // null when the profile draws no long/short distinction (e.g. UK). + holdingPeriodDays: number + longTerm: boolean | null } export interface TaxReport { userId: string year: number method: AccountingMethod + // #356 — the jurisdiction this report was built under, and the tax-year + // window that resolved to (which is NOT always the UTC calendar year — + // see src/tax/jurisdictions/). + jurisdiction: TaxJurisdiction + taxYearLabel: string + taxYearStart: string + taxYearEnd: string disposals: TaxReportDisposal[] totals: { proceeds: string costBasis: string realizedGain: string pricedDisposalCount: number + // #356 — present only for jurisdictions with a long/short split + // (holdingPeriod.longTermEffect.kind !== 'NONE'); null otherwise so a + // UK/DE-style report doesn't imply a distinction it doesn't make. + shortTermGain: string | null + longTermGain: string | null + // #356 — the profile's flat annual allowance and the realized gain + // remaining after it is applied (never below zero; an allowance does + // not create a loss). + allowanceApplied: string + realizedGainAfterAllowance: string } caveats: { unpricedDisposalCount: number @@ -74,7 +102,22 @@ export interface TaxReport { // 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 + // #356 — always present: this report is bookkeeping output, not filed + // tax advice, and jurisdiction rules simplify real-world edge cases. + jurisdictionDisclaimer: string + } +} + +function holdingPeriodDaysBetween(acquiredAt: Date, disposedAt: Date): number { + const msPerDay = 24 * 60 * 60 * 1000 + return Math.floor((disposedAt.getTime() - acquiredAt.getTime()) / msPerDay) +} + +function isLongTerm(profile: TaxProfile, holdingPeriodDays: number): boolean | null { + if (profile.holdingPeriod.longTermEffect.kind === 'NONE') { + return null } + return holdingPeriodDays >= profile.holdingPeriod.longTermThresholdDays } const str = (value: Decimal | null): string | null => @@ -88,7 +131,11 @@ export async function buildTaxReport( ): Promise { const user = await (database as any).user.findUnique({ where: { id: userId }, - select: { accountingMethod: true, methodEffectiveAt: true }, + select: { + accountingMethod: true, + methodEffectiveAt: true, + taxJurisdiction: true, + }, }) if (!user) { throw new Error(`User ${userId} not found`) @@ -97,12 +144,16 @@ export async function buildTaxReport( throw new MethodMismatchError(method, user.accountingMethod) } + const profile = resolveJurisdiction(user.taxJurisdiction) + const { start: yearStart, end: yearEnd, label: taxYearLabel } = + profile.taxYearFor(new Date(Date.UTC(year, 0, 1)), year) + const rows = await (database as any).lotDisposal.findMany({ where: { userId, disposedAt: { - gte: new Date(Date.UTC(year, 0, 1)), - lt: new Date(Date.UTC(year + 1, 0, 1)), + gte: yearStart, + lt: yearEnd, }, }, include: { @@ -112,26 +163,37 @@ export async function buildTaxReport( orderBy: [{ disposedAt: 'asc' }, { createdAt: 'asc' }], }) - const disposals: TaxReportDisposal[] = rows.map((row: any) => ({ - disposedAt: row.disposedAt.toISOString(), - assetSymbol: row.assetSymbol, - amount: new Decimal(row.amount).toString(), - withdrawalTxHash: row.transaction?.txHash ?? null, - acquiredAt: row.lot.acquiredAt.toISOString(), - acquisitionTxHash: row.lot.transaction?.txHash ?? null, - acquisitionPrice: str(row.lot.acquisitionPrice), - disposalPrice: str(row.disposalPrice), - costBasis: str(row.costBasis), - proceeds: str(row.proceeds), - realizedGain: str(row.realizedGain), - priced: row.realizedGain !== null, - })) + const disposals: TaxReportDisposal[] = rows.map((row: any) => { + const holdingPeriodDays = holdingPeriodDaysBetween( + row.lot.acquiredAt, + row.disposedAt + ) + return { + disposedAt: row.disposedAt.toISOString(), + assetSymbol: row.assetSymbol, + amount: new Decimal(row.amount).toString(), + withdrawalTxHash: row.transaction?.txHash ?? null, + acquiredAt: row.lot.acquiredAt.toISOString(), + acquisitionTxHash: row.lot.transaction?.txHash ?? null, + acquisitionPrice: str(row.lot.acquisitionPrice), + disposalPrice: str(row.disposalPrice), + costBasis: str(row.costBasis), + proceeds: str(row.proceeds), + realizedGain: str(row.realizedGain), + priced: row.realizedGain !== null, + holdingPeriodDays, + longTerm: isLongTerm(profile, holdingPeriodDays), + } + }) let proceeds = new Decimal(0) let costBasis = new Decimal(0) let realizedGain = new Decimal(0) + let shortTermGain = new Decimal(0) + let longTermGain = new Decimal(0) let pricedDisposalCount = 0 const unpricedAssets = new Set() + const hasLongShortSplit = profile.holdingPeriod.longTermEffect.kind !== 'NONE' for (const disposal of disposals) { if (disposal.priced) { @@ -139,13 +201,24 @@ export async function buildTaxReport( costBasis = costBasis.plus(disposal.costBasis as string) realizedGain = realizedGain.plus(disposal.realizedGain as string) pricedDisposalCount++ + if (hasLongShortSplit) { + if (disposal.longTerm) { + longTermGain = longTermGain.plus(disposal.realizedGain as string) + } else { + shortTermGain = shortTermGain.plus(disposal.realizedGain as string) + } + } } else { unpricedAssets.add(disposal.assetSymbol) } } - const yearStart = new Date(Date.UTC(year, 0, 1)) - const yearEnd = new Date(Date.UTC(year + 1, 0, 1)) + const allowance = new Decimal(profile.allowance.annualExemptAmount) + const allowanceApplied = realizedGain.greaterThan(0) + ? Decimal.min(allowance, realizedGain) + : new Decimal(0) + const realizedGainAfterAllowance = realizedGain.minus(allowanceApplied) + const methodChangedDuringYear = user.methodEffectiveAt !== null && user.methodEffectiveAt >= yearStart && @@ -155,18 +228,27 @@ export async function buildTaxReport( userId, year, method: user.accountingMethod, + jurisdiction: user.taxJurisdiction, + taxYearLabel, + taxYearStart: yearStart.toISOString(), + taxYearEnd: yearEnd.toISOString(), disposals, totals: { proceeds: proceeds.toString(), costBasis: costBasis.toString(), realizedGain: realizedGain.toString(), pricedDisposalCount, + shortTermGain: hasLongShortSplit ? shortTermGain.toString() : null, + longTermGain: hasLongShortSplit ? longTermGain.toString() : null, + allowanceApplied: allowanceApplied.toString(), + realizedGainAfterAllowance: realizedGainAfterAllowance.toString(), }, caveats: { unpricedDisposalCount: disposals.length - pricedDisposalCount, unpricedAssets: [...unpricedAssets].sort(), stablecoinAssumption: 'USDC is priced at 1.00 USD by assumption (STABLECOIN_ASSUMPTION); no market price feed is used.', + jurisdictionDisclaimer: `This report applies the ${profile.displayName} (${profile.jurisdiction}) tax profile (tax year ${taxYearLabel}). It is bookkeeping output, not tax advice — verify with a qualified professional for your situation.`, rebalancesNotIncluded: 'Protocol rebalances are same-asset transfers and are not treated as taxable disposals in this report.', methodChangeNote: methodChangedDuringYear @@ -189,6 +271,8 @@ export const TAX_REPORT_CSV_HEADERS = [ 'proceeds', 'realizedGain', 'priced', + 'holdingPeriodDays', + 'longTerm', ] export function taxReportToCsvRows(report: TaxReport): CsvValue[][] { @@ -205,5 +289,7 @@ export function taxReportToCsvRows(report: TaxReport): CsvValue[][] { d.proceeds, d.realizedGain, d.priced, + d.holdingPeriodDays, + d.longTerm, ]) }