Skip to content

Per-Jurisdiction Tax Profiles & Configurable Rule Sets #356

Description

@devsimze

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.tsDate.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.
  • Wash-sale detection (sibling issue) is US §1091-specific; UK/CA have their own "identical asset" matching.
  • Holding-period classification + 8949/TXF export are sibling issues (US-shaped).
  • prisma/schema.prismaUser (no jurisdiction field), CostBasisLot, LotDisposal.
  • docs/TAX_REPORT.md, ASSUMPTIONS.md.

Proposed Solution

1. Profile definition (src/tax/jurisdictions/, new)

interface JurisdictionProfile {
  id: string                       // "US" | "UK" | "DE" | "AU" | "CA"
  taxYear: { type: 'calendar' } | { type: 'custom', startMonth: number, startDay: number }  // UK 6 Apr, AU 1 Jul
  allowedMethods: CostBasisMethod[]        // constrains #317's method choice
  defaultMethod: CostBasisMethod
  holdingPeriod?: {                        // absent ⇒ no long/short distinction (UK)
    longTermAfterDays: number
    longTermEffect: '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.
  • Allowance in a non-USD currency while pricing is USD-only (pre-Multi-Method Tax Engine (LIFO / HIFO / Specific ID) with Multi-Currency Cost Basis #317): the allowance comparison needs an FX rate; until Multi-Method Tax Engine (LIFO / HIFO / Specific ID) with Multi-Currency Cost Basis #317 lands, non-USD profiles emit the pre-allowance number with a caveat rather than a wrong conversion.
  • 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

  1. src/tax/jurisdictions/JurisdictionProfile type + 5 profiles with provenance; User.taxJurisdiction + migration.
  2. Parameterize report.ts year boundaries from the profile; regression-lock the US/calendar path.
  3. 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.
  4. Holding-period effects (lower_rate / exempt / discount) in the classifier; symmetry tests.
  5. Allowances in report totals; export-format selection; PUT /api/v1/tax/settings.
  6. docs/TAX_REPORT.md jurisdiction matrix + ASSUMPTIONS.md + docs/openapi.yaml.

Acceptance Criteria

  • 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 LossMatcherus_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

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

Stellar WaveIssues in the Stellar wave program

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions