Add recurring transaction detection, subscriptions page, and UK tax reporting
- Recurring service: auto-detects direct debits/subscriptions from CSV imports using frequency analysis; manual toggle in transaction detail drawer - Subscriptions page (/subscriptions): groups recurring payments with monthly cost equivalents, next-payment badges, and re-scan trigger - UK Tax page (/tax): payslips/P60 entry, income tax + NI + CGT + dividend tax calculations, configurable rate tables per tax year (pre-seeded 2024/25 and 2025/26), editable in-app so Budget changes need no rebuild - Migration 0006: tax_rate_configs, tax_profiles, payslips, manual_cgt_disposals with RLS; seeds 2025/2026 rate configs for existing users - Chart tooltip fix: all Recharts tooltips now use TOOLTIP_STYLE constant so they render correctly across all dark/light themes Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
0b326cbd87
commit
afb5e99bb2
48 changed files with 6238 additions and 39 deletions
74
backend/app/db/models/tax.py
Normal file
74
backend/app/db/models/tax.py
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import uuid
|
||||
from datetime import date, datetime
|
||||
from decimal import Decimal
|
||||
|
||||
from sqlalchemy import Boolean, Date, DateTime, ForeignKey, Integer, LargeBinary, Numeric, SmallInteger, String, UniqueConstraint
|
||||
from sqlalchemy.dialects.postgresql import JSONB, UUID
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.db.base import Base
|
||||
|
||||
|
||||
class TaxRateConfig(Base):
|
||||
__tablename__ = "tax_rate_configs"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "tax_year", "rate_type", name="uq_tax_rate_configs_user_year_type"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tax_year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
rate_type: Mapped[str] = mapped_column(String(30), nullable=False) # income_tax|ni|cgt|dividend
|
||||
config: Mapped[dict] = mapped_column(JSONB, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
|
||||
class TaxProfile(Base):
|
||||
__tablename__ = "tax_profiles"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("user_id", "tax_year", name="uq_tax_profiles_user_year"),
|
||||
)
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tax_year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
employer_name_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
tax_code: Mapped[str] = mapped_column(String(20), nullable=False, default="1257L")
|
||||
is_cumulative: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
payslips: Mapped[list["Payslip"]] = relationship(back_populates="tax_profile", lazy="noload")
|
||||
|
||||
|
||||
class Payslip(Base):
|
||||
__tablename__ = "payslips"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tax_profile_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("tax_profiles.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
period_month: Mapped[int | None] = mapped_column(SmallInteger, nullable=True)
|
||||
period_year: Mapped[int] = mapped_column(SmallInteger, nullable=False)
|
||||
gross_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
income_tax_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
ni_withheld: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
net_pay: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
is_p60: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
|
||||
tax_profile: Mapped["TaxProfile"] = relationship(back_populates="payslips", lazy="noload")
|
||||
|
||||
|
||||
class ManualCGTDisposal(Base):
|
||||
__tablename__ = "manual_cgt_disposals"
|
||||
|
||||
id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), primary_key=True, default=uuid.uuid4)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(UUID(as_uuid=True), ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True)
|
||||
tax_year: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
disposal_date: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
asset_description_enc: Mapped[bytes] = mapped_column(LargeBinary, nullable=False)
|
||||
proceeds: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
cost_basis: Mapped[Decimal] = mapped_column(Numeric(14, 2), nullable=False)
|
||||
notes_enc: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False)
|
||||
Loading…
Add table
Add a link
Reference in a new issue