from typing import Dict, List, Optional, Sequence from .dates_uk_tax_year import uk_tax_year from .invest_capital_gains_tax_data import CGT_RATES, CGT_RATES_HISTORY, CGT_RATES_HORIZON, CGT_YEARS, CGT_YEARS_HISTORY, CGT_YEARS_HORIZON from .invest_capital_gains_tax_types import CgtDisposal, CgtLine, CgtResult from .money_amount import Money from .money_apply_rate import apply_rate def _gbp(minor: int) -> Money: return Money(minor=minor, currency="GBP") def _check_money(value: Money, name: str) -> None: if value.currency != "GBP": raise ValueError("%s must be in GBP, received %s" % (name, value.currency)) if isinstance(value.minor, bool) or not isinstance(value.minor, int): raise ValueError("%s must be a whole number of pence, received %s" % (name, value.minor)) def _pruned(history: str, horizon: Optional[str], date: str) -> str: if horizon is not None and date < horizon: return ( ": this build was installed with history=%s, so it only carries rules from %s. " "Reinstall with history=full for earlier years." % (history, horizon) ) return "" def _rate_for(disposal: CgtDisposal) -> List[int]: for rule in CGT_RATES: if rule.kind != disposal.kind or disposal.date < rule.valid_from: continue if rule.valid_to is not None and disposal.date > rule.valid_to: continue return [rule.lower_basis_points, rule.higher_basis_points, 0] raise ValueError( "no CGT rate for %s gains on %s" % (disposal.kind, disposal.date) + _pruned(CGT_RATES_HISTORY, CGT_RATES_HORIZON, disposal.date) ) def capital_gains_tax( tax_year_date: str, disposals: Sequence[CgtDisposal], taxable_income: Money, losses_brought_forward: Money, ) -> CgtResult: """Capital Gains Tax for an individual for one UK tax year. The year's losses come off its gains in full, even when that wastes the annual exempt amount; losses brought forward only bring the net gains down to the annual exempt amount. Losses and the exempt amount go against the gains charged at the highest rate first, and what taxable income leaves of the basic rate band goes to the gains whose two rates are furthest apart: that is the "most beneficial" order TCGA 1992 s1F and s1K allow. """ year = uk_tax_year(tax_year_date) rules = next( (row for row in CGT_YEARS if row.valid_from <= year.start and (row.valid_to is None or year.start <= row.valid_to)), None, ) if rules is None: raise ValueError( "no CGT rules for the %s tax year" % (year.label,) + _pruned(CGT_YEARS_HISTORY, CGT_YEARS_HORIZON, year.start) ) _check_money(taxable_income, "taxableIncome") _check_money(losses_brought_forward, "lossesBroughtForward") if taxable_income.minor < 0: raise ValueError("taxableIncome must not be negative, received %d" % (taxable_income.minor,)) if losses_brought_forward.minor < 0: raise ValueError("lossesBroughtForward must not be negative, received %d" % (losses_brought_forward.minor,)) # [lower, higher, amount]: gains that share a pair of rates. buckets: List[List[int]] = [] gains = 0 losses = 0 for disposal in disposals: _check_money(disposal.gain, "gain") if disposal.kind not in ("residential", "other"): raise ValueError('kind must be residential or other, received "%s"' % (disposal.kind,)) if disposal.date < year.start or disposal.date > year.end: raise ValueError("the disposal on %s is outside the %s tax year" % (disposal.date, year.label)) rate = _rate_for(disposal) if disposal.gain.minor <= 0: losses -= disposal.gain.minor continue gains += disposal.gain.minor bucket = next((b for b in buckets if b[0] == rate[0] and b[1] == rate[1]), None) if bucket is None: buckets.append([rate[0], rate[1], disposal.gain.minor]) else: bucket[2] += disposal.gain.minor net = gains - losses annual_exempt_amount = rules.annual_exempt_amount brought_forward_used = 0 exempt_used = 0 taxable = 0 carried = losses_brought_forward.minor if net > 0: brought_forward_used = min(losses_brought_forward.minor, max(0, net - annual_exempt_amount)) exempt_used = min(annual_exempt_amount, net - brought_forward_used) taxable = net - brought_forward_used - exempt_used carried -= brought_forward_used else: carried += -net # Deductions against the gains taxed hardest first. deductions = gains - taxable for bucket in sorted(buckets, key=lambda b: (-b[1], -b[0])): used = min(bucket[2], deductions) bucket[2] -= used deductions -= used # The basic rate band where it saves the most: the widest gap between the two rates. band_available = max(0, rules.basic_rate_band - taxable_income.minor) band = band_available charged: Dict[int, int] = {} def charge(rate: int, amount: int) -> None: if amount > 0: charged[rate] = charged.get(rate, 0) + amount for bucket in sorted(buckets, key=lambda b: (-(b[1] - b[0]), -b[1])): lower = min(bucket[2], band) band -= lower charge(bucket[0], lower) charge(bucket[1], bucket[2] - lower) lines = [ CgtLine(rate_basis_points=rate, gains=_gbp(amount), tax=apply_rate(_gbp(amount), rate, "down")) for rate, amount in sorted(charged.items()) ] tax = sum(line.tax.minor for line in lines) return CgtResult( tax_year=year.label, gains=_gbp(gains), losses=_gbp(losses), losses_brought_forward_used=_gbp(brought_forward_used), annual_exempt_amount=_gbp(annual_exempt_amount), annual_exempt_amount_used=_gbp(exempt_used), taxable_gains=_gbp(taxable), basic_rate_band_available=_gbp(band_available), lines=lines, tax=_gbp(tax), losses_carried_forward=_gbp(carried), )