Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -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';
Original file line number Diff line number Diff line change
@@ -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";
17 changes: 17 additions & 0 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down Expand Up @@ -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[]
Expand Down
44 changes: 44 additions & 0 deletions src/tax/jurisdictions/au.ts
Original file line number Diff line number Diff line change
@@ -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',
}
39 changes: 39 additions & 0 deletions src/tax/jurisdictions/ca.ts
Original file line number Diff line number Diff line change
@@ -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',
}
38 changes: 38 additions & 0 deletions src/tax/jurisdictions/de.ts
Original file line number Diff line number Diff line change
@@ -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',
}
30 changes: 30 additions & 0 deletions src/tax/jurisdictions/index.ts
Original file line number Diff line number Diff line change
@@ -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<TaxJurisdiction, TaxProfile> = {
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'
63 changes: 63 additions & 0 deletions src/tax/jurisdictions/types.ts
Original file line number Diff line number Diff line change
@@ -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
}
49 changes: 49 additions & 0 deletions src/tax/jurisdictions/uk.ts
Original file line number Diff line number Diff line change
@@ -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',
}
36 changes: 36 additions & 0 deletions src/tax/jurisdictions/us.ts
Original file line number Diff line number Diff line change
@@ -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',
}
Loading