from __future__ import annotations from datetime import datetime, timezone from decimal import Decimal from flask_login import UserMixin from sqlalchemy import UniqueConstraint from werkzeug.security import check_password_hash, generate_password_hash from .extensions import db, login_manager def utcnow() -> datetime: return datetime.now(timezone.utc) class TimestampMixin: created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) updated_at = db.Column( db.DateTime(timezone=True), default=utcnow, onupdate=utcnow, nullable=False ) class User(UserMixin, TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) username = db.Column(db.String(80), unique=True, nullable=False) display_name = db.Column(db.String(120), nullable=False) email = db.Column(db.String(255), unique=True, nullable=False) avatar_url = db.Column(db.String(255), nullable=True) password_hash = db.Column(db.String(255), nullable=False) role = db.Column(db.String(20), nullable=False, default="editor") is_active = db.Column(db.Boolean, nullable=False, default=True) notification_preference = db.relationship( "NotificationPreference", back_populates="user", uselist=False ) push_subscriptions = db.relationship("PushSubscription", back_populates="user") def set_password(self, password: str) -> None: self.password_hash = generate_password_hash(password) def check_password(self, password: str) -> bool: return check_password_hash(self.password_hash, password) def is_admin(self) -> bool: return self.role == "admin" @property def ui_name(self) -> str: return self.display_name @property def avatar_initials(self) -> str: parts = [part for part in self.ui_name.replace("-", " ").replace("_", " ").split() if part] if not parts: return "?" if len(parts) == 1: return parts[0][:2].upper() return f"{parts[0][0]}{parts[1][0]}".upper() @login_manager.user_loader def load_user(user_id: str) -> User | None: return db.session.get(User, int(user_id)) class Month(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) label = db.Column(db.String(7), unique=True, nullable=False) year = db.Column(db.Integer, nullable=False) month = db.Column(db.Integer, nullable=False) auto_created = db.Column(db.Boolean, nullable=False, default=False) is_locked = db.Column(db.Boolean, nullable=False, default=False) notes = db.Column(db.Text, nullable=True) savings_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=15) savings_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=20) vacation_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5) vacation_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=8) leisure_min_pct = db.Column(db.Numeric(5, 2), nullable=False, default=5) leisure_max_pct = db.Column(db.Numeric(5, 2), nullable=False, default=10) personal_split_desi_pct = db.Column(db.Numeric(5, 2), nullable=False, default=50) entry_values = db.relationship( "MonthlyEntryValue", back_populates="month", cascade="all, delete-orphan" ) incomes = db.relationship( "MonthlyIncome", back_populates="month", cascade="all, delete-orphan" ) allocations = db.relationship( "MonthlyAllocation", back_populates="month", cascade="all, delete-orphan" ) suggestions = db.relationship( "AllocationSuggestion", back_populates="month", cascade="all, delete-orphan" ) class Account(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, unique=True) slug = db.Column(db.String(120), nullable=False, unique=True) description = db.Column(db.Text, nullable=True) sort_order = db.Column(db.Integer, nullable=False, default=0) is_active = db.Column(db.Boolean, nullable=False, default=True) categories = db.relationship( "Category", back_populates="account", cascade="all, delete-orphan" ) class Category(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) community_account_id = db.Column( db.Integer, db.ForeignKey("community_account.id"), nullable=True ) name = db.Column(db.String(120), nullable=False) slug = db.Column(db.String(120), nullable=False) description = db.Column(db.Text, nullable=True) sort_order = db.Column(db.Integer, nullable=False, default=0) is_active = db.Column(db.Boolean, nullable=False, default=True) account = db.relationship("Account", back_populates="categories") community_account = db.relationship("CommunityAccount", back_populates="budget_categories") entries = db.relationship( "Entry", back_populates="category", cascade="all, delete-orphan" ) __table_args__ = (UniqueConstraint("account_id", "slug"),) class Entry(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) category_id = db.Column(db.Integer, db.ForeignKey("category.id"), nullable=False) name = db.Column(db.String(120), nullable=False) slug = db.Column(db.String(120), nullable=False) description = db.Column(db.Text, nullable=True) default_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) amount_type = db.Column(db.String(20), nullable=False, default="fixed") benefit_scope = db.Column(db.String(120), nullable=False, default="all-users") is_allocation_target = db.Column(db.Boolean, nullable=False, default=False) is_active = db.Column(db.Boolean, nullable=False, default=True) sort_order = db.Column(db.Integer, nullable=False, default=0) category = db.relationship("Category", back_populates="entries") monthly_values = db.relationship( "MonthlyEntryValue", back_populates="entry", cascade="all, delete-orphan" ) share_rules = db.relationship( "EntryShareRule", back_populates="entry", cascade="all, delete-orphan" ) __table_args__ = (UniqueConstraint("category_id", "slug"),) class MonthlyEntryValue(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False) planned_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) note = db.Column(db.Text, nullable=True) created_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) updated_by = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) month = db.relationship("Month", back_populates="entry_values") entry = db.relationship("Entry", back_populates="monthly_values") __table_args__ = (UniqueConstraint("month_id", "entry_id"),) class MonthlyIncome(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) label = db.Column(db.String(120), nullable=False) amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) sort_order = db.Column(db.Integer, nullable=False, default=0) month = db.relationship("Month", back_populates="incomes") class MonthlyAllocation(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) label = db.Column(db.String(120), nullable=False) amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) source = db.Column(db.String(30), nullable=False, default="manual") is_locked = db.Column(db.Boolean, nullable=False, default=False) sort_order = db.Column(db.Integer, nullable=False, default=0) month = db.relationship("Month", back_populates="allocations") target_account = db.relationship("Account") __table_args__ = (UniqueConstraint("month_id", "target_account_id"),) class AllocationSuggestion(db.Model): id = db.Column(db.Integer, primary_key=True) month_id = db.Column(db.Integer, db.ForeignKey("month.id"), nullable=False) target_account_id = db.Column(db.Integer, db.ForeignKey("account.id"), nullable=False) suggested_amount = db.Column(db.Numeric(12, 2), nullable=False, default=0) reason = db.Column(db.Text, nullable=True) strategy_key = db.Column(db.String(80), nullable=False) created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) month = db.relationship("Month", back_populates="suggestions") target_account = db.relationship("Account") class CostParticipant(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, unique=True) avatar_url = db.Column(db.String(255), nullable=True) is_app_user = db.Column(db.Boolean, nullable=False, default=False) linked_user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) is_external = db.Column(db.Boolean, nullable=False, default=False) is_active = db.Column(db.Boolean, nullable=False, default=True) linked_user = db.relationship("User") share_rules = db.relationship( "EntryShareRule", back_populates="participant", cascade="all, delete-orphan" ) @property def display_name(self) -> str: if self.is_app_user and self.linked_user is not None: return self.linked_user.ui_name return self.name @property def avatar_initials(self) -> str: parts = [part for part in self.display_name.replace("-", " ").replace("_", " ").split() if part] if not parts: return "?" if len(parts) == 1: return parts[0][:2].upper() return f"{parts[0][0]}{parts[1][0]}".upper() class EntryShareRule(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) entry_id = db.Column(db.Integer, db.ForeignKey("entry.id"), nullable=False) participant_id = db.Column( db.Integer, db.ForeignKey("cost_participant.id"), nullable=False ) share_type = db.Column(db.String(20), nullable=False, default="equal") share_value = db.Column(db.Numeric(12, 4), nullable=True) entry = db.relationship("Entry", back_populates="share_rules") participant = db.relationship("CostParticipant", back_populates="share_rules") __table_args__ = (UniqueConstraint("entry_id", "participant_id"),) class PushSubscription(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) endpoint = db.Column(db.Text, nullable=False) p256dh_key = db.Column(db.Text, nullable=False) auth_key = db.Column(db.Text, nullable=False) user_agent = db.Column(db.String(255), nullable=True) user = db.relationship("User", back_populates="push_subscriptions") class CommunityAccount(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) name = db.Column(db.String(120), nullable=False, unique=True) slug = db.Column(db.String(120), nullable=False, unique=True) description = db.Column(db.Text, nullable=True) account_type = db.Column(db.String(20), nullable=False, default="shared") linked_account_slug = db.Column(db.String(120), nullable=True) sort_order = db.Column(db.Integer, nullable=False, default=0) is_active = db.Column(db.Boolean, nullable=False, default=True) budget_categories = db.relationship("Category", back_populates="community_account") class NotificationPreference(TimestampMixin, db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False, unique=True) notify_month_end = db.Column(db.Boolean, nullable=False, default=True) notify_missing_distribution = db.Column(db.Boolean, nullable=False, default=True) notify_missing_values = db.Column(db.Boolean, nullable=False, default=True) user = db.relationship("User", back_populates="notification_preference") class InAppNotification(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=False) type = db.Column(db.String(50), nullable=False) title = db.Column(db.String(150), nullable=False) body = db.Column(db.Text, nullable=False) action_url = db.Column(db.String(255), nullable=True) is_read = db.Column(db.Boolean, nullable=False, default=False) created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) class AuditLog(db.Model): id = db.Column(db.Integer, primary_key=True) user_id = db.Column(db.Integer, db.ForeignKey("user.id"), nullable=True) action = db.Column(db.String(120), nullable=False) entity_type = db.Column(db.String(80), nullable=False) entity_id = db.Column(db.Integer, nullable=True) payload_json = db.Column(db.Text, nullable=True) created_at = db.Column(db.DateTime(timezone=True), default=utcnow, nullable=False) def to_decimal(value: object) -> Decimal: if value is None: return Decimal("0.00") if isinstance(value, Decimal): return value.quantize(Decimal("0.01")) return Decimal(str(value)).quantize(Decimal("0.01"))