"""SQLAlchemy ORM models for month-end closing sessions and their results.""" from __future__ import annotations import datetime as dt from sqlalchemy import ( Boolean, Column, Date, DateTime, Float, ForeignKey, Integer, String, Text, Index ) from sqlalchemy.orm import relationship from .database import Base def _now() -> dt.datetime: return dt.datetime.utcnow() class User(Base): """A named person who can sign in. Created via `python manage.py add-user` — there is no self-signup. The display name is what lands in reviewed_by / approved_by / confirmed_by, so accountability fields carry a verified identity instead of free text.""" __tablename__ = "users" id = Column(Integer, primary_key=True) username = Column(String(64), unique=True, nullable=False) display_name = Column(String(255), nullable=False) password_hash = Column(String(512), nullable=False) is_active = Column(Boolean, default=True) created_at = Column(DateTime, default=_now) class Session(Base): __tablename__ = "sessions" id = Column(Integer, primary_key=True) name = Column(String(255), nullable=False) reporting_month = Column(String(32)) # "2026-01" month_end_date = Column(Date) reporting_currency = Column(String(16), default="USD") clearing_lag_days = Column(Integer, default=2) rounding_tolerance = Column(Float, default=0.01) allowance_for_returns = Column(Float, default=0.0) manual_adjustment = Column(Float, default=0.0) manual_adjustment_note = Column(String(512), default="") # How the opening AR balance is established: zero (default) | carry_forward | manual opening_mode = Column(String(32), default="zero") opening_source_session_id = Column(Integer) # draft|processing|processed|blocked|completed|error # "blocked" = processed, but a month-end control failed, so no receivable figure is # released to the dashboard or to an export until it is resolved. status = Column(String(32), default="draft") blocked_reason = Column(Text, default="") # How payouts count as received: # auto — bank-receipt date when entered, clearing-lag heuristic otherwise (default) # manual — ONLY payouts with a bank-receipt date ≤ month-end count; no heuristic payout_mode = Column(String(32), default="auto") # Receipts or payout mode changed after the last processing run — the classification on # screen no longer reflects them until the closing is re-processed. needs_reprocess = Column(Boolean, default=False) progress_stage = Column(String(255), default="") progress_pct = Column(Float, default=0.0) progress_rows_done = Column(Integer, default=0) progress_rows_total = Column(Integer, default=0) eta_seconds = Column(Integer, default=0) error = Column(Text, default="") created_at = Column(DateTime, default=_now) updated_at = Column(DateTime, default=_now, onupdate=_now) files = relationship("SessionFile", back_populates="session", cascade="all, delete-orphan") settlements = relationship("Settlement", back_populates="session", cascade="all, delete-orphan") exceptions = relationship("Exception_", back_populates="session", cascade="all, delete-orphan") fx_rates = relationship("FxRate", back_populates="session", cascade="all, delete-orphan") reserves = relationship("Reserve", back_populates="session", cascade="all, delete-orphan") class SessionFile(Base): __tablename__ = "session_files" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) filename = Column(String(512)) stored_path = Column(String(1024)) size_bytes = Column(Integer) sha256 = Column(String(64)) worksheets = Column(Text) # JSON list data_sheet = Column(String(255)) imported_rows = Column(Integer, default=0) min_date = Column(Date) max_date = Column(Date) currency = Column(String(16)) marketplace = Column(String(64)) status = Column(String(32), default="uploaded") # uploaded|parsed|invalid message = Column(Text, default="") # Control C1: the worksheet's own declared extent vs what we actually consumed. sheet_last_row = Column(Integer, default=0) blank_rows_skipped = Column(Integer, default=0) helper_rows_skipped = Column(Integer, default=0) session = relationship("Session", back_populates="files") class Settlement(Base): __tablename__ = "settlements" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) account_type = Column(String(64)) settlement_id = Column(String(255)) order_total = Column(Float, default=0.0) transfer_total = Column(Float, default=0.0) transfer_amount = Column(Float) # boundary/received transfer amount if any transfer_date = Column(Date) transfer_received = Column(Boolean) row_count = Column(Integer, default=0) first_date = Column(Date) last_date = Column(Date) status = Column(String(32)) # paid|receivable session = relationship("Session", back_populates="settlements") Index("ix_settlement_session", Settlement.session_id, Settlement.marketplace, Settlement.account_type) class Transaction(Base): __tablename__ = "transactions" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) source_file = Column(String(512)) source_sheet = Column(String(255)) source_row = Column(Integer) marketplace = Column(String(64)) settlement_id = Column(String(255)) order_id = Column(String(255)) sku = Column(String(255)) txn_type = Column(String(255)) # original (possibly localized) type txn_type_en = Column(String(255)) # canonical English type account_type = Column(String(64)) posted_date = Column(Date) total = Column(Float, default=0.0) currency = Column(String(16), default="USD") settlement_status = Column(String(32)) # paid|receivable receivable_flag = Column(Boolean, default=False) storage_flag = Column(Boolean, default=False) # storage-fee detection drill-down Index("ix_txn_session", Transaction.session_id) class MappingRule(Base): """Admin-defined header alias: normalized source header -> canonical field. Lets Finance map new/renamed Amazon headers without code changes.""" __tablename__ = "mapping_rules" id = Column(Integer, primary_key=True) normalized_header = Column(String(255), unique=True, nullable=False) field = Column(String(128), nullable=False) created_at = Column(DateTime, default=_now) Index("ix_txn_settlement", Transaction.session_id, Transaction.settlement_id) Index("ix_txn_type", Transaction.session_id, Transaction.txn_type) Index("ix_txn_recv", Transaction.session_id, Transaction.receivable_flag) class Exception_(Base): __tablename__ = "exceptions" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) category = Column(String(128)) severity = Column(String(32)) # error|warning|info detail = Column(Text) source = Column(String(512), default="") session = relationship("Session", back_populates="exceptions") class FxRate(Base): __tablename__ = "fx_rates" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) currency = Column(String(16), default="USD") rate = Column(Float, default=1.0) source = Column(String(64), default="manual") rate_date = Column(Date) # Control C5: a seeded default is a SUGGESTION, not a rate. Until someone confirms it for # this reporting month the closing is blocked — otherwise a July close silently values EUR # at the hardcoded January rate. confirmed_by = Column(String(255), default="") confirmed_at = Column(DateTime) confirmed_month = Column(String(32), default="") # reporting month the confirmation is for session = relationship("Session", back_populates="fx_rates") class PayoutReceipt(Base): """ When a payout actually reached the BANK — entered by Finance per payout. Amazon's Transfer row carries the date Amazon *initiated* the payout; the money lands 3-5 working days later. A receipt overrides the clearing-lag heuristic for its payout: received iff bank_date ≤ month-end. Keyed exactly like the engine's transfer overrides, (marketplace, account_type bucket, settlement_id). """ __tablename__ = "payout_receipts" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64), nullable=False) account_type = Column(String(64), nullable=False) # bucket label, e.g. "(unspecified)" settlement_id = Column(String(255), nullable=False) bank_date = Column(Date, nullable=False) bank_amount = Column(Float) # optional; None = same as Amazon amount note = Column(String(512), default="") entered_by = Column(String(255), default="") updated_at = Column(DateTime, default=_now, onupdate=_now) __table_args__ = ( Index("ix_payout_receipts_key", "session_id", "marketplace", "account_type", "settlement_id", unique=True), ) class ControlResult(Base): """Outcome of one month-end control (see core/controls.py). One row per control per close.""" __tablename__ = "control_results" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) key = Column(String(32)) # "C1".."C6" label = Column(String(255)) status = Column(String(32)) # pass|fail|not_applicable severity = Column(String(32), default="error") # error|warning|info detail = Column(Text, default="") evidence = Column(Text, default="") # JSON list of strings checked_at = Column(DateTime, default=_now) class Reserve(Base): __tablename__ = "reserves" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) account_type = Column(String(64)) amount = Column(Float, default=0.0) session = relationship("Session", back_populates="reserves") class ReceivableResultRow(Base): __tablename__ = "receivable_results" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) account_type = Column(String(64)) # or "TOTAL" additional_sales = Column(Float, default=0.0) reserve = Column(Float, default=0.0) receivable_local = Column(Float, default=0.0) fx_rate = Column(Float, default=1.0) receivable_usd = Column(Float, default=0.0) currency = Column(String(16), default="USD") class ReconciliationRow(Base): __tablename__ = "reconciliation" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True) uploaded_total = Column(Float) receivable_orders = Column(Float) paid_orders = Column(Float) transfers_total = Column(Float) reserve_total = Column(Float) manual_adjustments = Column(Float) final_receivable_usd = Column(Float) identity_difference = Column(Float) status = Column(String(64)) notes = Column(Text, default="") # AR roll-forward components received_payouts = Column(Float, default=0.0) # Σ transfers received (negative) all_payouts = Column(Float, default=0.0) # Σ all transfers (negative) class FxProviderRate(Base): """Cache of rates fetched from the FX provider (Frankfurter by default). One row per (provider, date, currency); `rate` is USD per 1 unit of local currency — the same orientation as FxRate.rate, i.e. usd = local * rate. Caching makes a re-fetch idempotent and keeps month-end seeding working offline once fetched.""" __tablename__ = "fx_provider_rates" id = Column(Integer, primary_key=True) provider = Column(String(32), nullable=False, default="frankfurter") rate_date = Column(Date, nullable=False) currency = Column(String(16), nullable=False) rate = Column(Float, nullable=False) fetched_at = Column(DateTime, default=_now) __table_args__ = ( Index("ix_fx_provider_key", "provider", "rate_date", "currency", unique=True), ) class FxRateDaily(Base): """Optional per-date FX override. Falls back to the marketplace's month rate.""" __tablename__ = "fx_rates_daily" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) rate_date = Column(Date) rate = Column(Float, default=1.0) source = Column(String(64), default="manual") Index("ix_fx_daily", FxRateDaily.session_id, FxRateDaily.marketplace, FxRateDaily.rate_date) class MarketPayout(Base): """Per-marketplace disbursement totals for the AR roll-forward. Transfers are attributed to the OWNER of their tagged settlement, not the file they appear in — Amazon repeats account-level payouts across country reports (e.g. the Belgium chain's transfers sit inside the Germany file). """ __tablename__ = "market_payouts" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) received_payouts = Column(Float, default=0.0) # negative all_payouts = Column(Float, default=0.0) # negative Index("ix_market_payout_session", MarketPayout.session_id, MarketPayout.marketplace) class OpeningBalance(Base): __tablename__ = "opening_balances" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) marketplace = Column(String(64)) amount = Column(Float, default=0.0) reason = Column(String(512), default="") source = Column(String(64), default="manual") # manual | carried_forward updated_at = Column(DateTime, default=_now, onupdate=_now) Index("ix_opening_session", OpeningBalance.session_id, OpeningBalance.marketplace) class FinanceControl(Base): """Finance's manual control-sheet amounts + sign-off, for the reconciliation section.""" __tablename__ = "finance_control" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True) gross_sales = Column(Float) refunds = Column(Float) net_revenue = Column(Float) disbursements = Column(Float) closing_receivable = Column(Float) tolerance = Column(Float, default=1.0) verified_by = Column(String(255), default="") verified_at = Column(DateTime) comment = Column(Text, default="") class JournalEntry(Base): __tablename__ = "journal_entries" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False, unique=True) entry_no = Column(String(128), default="") data = Column(Text, default="") # JSON: periods + lines + receivable # Two-step sign-off. Approval is what publishes this month's journal to the Accounts # Summary. Re-processing rebuilds the journal row, so both clear automatically whenever # the numbers change — a sign-off only ever attests to figures the signer actually saw # (entry_no is carried over; see jobs.run_processing). reviewed_by = Column(String(255), default="") reviewed_at = Column(DateTime) approved_by = Column(String(255), default="") approved_at = Column(DateTime) class ExportRecord(Base): __tablename__ = "exports" id = Column(Integer, primary_key=True) session_id = Column(Integer, ForeignKey("sessions.id"), nullable=False) kind = Column(String(32), default="full") # full | summary path = Column(String(1024)) sha256 = Column(String(64)) size_bytes = Column(Integer) generated_at = Column(DateTime, default=_now)