You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
The tax engine hard-codes US assumptions: UTC calendar-year boundaries (src/tax/report.ts), a US-style long-term threshold (implied), and US export formats. A user in the UK (different tax year, £3,000 CGT allowance, "same-day" and "30-day bed-and-breakfast" matching rules, no long/short split), Germany (1-year speculative-period exemption), Australia (50% CGT discount after 12 months, July–June year), or Canada (50% inclusion rate, superficial-loss rule) gets numbers that are simply wrong for them. This issue introduces per-jurisdiction tax profiles: a declarative config of the rules that vary by country — tax-year boundaries, holding-period thresholds and their effects, loss-matching rules, allowances, and the applicable report/export format — selected per user, applied by the existing pure cores.
interfaceJurisdictionProfile{id: string// "US" | "UK" | "DE" | "AU" | "CA"taxYear: {type: 'calendar'}|{type: 'custom',startMonth: number,startDay: number}// UK 6 Apr, AU 1 JulallowedMethods: CostBasisMethod[]// constrains #317's method choicedefaultMethod: CostBasisMethodholdingPeriod?: {// absent ⇒ no long/short distinction (UK)longTermAfterDays: numberlongTermEffect: 'lower_rate'|'exempt'|'discount',discountPct?: number// DE exempt, AU 50% discount}lossMatching: 'us_wash_sale'|'uk_share_matching'|'ca_superficial_loss'|'none'allowances?: {annualExemptAmount: number,currency: string}// UK £3,000, etc.reportFormat: 'us_8949'|'uk_cgt_summary'|'generic'roundingCurrency: string}
Ship 5 profiles (US/UK/DE/AU/CA), each with cited provenance and a "verify with a local advisor" disclaimer. New jurisdictions are added as config, no code change.
2. User selection
User.taxJurisdiction (string, nullable → defaults to US with a caveat that it's an assumption).
PUT /api/v1/tax/settings — set jurisdiction (+ method, within allowedMethods); validated; change is audit-logged and invalidates cached reports.
3. Applying the profile
Year boundaries: report.ts takes { start, end } from profile.taxYear + the requested year label; existing UTC-calendar behavior is exactly the US profile (regression-locked).
Holding period: the classifier (sibling issue) reads profile.holdingPeriod; absent ⇒ every disposal is N/A; discount applies AU's 50%; exempt zeroes gain for lots past the DE speculative period (and excludes the loss symmetrically — documented).
Loss matching: a strategy interface LossMatcher with us_wash_sale (the sibling issue's detector), uk_share_matching (same-day → 30-day → §104 pool), ca_superficial_loss, and none. Each is a pure module; the report calls the profile's matcher.
Allowances: report totals show gain before and after the annual exempt amount; carryforward handling per profile.
Export format: the export route (sibling issue) picks the formatter from profile.reportFormat.
4. Docs
docs/TAX_REPORT.md gains a per-jurisdiction matrix; ASSUMPTIONS.md records the US default and each profile's simplifications.
Edge Cases & Failure Modes
Mid-year jurisdiction change (user relocates): out of scope to split a year across profiles — the report uses the profile as of generation time and the caveat flags that a relocation year may need manual apportionment.
UK §104 pooling is fundamentally different from lot-by-lot FIFO: the uk_share_matching matcher maintains a pooled average cost; it must not try to reuse the FIFO lot consumer. This is the main implementation risk and gets its own fixture suite from HMRC examples.
DE speculative-period exemption: exempt gains AND disallowed losses past 1 year — asymmetry here would be a real bug; explicit test.
Custom tax year + "year" label: year=2026 for UK means 6 Apr 2026 – 5 Apr 2027; the API documents the convention and echoes the resolved { start, end } in the response.
allowedMethods excludes the user's current method: switching jurisdiction forces a method choice + full recompute, surfaced as a required action, not a silent change.
Default assumption: a null jurisdiction is treated as US but every report says so prominently.
Security & Privacy Considerations
Jurisdiction is user-declared, owner-scoped, audit-logged on change; no inference from IP or wallet.
Profiles are static config with public-source provenance; no per-user calibration.
All matchers are pure over the caller's own ledger; no cross-user data.
Every report/export carries the profile id, its simplification caveats, and a "not tax advice / verify locally" disclaimer.
Out of Scope
US state taxes; sub-national rules generally.
Full rate/bracket computation (estimates use profile default rates only).
Splitting a tax year across two jurisdictions for relocations.
Treaty / foreign-tax-credit handling.
Jurisdictions beyond the initial five (added later as config).
Suggested Implementation Plan
src/tax/jurisdictions/ — JurisdictionProfile type + 5 profiles with provenance; User.taxJurisdiction + migration.
Parameterize report.ts year boundaries from the profile; regression-lock the US/calendar path.
LossMatcher interface + uk_share_matching (§104 pool) + ca_superficial_loss pure modules with fixture suites from official examples; wire us_wash_sale to the sibling detector.
Holding-period effects (lower_rate / exempt / discount) in the classifier; symmetry tests.
Allowances in report totals; export-format selection; PUT /api/v1/tax/settings.
JurisdictionProfile config with 5 shipped profiles (US/UK/DE/AU/CA), each with cited provenance; new jurisdictions require config only
User.taxJurisdiction selectable via PUT /api/v1/tax/settings (method constrained to allowedMethods), audit-logged, cache-invalidating; null defaults to US with a prominent caveat
Report year boundaries come from the profile; the US/calendar path is regression-locked to current behavior
Pluggable LossMatcher — us_wash_sale, uk_share_matching (§104 pool), ca_superficial_loss, none — each pure and fixture-tested against official examples
Holding-period effects support lower_rate, exempt (symmetric gain/loss), and discount (AU 50%); annual allowances shown pre/post in totals
Export format is selected from the profile; every report/export carries the profile id, its simplifications, and a "verify locally" disclaimer
docs/TAX_REPORT.md + ASSUMPTIONS.md + docs/openapi.yaml updated; unit + integration tests green
Problem Statement
The tax engine hard-codes US assumptions: UTC calendar-year boundaries (
src/tax/report.ts), a US-style long-term threshold (implied), and US export formats. A user in the UK (different tax year, £3,000 CGT allowance, "same-day" and "30-day bed-and-breakfast" matching rules, no long/short split), Germany (1-year speculative-period exemption), Australia (50% CGT discount after 12 months, July–June year), or Canada (50% inclusion rate, superficial-loss rule) gets numbers that are simply wrong for them. This issue introduces per-jurisdiction tax profiles: a declarative config of the rules that vary by country — tax-year boundaries, holding-period thresholds and their effects, loss-matching rules, allowances, and the applicable report/export format — selected per user, applied by the existing pure cores.Current State
src/tax/report.ts—Date.UTC(year, 0, 1)…Date.UTC(year+1, 0, 1);method: 'FIFO'literal;caveats.stablecoinAssumption.src/tax/fifo.ts— pure FIFO consumption; Multi-Method Tax Engine (LIFO / HIFO / Specific ID) with Multi-Currency Cost Basis #317 (open) adds LIFO/HIFO/SpecificID as sibling functions.prisma/schema.prisma—User(no jurisdiction field),CostBasisLot,LotDisposal.docs/TAX_REPORT.md,ASSUMPTIONS.md.Proposed Solution
1. Profile definition (
src/tax/jurisdictions/, new)provenanceand a "verify with a local advisor" disclaimer. New jurisdictions are added as config, no code change.2. User selection
User.taxJurisdiction(string, nullable → defaults toUSwith a caveat that it's an assumption).PUT /api/v1/tax/settings— set jurisdiction (+ method, withinallowedMethods); validated; change is audit-logged and invalidates cached reports.3. Applying the profile
report.tstakes{ start, end }fromprofile.taxYear+ the requested year label; existing UTC-calendar behavior is exactly theUSprofile (regression-locked).profile.holdingPeriod; absent ⇒ every disposal isN/A;discountapplies AU's 50%;exemptzeroes gain for lots past the DE speculative period (and excludes the loss symmetrically — documented).LossMatcherwithus_wash_sale(the sibling issue's detector),uk_share_matching(same-day → 30-day → §104 pool),ca_superficial_loss, andnone. Each is a pure module; the report calls the profile's matcher.profile.reportFormat.4. Docs
docs/TAX_REPORT.mdgains a per-jurisdiction matrix;ASSUMPTIONS.mdrecords theUSdefault and each profile's simplifications.Edge Cases & Failure Modes
§104 poolingis fundamentally different from lot-by-lot FIFO: theuk_share_matchingmatcher maintains a pooled average cost; it must not try to reuse the FIFO lot consumer. This is the main implementation risk and gets its own fixture suite from HMRC examples.year=2026for UK means 6 Apr 2026 – 5 Apr 2027; the API documents the convention and echoes the resolved{ start, end }in the response.allowedMethodsexcludes the user's current method: switching jurisdiction forces a method choice + full recompute, surfaced as a required action, not a silent change.USbut every report says so prominently.Security & Privacy Considerations
Out of Scope
Suggested Implementation Plan
src/tax/jurisdictions/—JurisdictionProfiletype + 5 profiles with provenance;User.taxJurisdiction+ migration.report.tsyear boundaries from the profile; regression-lock theUS/calendar path.LossMatcherinterface +uk_share_matching(§104 pool) +ca_superficial_losspure modules with fixture suites from official examples; wireus_wash_saleto the sibling detector.lower_rate/exempt/discount) in the classifier; symmetry tests.PUT /api/v1/tax/settings.docs/TAX_REPORT.mdjurisdiction matrix +ASSUMPTIONS.md+docs/openapi.yaml.Acceptance Criteria
JurisdictionProfileconfig with 5 shipped profiles (US/UK/DE/AU/CA), each with cited provenance; new jurisdictions require config onlyUser.taxJurisdictionselectable viaPUT /api/v1/tax/settings(method constrained toallowedMethods), audit-logged, cache-invalidating; null defaults toUSwith a prominent caveatUS/calendar path is regression-locked to current behaviorLossMatcher—us_wash_sale,uk_share_matching(§104 pool),ca_superficial_loss,none— each pure and fixture-tested against official exampleslower_rate,exempt(symmetric gain/loss), anddiscount(AU 50%); annual allowances shown pre/post in totalsdocs/TAX_REPORT.md+ASSUMPTIONS.md+docs/openapi.yamlupdated; unit + integration tests green