feat: add tests for import polling, login page, money utilities, and query keys
- Implemented tests for import polling functionality, ensuring correct behavior for active and terminal import statuses. - Created tests for the login page, validating user interactions and error handling. - Added unit tests for money utility functions, covering parsing, formatting, and summation of monetary values. - Developed tests for query keys, ensuring proper scoping and invalidation roots. chore: setup testing environment and configuration - Added setup file for testing with Vitest and Testing Library. - Configured TypeScript and Vite for the frontend project. - Created Docker and Nginx configurations for local development and production environments. - Established a PostgreSQL initialization script for setting up application roles. docs: add README and environment configuration for infra - Included README for the infra directory, detailing local development setup and service descriptions. - Created an example environment configuration file for local development with placeholders for sensitive information.
This commit is contained in:
@@ -0,0 +1,7 @@
|
||||
.venv/
|
||||
var/
|
||||
__pycache__/
|
||||
*.pyc
|
||||
.pytest_cache/
|
||||
.mypy_cache/
|
||||
.ruff_cache/
|
||||
@@ -0,0 +1 @@
|
||||
3.12
|
||||
@@ -0,0 +1,39 @@
|
||||
[alembic]
|
||||
script_location = alembic
|
||||
prepend_sys_path = .
|
||||
file_template = %%(year)d%%(month).2d%%(day).2d_%%(rev)s_%%(slug)s
|
||||
version_path_separator = os
|
||||
|
||||
[loggers]
|
||||
keys = root,sqlalchemy,alembic
|
||||
|
||||
[handlers]
|
||||
keys = console
|
||||
|
||||
[formatters]
|
||||
keys = generic
|
||||
|
||||
[logger_root]
|
||||
level = WARN
|
||||
handlers = console
|
||||
qualname =
|
||||
|
||||
[logger_sqlalchemy]
|
||||
level = WARN
|
||||
handlers =
|
||||
qualname = sqlalchemy.engine
|
||||
|
||||
[logger_alembic]
|
||||
level = INFO
|
||||
handlers =
|
||||
qualname = alembic
|
||||
|
||||
[handler_console]
|
||||
class = StreamHandler
|
||||
args = (sys.stderr,)
|
||||
level = NOTSET
|
||||
formatter = generic
|
||||
|
||||
[formatter_generic]
|
||||
format = %(levelname)-5.5s [%(name)s] %(message)s
|
||||
datefmt = %H:%M:%S
|
||||
@@ -0,0 +1,45 @@
|
||||
from logging.config import fileConfig
|
||||
|
||||
from sqlalchemy import engine_from_config, pool
|
||||
|
||||
from alembic import context
|
||||
from app.config import get_settings
|
||||
from app.db import models as _models # noqa: F401
|
||||
from app.db.base import Base
|
||||
|
||||
config = context.config
|
||||
config.set_main_option("sqlalchemy.url", get_settings().database_url)
|
||||
if config.config_file_name is not None:
|
||||
fileConfig(config.config_file_name)
|
||||
|
||||
target_metadata = Base.metadata
|
||||
|
||||
|
||||
def run_migrations_offline() -> None:
|
||||
context.configure(
|
||||
url=config.get_main_option("sqlalchemy.url"),
|
||||
target_metadata=target_metadata,
|
||||
literal_binds=True,
|
||||
dialect_opts={"paramstyle": "named"},
|
||||
compare_type=True,
|
||||
)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
def run_migrations_online() -> None:
|
||||
connectable = engine_from_config(
|
||||
config.get_section(config.config_ini_section, {}),
|
||||
prefix="sqlalchemy.",
|
||||
poolclass=pool.NullPool,
|
||||
)
|
||||
with connectable.connect() as connection:
|
||||
context.configure(connection=connection, target_metadata=target_metadata, compare_type=True)
|
||||
with context.begin_transaction():
|
||||
context.run_migrations()
|
||||
|
||||
|
||||
if context.is_offline_mode():
|
||||
run_migrations_offline()
|
||||
else:
|
||||
run_migrations_online()
|
||||
@@ -0,0 +1,24 @@
|
||||
"""${message}
|
||||
|
||||
Revision ID: ${up_revision}
|
||||
Revises: ${down_revision | comma,n}
|
||||
Create Date: ${create_date}
|
||||
"""
|
||||
from typing import Sequence, Union
|
||||
|
||||
from alembic import op
|
||||
import sqlalchemy as sa
|
||||
${imports if imports else ""}
|
||||
|
||||
revision: str = ${repr(up_revision)}
|
||||
down_revision: Union[str, None] = ${repr(down_revision)}
|
||||
branch_labels: Union[str, Sequence[str], None] = ${repr(branch_labels)}
|
||||
depends_on: Union[str, Sequence[str], None] = ${repr(depends_on)}
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
${upgrades if upgrades else "pass"}
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
${downgrades if downgrades else "pass"}
|
||||
@@ -0,0 +1,85 @@
|
||||
"""Initial modular-monolith schema and tenant policies."""
|
||||
|
||||
from alembic import op
|
||||
from app.db import models as _models # noqa: F401
|
||||
from app.db.base import Base
|
||||
|
||||
revision = "0001"
|
||||
down_revision = None
|
||||
branch_labels = None
|
||||
depends_on = None
|
||||
|
||||
TENANT_TABLES = (
|
||||
"accounts",
|
||||
"operations",
|
||||
"postings",
|
||||
"categories",
|
||||
"budget_periods",
|
||||
"plan_assignments",
|
||||
"expected_incomes",
|
||||
"expected_income_matches",
|
||||
"funding_movements",
|
||||
"period_closures",
|
||||
"income_sources",
|
||||
"payees",
|
||||
"payee_aliases",
|
||||
"categorization_rules",
|
||||
"statement_documents",
|
||||
"parse_runs",
|
||||
"source_records",
|
||||
"source_record_links",
|
||||
"dedup_candidates",
|
||||
"data_exports",
|
||||
)
|
||||
|
||||
|
||||
def upgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.create_all(bind=bind)
|
||||
if bind.dialect.name != "postgresql":
|
||||
return
|
||||
for table in TENANT_TABLES:
|
||||
op.execute(f"ALTER TABLE {table} ENABLE ROW LEVEL SECURITY")
|
||||
op.execute(f"ALTER TABLE {table} FORCE ROW LEVEL SECURITY")
|
||||
op.execute(
|
||||
f"CREATE POLICY tenant_isolation ON {table} "
|
||||
"USING (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid) "
|
||||
"WITH CHECK (space_id = NULLIF(current_setting('app.space_id', true), '')::uuid)"
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION prevent_last_owner_removal() RETURNS trigger AS $$
|
||||
BEGIN
|
||||
IF OLD.role = 'owner' AND (TG_OP = 'DELETE' OR NEW.role <> 'owner') AND
|
||||
NOT EXISTS (
|
||||
SELECT 1 FROM memberships
|
||||
WHERE space_id = OLD.space_id AND role = 'owner' AND id <> OLD.id
|
||||
) THEN
|
||||
RAISE EXCEPTION 'financial space must retain an owner';
|
||||
END IF;
|
||||
RETURN CASE WHEN TG_OP = 'DELETE' THEN OLD ELSE NEW END;
|
||||
END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER memberships_keep_owner
|
||||
BEFORE UPDATE OR DELETE ON memberships
|
||||
FOR EACH ROW EXECUTE FUNCTION prevent_last_owner_removal();
|
||||
"""
|
||||
)
|
||||
op.execute(
|
||||
"""
|
||||
CREATE FUNCTION audit_events_append_only() RETURNS trigger AS $$
|
||||
BEGIN RAISE EXCEPTION 'audit events are append-only'; END;
|
||||
$$ LANGUAGE plpgsql;
|
||||
CREATE TRIGGER audit_events_no_mutation
|
||||
BEFORE UPDATE OR DELETE ON audit_events
|
||||
FOR EACH ROW EXECUTE FUNCTION audit_events_append_only();
|
||||
"""
|
||||
)
|
||||
|
||||
|
||||
def downgrade() -> None:
|
||||
bind = op.get_bind()
|
||||
Base.metadata.drop_all(bind=bind)
|
||||
if bind.dialect.name == "postgresql":
|
||||
op.execute("DROP FUNCTION IF EXISTS prevent_last_owner_removal()")
|
||||
op.execute("DROP FUNCTION IF EXISTS audit_events_append_only()")
|
||||
@@ -0,0 +1,109 @@
|
||||
from datetime import date
|
||||
|
||||
from fastapi import APIRouter, Query
|
||||
|
||||
from app.analytics import service
|
||||
from app.dependencies import Context, Db
|
||||
|
||||
router = APIRouter(prefix="/analytics", tags=["analytics"])
|
||||
|
||||
|
||||
@router.get("/category-spend")
|
||||
def category_spend(
|
||||
date_from: date = Query(alias="from"),
|
||||
date_to: date = Query(alias="to"),
|
||||
*,
|
||||
db: Db,
|
||||
context: Context,
|
||||
) -> list[dict]:
|
||||
rows = service.category_spend(
|
||||
db, space_id=context.space.id, date_from=date_from, date_to=date_to
|
||||
)
|
||||
return [
|
||||
{
|
||||
"categoryId": row["category_id"],
|
||||
"name": row["category"] if not row["category"].startswith("category.") else None,
|
||||
"nameKey": row["category"] if row["category"].startswith("category.") else None,
|
||||
"amountKopecks": row["amount_kopecks"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.get("/cash-flow")
|
||||
def cash_flow(
|
||||
date_from: date = Query(alias="from"),
|
||||
date_to: date = Query(alias="to"),
|
||||
*,
|
||||
db: Db,
|
||||
context: Context,
|
||||
) -> list[dict]:
|
||||
rows = service.cash_flow(db, space_id=context.space.id, date_from=date_from, date_to=date_to)
|
||||
months: dict[str, dict[str, int | str]] = {}
|
||||
for row in rows:
|
||||
month = row["date"].strftime("%Y-%m")
|
||||
bucket = months.setdefault(
|
||||
month,
|
||||
{
|
||||
"month": month,
|
||||
"incomeKopecks": 0,
|
||||
"expenseKopecks": 0,
|
||||
"refundsKopecks": 0,
|
||||
"netKopecks": 0,
|
||||
},
|
||||
)
|
||||
amount = int(row["amount_kopecks"])
|
||||
key = {
|
||||
"income": "incomeKopecks",
|
||||
"expense": "expenseKopecks",
|
||||
"refund": "refundsKopecks",
|
||||
}[row["type"]]
|
||||
bucket[key] = int(bucket[key]) + abs(amount)
|
||||
bucket["netKopecks"] = int(bucket["netKopecks"]) + amount
|
||||
return list(months.values())
|
||||
|
||||
|
||||
@router.get("/plan-fact")
|
||||
def plan_fact(month: str, db: Db, context: Context) -> dict:
|
||||
from app.budgeting.router import parse_month
|
||||
|
||||
year, month_number = parse_month(month)
|
||||
state = service.plan_fact(db, space_id=context.space.id, year=year, month=month_number)
|
||||
categories = state.get("monthly_categories", [])
|
||||
return {
|
||||
"month": month,
|
||||
"items": [
|
||||
{
|
||||
"categoryId": row["category_id"],
|
||||
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
|
||||
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
|
||||
"plannedKopecks": row["planned"],
|
||||
"actualKopecks": row["spent"],
|
||||
}
|
||||
for row in categories
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/balances")
|
||||
def balances(db: Db, context: Context) -> dict:
|
||||
from datetime import UTC, datetime
|
||||
|
||||
rows = service.balances(db, space_id=context.space.id)
|
||||
assets = sum(max(row["balance_kopecks"], 0) for row in rows if row["kind"] != "credit")
|
||||
liabilities = sum(row["debt_kopecks"] for row in rows)
|
||||
return {
|
||||
"asOf": datetime.now(UTC).date(),
|
||||
"assetsKopecks": assets,
|
||||
"liabilitiesKopecks": liabilities,
|
||||
"netKopecks": assets - liabilities,
|
||||
"items": [
|
||||
{
|
||||
"accountId": row["account_id"],
|
||||
"name": row["account"],
|
||||
"type": row["kind"],
|
||||
"balanceKopecks": row["balance_kopecks"],
|
||||
}
|
||||
for row in rows
|
||||
],
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.budgeting.models import PeriodClosure
|
||||
from app.budgeting.service import compute_period_state, get_or_create_period
|
||||
from app.categorization.service import resolve_category_name
|
||||
from app.ledger.models import Account, Category, Operation
|
||||
from app.ledger.service import account_balance
|
||||
|
||||
|
||||
def category_spend(
|
||||
db: Session, *, space_id: uuid.UUID, date_from: date, date_to: date
|
||||
) -> list[dict]:
|
||||
expenses = (
|
||||
db.query(Operation)
|
||||
.filter(
|
||||
Operation.space_id == space_id,
|
||||
Operation.status == "confirmed",
|
||||
Operation.type == "expense",
|
||||
Operation.occurred_on >= date_from,
|
||||
Operation.occurred_on <= date_to,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
totals: dict[uuid.UUID | None, int] = defaultdict(int)
|
||||
for expense in expenses:
|
||||
refunded = (
|
||||
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
|
||||
.filter(
|
||||
Operation.space_id == space_id,
|
||||
Operation.status == "confirmed",
|
||||
Operation.type == "refund",
|
||||
Operation.parent_operation_id == expense.id,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
totals[expense.category_id] += expense.amount_kopecks - int(refunded or 0)
|
||||
categories = {
|
||||
item.id: resolve_category_name(item)
|
||||
for item in db.query(Category).filter_by(space_id=space_id).all()
|
||||
}
|
||||
return [
|
||||
{
|
||||
"category_id": category_id,
|
||||
"category": (
|
||||
categories.get(category_id, "category.unnamed")
|
||||
if category_id is not None
|
||||
else "category.uncategorized"
|
||||
),
|
||||
"amount_kopecks": amount,
|
||||
}
|
||||
for category_id, amount in sorted(totals.items(), key=lambda item: item[1], reverse=True)
|
||||
]
|
||||
|
||||
|
||||
def cash_flow(db: Session, *, space_id: uuid.UUID, date_from: date, date_to: date) -> list[dict]:
|
||||
operations = (
|
||||
db.query(Operation)
|
||||
.filter(Operation.space_id == space_id, Operation.status == "confirmed")
|
||||
.all()
|
||||
)
|
||||
totals: dict[tuple[date, str], int] = defaultdict(int)
|
||||
for operation in operations:
|
||||
effective = operation.posted_on or operation.occurred_on
|
||||
if not date_from <= effective <= date_to or operation.type == "transfer":
|
||||
continue
|
||||
signed = (
|
||||
operation.amount_kopecks
|
||||
if operation.type in {"income", "refund"}
|
||||
else -operation.amount_kopecks
|
||||
)
|
||||
totals[(effective, operation.type)] += signed
|
||||
return [
|
||||
{"date": day, "type": kind, "amount_kopecks": amount}
|
||||
for (day, kind), amount in sorted(totals.items())
|
||||
]
|
||||
|
||||
|
||||
def plan_fact(db: Session, *, space_id: uuid.UUID, year: int, month: int) -> dict:
|
||||
period = get_or_create_period(db, space_id=space_id, year=year, month=month)
|
||||
if period.status == "closed":
|
||||
closure = db.query(PeriodClosure).filter_by(period_id=period.id).one_or_none()
|
||||
if closure is not None:
|
||||
return closure.snapshot
|
||||
return compute_period_state(db, space_id=space_id, period=period)
|
||||
|
||||
|
||||
def balances(db: Session, *, space_id: uuid.UUID) -> list[dict]:
|
||||
result = []
|
||||
for account in db.query(Account).filter_by(space_id=space_id).order_by(Account.created_at):
|
||||
balance = account_balance(db, account)
|
||||
result.append(
|
||||
{
|
||||
"account_id": account.id,
|
||||
"account": account.name,
|
||||
"kind": account.kind,
|
||||
"balance_kopecks": balance,
|
||||
"debt_kopecks": max(-balance, 0) if account.kind == "credit" else 0,
|
||||
}
|
||||
)
|
||||
return result
|
||||
@@ -0,0 +1,25 @@
|
||||
import uuid
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import JSON, ForeignKey, String, Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin
|
||||
|
||||
|
||||
class AuditEvent(Base, PkMixin, TimestampMixin):
|
||||
"""Append-only domain/security event. Never stores raw financial payloads."""
|
||||
|
||||
__tablename__ = "audit_events"
|
||||
__table_args__ = (
|
||||
{"comment": "Append-only audit trail; updates and deletes are blocked by trigger in PG."},
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True, index=True
|
||||
)
|
||||
event_type: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
detail: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
@@ -0,0 +1,23 @@
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit.models import AuditEvent
|
||||
|
||||
|
||||
def record(
|
||||
db: Session,
|
||||
*,
|
||||
event_type: str,
|
||||
space_id: Any = None,
|
||||
user_id: Any = None,
|
||||
detail: dict[str, Any] | None = None,
|
||||
) -> None:
|
||||
db.add(
|
||||
AuditEvent(
|
||||
space_id=space_id,
|
||||
user_id=user_id,
|
||||
event_type=event_type,
|
||||
detail=detail,
|
||||
)
|
||||
)
|
||||
@@ -0,0 +1,188 @@
|
||||
import uuid
|
||||
from datetime import date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
|
||||
|
||||
PERIOD_STATUSES = ("open", "closed")
|
||||
FUNDING_KINDS = ("fund", "unfund")
|
||||
|
||||
|
||||
class BudgetPeriod(Base, PkMixin, TimestampMixin):
|
||||
"""Calendar month budget period in the space timezone (month granularity)."""
|
||||
|
||||
__tablename__ = "budget_periods"
|
||||
__table_args__ = (
|
||||
CheckConstraint("status IN ('open', 'closed')", name="status_valid"),
|
||||
CheckConstraint("month BETWEEN 1 AND 12", name="month_valid"),
|
||||
UniqueConstraint("space_id", "year", "month", name="uq_budget_periods_space_ym"),
|
||||
UniqueConstraint("space_id", "id", name="uq_budget_periods_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
year: Mapped[int] = mapped_column(nullable=False)
|
||||
month: Mapped[int] = mapped_column(nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(8), nullable=False, default="open")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
|
||||
|
||||
class PlanAssignment(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "plan_assignments"
|
||||
__table_args__ = (
|
||||
CheckConstraint("planned_kopecks >= 0", name="planned_nonnegative"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "period_id"],
|
||||
["budget_periods.space_id", "budget_periods.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_plan_assignments_period_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
name="fk_plan_assignments_category_tenant",
|
||||
),
|
||||
UniqueConstraint("period_id", "category_id", name="uq_plan_assignments_period_category"),
|
||||
UniqueConstraint("space_id", "id", name="uq_plan_assignments_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
planned_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
|
||||
|
||||
class ExpectedIncome(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "expected_incomes"
|
||||
__table_args__ = (
|
||||
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "period_id"],
|
||||
["budget_periods.space_id", "budget_periods.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_expected_incomes_period_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_expected_incomes_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
expected_on: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
source_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
|
||||
|
||||
class ExpectedIncomeMatch(Base, PkMixin, TimestampMixin):
|
||||
"""Links one actual income operation to one expected income (1:1 per operation)."""
|
||||
|
||||
__tablename__ = "expected_income_matches"
|
||||
__table_args__ = (
|
||||
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "expected_income_id"],
|
||||
["expected_incomes.space_id", "expected_incomes.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_income_matches_expected_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
name="fk_income_matches_operation_tenant",
|
||||
),
|
||||
UniqueConstraint("operation_id", name="uq_income_matches_operation"),
|
||||
UniqueConstraint("space_id", "id", name="uq_income_matches_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
expected_income_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class FundingMovement(Base, PkMixin, TimestampMixin):
|
||||
"""Manual money movement between the general pool and a category/reserve."""
|
||||
|
||||
__tablename__ = "funding_movements"
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('fund', 'unfund')", name="kind_valid"),
|
||||
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "period_id"],
|
||||
["budget_periods.space_id", "budget_periods.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_funding_movements_period_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
name="fk_funding_movements_category_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_funding_movements_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
note: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class PeriodClosure(Base, PkMixin, TimestampMixin):
|
||||
"""Immutable-at-rest snapshot written when a period closes."""
|
||||
|
||||
__tablename__ = "period_closures"
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "period_id"],
|
||||
["budget_periods.space_id", "budget_periods.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_period_closures_period_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_period_closures_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
period_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
snapshot: Mapped[dict[str, Any]] = mapped_column(JSON, nullable=False)
|
||||
closed_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
closed_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -0,0 +1,296 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from app.budgeting import service
|
||||
from app.dependencies import Context, Csrf, Db
|
||||
|
||||
router = APIRouter(prefix="/budget", tags=["budget"])
|
||||
|
||||
|
||||
class CategoryBody(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: Literal["monthly", "savings_envelope"] = "monthly"
|
||||
|
||||
|
||||
class AssignmentItem(BaseModel):
|
||||
category_id: uuid.UUID
|
||||
planned_kopecks: int = Field(ge=0)
|
||||
|
||||
|
||||
class AssignmentsBody(BaseModel):
|
||||
items: list[AssignmentItem]
|
||||
|
||||
|
||||
class FundingBody(BaseModel):
|
||||
category_id: uuid.UUID
|
||||
amount_kopecks: int = Field(gt=0)
|
||||
kind: Literal["fund", "unfund"] = "fund"
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class ExpectedIncomeBody(BaseModel):
|
||||
expected_on: date
|
||||
amount_kopecks: int = Field(gt=0)
|
||||
source_name: str | None = Field(default=None, max_length=200)
|
||||
note: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
def period_for(db: Db, context: Context, year: int, month: int):
|
||||
return service.get_or_create_period(db, space_id=context.space.id, year=year, month=month)
|
||||
|
||||
|
||||
class MonthBody(BaseModel):
|
||||
month: str
|
||||
|
||||
|
||||
class PlanBody(MonthBody):
|
||||
plannedKopecks: int = Field(ge=0)
|
||||
|
||||
|
||||
class CategoryFundingBody(MonthBody):
|
||||
amountKopecks: int = Field(gt=0)
|
||||
|
||||
|
||||
def parse_month(value: str) -> tuple[int, int]:
|
||||
try:
|
||||
year_text, month_text = value.split("-", 1)
|
||||
year, month = int(year_text), int(month_text)
|
||||
except (ValueError, AttributeError) as exc:
|
||||
from app.errors import bad_request
|
||||
|
||||
raise bad_request("BUDGET_PERIOD_INVALID", "Month must use YYYY-MM") from exc
|
||||
service.validate_year_month(year, month)
|
||||
return year, month
|
||||
|
||||
|
||||
def frontend_summary(state: dict) -> dict:
|
||||
monthly = state["monthly_categories"]
|
||||
envelopes = state["envelopes"]
|
||||
funded = sum(row["funded"] for row in monthly) + sum(row["funded_total"] for row in envelopes)
|
||||
spent = sum(row["spent"] for row in monthly)
|
||||
available = sum(row["available"] for row in monthly + envelopes)
|
||||
return {
|
||||
"month": f"{state['period']['year']:04d}-{state['period']['month']:02d}",
|
||||
"status": state["period"]["status"],
|
||||
"projectedPoolKopecks": state["carry_pool"]
|
||||
+ state["expected_income_total"]
|
||||
- state["planned_total"],
|
||||
"actualPoolKopecks": state["pool"],
|
||||
"plannedTotalKopecks": state["planned_total"],
|
||||
"fundedTotalKopecks": funded,
|
||||
"spentTotalKopecks": spent,
|
||||
"availableKopecks": available,
|
||||
"warnings": [{"code": code} for code in state["warnings"]],
|
||||
"blockers": state["close_blockers"],
|
||||
"expectedIncomes": [
|
||||
{
|
||||
"id": item["id"],
|
||||
"expectedOn": item["expected_on"],
|
||||
"amountKopecks": item["amount_kopecks"],
|
||||
"sourceName": item["source_name"],
|
||||
"matchedKopecks": item["matched_kopecks"],
|
||||
"status": "matched"
|
||||
if item["matched_kopecks"] >= item["amount_kopecks"]
|
||||
else "partially_matched"
|
||||
if item["matched_kopecks"]
|
||||
else "expected",
|
||||
}
|
||||
for item in state["expected_incomes"]
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
@router.get("/summary")
|
||||
def summary(month: str, db: Db, context: Context) -> dict:
|
||||
year, month_number = parse_month(month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||
return frontend_summary(state)
|
||||
|
||||
|
||||
@router.get("/categories")
|
||||
def categories(month: str, db: Db, context: Context) -> list[dict]:
|
||||
year, month_number = parse_month(month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||
rows = state["monthly_categories"] + [
|
||||
{
|
||||
"category_id": row["category_id"],
|
||||
"name": row["name"],
|
||||
"planned": 0,
|
||||
"funded": row["funded_total"],
|
||||
"spent": row["funded_total"] - row["available"],
|
||||
"available": row["available"],
|
||||
}
|
||||
for row in state["envelopes"]
|
||||
]
|
||||
category_by_id = {
|
||||
str(item.id): item for item in service.list_categories(db, space_id=context.space.id)
|
||||
}
|
||||
return [
|
||||
{
|
||||
"id": row["category_id"],
|
||||
"name": row["name"] if not str(row["name"]).startswith("category.") else None,
|
||||
"nameKey": row["name"] if str(row["name"]).startswith("category.") else None,
|
||||
"kind": category_by_id[row["category_id"]].kind,
|
||||
"archived": False,
|
||||
"creditAccountId": category_by_id[row["category_id"]].linked_account_id,
|
||||
"plannedKopecks": row["planned"],
|
||||
"fundedKopecks": row["funded"],
|
||||
"spentKopecks": row["spent"],
|
||||
"availableKopecks": row["available"],
|
||||
}
|
||||
for row in rows
|
||||
]
|
||||
|
||||
|
||||
@router.post("/categories", status_code=201)
|
||||
def create_category(body: CategoryBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
category = service.create_category(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
kind=body.kind,
|
||||
name=body.name,
|
||||
)
|
||||
return {"id": category.id, "kind": category.kind, "name": body.name}
|
||||
|
||||
|
||||
@router.patch("/categories/{category_id}")
|
||||
def update_plan(
|
||||
category_id: uuid.UUID, body: PlanBody, db: Db, context: Context, _csrf: Csrf
|
||||
) -> dict:
|
||||
year, month_number = parse_month(body.month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
service.set_assignments(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
period=period,
|
||||
items=[{"category_id": category_id, "planned_kopecks": body.plannedKopecks}],
|
||||
)
|
||||
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
|
||||
|
||||
|
||||
@router.post("/categories/{category_id}/fund")
|
||||
def fund_category(
|
||||
category_id: uuid.UUID,
|
||||
body: CategoryFundingBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> dict:
|
||||
year, month_number = parse_month(body.month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
service.fund(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
period=period,
|
||||
category_id=category_id,
|
||||
amount_kopecks=body.amountKopecks,
|
||||
kind="fund",
|
||||
)
|
||||
return next(item for item in categories(body.month, db, context) if item["id"] == category_id)
|
||||
|
||||
|
||||
@router.put("/{year}/{month}/assignments")
|
||||
def assignments(
|
||||
year: int, month: int, body: AssignmentsBody, db: Db, context: Context, _csrf: Csrf
|
||||
) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
rows = service.set_assignments(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
period=period,
|
||||
items=[item.model_dump() for item in body.items],
|
||||
)
|
||||
return {"count": len(rows)}
|
||||
|
||||
|
||||
@router.post("/{year}/{month}/funding", status_code=201)
|
||||
def fund(year: int, month: int, body: FundingBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
movement = service.fund(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
period=period,
|
||||
**body.model_dump(),
|
||||
)
|
||||
return {"id": movement.id}
|
||||
|
||||
|
||||
@router.post("/{year}/{month}/expected-incomes", status_code=201)
|
||||
def expected_income(
|
||||
year: int,
|
||||
month: int,
|
||||
body: ExpectedIncomeBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
income = service.add_expected_income(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
period=period,
|
||||
**body.model_dump(),
|
||||
)
|
||||
return {"id": income.id}
|
||||
|
||||
|
||||
@router.post("/{year}/{month}/close")
|
||||
def close(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
return service.close_period(db, space_id=context.space.id, user=context.user, period=period)
|
||||
|
||||
|
||||
@router.post("/{year}/{month}/reopen")
|
||||
def reopen(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
affected = service.reopen_period(
|
||||
db, space_id=context.space.id, user=context.user, period=period
|
||||
)
|
||||
return {"affected_period_ids": [item.id for item in affected]}
|
||||
|
||||
|
||||
@router.post("/{year}/{month}/copy-previous")
|
||||
def copy_previous(year: int, month: int, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
period = period_for(db, context, year, month)
|
||||
return service.copy_previous_month(
|
||||
db, space_id=context.space.id, user=context.user, period=period
|
||||
)
|
||||
|
||||
|
||||
@router.post("/close")
|
||||
def close_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
year, month_number = parse_month(body.month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
service.close_period(db, space_id=context.space.id, user=context.user, period=period)
|
||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||
return frontend_summary(state)
|
||||
|
||||
|
||||
@router.post("/reopen")
|
||||
def reopen_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
year, month_number = parse_month(body.month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
service.reopen_period(db, space_id=context.space.id, user=context.user, period=period)
|
||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||
return frontend_summary(state)
|
||||
|
||||
|
||||
@router.post("/copy-previous")
|
||||
def copy_frontend(body: MonthBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
year, month_number = parse_month(body.month)
|
||||
period = period_for(db, context, year, month_number)
|
||||
service.copy_previous_month(db, space_id=context.space.id, user=context.user, period=period)
|
||||
state = service.compute_period_state(db, space_id=context.space.id, period=period)
|
||||
return frontend_summary(state)
|
||||
@@ -0,0 +1,868 @@
|
||||
import calendar
|
||||
import uuid
|
||||
from collections import defaultdict
|
||||
from datetime import UTC, date, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.budgeting.models import (
|
||||
BudgetPeriod,
|
||||
ExpectedIncome,
|
||||
ExpectedIncomeMatch,
|
||||
FundingMovement,
|
||||
PeriodClosure,
|
||||
PlanAssignment,
|
||||
)
|
||||
from app.categorization.service import resolve_category_name
|
||||
from app.errors import bad_request, conflict, not_found
|
||||
from app.identity.models import User
|
||||
from app.ledger.models import Account, Category, Operation
|
||||
from app.ledger.service import ensure_period_open_for_date as _ledger_guard # noqa: F401
|
||||
|
||||
|
||||
def validate_year_month(year: int, month: int) -> None:
|
||||
if not 1 <= month <= 12 or year < 2000:
|
||||
raise bad_request("BUDGET_PERIOD_INVALID", "Invalid year/month")
|
||||
|
||||
|
||||
def period_bounds(year: int, month: int) -> tuple[date, date]:
|
||||
start = date(year, month, 1)
|
||||
if month == 12:
|
||||
end = date(year + 1, 1, 1)
|
||||
else:
|
||||
end = date(year, month + 1, 1)
|
||||
return start, end
|
||||
|
||||
|
||||
def shift_month(d: date, months: int) -> date:
|
||||
index = d.year * 12 + (d.month - 1) + months
|
||||
year, month = divmod(index, 12)
|
||||
month += 1
|
||||
return date(year, month, min(d.day, calendar.monthrange(year, month)[1]))
|
||||
|
||||
|
||||
def get_period(db: Session, *, space_id: uuid.UUID, period_id: uuid.UUID) -> BudgetPeriod:
|
||||
period = db.query(BudgetPeriod).filter_by(id=period_id, space_id=space_id).one_or_none()
|
||||
if period is None:
|
||||
raise not_found("BUDGET_PERIOD_NOT_FOUND", "Budget period not found")
|
||||
return period
|
||||
|
||||
|
||||
def get_or_create_period(
|
||||
db: Session, *, space_id: uuid.UUID, year: int, month: int
|
||||
) -> BudgetPeriod:
|
||||
validate_year_month(year, month)
|
||||
period = (
|
||||
db.query(BudgetPeriod).filter_by(space_id=space_id, year=year, month=month).one_or_none()
|
||||
)
|
||||
if period is None:
|
||||
period = BudgetPeriod(space_id=space_id, year=year, month=month, status="open")
|
||||
db.add(period)
|
||||
db.flush()
|
||||
return period
|
||||
|
||||
|
||||
def list_periods(db: Session, *, space_id: uuid.UUID) -> list[BudgetPeriod]:
|
||||
return (
|
||||
db.query(BudgetPeriod)
|
||||
.filter_by(space_id=space_id)
|
||||
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def _previous_ym(year: int, month: int) -> tuple[int, int]:
|
||||
return (year - 1, 12) if month == 1 else (year, month - 1)
|
||||
|
||||
|
||||
def _closure_snapshot(db: Session, *, space_id: uuid.UUID, year: int, month: int) -> dict | None:
|
||||
period = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter_by(space_id=space_id, year=year, month=month, status="closed")
|
||||
.one_or_none()
|
||||
)
|
||||
if period is None:
|
||||
return None
|
||||
closure = (
|
||||
db.query(PeriodClosure)
|
||||
.filter_by(period_id=period.id)
|
||||
.order_by(PeriodClosure.closed_at.desc())
|
||||
.first()
|
||||
)
|
||||
return closure.snapshot if closure else None
|
||||
|
||||
|
||||
def _balance_until(db: Session, account: Account, end: date) -> int:
|
||||
from app.ledger.models import Posting
|
||||
|
||||
total = (
|
||||
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
||||
.join(Operation, Operation.id == Posting.operation_id)
|
||||
.filter(
|
||||
Posting.account_id == account.id,
|
||||
Operation.status == "confirmed",
|
||||
func.coalesce(Operation.posted_on, Operation.occurred_on) <= end,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return account.opening_amount_kopecks + int(total or 0)
|
||||
|
||||
|
||||
def compute_period_state(db: Session, *, space_id: uuid.UUID, period: BudgetPeriod) -> dict:
|
||||
"""Deterministic budget state.
|
||||
|
||||
Resource model:
|
||||
- income lands in the general pool;
|
||||
- categorized debit/cash expenses consume their category's available amount;
|
||||
- uncategorized debit/cash expenses reduce the pool directly (unallocated deficit);
|
||||
- credit expenses grow debt; the funded portion of a credit expense moves from
|
||||
the category into the account's credit reserve (derived, not stored);
|
||||
- a credit payment is covered by the credit reserve first, the remainder
|
||||
reduces the pool (which may go negative);
|
||||
- a refund on a credit card reduces debt and releases any surplus reserve;
|
||||
- a same-period refund restores the parent purchase's category; a refund on
|
||||
an uncategorized expense restores the pool;
|
||||
- monthly categories fold their residual into the pool at close; savings
|
||||
envelopes and credit reserves persist via all-time/ snapshot carries.
|
||||
"""
|
||||
start, end = period_bounds(period.year, period.month)
|
||||
|
||||
accounts = db.query(Account).filter_by(space_id=space_id).all()
|
||||
account_by_id = {a.id: a for a in accounts}
|
||||
categories = db.query(Category).filter_by(space_id=space_id, archived=False).all()
|
||||
|
||||
ops = (
|
||||
db.query(Operation)
|
||||
.filter(
|
||||
Operation.space_id == space_id,
|
||||
Operation.status == "confirmed",
|
||||
Operation.occurred_on >= start,
|
||||
Operation.occurred_on < end,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
|
||||
income_total = 0
|
||||
monthly_consumption: dict[Any, int] = defaultdict(int)
|
||||
envelope_consumption_total: dict[Any, int] = defaultdict(int)
|
||||
credit_expense_period: dict[Any, int] = defaultdict(int)
|
||||
asset_expense_period_uncategorized = 0
|
||||
portions_by_account: dict[Any, int] = defaultdict(int)
|
||||
expense_account_by_cat: dict[Any, dict] = defaultdict(lambda: defaultdict(int))
|
||||
payments_by_account: dict[Any, int] = defaultdict(int)
|
||||
refunds_to_credit_by_account: dict[Any, int] = defaultdict(int)
|
||||
refund_back_uncategorized_period = 0
|
||||
|
||||
for op in ops:
|
||||
if op.type == "income":
|
||||
income_total += op.amount_kopecks
|
||||
elif op.type == "expense":
|
||||
account = account_by_id[op.account_id]
|
||||
if account.kind == "credit":
|
||||
credit_expense_period[op.category_id] += op.amount_kopecks
|
||||
expense_account_by_cat[op.category_id][account.id] += op.amount_kopecks
|
||||
if op.category_id is None:
|
||||
if account.kind != "credit":
|
||||
asset_expense_period_uncategorized += op.amount_kopecks
|
||||
else:
|
||||
monthly_consumption[op.category_id] += op.amount_kopecks
|
||||
elif op.type == "transfer":
|
||||
counter = (
|
||||
account_by_id.get(op.counter_account_id)
|
||||
if op.counter_account_id is not None
|
||||
else None
|
||||
)
|
||||
if counter is not None and counter.kind == "credit":
|
||||
payments_by_account[counter.id] += op.amount_kopecks
|
||||
elif op.type == "refund":
|
||||
parent_account = account_by_id.get(op.account_id)
|
||||
parent = db.get(Operation, op.parent_operation_id) if op.parent_operation_id else None
|
||||
if parent_account is not None and parent_account.kind == "credit":
|
||||
refunds_to_credit_by_account[parent_account.id] += op.amount_kopecks
|
||||
elif parent is not None and parent.category_id is not None:
|
||||
same_period = start <= parent.occurred_on < end
|
||||
if same_period:
|
||||
monthly_consumption[parent.category_id] -= op.amount_kopecks
|
||||
else:
|
||||
refund_back_uncategorized_period += op.amount_kopecks
|
||||
else:
|
||||
refund_back_uncategorized_period += op.amount_kopecks
|
||||
|
||||
# Funding movements of this period and all-time envelope funding.
|
||||
funded_period: dict[Any, int] = defaultdict(int)
|
||||
for kind, sign in (("fund", 1), ("unfund", -1)):
|
||||
rows = (
|
||||
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
|
||||
.filter(
|
||||
FundingMovement.space_id == space_id,
|
||||
FundingMovement.period_id == period.id,
|
||||
FundingMovement.kind == kind,
|
||||
)
|
||||
.group_by(FundingMovement.category_id)
|
||||
.all()
|
||||
)
|
||||
for category_id, total in rows:
|
||||
funded_period[category_id] += sign * int(total or 0)
|
||||
funding_total = sum(funded_period.values())
|
||||
|
||||
funded_all_time: dict[Any, int] = defaultdict(int)
|
||||
for kind, sign in (("fund", 1), ("unfund", -1)):
|
||||
rows = (
|
||||
db.query(FundingMovement.category_id, func.sum(FundingMovement.amount_kopecks))
|
||||
.filter(
|
||||
FundingMovement.space_id == space_id,
|
||||
FundingMovement.kind == kind,
|
||||
)
|
||||
.group_by(FundingMovement.category_id)
|
||||
.all()
|
||||
)
|
||||
for category_id, total in rows:
|
||||
funded_all_time[category_id] += sign * int(total or 0)
|
||||
|
||||
# All-time envelope consumption up to period end.
|
||||
for cat in categories:
|
||||
if cat.kind != "savings_envelope":
|
||||
continue
|
||||
consumed = (
|
||||
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
|
||||
.filter(
|
||||
Operation.space_id == space_id,
|
||||
Operation.status == "confirmed",
|
||||
Operation.type == "expense",
|
||||
Operation.category_id == cat.id,
|
||||
Operation.occurred_on < end,
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
restored = 0
|
||||
refunds = (
|
||||
db.query(Operation)
|
||||
.filter(
|
||||
Operation.space_id == space_id,
|
||||
Operation.status == "confirmed",
|
||||
Operation.type == "refund",
|
||||
Operation.occurred_on < end,
|
||||
Operation.parent_operation_id.is_not(None),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
for refund in refunds:
|
||||
parent = db.get(Operation, refund.parent_operation_id)
|
||||
if (
|
||||
parent is not None
|
||||
and parent.category_id == cat.id
|
||||
and parent.occurred_on.year == refund.occurred_on.year
|
||||
and parent.occurred_on.month == refund.occurred_on.month
|
||||
):
|
||||
restored += refund.amount_kopecks
|
||||
envelope_consumption_total[cat.id] += int(consumed or 0) - restored
|
||||
|
||||
# Credit funded portions per category (period-scoped derivation).
|
||||
credit_funded_portion: dict[Any, int] = defaultdict(int)
|
||||
for category_id, credit_amount in list(credit_expense_period.items()):
|
||||
if category_id is None:
|
||||
continue
|
||||
noncredit_consumption = monthly_consumption[category_id] - credit_amount
|
||||
available = funded_period[category_id] - noncredit_consumption
|
||||
portion = min(max(available, 0), credit_amount)
|
||||
credit_funded_portion[category_id] += portion
|
||||
if credit_amount:
|
||||
allocation = expense_account_by_cat[category_id]
|
||||
remaining = portion
|
||||
for index, (account_id, amount) in enumerate(allocation.items()):
|
||||
account_portion = (
|
||||
remaining if index == len(allocation) - 1 else portion * amount // credit_amount
|
||||
)
|
||||
portions_by_account[account_id] += account_portion
|
||||
remaining -= account_portion
|
||||
|
||||
prev_snapshot = _closure_snapshot(
|
||||
db,
|
||||
space_id=space_id,
|
||||
year=_previous_ym(period.year, period.month)[0],
|
||||
month=_previous_ym(period.year, period.month)[1],
|
||||
)
|
||||
carry_pool = int(prev_snapshot.get("carry_pool", 0)) if prev_snapshot else 0
|
||||
carry_reserves: dict[str, int] = (
|
||||
{k: int(v) for k, v in prev_snapshot.get("credit_reserves", {}).items()}
|
||||
if prev_snapshot
|
||||
else {}
|
||||
)
|
||||
|
||||
# Reserve and debt per credit account.
|
||||
credit_reserves: dict[str, int] = {}
|
||||
credit_debt: dict[str, int] = {}
|
||||
releases_payment_total = 0
|
||||
surplus_release_total = 0
|
||||
for account in accounts:
|
||||
if account.kind != "credit":
|
||||
continue
|
||||
key = str(account.id)
|
||||
reserve = carry_reserves.get(key, 0) + portions_by_account[account.id]
|
||||
payment = payments_by_account[account.id]
|
||||
covered = min(reserve, payment)
|
||||
releases_payment_total += covered
|
||||
reserve -= covered
|
||||
reserve -= refunds_to_credit_by_account[account.id]
|
||||
debt = max(-_balance_until(db, account, end), 0)
|
||||
surplus = max(0, reserve - debt)
|
||||
surplus_release_total += surplus
|
||||
credit_reserves[key] = min(max(reserve, 0), debt)
|
||||
credit_debt[key] = debt
|
||||
|
||||
uncategorized_outflow = asset_expense_period_uncategorized - refund_back_uncategorized_period
|
||||
payments_total = sum(payments_by_account.values())
|
||||
|
||||
pool = (
|
||||
carry_pool
|
||||
+ income_total
|
||||
- uncategorized_outflow
|
||||
- (payments_total - releases_payment_total)
|
||||
+ surplus_release_total
|
||||
- funding_total
|
||||
)
|
||||
|
||||
monthly_rows: list[dict[str, Any]] = []
|
||||
monthly_residual_total = 0
|
||||
for cat in categories:
|
||||
if cat.kind != "monthly":
|
||||
continue
|
||||
available = funded_period[cat.id] - monthly_consumption[cat.id]
|
||||
monthly_residual_total += available
|
||||
monthly_rows.append(
|
||||
{
|
||||
"category_id": str(cat.id),
|
||||
"name": resolve_category_name(cat),
|
||||
"planned": 0,
|
||||
"funded": funded_period[cat.id],
|
||||
"spent": monthly_consumption[cat.id],
|
||||
"available": available,
|
||||
}
|
||||
)
|
||||
envelope_rows: list[dict[str, Any]] = []
|
||||
for cat in categories:
|
||||
if cat.kind != "savings_envelope" or cat.system_key == "credit_reserve":
|
||||
continue
|
||||
available = funded_all_time[cat.id] - envelope_consumption_total[cat.id]
|
||||
envelope_rows.append(
|
||||
{
|
||||
"category_id": str(cat.id),
|
||||
"name": resolve_category_name(cat),
|
||||
"funded_total": funded_all_time[cat.id],
|
||||
"available": available,
|
||||
}
|
||||
)
|
||||
|
||||
assignments = db.query(PlanAssignment).filter_by(period_id=period.id).all()
|
||||
planned_by_cat = {a.category_id: a.planned_kopecks for a in assignments}
|
||||
for row in monthly_rows:
|
||||
cid = uuid.UUID(str(row["category_id"]))
|
||||
row["planned"] = planned_by_cat.get(cid, 0)
|
||||
planned_total = sum(planned_by_cat.values())
|
||||
|
||||
expected = db.query(ExpectedIncome).filter_by(period_id=period.id).all()
|
||||
expected_total = sum(e.amount_kopecks for e in expected)
|
||||
matched_by_expected: dict[Any, int] = defaultdict(int)
|
||||
for m in (
|
||||
db.query(ExpectedIncomeMatch)
|
||||
.filter(ExpectedIncomeMatch.expected_income_id.in_([e.id for e in expected] or [None]))
|
||||
.all()
|
||||
if expected
|
||||
else []
|
||||
):
|
||||
matched_by_expected[m.expected_income_id] += m.amount_kopecks
|
||||
|
||||
uncategorized_count = sum(1 for op in ops if op.type == "expense" and op.category_id is None)
|
||||
negative_envelopes = [str(row["name"]) for row in envelope_rows if int(row["available"]) < 0]
|
||||
|
||||
warnings = []
|
||||
if planned_total > carry_pool + expected_total:
|
||||
warnings.append("plan_imbalance")
|
||||
for key, debt in credit_debt.items():
|
||||
if debt > credit_reserves.get(key, 0):
|
||||
warnings.append("underfunded_credit_debt")
|
||||
break
|
||||
if uncategorized_count:
|
||||
warnings.append("uncategorized_expenses")
|
||||
if negative_envelopes:
|
||||
warnings.append("negative_envelopes")
|
||||
|
||||
return {
|
||||
"period": {
|
||||
"id": str(period.id),
|
||||
"year": period.year,
|
||||
"month": period.month,
|
||||
"status": period.status,
|
||||
},
|
||||
"pool": pool,
|
||||
"carry_pool": carry_pool,
|
||||
"income_total": income_total,
|
||||
"planned_total": planned_total,
|
||||
"expected_income_total": expected_total,
|
||||
"expected_incomes": [
|
||||
{
|
||||
"id": str(e.id),
|
||||
"expected_on": e.expected_on.isoformat(),
|
||||
"amount_kopecks": e.amount_kopecks,
|
||||
"source_name": e.source_name,
|
||||
"matched_kopecks": matched_by_expected.get(e.id, 0),
|
||||
}
|
||||
for e in expected
|
||||
],
|
||||
"monthly_categories": monthly_rows,
|
||||
"envelopes": envelope_rows,
|
||||
"credit_reserves": credit_reserves,
|
||||
"credit_debt": credit_debt,
|
||||
"uncategorized_expenses": uncategorized_count,
|
||||
"warnings": warnings,
|
||||
"close_blockers": (
|
||||
[]
|
||||
if period.status == "closed"
|
||||
else _close_blockers(
|
||||
db,
|
||||
space_id=space_id,
|
||||
period=period,
|
||||
uncategorized_count=uncategorized_count,
|
||||
negative_envelopes=negative_envelopes,
|
||||
)
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def _close_blockers(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
period: BudgetPeriod,
|
||||
uncategorized_count: int,
|
||||
negative_envelopes: list[str],
|
||||
) -> list[dict]:
|
||||
from app.imports.models import StatementDocument
|
||||
|
||||
blockers = []
|
||||
unresolved = (
|
||||
db.query(StatementDocument.id)
|
||||
.filter(
|
||||
StatementDocument.space_id == space_id,
|
||||
StatementDocument.status.in_(
|
||||
["uploaded", "queued", "processing", "ready", "review_required"]
|
||||
),
|
||||
)
|
||||
.count()
|
||||
)
|
||||
if unresolved:
|
||||
blockers.append({"code": "IMPORTS_UNRESOLVED", "count": unresolved})
|
||||
if uncategorized_count:
|
||||
blockers.append({"code": "BUDGET_UNCATEGORIZED_EXPENSES", "count": uncategorized_count})
|
||||
if negative_envelopes:
|
||||
blockers.append({"code": "BUDGET_NEGATIVE_ENVELOPE", "categories": negative_envelopes})
|
||||
return blockers
|
||||
|
||||
|
||||
def require_open(period: BudgetPeriod) -> None:
|
||||
if period.status != "open":
|
||||
raise conflict(
|
||||
"BUDGET_PERIOD_CLOSED", "Budget period is closed", details={"period_id": str(period.id)}
|
||||
)
|
||||
|
||||
|
||||
def get_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID) -> Category:
|
||||
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
|
||||
if category is None:
|
||||
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
||||
return category
|
||||
|
||||
|
||||
def create_category(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
kind: str,
|
||||
name: str,
|
||||
) -> Category:
|
||||
if kind not in ("monthly", "savings_envelope"):
|
||||
raise bad_request("BUDGET_CATEGORY_KIND_INVALID", "Unknown category kind")
|
||||
category = Category(space_id=space_id, kind=kind, custom_name=name)
|
||||
db.add(category)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="category.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"category_id": str(category.id)},
|
||||
)
|
||||
return category
|
||||
|
||||
|
||||
def ensure_credit_reserve_category(
|
||||
db: Session, *, space_id: uuid.UUID, account: Account
|
||||
) -> Category:
|
||||
category = (
|
||||
db.query(Category)
|
||||
.filter_by(space_id=space_id, system_key="credit_reserve", linked_account_id=account.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if category is None:
|
||||
category = Category(
|
||||
space_id=space_id,
|
||||
kind="savings_envelope",
|
||||
system_key="credit_reserve",
|
||||
linked_account_id=account.id,
|
||||
)
|
||||
db.add(category)
|
||||
db.flush()
|
||||
return category
|
||||
|
||||
|
||||
def list_categories(
|
||||
db: Session, *, space_id: uuid.UUID, include_archived: bool = False
|
||||
) -> list[Category]:
|
||||
query = db.query(Category).filter_by(space_id=space_id)
|
||||
if not include_archived:
|
||||
query = query.filter(Category.archived.is_(False))
|
||||
return query.order_by(Category.created_at).all()
|
||||
|
||||
|
||||
def archive_category(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID
|
||||
) -> Category:
|
||||
category = get_category(db, space_id=space_id, category_id=category_id)
|
||||
if category.system_key == "credit_reserve":
|
||||
raise conflict("BUDGET_CATEGORY_SYSTEM", "System categories cannot be archived")
|
||||
category.archived = True
|
||||
audit.record(
|
||||
db,
|
||||
event_type="category.archived",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"category_id": str(category.id)},
|
||||
)
|
||||
return category
|
||||
|
||||
|
||||
def rename_category(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, category_id: uuid.UUID, name: str
|
||||
) -> Category:
|
||||
category = get_category(db, space_id=space_id, category_id=category_id)
|
||||
if category.system_key is not None:
|
||||
category.name_override = name
|
||||
else:
|
||||
category.custom_name = name
|
||||
audit.record(
|
||||
db,
|
||||
event_type="category.renamed",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"category_id": str(category.id)},
|
||||
)
|
||||
return category
|
||||
|
||||
|
||||
def set_assignments(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
period: BudgetPeriod,
|
||||
items: list[dict],
|
||||
) -> list[PlanAssignment]:
|
||||
require_open(period)
|
||||
result = []
|
||||
for item in items:
|
||||
category = get_category(db, space_id=space_id, category_id=item["category_id"])
|
||||
if category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||
planned = item["planned_kopecks"]
|
||||
if planned < 0:
|
||||
raise bad_request("BUDGET_PLANNED_NEGATIVE", "Planned amount cannot be negative")
|
||||
assignment = (
|
||||
db.query(PlanAssignment)
|
||||
.filter_by(period_id=period.id, category_id=category.id)
|
||||
.one_or_none()
|
||||
)
|
||||
if assignment is None:
|
||||
assignment = PlanAssignment(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
category_id=category.id,
|
||||
planned_kopecks=planned,
|
||||
)
|
||||
db.add(assignment)
|
||||
else:
|
||||
assignment.planned_kopecks = planned
|
||||
result.append(assignment)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="budget.assignments_set",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"period_id": str(period.id), "count": len(result)},
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
def add_expected_income(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
period: BudgetPeriod,
|
||||
expected_on: date,
|
||||
amount_kopecks: int,
|
||||
source_name: str | None,
|
||||
note: str | None = None,
|
||||
) -> ExpectedIncome:
|
||||
require_open(period)
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
income = ExpectedIncome(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
expected_on=expected_on,
|
||||
amount_kopecks=amount_kopecks,
|
||||
source_name=source_name,
|
||||
note=note,
|
||||
)
|
||||
db.add(income)
|
||||
db.flush()
|
||||
return income
|
||||
|
||||
|
||||
def match_expected_income(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
expected_income_id: uuid.UUID,
|
||||
operation_id: uuid.UUID,
|
||||
amount_kopecks: int | None = None,
|
||||
) -> ExpectedIncomeMatch:
|
||||
expected = (
|
||||
db.query(ExpectedIncome).filter_by(id=expected_income_id, space_id=space_id).one_or_none()
|
||||
)
|
||||
if expected is None:
|
||||
raise not_found("BUDGET_EXPECTED_INCOME_NOT_FOUND", "Expected income not found")
|
||||
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
|
||||
if operation is None or operation.type != "income" or operation.status != "confirmed":
|
||||
raise not_found("BUDGET_INCOME_OPERATION_NOT_FOUND", "Confirmed income operation not found")
|
||||
existing = db.query(ExpectedIncomeMatch).filter_by(operation_id=operation.id).one_or_none()
|
||||
if existing is not None:
|
||||
raise conflict("BUDGET_INCOME_ALREADY_MATCHED", "Income already matched to expected income")
|
||||
amount = (
|
||||
amount_kopecks
|
||||
if amount_kopecks is not None
|
||||
else min(operation.amount_kopecks, expected.amount_kopecks)
|
||||
)
|
||||
if amount <= 0 or amount > operation.amount_kopecks:
|
||||
raise bad_request(
|
||||
"BUDGET_MATCH_AMOUNT_INVALID",
|
||||
"Match amount must be within the income operation",
|
||||
)
|
||||
match = ExpectedIncomeMatch(
|
||||
space_id=space_id,
|
||||
expected_income_id=expected.id,
|
||||
operation_id=operation.id,
|
||||
amount_kopecks=amount,
|
||||
)
|
||||
db.add(match)
|
||||
db.flush()
|
||||
return match
|
||||
|
||||
|
||||
def fund(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
period: BudgetPeriod,
|
||||
category_id: uuid.UUID,
|
||||
amount_kopecks: int,
|
||||
kind: str,
|
||||
note: str | None = None,
|
||||
) -> FundingMovement:
|
||||
require_open(period)
|
||||
if kind not in ("fund", "unfund"):
|
||||
raise bad_request("BUDGET_FUNDING_KIND_INVALID", "Unknown funding kind")
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
get_category(db, space_id=space_id, category_id=category_id)
|
||||
movement = FundingMovement(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
category_id=category_id,
|
||||
kind=kind,
|
||||
amount_kopecks=amount_kopecks,
|
||||
note=note,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(movement)
|
||||
db.flush()
|
||||
return movement
|
||||
|
||||
|
||||
def close_period(db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod) -> dict:
|
||||
period = (
|
||||
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
|
||||
)
|
||||
require_open(period)
|
||||
previous_year, previous_month = _previous_ym(period.year, period.month)
|
||||
earlier_period = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter(
|
||||
BudgetPeriod.space_id == space_id,
|
||||
(BudgetPeriod.year < period.year)
|
||||
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month < period.month)),
|
||||
)
|
||||
.order_by(BudgetPeriod.year.desc(), BudgetPeriod.month.desc())
|
||||
.with_for_update()
|
||||
.first()
|
||||
)
|
||||
if earlier_period is not None and (
|
||||
earlier_period.year != previous_year
|
||||
or earlier_period.month != previous_month
|
||||
or earlier_period.status != "closed"
|
||||
):
|
||||
raise conflict(
|
||||
"BUDGET_CLOSE_OUT_OF_ORDER",
|
||||
"Close the previous calendar month before this period",
|
||||
details={"year": previous_year, "month": previous_month},
|
||||
)
|
||||
state = compute_period_state(db, space_id=space_id, period=period)
|
||||
if state["close_blockers"]:
|
||||
raise conflict(
|
||||
"BUDGET_CLOSE_BLOCKED",
|
||||
"Period has blocking issues",
|
||||
details={"blockers": state["close_blockers"]},
|
||||
)
|
||||
monthly_residual = sum(row["available"] for row in state["monthly_categories"])
|
||||
snapshot = {
|
||||
"period": state["period"],
|
||||
"closed_at": datetime.now(UTC).isoformat(),
|
||||
"pool": state["pool"],
|
||||
"carry_pool": state["pool"] + monthly_residual,
|
||||
"income_total": state["income_total"],
|
||||
"planned_total": state["planned_total"],
|
||||
"monthly_categories": state["monthly_categories"],
|
||||
"envelopes": state["envelopes"],
|
||||
"credit_reserves": state["credit_reserves"],
|
||||
"credit_debt": state["credit_debt"],
|
||||
"warnings": state["warnings"],
|
||||
}
|
||||
closure = PeriodClosure(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
snapshot=snapshot,
|
||||
closed_at=datetime.now(UTC),
|
||||
closed_by_user_id=user.id,
|
||||
)
|
||||
period.status = "closed"
|
||||
db.add(closure)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="budget.closed",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"period_id": str(period.id)},
|
||||
)
|
||||
return snapshot
|
||||
|
||||
|
||||
def reopen_period(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
|
||||
) -> list[BudgetPeriod]:
|
||||
period = (
|
||||
db.query(BudgetPeriod).filter_by(id=period.id, space_id=space_id).with_for_update().one()
|
||||
)
|
||||
if period.status != "closed":
|
||||
raise conflict("BUDGET_PERIOD_NOT_CLOSED", "Only closed periods can be reopened")
|
||||
affected = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter(
|
||||
BudgetPeriod.space_id == space_id,
|
||||
BudgetPeriod.status == "closed",
|
||||
(BudgetPeriod.year > period.year)
|
||||
| ((BudgetPeriod.year == period.year) & (BudgetPeriod.month >= period.month)),
|
||||
)
|
||||
.order_by(BudgetPeriod.year, BudgetPeriod.month)
|
||||
.all()
|
||||
)
|
||||
for p in affected:
|
||||
p.status = "open"
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="budget.reopened",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"period_id": str(period.id), "impacted": [str(p.id) for p in affected]},
|
||||
)
|
||||
return affected
|
||||
|
||||
|
||||
def copy_previous_month(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, period: BudgetPeriod
|
||||
) -> dict:
|
||||
require_open(period)
|
||||
prev_year, prev_month = _previous_ym(period.year, period.month)
|
||||
previous = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter_by(space_id=space_id, year=prev_year, month=prev_month)
|
||||
.one_or_none()
|
||||
)
|
||||
if previous is None:
|
||||
raise not_found("BUDGET_PREVIOUS_PERIOD_MISSING", "Previous month period does not exist")
|
||||
copied_assignments = 0
|
||||
for assignment in db.query(PlanAssignment).filter_by(period_id=previous.id).all():
|
||||
category = get_category(db, space_id=space_id, category_id=assignment.category_id)
|
||||
if category.archived:
|
||||
continue
|
||||
existing = (
|
||||
db.query(PlanAssignment)
|
||||
.filter_by(period_id=period.id, category_id=assignment.category_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is None:
|
||||
db.add(
|
||||
PlanAssignment(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
category_id=assignment.category_id,
|
||||
planned_kopecks=assignment.planned_kopecks,
|
||||
)
|
||||
)
|
||||
else:
|
||||
existing.planned_kopecks = assignment.planned_kopecks
|
||||
copied_assignments += 1
|
||||
copied_expected = 0
|
||||
for expected in db.query(ExpectedIncome).filter_by(period_id=previous.id).all():
|
||||
db.add(
|
||||
ExpectedIncome(
|
||||
space_id=space_id,
|
||||
period_id=period.id,
|
||||
expected_on=shift_month(expected.expected_on, 1),
|
||||
amount_kopecks=expected.amount_kopecks,
|
||||
source_name=expected.source_name,
|
||||
note=expected.note,
|
||||
)
|
||||
)
|
||||
copied_expected += 1
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="budget.copied",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={
|
||||
"period_id": str(period.id),
|
||||
"assignments": copied_assignments,
|
||||
"expected": copied_expected,
|
||||
},
|
||||
)
|
||||
return {"assignments_copied": copied_assignments, "expected_incomes_copied": copied_expected}
|
||||
@@ -0,0 +1,113 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy import (
|
||||
Boolean,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin
|
||||
|
||||
|
||||
class IncomeSource(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "income_sources"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("space_id", "name", name="uq_income_sources_space_name"),
|
||||
UniqueConstraint("space_id", "id", name="uq_income_sources_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class Payee(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "payees"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("space_id", "name", name="uq_payees_space_name"),
|
||||
UniqueConstraint("space_id", "id", name="uq_payees_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
|
||||
|
||||
class PayeeAlias(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "payee_aliases"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("space_id", "alias", name="uq_payee_aliases_space_alias"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "payee_id"],
|
||||
["payees.space_id", "payees.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_payee_aliases_payee_tenant",
|
||||
),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
payee_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
alias: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
|
||||
|
||||
class CategorizationRule(Base, PkMixin, TimestampMixin):
|
||||
"""Confirmed payee rule: auto-applies category to matching future operations."""
|
||||
|
||||
__tablename__ = "categorization_rules"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("space_id", "payee_id", name="uq_rules_space_payee"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "payee_id"],
|
||||
["payees.space_id", "payees.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_rules_payee_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
name="fk_rules_category_tenant",
|
||||
),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
payee_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
category_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class MccCode(Base):
|
||||
"""Global versioned MCC dictionary (not space-scoped, outside RLS)."""
|
||||
|
||||
__tablename__ = "mcc_codes"
|
||||
|
||||
code: Mapped[int] = mapped_column(Integer, primary_key=True)
|
||||
title: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
dictionary_version: Mapped[int] = mapped_column(nullable=False, default=1)
|
||||
|
||||
|
||||
class BankCategoryMapping(Base, PkMixin):
|
||||
"""Maps a bank's raw category string to an MCC for suggestion purposes."""
|
||||
|
||||
__tablename__ = "bank_category_mappings"
|
||||
__table_args__ = (
|
||||
UniqueConstraint("bank_code", "bank_category", name="uq_bank_mappings_bank_category"),
|
||||
)
|
||||
|
||||
bank_code: Mapped[str] = mapped_column(String(32), nullable=False, index=True)
|
||||
bank_category: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
mcc_code: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
@@ -0,0 +1,97 @@
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.categorization.models import CategorizationRule, IncomeSource, Payee, PayeeAlias
|
||||
from app.errors import conflict, not_found
|
||||
from app.ledger.models import Category
|
||||
|
||||
|
||||
def resolve_category_name(category: Category) -> str:
|
||||
"""Standard categories resolve to a translation key; overrides/customs are literal."""
|
||||
if category.custom_name is not None:
|
||||
return category.custom_name
|
||||
if category.name_override is not None:
|
||||
return category.name_override
|
||||
if category.system_key is not None:
|
||||
return f"category.{category.system_key}"
|
||||
return "category.unnamed"
|
||||
|
||||
|
||||
def get_income_source(db: Session, *, space_id, income_source_id) -> IncomeSource:
|
||||
obj = db.query(IncomeSource).filter_by(id=income_source_id, space_id=space_id).one_or_none()
|
||||
if obj is None:
|
||||
raise not_found("INCOME_SOURCE_NOT_FOUND", "Income source not found")
|
||||
return obj
|
||||
|
||||
|
||||
def ensure_income_source(db: Session, *, space_id, name: str) -> IncomeSource:
|
||||
obj = db.query(IncomeSource).filter_by(space_id=space_id, name=name).one_or_none()
|
||||
if obj is None:
|
||||
obj = IncomeSource(space_id=space_id, name=name)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
def get_or_create_payee(db: Session, *, space_id, name: str, alias: str | None = None) -> Payee:
|
||||
payee = db.query(Payee).filter_by(space_id=space_id, name=name).one_or_none()
|
||||
if payee is None:
|
||||
payee = Payee(space_id=space_id, name=name)
|
||||
db.add(payee)
|
||||
db.flush()
|
||||
if alias:
|
||||
add_alias(db, space_id=space_id, payee_id=payee.id, alias=alias)
|
||||
return payee
|
||||
|
||||
|
||||
def add_alias(db: Session, *, space_id, payee_id, alias: str) -> PayeeAlias:
|
||||
existing = db.query(PayeeAlias).filter_by(space_id=space_id, alias=alias).one_or_none()
|
||||
if existing is not None:
|
||||
if existing.payee_id != payee_id:
|
||||
raise conflict(
|
||||
"PAYEE_ALIAS_TAKEN",
|
||||
"Alias already mapped to another payee",
|
||||
)
|
||||
return existing
|
||||
obj = PayeeAlias(space_id=space_id, payee_id=payee_id, alias=alias)
|
||||
db.add(obj)
|
||||
db.flush()
|
||||
return obj
|
||||
|
||||
|
||||
def find_payee_by_description(db: Session, *, space_id, description: str) -> Payee | None:
|
||||
if not description:
|
||||
return None
|
||||
alias = (
|
||||
db.query(PayeeAlias)
|
||||
.filter(PayeeAlias.space_id == space_id, PayeeAlias.alias == description)
|
||||
.one_or_none()
|
||||
)
|
||||
if alias is not None:
|
||||
return db.get(Payee, alias.payee_id)
|
||||
payee = db.query(Payee).filter_by(space_id=space_id, name=description).one_or_none()
|
||||
return payee
|
||||
|
||||
|
||||
def rule_category_for_payee(db: Session, *, space_id, payee_id) -> CategorizationRule | None:
|
||||
return (
|
||||
db.query(CategorizationRule).filter_by(space_id=space_id, payee_id=payee_id).one_or_none()
|
||||
)
|
||||
|
||||
|
||||
def set_payee_rule(db: Session, *, space_id, payee_id, category_id, user_id) -> CategorizationRule:
|
||||
payee = db.query(Payee).filter_by(id=payee_id, space_id=space_id).one_or_none()
|
||||
if payee is None:
|
||||
raise not_found("PAYEE_NOT_FOUND", "Payee not found")
|
||||
rule = rule_category_for_payee(db, space_id=space_id, payee_id=payee_id)
|
||||
if rule is None:
|
||||
rule = CategorizationRule(
|
||||
space_id=space_id,
|
||||
payee_id=payee_id,
|
||||
category_id=category_id,
|
||||
created_by_user_id=user_id,
|
||||
)
|
||||
db.add(rule)
|
||||
else:
|
||||
rule.category_id = category_id
|
||||
db.flush()
|
||||
return rule
|
||||
@@ -0,0 +1,68 @@
|
||||
import base64
|
||||
import binascii
|
||||
from functools import lru_cache
|
||||
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(extra="ignore")
|
||||
|
||||
environment: str = "development"
|
||||
database_url: str = "postgresql+psycopg://fin:fin@localhost:5432/fin"
|
||||
redis_url: str = "redis://localhost:6379/0"
|
||||
admin_bootstrap_token: str = ""
|
||||
|
||||
imports_kek_b64: str = ""
|
||||
blob_dir: str = "./var/blobs"
|
||||
s3_endpoint_url: str = ""
|
||||
s3_region: str = "us-east-1"
|
||||
s3_access_key_id: str = ""
|
||||
s3_secret_access_key: str = ""
|
||||
s3_bucket_statements: str = "finance-statements"
|
||||
smtp_host: str = ""
|
||||
smtp_port: int = 1025
|
||||
smtp_use_tls: bool = False
|
||||
smtp_from: str = "no-reply@finance.local"
|
||||
public_base_url: str = "http://localhost:8080"
|
||||
|
||||
session_cookie_name: str = "fin_session"
|
||||
csrf_cookie_name: str = "csrf_token"
|
||||
cookie_secure: bool = True
|
||||
session_ttl_days: int = 14
|
||||
invite_ttl_days: int = 7
|
||||
reset_ttl_hours: int = 4
|
||||
|
||||
max_upload_bytes: int = 20 * 1024 * 1024
|
||||
max_xlsx_rows: int = 10_000
|
||||
max_pdf_pages: int = 200
|
||||
max_archive_uncompressed_bytes: int = 64 * 1024 * 1024
|
||||
|
||||
deletion_window_days: int = 30
|
||||
|
||||
celery_task_always_eager: bool = False
|
||||
celery_broker_url: str = ""
|
||||
|
||||
default_locale: str = "ru"
|
||||
default_timezone: str = "Europe/Moscow"
|
||||
|
||||
@property
|
||||
def broker_url(self) -> str:
|
||||
return self.celery_broker_url or self.redis_url
|
||||
|
||||
@property
|
||||
def kek(self) -> bytes:
|
||||
if not self.imports_kek_b64:
|
||||
raise RuntimeError("IMPORTS_KEK_B64 must be configured (base64-encoded 32-byte KEK)")
|
||||
try:
|
||||
raw = base64.b64decode(self.imports_kek_b64, validate=True)
|
||||
except (binascii.Error, ValueError) as exc:
|
||||
raise RuntimeError("IMPORTS_KEK_B64 is not valid base64") from exc
|
||||
if len(raw) != 32:
|
||||
raise RuntimeError("IMPORTS_KEK_B64 must decode to exactly 32 bytes")
|
||||
return raw
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_settings() -> Settings:
|
||||
return Settings()
|
||||
@@ -0,0 +1,52 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import DateTime, MetaData, TypeDecorator
|
||||
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column
|
||||
|
||||
NAMING_CONVENTION = {
|
||||
"ix": "ix_%(column_0_label)s",
|
||||
"uq": "uq_%(table_name)s_%(column_0_name)s",
|
||||
"ck": "ck_%(table_name)s_%(constraint_name)s",
|
||||
"fk": "fk_%(table_name)s_%(column_0_name)s_%(referred_table_name)s",
|
||||
"pk": "pk_%(table_name)s",
|
||||
}
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
metadata = MetaData(naming_convention=NAMING_CONVENTION)
|
||||
|
||||
|
||||
class UTCDateTime(TypeDecorator):
|
||||
"""Datetimes stored as naive UTC in the DB and exposed tz-aware (UTC) in Python."""
|
||||
|
||||
impl = DateTime
|
||||
cache_ok = True
|
||||
|
||||
def process_bind_param(self, value: Any, dialect: Any) -> Any:
|
||||
if value is not None:
|
||||
if value.tzinfo is None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
value = value.astimezone(UTC).replace(tzinfo=None)
|
||||
return value
|
||||
|
||||
def process_result_value(self, value: Any, dialect: Any) -> Any:
|
||||
if value is not None:
|
||||
value = value.replace(tzinfo=UTC)
|
||||
return value
|
||||
|
||||
|
||||
def utcnow() -> datetime:
|
||||
return datetime.now(UTC)
|
||||
|
||||
|
||||
class PkMixin:
|
||||
id: Mapped[uuid.UUID] = mapped_column(primary_key=True, default=uuid.uuid4)
|
||||
|
||||
|
||||
class TimestampMixin:
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime] = mapped_column(
|
||||
UTCDateTime(), default=utcnow, onupdate=utcnow, nullable=False
|
||||
)
|
||||
@@ -0,0 +1,63 @@
|
||||
"""Import every mapped class so Alembic sees complete metadata."""
|
||||
|
||||
from app.audit.models import AuditEvent
|
||||
from app.budgeting.models import (
|
||||
BudgetPeriod,
|
||||
ExpectedIncome,
|
||||
ExpectedIncomeMatch,
|
||||
FundingMovement,
|
||||
PeriodClosure,
|
||||
PlanAssignment,
|
||||
)
|
||||
from app.categorization.models import (
|
||||
BankCategoryMapping,
|
||||
CategorizationRule,
|
||||
IncomeSource,
|
||||
MccCode,
|
||||
Payee,
|
||||
PayeeAlias,
|
||||
)
|
||||
from app.exports.models import DataExport, DeletionLedger
|
||||
from app.identity.models import Invitation, PasswordReset, Session, User
|
||||
from app.imports.models import (
|
||||
DedupCandidate,
|
||||
ParseRun,
|
||||
SourceRecord,
|
||||
SourceRecordLink,
|
||||
StatementDocument,
|
||||
)
|
||||
from app.ledger.models import Account, Category, Operation, Posting
|
||||
from app.spaces.models import FinancialSpace, Membership
|
||||
|
||||
__all__ = [
|
||||
"Account",
|
||||
"AuditEvent",
|
||||
"BankCategoryMapping",
|
||||
"BudgetPeriod",
|
||||
"Category",
|
||||
"CategorizationRule",
|
||||
"DataExport",
|
||||
"DedupCandidate",
|
||||
"DeletionLedger",
|
||||
"ExpectedIncome",
|
||||
"ExpectedIncomeMatch",
|
||||
"FinancialSpace",
|
||||
"FundingMovement",
|
||||
"IncomeSource",
|
||||
"Invitation",
|
||||
"MccCode",
|
||||
"Membership",
|
||||
"Operation",
|
||||
"ParseRun",
|
||||
"PasswordReset",
|
||||
"Payee",
|
||||
"PayeeAlias",
|
||||
"PeriodClosure",
|
||||
"PlanAssignment",
|
||||
"Posting",
|
||||
"Session",
|
||||
"SourceRecord",
|
||||
"SourceRecordLink",
|
||||
"StatementDocument",
|
||||
"User",
|
||||
]
|
||||
@@ -0,0 +1,71 @@
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import create_engine, event, text
|
||||
from sqlalchemy.engine import Engine
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_engine: Engine | None = None
|
||||
_session_factory: sessionmaker[Session] | None = None
|
||||
|
||||
|
||||
def get_engine() -> Engine:
|
||||
global _engine
|
||||
if _engine is None:
|
||||
_engine = create_engine(get_settings().database_url, pool_pre_ping=True)
|
||||
if _engine.dialect.name == "sqlite":
|
||||
|
||||
@event.listens_for(_engine, "connect")
|
||||
def _sqlite_pragmas(dbapi_connection: Any, _record: Any) -> None:
|
||||
cursor = dbapi_connection.cursor()
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
return _engine
|
||||
|
||||
|
||||
def get_session_factory() -> sessionmaker[Session]:
|
||||
global _session_factory
|
||||
if _session_factory is None:
|
||||
_session_factory = sessionmaker(bind=get_engine(), expire_on_commit=False)
|
||||
return _session_factory
|
||||
|
||||
|
||||
def get_db() -> Iterator[Session]:
|
||||
session = get_session_factory()()
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
|
||||
|
||||
def set_space_context(session: Session, space_id: Any) -> None:
|
||||
"""Transaction-local RLS context. No-op on SQLite (tests); fail-closed on PostgreSQL."""
|
||||
if session.get_bind().dialect.name == "postgresql":
|
||||
session.execute(
|
||||
text("select set_config('app.space_id', :sid, true)"),
|
||||
{"sid": str(space_id)},
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def session_scope(space_id: Any = None) -> Iterator[Session]:
|
||||
"""Transactional scope for workers/endpoints that own the transaction lifecycle."""
|
||||
session = get_session_factory()()
|
||||
try:
|
||||
if space_id is not None:
|
||||
set_space_context(session, space_id)
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
finally:
|
||||
session.close()
|
||||
@@ -0,0 +1,67 @@
|
||||
from dataclasses import dataclass
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import Cookie, Depends, Header
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.config import get_settings
|
||||
from app.db.session import get_db, set_space_context
|
||||
from app.errors import forbidden, unauthorized
|
||||
from app.identity.models import Session as UserSession
|
||||
from app.identity.models import User
|
||||
from app.identity.service import get_active_session
|
||||
from app.security import constant_time_equals
|
||||
from app.spaces.models import FinancialSpace, Membership
|
||||
from app.spaces.service import list_spaces_for_user
|
||||
|
||||
Db = Annotated[Session, Depends(get_db)]
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RequestContext:
|
||||
user: User
|
||||
session: UserSession
|
||||
space: FinancialSpace
|
||||
membership: Membership
|
||||
|
||||
|
||||
def current_session(
|
||||
db: Db,
|
||||
session_token: Annotated[str | None, Cookie(alias=get_settings().session_cookie_name)] = None,
|
||||
) -> tuple[User, UserSession]:
|
||||
if not session_token:
|
||||
raise unauthorized("IDENTITY_SESSION_MISSING", "Authentication required")
|
||||
session = get_active_session(db, token=session_token)
|
||||
user = db.get(User, session.user_id)
|
||||
if user is None or user.status != "active":
|
||||
raise unauthorized("IDENTITY_USER_INACTIVE", "Authentication required")
|
||||
return user, session
|
||||
|
||||
|
||||
CurrentSession = Annotated[tuple[User, UserSession], Depends(current_session)]
|
||||
|
||||
|
||||
def current_context(db: Db, auth: CurrentSession) -> RequestContext:
|
||||
user, session = auth
|
||||
spaces = list_spaces_for_user(db, user=user)
|
||||
if not spaces:
|
||||
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
|
||||
space, membership = spaces[0]
|
||||
if space.status != "active":
|
||||
raise forbidden("SPACE_BLOCKED", "Financial space is unavailable")
|
||||
set_space_context(db, space.id)
|
||||
return RequestContext(user=user, session=session, space=space, membership=membership)
|
||||
|
||||
|
||||
Context = Annotated[RequestContext, Depends(current_context)]
|
||||
|
||||
|
||||
def require_csrf(
|
||||
context: Context,
|
||||
csrf_header: Annotated[str | None, Header(alias="X-CSRF-Token")] = None,
|
||||
) -> None:
|
||||
if not csrf_header or not constant_time_equals(context.session.csrf_token, csrf_header):
|
||||
raise forbidden("IDENTITY_CSRF_INVALID", "CSRF token is missing or invalid")
|
||||
|
||||
|
||||
Csrf = Annotated[None, Depends(require_csrf)]
|
||||
@@ -0,0 +1,48 @@
|
||||
from typing import Any
|
||||
|
||||
|
||||
class AppError(Exception):
|
||||
"""Domain error rendered as a machine-readable JSON error payload."""
|
||||
|
||||
status_code: int = 400
|
||||
code: str = "APP_ERROR"
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
message: str,
|
||||
*,
|
||||
code: str | None = None,
|
||||
status_code: int | None = None,
|
||||
details: Any = None,
|
||||
) -> None:
|
||||
super().__init__(message)
|
||||
self.message = message
|
||||
if code is not None:
|
||||
self.code = code
|
||||
if status_code is not None:
|
||||
self.status_code = status_code
|
||||
self.details = details
|
||||
|
||||
|
||||
def bad_request(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=400, details=details)
|
||||
|
||||
|
||||
def unauthorized(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=401, details=details)
|
||||
|
||||
|
||||
def forbidden(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=403, details=details)
|
||||
|
||||
|
||||
def not_found(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=404, details=details)
|
||||
|
||||
|
||||
def conflict(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=409, details=details)
|
||||
|
||||
|
||||
def unprocessable(code: str, message: str, details: Any = None) -> AppError:
|
||||
return AppError(message, code=code, status_code=422, details=details)
|
||||
@@ -0,0 +1,36 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, LargeBinary, String, Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
|
||||
|
||||
|
||||
class DataExport(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "data_exports"
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
requested_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="queued")
|
||||
object_key: Mapped[str | None] = mapped_column(String(64), nullable=True, unique=True)
|
||||
dek_wrapped: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
dek_wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
content_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
expires_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
consumed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
|
||||
|
||||
class DeletionLedger(Base, PkMixin, TimestampMixin):
|
||||
"""Durable deletion marker reapplied after disaster restore."""
|
||||
|
||||
__tablename__ = "deletion_ledger"
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, unique=True, index=True)
|
||||
scheduled_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
completed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
@@ -0,0 +1,60 @@
|
||||
import uuid
|
||||
|
||||
from fastapi import APIRouter, Response
|
||||
|
||||
from app.dependencies import Context, Csrf, Db
|
||||
from app.exports import service
|
||||
from app.exports.tasks import enqueue_export
|
||||
|
||||
router = APIRouter(prefix="/exports", tags=["data"])
|
||||
|
||||
|
||||
@router.post("", status_code=202)
|
||||
def request_export(db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
job = service.request_export(db, space_id=context.space.id, user_id=context.user.id)
|
||||
db.commit()
|
||||
try:
|
||||
enqueue_export(job.id, context.space.id)
|
||||
except Exception as exc:
|
||||
from app.db.session import set_space_context
|
||||
from app.errors import AppError
|
||||
|
||||
set_space_context(db, context.space.id)
|
||||
job = service.get_export(db, space_id=context.space.id, export_id=job.id)
|
||||
job.status = "failed"
|
||||
job.error_code = "EXPORT_QUEUE_FAILED"
|
||||
db.commit()
|
||||
raise AppError(
|
||||
"Export queue is unavailable",
|
||||
code="EXPORT_QUEUE_FAILED",
|
||||
status_code=503,
|
||||
) from exc
|
||||
return {
|
||||
"id": job.id,
|
||||
"status": job.status,
|
||||
"downloadUrl": None,
|
||||
"createdAt": job.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{export_id}")
|
||||
def export_status(export_id: uuid.UUID, db: Db, context: Context) -> dict:
|
||||
job = service.get_export(db, space_id=context.space.id, export_id=export_id)
|
||||
return {
|
||||
"id": job.id,
|
||||
"status": job.status,
|
||||
"downloadUrl": f"/api/v1/exports/{job.id}/download" if job.status == "ready" else None,
|
||||
"createdAt": job.created_at,
|
||||
"expiresAt": job.expires_at,
|
||||
"errorCode": job.error_code,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/{export_id}/download")
|
||||
def download(export_id: uuid.UUID, db: Db, context: Context) -> Response:
|
||||
raw = service.consume_export(db, space_id=context.space.id, export_id=export_id)
|
||||
return Response(
|
||||
raw,
|
||||
media_type="application/zip",
|
||||
headers={"Content-Disposition": 'attachment; filename="finance-export.zip"'},
|
||||
)
|
||||
@@ -0,0 +1,194 @@
|
||||
import csv
|
||||
import io
|
||||
import json
|
||||
import uuid
|
||||
import zipfile
|
||||
from collections.abc import Sequence
|
||||
from datetime import UTC, datetime, timedelta
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit.models import AuditEvent
|
||||
from app.budgeting.models import BudgetPeriod, ExpectedIncome, FundingMovement, PlanAssignment
|
||||
from app.config import get_settings
|
||||
from app.errors import conflict, not_found
|
||||
from app.exports.models import DataExport, DeletionLedger
|
||||
from app.imports.blob import get_blob_store, new_object_key
|
||||
from app.imports.crypto import decrypt_file, encrypt_file
|
||||
from app.imports.models import StatementDocument
|
||||
from app.ledger.models import Account, Category, Operation
|
||||
from app.spaces.models import FinancialSpace
|
||||
|
||||
|
||||
def request_export(db: Session, *, space_id: uuid.UUID, user_id: uuid.UUID) -> DataExport:
|
||||
job = DataExport(space_id=space_id, requested_by_user_id=user_id, status="queued")
|
||||
db.add(job)
|
||||
db.flush()
|
||||
return job
|
||||
|
||||
|
||||
def get_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> DataExport:
|
||||
job = db.query(DataExport).filter_by(id=export_id, space_id=space_id).one_or_none()
|
||||
if job is None:
|
||||
raise not_found("EXPORT_NOT_FOUND", "Data export not found")
|
||||
return job
|
||||
|
||||
|
||||
def _json_rows(rows: Sequence[object], fields: tuple[str, ...]) -> list[dict]:
|
||||
return [{field: getattr(row, field) for field in fields} for row in rows]
|
||||
|
||||
|
||||
def _csv_bytes(rows: list[dict]) -> bytes:
|
||||
output = io.StringIO()
|
||||
if rows:
|
||||
writer = csv.DictWriter(output, fieldnames=list(rows[0]))
|
||||
writer.writeheader()
|
||||
writer.writerows(rows)
|
||||
return output.getvalue().encode()
|
||||
|
||||
|
||||
def build_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> None:
|
||||
job = get_export(db, space_id=space_id, export_id=export_id)
|
||||
if job.status == "ready":
|
||||
return
|
||||
job.status = "processing"
|
||||
datasets = {
|
||||
"accounts": _json_rows(
|
||||
db.query(Account).filter_by(space_id=space_id).all(),
|
||||
("id", "name", "kind", "currency", "opening_amount_kopecks", "opened_on", "status"),
|
||||
),
|
||||
"operations": _json_rows(
|
||||
db.query(Operation).filter_by(space_id=space_id).all(),
|
||||
(
|
||||
"id",
|
||||
"type",
|
||||
"status",
|
||||
"amount_kopecks",
|
||||
"account_id",
|
||||
"counter_account_id",
|
||||
"category_id",
|
||||
"occurred_on",
|
||||
"posted_on",
|
||||
"description",
|
||||
),
|
||||
),
|
||||
"categories": _json_rows(
|
||||
db.query(Category).filter_by(space_id=space_id).all(),
|
||||
("id", "kind", "system_key", "name_override", "custom_name", "archived"),
|
||||
),
|
||||
"budget_periods": _json_rows(
|
||||
db.query(BudgetPeriod).filter_by(space_id=space_id).all(),
|
||||
("id", "year", "month", "status"),
|
||||
),
|
||||
"plan_assignments": _json_rows(
|
||||
db.query(PlanAssignment).filter_by(space_id=space_id).all(),
|
||||
("id", "period_id", "category_id", "planned_kopecks"),
|
||||
),
|
||||
"funding": _json_rows(
|
||||
db.query(FundingMovement).filter_by(space_id=space_id).all(),
|
||||
("id", "period_id", "category_id", "kind", "amount_kopecks"),
|
||||
),
|
||||
"expected_incomes": _json_rows(
|
||||
db.query(ExpectedIncome).filter_by(space_id=space_id).all(),
|
||||
("id", "period_id", "expected_on", "amount_kopecks", "source_name"),
|
||||
),
|
||||
"audit": _json_rows(
|
||||
db.query(AuditEvent).filter_by(space_id=space_id).all(),
|
||||
("id", "event_type", "created_at", "detail"),
|
||||
),
|
||||
}
|
||||
raw = io.BytesIO()
|
||||
with zipfile.ZipFile(raw, "w", compression=zipfile.ZIP_DEFLATED) as archive:
|
||||
for name, rows in datasets.items():
|
||||
archive.writestr(f"{name}.csv", _csv_bytes(rows))
|
||||
archive.writestr("data.json", json.dumps(datasets, default=str, ensure_ascii=False, indent=2))
|
||||
encrypted = encrypt_file(raw.getvalue())
|
||||
key = new_object_key()
|
||||
get_blob_store().put(key, encrypted.ciphertext)
|
||||
job.object_key = key
|
||||
job.dek_wrapped = encrypted.dek_wrapped
|
||||
job.dek_wrap_nonce = encrypted.wrap_nonce
|
||||
job.content_nonce = encrypted.content_nonce
|
||||
job.expires_at = datetime.now(UTC) + timedelta(hours=1)
|
||||
job.status = "ready"
|
||||
|
||||
|
||||
def consume_export(db: Session, *, space_id: uuid.UUID, export_id: uuid.UUID) -> bytes:
|
||||
job = get_export(db, space_id=space_id, export_id=export_id)
|
||||
now = datetime.now(UTC)
|
||||
if job.status != "ready" or job.consumed_at is not None or not job.expires_at or job.expires_at <= now:
|
||||
raise conflict("EXPORT_UNAVAILABLE", "Export is not ready, expired, or already downloaded")
|
||||
if not all((job.object_key, job.dek_wrapped, job.dek_wrap_nonce, job.content_nonce)):
|
||||
raise conflict("EXPORT_UNAVAILABLE", "Export key material is unavailable")
|
||||
assert job.object_key is not None
|
||||
assert job.dek_wrapped is not None
|
||||
assert job.dek_wrap_nonce is not None
|
||||
assert job.content_nonce is not None
|
||||
raw = decrypt_file(
|
||||
ciphertext=get_blob_store().get(job.object_key),
|
||||
content_nonce=job.content_nonce,
|
||||
dek_wrapped=job.dek_wrapped,
|
||||
wrap_nonce=job.dek_wrap_nonce,
|
||||
)
|
||||
job.consumed_at = now
|
||||
job.status = "consumed"
|
||||
job.dek_wrapped = None
|
||||
job.dek_wrap_nonce = None
|
||||
job.content_nonce = None
|
||||
return raw
|
||||
|
||||
|
||||
def schedule_deletion(db: Session, *, space: FinancialSpace) -> datetime:
|
||||
if space.status == "blocked" and space.deletion_scheduled_at:
|
||||
return space.deletion_scheduled_at
|
||||
when = datetime.now(UTC) + timedelta(days=get_settings().deletion_window_days)
|
||||
space.status = "blocked"
|
||||
space.deletion_scheduled_at = when
|
||||
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
|
||||
if marker is None:
|
||||
db.add(DeletionLedger(space_id=space.id, scheduled_at=when))
|
||||
else:
|
||||
marker.scheduled_at = when
|
||||
marker.completed_at = None
|
||||
return when
|
||||
|
||||
|
||||
def cancel_deletion(db: Session, *, space: FinancialSpace) -> None:
|
||||
if space.status != "blocked" or not space.deletion_scheduled_at:
|
||||
raise conflict("SPACE_DELETION_NOT_SCHEDULED", "Space deletion is not scheduled")
|
||||
space.status = "active"
|
||||
space.deletion_scheduled_at = None
|
||||
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one_or_none()
|
||||
if marker:
|
||||
db.delete(marker)
|
||||
|
||||
|
||||
def purge_due_spaces(db: Session) -> int:
|
||||
now = datetime.now(UTC)
|
||||
spaces = (
|
||||
db.query(FinancialSpace)
|
||||
.filter(
|
||||
FinancialSpace.status == "blocked",
|
||||
FinancialSpace.deletion_scheduled_at.is_not(None),
|
||||
FinancialSpace.deletion_scheduled_at <= now,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
store = get_blob_store()
|
||||
purged = 0
|
||||
for space in spaces:
|
||||
from app.db.session import set_space_context
|
||||
|
||||
set_space_context(db, space.id)
|
||||
for document in db.query(StatementDocument).filter_by(space_id=space.id).all():
|
||||
store.delete(document.object_key)
|
||||
document.dek_wrapped = None
|
||||
for export in db.query(DataExport).filter_by(space_id=space.id).all():
|
||||
if export.object_key:
|
||||
store.delete(export.object_key)
|
||||
export.dek_wrapped = None
|
||||
marker = db.query(DeletionLedger).filter_by(space_id=space.id).one()
|
||||
marker.completed_at = now
|
||||
db.delete(space)
|
||||
purged += 1
|
||||
return purged
|
||||
@@ -0,0 +1,27 @@
|
||||
import uuid
|
||||
|
||||
from app.db.session import session_scope
|
||||
from app.exports.service import build_export, purge_due_spaces
|
||||
from app.imports.celery_app import celery_app
|
||||
|
||||
|
||||
@celery_app.task(name="exports.build", bind=True, max_retries=2, default_retry_delay=20)
|
||||
def build_export_task(self, export_id: str, space_id: str) -> str:
|
||||
try:
|
||||
with session_scope(uuid.UUID(space_id)) as db:
|
||||
build_export(db, space_id=uuid.UUID(space_id), export_id=uuid.UUID(export_id))
|
||||
except Exception as exc:
|
||||
if self.request.retries < 2:
|
||||
raise self.retry(exc=exc) from exc
|
||||
raise
|
||||
return export_id
|
||||
|
||||
|
||||
def enqueue_export(export_id: uuid.UUID, space_id: uuid.UUID) -> None:
|
||||
build_export_task.delay(str(export_id), str(space_id))
|
||||
|
||||
|
||||
@celery_app.task(name="spaces.purge_due")
|
||||
def purge_due_spaces_task() -> int:
|
||||
with session_scope() as db:
|
||||
return purge_due_spaces(db)
|
||||
@@ -0,0 +1,22 @@
|
||||
import smtplib
|
||||
from email.message import EmailMessage
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def send_token_email(*, recipient: str, token: str, kind: str) -> None:
|
||||
settings = get_settings()
|
||||
if not settings.smtp_host:
|
||||
return
|
||||
path = "invite/accept" if kind == "invite" else "reset-password"
|
||||
link = f"{settings.public_base_url.rstrip('/')}/{path}?token={token}"
|
||||
subject = "Finance invitation / Приглашение" if kind == "invite" else "Finance password reset"
|
||||
message = EmailMessage()
|
||||
message["From"] = settings.smtp_from
|
||||
message["To"] = recipient
|
||||
message["Subject"] = subject
|
||||
message.set_content(f"Open this one-time link / Откройте одноразовую ссылку:\n\n{link}\n")
|
||||
with smtplib.SMTP(settings.smtp_host, settings.smtp_port, timeout=10) as client:
|
||||
if settings.smtp_use_tls:
|
||||
client.starttls()
|
||||
client.send_message(message)
|
||||
@@ -0,0 +1,65 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, Index, String, UniqueConstraint, Uuid, func
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
|
||||
|
||||
|
||||
class User(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "users"
|
||||
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
__table_args__ = (Index("uq_users_email_casefold", func.lower(email), unique=True),)
|
||||
password_hash: Mapped[str] = mapped_column(String(256), nullable=False)
|
||||
display_name: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
locale: Mapped[str] = mapped_column(String(8), nullable=False, default="ru")
|
||||
timezone: Mapped[str] = mapped_column(String(64), nullable=False, default="Europe/Moscow")
|
||||
is_admin: Mapped[bool] = mapped_column(nullable=False, default=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
|
||||
|
||||
class Invitation(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "invitations"
|
||||
__table_args__ = (UniqueConstraint("space_id", "email", name="uq_invitations_space_email"),)
|
||||
|
||||
space_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=True, index=True
|
||||
)
|
||||
email: Mapped[str] = mapped_column(String(320), nullable=False, index=True)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
invited_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="owner")
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
accepted_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
accepted_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class PasswordReset(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "password_resets"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
used_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
|
||||
|
||||
class Session(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "sessions"
|
||||
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
token_hash: Mapped[str] = mapped_column(String(64), nullable=False, unique=True, index=True)
|
||||
csrf_token: Mapped[str] = mapped_column(String(64), nullable=False)
|
||||
expires_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
revoked_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
revoked_reason: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
last_seen_at: Mapped[datetime] = mapped_column(UTCDateTime(), nullable=False)
|
||||
@@ -0,0 +1,164 @@
|
||||
from typing import Annotated, Literal
|
||||
|
||||
from fastapi import APIRouter, Header, Request, Response
|
||||
from pydantic import BaseModel, ConfigDict, EmailStr, Field
|
||||
|
||||
from app.config import get_settings
|
||||
from app.dependencies import CurrentSession, Db
|
||||
from app.errors import forbidden
|
||||
from app.identity import service
|
||||
from app.identity.mailer import send_token_email
|
||||
from app.ratelimit import allow
|
||||
from app.security import constant_time_equals
|
||||
|
||||
router = APIRouter(tags=["identity"])
|
||||
|
||||
|
||||
class InviteBody(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class AcceptInviteBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
token: str
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
display_name: str | None = Field(default=None, alias="fullName", max_length=120)
|
||||
locale: Literal["ru", "en"] = "ru"
|
||||
timezone: str = "Europe/Moscow"
|
||||
|
||||
|
||||
class LoginBody(BaseModel):
|
||||
email: EmailStr
|
||||
password: str
|
||||
|
||||
|
||||
class ResetRequestBody(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
class ResetBody(BaseModel):
|
||||
token: str
|
||||
password: str = Field(min_length=12, max_length=256)
|
||||
|
||||
|
||||
def _set_auth_cookies(response: Response, *, token: str, csrf: str) -> None:
|
||||
settings = get_settings()
|
||||
max_age = settings.session_ttl_days * 86400
|
||||
response.set_cookie(
|
||||
settings.session_cookie_name,
|
||||
token,
|
||||
httponly=True,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
path="/",
|
||||
)
|
||||
response.set_cookie(
|
||||
settings.csrf_cookie_name,
|
||||
csrf,
|
||||
httponly=False,
|
||||
secure=settings.cookie_secure,
|
||||
samesite="lax",
|
||||
max_age=max_age,
|
||||
path="/",
|
||||
)
|
||||
|
||||
|
||||
@router.post("/admin/invitations", status_code=201)
|
||||
def create_invite(
|
||||
body: InviteBody,
|
||||
db: Db,
|
||||
admin_token: Annotated[str | None, Header(alias="X-Admin-Token")] = None,
|
||||
) -> dict:
|
||||
expected = get_settings().admin_bootstrap_token
|
||||
if not expected or not admin_token or not constant_time_equals(admin_token, expected):
|
||||
raise forbidden("IDENTITY_ADMIN_TOKEN_INVALID", "Admin authorization required")
|
||||
invitation, token = service.create_invitation(db, email=str(body.email))
|
||||
db.commit()
|
||||
send_token_email(recipient=invitation.email, token=token, kind="invite")
|
||||
return {"id": invitation.id, "token": token, "expires_at": invitation.expires_at}
|
||||
|
||||
|
||||
@router.post("/auth/invitations/accept", status_code=201)
|
||||
def accept_invite(body: AcceptInviteBody, db: Db, response: Response) -> dict:
|
||||
user = service.accept_invitation(db, **body.model_dump(by_alias=False))
|
||||
session, token = service.create_session(db, user=user)
|
||||
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"fullName": user.display_name or "",
|
||||
"locale": user.locale,
|
||||
"timezone": user.timezone,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/login")
|
||||
def login(body: LoginBody, db: Db, response: Response, request: Request) -> dict:
|
||||
client = request.client.host if request.client else "unknown"
|
||||
if not allow(f"login:{client}:{str(body.email).casefold()}", limit=10, window_seconds=900):
|
||||
from app.errors import AppError
|
||||
|
||||
raise AppError("Too many login attempts", code="RATE_LIMITED", status_code=429)
|
||||
user = service.authenticate(db, email=str(body.email), password=body.password)
|
||||
session, token = service.create_session(db, user=user)
|
||||
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"fullName": user.display_name or "",
|
||||
"locale": user.locale,
|
||||
"timezone": user.timezone,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/password-reset", status_code=202)
|
||||
def request_password_reset(body: ResetRequestBody, db: Db, request: Request) -> dict:
|
||||
client = request.client.host if request.client else "unknown"
|
||||
if not allow(f"password-reset:{client}", limit=5, window_seconds=900):
|
||||
from app.errors import AppError
|
||||
|
||||
raise AppError("Too many reset attempts", code="RATE_LIMITED", status_code=429)
|
||||
token = service.create_password_reset(db, email=str(body.email))
|
||||
if token:
|
||||
send_token_email(recipient=str(body.email), token=token, kind="reset")
|
||||
result: dict[str, object] = {"accepted": True}
|
||||
if get_settings().environment == "development" and token:
|
||||
result["token"] = token
|
||||
return result
|
||||
|
||||
|
||||
@router.post("/auth/password-reset/confirm")
|
||||
def confirm_password_reset(body: ResetBody, db: Db, response: Response) -> dict:
|
||||
user = service.reset_password(db, token=body.token, password=body.password)
|
||||
session, token = service.create_session(db, user=user)
|
||||
_set_auth_cookies(response, token=token, csrf=session.csrf_token)
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"fullName": user.display_name or "",
|
||||
"locale": user.locale,
|
||||
"timezone": user.timezone,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/auth/me")
|
||||
def me(auth: CurrentSession) -> dict:
|
||||
user, _ = auth
|
||||
return {
|
||||
"id": user.id,
|
||||
"email": user.email,
|
||||
"fullName": user.display_name or "",
|
||||
"locale": user.locale,
|
||||
"timezone": user.timezone,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/auth/logout", status_code=204)
|
||||
def logout(auth: CurrentSession, db: Db, response: Response) -> None:
|
||||
_, session = auth
|
||||
service.revoke_session(db, session=session)
|
||||
settings = get_settings()
|
||||
response.delete_cookie(settings.session_cookie_name, path="/")
|
||||
response.delete_cookie(settings.csrf_cookie_name, path="/")
|
||||
@@ -0,0 +1,188 @@
|
||||
import uuid
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from sqlalchemy.orm import Session as DbSession
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.config import get_settings
|
||||
from app.errors import bad_request, conflict, unauthorized
|
||||
from app.identity.models import Invitation, PasswordReset, Session, User
|
||||
from app.security import hash_password, new_token, token_digest, verify_password
|
||||
from app.spaces.service import create_space
|
||||
|
||||
|
||||
def normalize_email(email: str) -> str:
|
||||
return email.strip().casefold()
|
||||
|
||||
|
||||
def validate_profile(locale: str, timezone: str) -> None:
|
||||
if locale not in {"ru", "en"}:
|
||||
raise bad_request("IDENTITY_LOCALE_INVALID", "Locale must be ru or en")
|
||||
try:
|
||||
ZoneInfo(timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise bad_request("IDENTITY_TIMEZONE_INVALID", "Unknown timezone") from exc
|
||||
|
||||
|
||||
def create_invitation(
|
||||
db: DbSession,
|
||||
*,
|
||||
email: str,
|
||||
invited_by_user_id: uuid.UUID | None = None,
|
||||
space_id: uuid.UUID | None = None,
|
||||
) -> tuple[Invitation, str]:
|
||||
normalized = normalize_email(email)
|
||||
if db.query(User.id).filter(User.email == normalized).first() is not None:
|
||||
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
|
||||
token = new_token()
|
||||
invitation = Invitation(
|
||||
email=normalized,
|
||||
token_hash=token_digest(token),
|
||||
invited_by_user_id=invited_by_user_id,
|
||||
space_id=space_id,
|
||||
role="owner" if space_id is None else "member",
|
||||
expires_at=datetime.now(UTC) + timedelta(days=get_settings().invite_ttl_days),
|
||||
)
|
||||
db.add(invitation)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="identity.invitation_created",
|
||||
user_id=invited_by_user_id,
|
||||
detail={"invitation_id": str(invitation.id)},
|
||||
)
|
||||
return invitation, token
|
||||
|
||||
|
||||
def accept_invitation(
|
||||
db: DbSession,
|
||||
*,
|
||||
token: str,
|
||||
password: str,
|
||||
display_name: str | None,
|
||||
locale: str,
|
||||
timezone: str,
|
||||
) -> User:
|
||||
validate_profile(locale, timezone)
|
||||
if len(password) < 12:
|
||||
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
|
||||
invitation = (
|
||||
db.query(Invitation)
|
||||
.filter(Invitation.token_hash == token_digest(token))
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
if invitation is None or invitation.accepted_at is not None or invitation.expires_at <= now:
|
||||
raise unauthorized("IDENTITY_INVITATION_INVALID", "Invitation is invalid or expired")
|
||||
if db.query(User.id).filter(User.email == invitation.email).first() is not None:
|
||||
raise conflict("IDENTITY_EMAIL_EXISTS", "A user with this email already exists")
|
||||
user = User(
|
||||
email=invitation.email,
|
||||
password_hash=hash_password(password),
|
||||
display_name=display_name,
|
||||
locale=locale,
|
||||
timezone=timezone,
|
||||
)
|
||||
db.add(user)
|
||||
db.flush()
|
||||
if invitation.space_id is None:
|
||||
space_name = f"{display_name or invitation.email} finance"
|
||||
space = create_space(db, user=user, name=space_name)
|
||||
invitation.space_id = space.id
|
||||
else:
|
||||
from app.spaces.models import Membership
|
||||
|
||||
db.add(Membership(space_id=invitation.space_id, user_id=user.id, role=invitation.role))
|
||||
invitation.accepted_at = now
|
||||
invitation.accepted_user_id = user.id
|
||||
audit.record(db, event_type="identity.invitation_accepted", user_id=user.id)
|
||||
return user
|
||||
|
||||
|
||||
def authenticate(db: DbSession, *, email: str, password: str) -> User:
|
||||
user = (
|
||||
db.query(User)
|
||||
.filter(User.email == normalize_email(email), User.status == "active")
|
||||
.one_or_none()
|
||||
)
|
||||
if user is None or not verify_password(user.password_hash, password):
|
||||
raise unauthorized("IDENTITY_CREDENTIALS_INVALID", "Invalid email or password")
|
||||
return user
|
||||
|
||||
|
||||
def create_session(db: DbSession, *, user: User) -> tuple[Session, str]:
|
||||
token = new_token()
|
||||
now = datetime.now(UTC)
|
||||
session = Session(
|
||||
user_id=user.id,
|
||||
token_hash=token_digest(token),
|
||||
csrf_token=new_token(),
|
||||
expires_at=now + timedelta(days=get_settings().session_ttl_days),
|
||||
last_seen_at=now,
|
||||
)
|
||||
db.add(session)
|
||||
db.flush()
|
||||
audit.record(db, event_type="identity.session_created", user_id=user.id)
|
||||
return session, token
|
||||
|
||||
|
||||
def get_active_session(db: DbSession, *, token: str) -> Session:
|
||||
session = db.query(Session).filter(Session.token_hash == token_digest(token)).one_or_none()
|
||||
now = datetime.now(UTC)
|
||||
if session is None or session.revoked_at is not None or session.expires_at <= now:
|
||||
raise unauthorized("IDENTITY_SESSION_INVALID", "Authentication required")
|
||||
session.last_seen_at = now
|
||||
return session
|
||||
|
||||
|
||||
def revoke_session(db: DbSession, *, session: Session, reason: str = "logout") -> None:
|
||||
if session.revoked_at is None:
|
||||
session.revoked_at = datetime.now(UTC)
|
||||
session.revoked_reason = reason
|
||||
audit.record(db, event_type="identity.session_revoked", user_id=session.user_id)
|
||||
|
||||
|
||||
def create_password_reset(db: DbSession, *, email: str) -> str | None:
|
||||
user = (
|
||||
db.query(User)
|
||||
.filter(User.email == normalize_email(email), User.status == "active")
|
||||
.one_or_none()
|
||||
)
|
||||
if user is None:
|
||||
return None
|
||||
token = new_token()
|
||||
db.add(
|
||||
PasswordReset(
|
||||
user_id=user.id,
|
||||
token_hash=token_digest(token),
|
||||
expires_at=datetime.now(UTC) + timedelta(hours=get_settings().reset_ttl_hours),
|
||||
)
|
||||
)
|
||||
audit.record(db, event_type="identity.password_reset_requested", user_id=user.id)
|
||||
return token
|
||||
|
||||
|
||||
def reset_password(db: DbSession, *, token: str, password: str) -> User:
|
||||
if len(password) < 12:
|
||||
raise bad_request("IDENTITY_PASSWORD_WEAK", "Password must contain at least 12 characters")
|
||||
reset = (
|
||||
db.query(PasswordReset)
|
||||
.filter(PasswordReset.token_hash == token_digest(token))
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
now = datetime.now(UTC)
|
||||
if reset is None or reset.used_at is not None or reset.expires_at <= now:
|
||||
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
|
||||
user = db.get(User, reset.user_id)
|
||||
if user is None:
|
||||
raise unauthorized("IDENTITY_RESET_INVALID", "Password reset is invalid or expired")
|
||||
user.password_hash = hash_password(password)
|
||||
reset.used_at = now
|
||||
db.query(Session).filter(Session.user_id == user.id, Session.revoked_at.is_(None)).update(
|
||||
{Session.revoked_at: now, Session.revoked_reason: "password_reset"}
|
||||
)
|
||||
audit.record(db, event_type="identity.password_reset_completed", user_id=user.id)
|
||||
return user
|
||||
@@ -0,0 +1,38 @@
|
||||
from app.imports.adapters.alfa_xlsx import AlfaCreditXlsxAdapter, AlfaDebitXlsxAdapter
|
||||
from app.imports.adapters.base import StatementAdapter, UnsupportedStatementLayout
|
||||
from app.imports.adapters.ozon_pdf import OzonCreditPdfAdapter, OzonDebitPdfAdapter
|
||||
from app.imports.adapters.rocketbank_pdf import RocketbankDebitPdfAdapter
|
||||
|
||||
ADAPTERS: tuple[StatementAdapter, ...] = (
|
||||
AlfaDebitXlsxAdapter(),
|
||||
AlfaCreditXlsxAdapter(),
|
||||
OzonDebitPdfAdapter(),
|
||||
OzonCreditPdfAdapter(),
|
||||
RocketbankDebitPdfAdapter(),
|
||||
)
|
||||
|
||||
XLSX_MAGIC = b"PK\x03\x04"
|
||||
PDF_MAGIC = b"%PDF-"
|
||||
|
||||
|
||||
def magic_kind(raw: bytes) -> str | None:
|
||||
if raw.startswith(XLSX_MAGIC):
|
||||
return "xlsx"
|
||||
if raw.startswith(PDF_MAGIC):
|
||||
return "pdf"
|
||||
return None
|
||||
|
||||
|
||||
def detect_adapter(raw: bytes, magic: str) -> StatementAdapter:
|
||||
attempted: list[str] = []
|
||||
for adapter in ADAPTERS:
|
||||
expected = "xlsx" if adapter.adapter_id.endswith("xlsx") else "pdf"
|
||||
if expected != magic:
|
||||
continue
|
||||
attempted.append(adapter.adapter_id)
|
||||
if adapter.detect(raw):
|
||||
return adapter
|
||||
raise UnsupportedStatementLayout(
|
||||
"no registered adapter recognizes this document layout; "
|
||||
f"attempted: {', '.join(attempted) or 'none for this file type'}"
|
||||
)
|
||||
@@ -0,0 +1,210 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime
|
||||
|
||||
from app.errors import AppError
|
||||
from app.imports.adapters.base import (
|
||||
DIRECTION_IN,
|
||||
DIRECTION_OUT,
|
||||
PARSER_VERSION,
|
||||
SourceRow,
|
||||
StatementDraft,
|
||||
UnsupportedStatementLayout,
|
||||
account_fingerprint,
|
||||
amount_to_kopecks,
|
||||
)
|
||||
from app.imports.readers import open_xlsx
|
||||
|
||||
DATE_HEADERS = ("дата операции", "дата")
|
||||
AMOUNT_HEADERS = ("сумма в валюте счёта", "сумма операции", "сумма")
|
||||
DESCRIPTION_HEADERS = ("описание", "назначение", "комментарий")
|
||||
CATEGORY_HEADERS = ("категория",)
|
||||
ACCOUNT_HEADERS = ("номер карты", "номер счёта", "карта")
|
||||
|
||||
|
||||
class AlfaXlsxAdapterBase:
|
||||
"""Alfa statement contract: XLSX with a header row and bank marker 'Альфа'."""
|
||||
|
||||
adapter_id: str = ""
|
||||
product: str = ""
|
||||
product_markers: tuple[str, ...] = ()
|
||||
|
||||
def detect(self, raw: bytes) -> bool:
|
||||
try:
|
||||
text = _scan_text(raw)
|
||||
except AppError:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return "альфа" in lowered and any(m in lowered for m in self.product_markers)
|
||||
|
||||
def parse(self, raw: bytes) -> StatementDraft:
|
||||
with open_xlsx(raw) as workbook:
|
||||
sheet = workbook.worksheets[0]
|
||||
rows = []
|
||||
for index, row in enumerate(sheet.iter_rows(values_only=True)):
|
||||
if index > get_settings_row_cap():
|
||||
raise UnsupportedStatementLayout("row quota exceeded during parse")
|
||||
rows.append(row)
|
||||
header_idx, columns = _find_header(rows)
|
||||
full_text = _rows_text(rows).lower()
|
||||
if "альфа" not in full_text:
|
||||
raise UnsupportedStatementLayout("Alfa bank marker not found")
|
||||
if not any(m in full_text for m in self.product_markers):
|
||||
raise UnsupportedStatementLayout(
|
||||
f"expected {self.product} statement markers not found"
|
||||
)
|
||||
fingerprint = None
|
||||
masked = _find_account_value(rows)
|
||||
if masked:
|
||||
fingerprint = account_fingerprint("alfa", masked)
|
||||
parsed: list[SourceRow] = []
|
||||
for offset, row in enumerate(rows[header_idx + 1 :], start=header_idx + 1):
|
||||
if all(cell is None for cell in row):
|
||||
continue
|
||||
occurred = _parse_date(row[columns["date"]])
|
||||
amount_raw = row[columns["amount"]]
|
||||
warnings: list[str] = []
|
||||
if occurred is None:
|
||||
warnings.append("occurred_date_missing")
|
||||
try:
|
||||
amount = amount_to_kopecks(amount_raw)
|
||||
except UnsupportedStatementLayout:
|
||||
amount = 0
|
||||
warnings.append("amount_unparseable")
|
||||
description = (
|
||||
_cell_str(row[columns["description"]]) if columns.get("description") else None
|
||||
)
|
||||
bank_category = (
|
||||
_cell_str(row[columns["category"]]) if columns.get("category") else None
|
||||
)
|
||||
external_id = (
|
||||
_cell_str(row[columns["external"]]) if columns.get("external") else None
|
||||
)
|
||||
parsed.append(
|
||||
SourceRow(
|
||||
external_id=external_id,
|
||||
occurred_on=occurred,
|
||||
posted_on=None,
|
||||
amount_kopecks=amount,
|
||||
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
|
||||
description=description,
|
||||
mcc=None,
|
||||
bank_category=bank_category,
|
||||
location=None,
|
||||
row_ref=f"sheet0:row{offset + 1}",
|
||||
warnings=tuple(warnings),
|
||||
)
|
||||
)
|
||||
return StatementDraft(
|
||||
adapter_id=self.adapter_id,
|
||||
parser_version=PARSER_VERSION,
|
||||
bank_code="alfa",
|
||||
product=self.product,
|
||||
account_fingerprint=fingerprint,
|
||||
currency="RUB",
|
||||
period_start=min((r.occurred_on for r in parsed if r.occurred_on), default=None),
|
||||
period_end=max((r.occurred_on for r in parsed if r.occurred_on), default=None),
|
||||
opening_balance_kopecks=None,
|
||||
closing_balance_kopecks=None,
|
||||
rows=tuple(parsed),
|
||||
)
|
||||
|
||||
|
||||
def get_settings_row_cap() -> int:
|
||||
from app.config import get_settings
|
||||
|
||||
return get_settings().max_xlsx_rows
|
||||
|
||||
|
||||
def _scan_text(raw: bytes) -> str:
|
||||
with open_xlsx(raw) as workbook:
|
||||
return _rows_text(
|
||||
[
|
||||
row
|
||||
for index, row in enumerate(workbook.worksheets[0].iter_rows(values_only=True))
|
||||
if index < 40
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
def _rows_text(rows: list[tuple]) -> str:
|
||||
parts = []
|
||||
for row in rows:
|
||||
for cell in row:
|
||||
if cell is not None:
|
||||
parts.append(str(cell))
|
||||
return " | ".join(parts)
|
||||
|
||||
|
||||
def _cell_str(value: object) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
text = str(value).strip()
|
||||
return text or None
|
||||
|
||||
|
||||
def _parse_date(value: object) -> date | None:
|
||||
if value is None:
|
||||
return None
|
||||
if isinstance(value, datetime):
|
||||
return value.date()
|
||||
if isinstance(value, date):
|
||||
return value
|
||||
text = str(value).strip()
|
||||
for fmt in ("%d.%m.%Y", "%Y-%m-%d", "%d.%m.%Y %H:%M:%S"):
|
||||
try:
|
||||
return datetime.strptime(text, fmt).date()
|
||||
except ValueError:
|
||||
continue
|
||||
return None
|
||||
|
||||
|
||||
def _find_header(rows: list[tuple]) -> tuple[int, dict[str, int]]:
|
||||
for index, row in enumerate(rows[:20]):
|
||||
cells = [str(c).strip().lower() if c is not None else "" for c in row]
|
||||
date_col = next((i for i, c in enumerate(cells) if c in DATE_HEADERS), None)
|
||||
amount_col = next((i for i, c in enumerate(cells) if c in AMOUNT_HEADERS), None)
|
||||
if date_col is None or amount_col is None:
|
||||
continue
|
||||
columns = {"date": date_col, "amount": amount_col}
|
||||
for key, synonyms in (
|
||||
("description", DESCRIPTION_HEADERS),
|
||||
("category", CATEGORY_HEADERS),
|
||||
("external", ("id операции", "номер операции")),
|
||||
):
|
||||
columns[key] = next( # type: ignore[assignment]
|
||||
(i for i, c in enumerate(cells) if c in synonyms), None
|
||||
)
|
||||
return index, columns
|
||||
raise UnsupportedStatementLayout("statement header row with date/amount columns not found")
|
||||
|
||||
|
||||
def _find_account_value(rows: list[tuple]) -> str | None:
|
||||
for row in rows[:20]:
|
||||
for index, cell in enumerate(row):
|
||||
if cell is None:
|
||||
continue
|
||||
header = str(row[index - 1]).strip().lower() if index > 0 and row[index - 1] else ""
|
||||
if header in ACCOUNT_HEADERS:
|
||||
return str(cell).strip()
|
||||
masked = None
|
||||
for row in rows[:20]:
|
||||
for cell in row:
|
||||
if cell is None:
|
||||
continue
|
||||
text = str(cell)
|
||||
if len(text) >= 4 and any(ch.isdigit() for ch in text) and ("*" in text or "•" in text):
|
||||
masked = text.strip()
|
||||
return masked
|
||||
|
||||
|
||||
class AlfaDebitXlsxAdapter(AlfaXlsxAdapterBase):
|
||||
adapter_id = "alfa_debit_xlsx"
|
||||
product = "debit"
|
||||
product_markers = ("дебет", "дебетовая")
|
||||
|
||||
|
||||
class AlfaCreditXlsxAdapter(AlfaXlsxAdapterBase):
|
||||
adapter_id = "alfa_credit_xlsx"
|
||||
product = "credit"
|
||||
product_markers = ("кредит", "кредитная")
|
||||
@@ -0,0 +1,96 @@
|
||||
import hashlib
|
||||
from dataclasses import dataclass
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Protocol
|
||||
|
||||
from app.errors import AppError
|
||||
|
||||
DIRECTION_IN = "in"
|
||||
DIRECTION_OUT = "out"
|
||||
PARSER_VERSION = "1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class SourceRow:
|
||||
external_id: str | None
|
||||
occurred_on: date | None
|
||||
posted_on: date | None
|
||||
amount_kopecks: int
|
||||
direction: str
|
||||
description: str | None
|
||||
mcc: int | None
|
||||
bank_category: str | None
|
||||
location: str | None
|
||||
row_ref: str
|
||||
warnings: tuple[str, ...] = ()
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class StatementDraft:
|
||||
adapter_id: str
|
||||
parser_version: str
|
||||
bank_code: str
|
||||
product: str
|
||||
account_fingerprint: str | None
|
||||
currency: str
|
||||
period_start: date | None
|
||||
period_end: date | None
|
||||
opening_balance_kopecks: int | None
|
||||
closing_balance_kopecks: int | None
|
||||
rows: tuple[SourceRow, ...]
|
||||
|
||||
|
||||
class UnsupportedStatementLayout(AppError):
|
||||
"""Stable diagnostic for unknown bank layouts; never guesses."""
|
||||
|
||||
def __init__(self, detail: str) -> None:
|
||||
super().__init__(
|
||||
detail,
|
||||
code="IMPORT_UNSUPPORTED_LAYOUT",
|
||||
status_code=422,
|
||||
)
|
||||
|
||||
|
||||
class StatementAdapter(Protocol):
|
||||
adapter_id: str
|
||||
|
||||
def detect(self, raw: bytes) -> bool: ...
|
||||
def parse(self, raw: bytes) -> StatementDraft: ...
|
||||
|
||||
|
||||
def amount_to_kopecks(value: object) -> int:
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
if isinstance(value, float):
|
||||
raise UnsupportedStatementLayout(
|
||||
"float amounts are not accepted; cell must be text/decimal"
|
||||
)
|
||||
if isinstance(value, Decimal):
|
||||
return int(value.quantize(Decimal("0.01")).scaleb(2))
|
||||
if isinstance(value, str):
|
||||
cleaned = value.replace(" ", "").replace("\u00a0", "").replace(",", ".")
|
||||
try:
|
||||
return int(Decimal(cleaned).quantize(Decimal("0.01")).scaleb(2))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise UnsupportedStatementLayout(f"unparseable amount: {value!r}") from exc
|
||||
raise UnsupportedStatementLayout(f"unsupported amount cell type: {type(value).__name__}")
|
||||
|
||||
|
||||
def account_fingerprint(bank_code: str, masked_identifier: str) -> str:
|
||||
digest = hashlib.sha256(f"{bank_code}|{masked_identifier}".encode()).hexdigest()
|
||||
return digest[:64]
|
||||
|
||||
|
||||
def normalize_description(description: str | None) -> str:
|
||||
if not description:
|
||||
return ""
|
||||
return " ".join(description.lower().split())
|
||||
|
||||
|
||||
def record_fingerprint(
|
||||
occurred_on: date | None, amount_kopecks: int, description: str | None
|
||||
) -> str:
|
||||
occurred = occurred_on.isoformat() if occurred_on else ""
|
||||
payload = f"{occurred}|{amount_kopecks}|{normalize_description(description)[:120]}"
|
||||
return hashlib.sha256(payload.encode("utf-8")).hexdigest()
|
||||
@@ -0,0 +1,134 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from datetime import date, datetime
|
||||
|
||||
from app.imports.adapters.base import (
|
||||
DIRECTION_IN,
|
||||
DIRECTION_OUT,
|
||||
PARSER_VERSION,
|
||||
SourceRow,
|
||||
StatementDraft,
|
||||
UnsupportedStatementLayout,
|
||||
account_fingerprint,
|
||||
amount_to_kopecks,
|
||||
)
|
||||
from app.imports.readers import open_pdf
|
||||
|
||||
PERIOD_RE = re.compile(
|
||||
r"период:?\s*(\d{2}\.\d{2}\.\d{4})\s*[-–—]\s*(\d{2}\.\d{2}\.\d{4})", re.IGNORECASE
|
||||
)
|
||||
ROW_RE = re.compile(
|
||||
r"^(?P<date>\d{2}\.\d{2}\.\d{4})\s+(?P<desc>.+?)\s+(?P<amount>-?\d[\d\s]*,\d{2})"
|
||||
r"(?:\s*(?:₽|руб\.?|RUB))?$"
|
||||
)
|
||||
MASK_RE = re.compile(r"(?:\*{2,}|•{2,})\s*(\d{4})")
|
||||
|
||||
|
||||
class OzonPdfAdapterBase:
|
||||
"""Ozon statement contract: text-layer PDF, per-page line scanning."""
|
||||
|
||||
adapter_id: str = ""
|
||||
product: str = ""
|
||||
bank_markers: tuple[str, ...] = ("ozon банк", "озон банк", "ozon bank")
|
||||
product_markers: tuple[str, ...] = ()
|
||||
|
||||
def detect(self, raw: bytes) -> bool:
|
||||
try:
|
||||
text = _first_page_text(raw)
|
||||
except Exception:
|
||||
return False
|
||||
lowered = text.lower()
|
||||
return any(m in lowered for m in self.bank_markers) and any(
|
||||
m in lowered for m in self.product_markers
|
||||
)
|
||||
|
||||
def parse(self, raw: bytes) -> StatementDraft:
|
||||
with open_pdf(raw) as pdf:
|
||||
lines: list[str] = []
|
||||
period_start: date | None = None
|
||||
period_end: date | None = None
|
||||
masks: list[str] = []
|
||||
for page_index, page in enumerate(pdf.pages):
|
||||
text = page.extract_text() or ""
|
||||
if page_index == 0 and not text.strip():
|
||||
raise UnsupportedStatementLayout(
|
||||
"PDF has no extractable text layer; OCR is not supported"
|
||||
)
|
||||
lowered = text.lower()
|
||||
if not any(m in lowered for m in self.bank_markers):
|
||||
raise UnsupportedStatementLayout("bank marker not found in PDF text")
|
||||
if not any(m in lowered for m in self.product_markers):
|
||||
raise UnsupportedStatementLayout(
|
||||
f"expected {self.product} statement markers not found"
|
||||
)
|
||||
match = PERIOD_RE.search(text)
|
||||
if match and period_start is None:
|
||||
period_start = datetime.strptime(match.group(1), "%d.%m.%Y").date()
|
||||
period_end = datetime.strptime(match.group(2), "%d.%m.%Y").date()
|
||||
masks.extend(MASK_RE.findall(text))
|
||||
for line in text.splitlines():
|
||||
line = line.strip()
|
||||
if ROW_RE.match(line):
|
||||
lines.append(line)
|
||||
if not lines:
|
||||
raise UnsupportedStatementLayout("no statement rows matched the layout contract")
|
||||
fingerprint = (
|
||||
account_fingerprint(self.bank_markers[0].split()[0], f"**{masks[0]}")
|
||||
if masks
|
||||
else None
|
||||
)
|
||||
parsed = [_parse_row(line, f"page:line{n + 1}") for n, line in enumerate(lines)]
|
||||
return StatementDraft(
|
||||
adapter_id=self.adapter_id,
|
||||
parser_version=PARSER_VERSION,
|
||||
bank_code=self.bank_markers[0].split()[0],
|
||||
product=self.product,
|
||||
account_fingerprint=fingerprint,
|
||||
currency="RUB",
|
||||
period_start=period_start,
|
||||
period_end=period_end,
|
||||
opening_balance_kopecks=None,
|
||||
closing_balance_kopecks=None,
|
||||
rows=tuple(parsed),
|
||||
)
|
||||
|
||||
|
||||
def _parse_row(line: str, row_ref: str) -> SourceRow:
|
||||
match = ROW_RE.match(line)
|
||||
assert match is not None
|
||||
occurred = datetime.strptime(match.group("date"), "%d.%m.%Y").date()
|
||||
raw_amount = match.group("amount").replace(" ", "")
|
||||
amount = amount_to_kopecks(raw_amount)
|
||||
return SourceRow(
|
||||
external_id=None,
|
||||
occurred_on=occurred,
|
||||
posted_on=None,
|
||||
amount_kopecks=amount,
|
||||
direction=DIRECTION_IN if amount >= 0 else DIRECTION_OUT,
|
||||
description=match.group("desc").strip(),
|
||||
mcc=None,
|
||||
bank_category=None,
|
||||
location=None,
|
||||
row_ref=row_ref,
|
||||
warnings=() if occurred else ("occurred_date_missing",),
|
||||
)
|
||||
|
||||
|
||||
def _first_page_text(raw: bytes) -> str:
|
||||
with open_pdf(raw) as pdf:
|
||||
if not pdf.pages:
|
||||
raise UnsupportedStatementLayout("PDF has no pages")
|
||||
return pdf.pages[0].extract_text() or ""
|
||||
|
||||
|
||||
class OzonDebitPdfAdapter(OzonPdfAdapterBase):
|
||||
adapter_id = "ozon_debit_pdf"
|
||||
product = "debit"
|
||||
product_markers = ("дебетов",)
|
||||
|
||||
|
||||
class OzonCreditPdfAdapter(OzonPdfAdapterBase):
|
||||
adapter_id = "ozon_credit_pdf"
|
||||
product = "credit"
|
||||
product_markers = ("кредитн", "кредитная")
|
||||
@@ -0,0 +1,12 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.imports.adapters.ozon_pdf import OzonPdfAdapterBase
|
||||
|
||||
|
||||
class RocketbankDebitPdfAdapter(OzonPdfAdapterBase):
|
||||
"""Rocketbank debit statement contract: same text-layer line scanning shape."""
|
||||
|
||||
adapter_id = "rocketbank_debit_pdf"
|
||||
product = "debit"
|
||||
bank_markers = ("рокетбанк", "rocketbank")
|
||||
product_markers = ("дебетов", "дебетовая")
|
||||
@@ -0,0 +1,77 @@
|
||||
import os
|
||||
from pathlib import Path
|
||||
from typing import Protocol
|
||||
|
||||
import boto3
|
||||
from botocore.exceptions import ClientError
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
class BlobStore(Protocol):
|
||||
def put(self, key: str, data: bytes) -> None: ...
|
||||
def get(self, key: str) -> bytes: ...
|
||||
def delete(self, key: str) -> None: ...
|
||||
|
||||
|
||||
class LocalDirectoryBlobStore:
|
||||
"""S3-neutral local store; production swaps in an S3-compatible client."""
|
||||
|
||||
def __init__(self, base_dir: str) -> None:
|
||||
self._base = Path(base_dir)
|
||||
|
||||
def _path(self, key: str) -> Path:
|
||||
if "/" in key or ".." in key or not key:
|
||||
raise ValueError("invalid blob key")
|
||||
return self._base / key
|
||||
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
path = self._path(key)
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(data)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
return self._path(key).read_bytes()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
path = self._path(key)
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
|
||||
class S3BlobStore:
|
||||
def __init__(self) -> None:
|
||||
settings = get_settings()
|
||||
self._bucket = settings.s3_bucket_statements
|
||||
self._client = boto3.client(
|
||||
"s3",
|
||||
endpoint_url=settings.s3_endpoint_url,
|
||||
region_name=settings.s3_region,
|
||||
aws_access_key_id=settings.s3_access_key_id,
|
||||
aws_secret_access_key=settings.s3_secret_access_key,
|
||||
)
|
||||
try:
|
||||
self._client.head_bucket(Bucket=self._bucket)
|
||||
except ClientError:
|
||||
self._client.create_bucket(Bucket=self._bucket)
|
||||
|
||||
def put(self, key: str, data: bytes) -> None:
|
||||
self._client.put_object(Bucket=self._bucket, Key=key, Body=data)
|
||||
|
||||
def get(self, key: str) -> bytes:
|
||||
response = self._client.get_object(Bucket=self._bucket, Key=key)
|
||||
return response["Body"].read()
|
||||
|
||||
def delete(self, key: str) -> None:
|
||||
self._client.delete_object(Bucket=self._bucket, Key=key)
|
||||
|
||||
|
||||
def get_blob_store() -> BlobStore:
|
||||
settings = get_settings()
|
||||
if settings.s3_endpoint_url:
|
||||
return S3BlobStore()
|
||||
return LocalDirectoryBlobStore(settings.blob_dir)
|
||||
|
||||
|
||||
def new_object_key() -> str:
|
||||
return os.urandom(16).hex()
|
||||
@@ -0,0 +1,34 @@
|
||||
from celery import Celery
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
|
||||
def create_celery_app() -> Celery:
|
||||
settings = get_settings()
|
||||
app = Celery(
|
||||
"finance",
|
||||
broker=settings.broker_url,
|
||||
include=["app.imports.tasks", "app.exports.tasks"],
|
||||
)
|
||||
app.conf.update(
|
||||
task_acks_late=True,
|
||||
task_acks_on_failure_or_timeout=False,
|
||||
worker_prefetch_multiplier=1,
|
||||
worker_max_tasks_per_child=50,
|
||||
worker_max_memory_per_child=250_000,
|
||||
task_time_limit=600,
|
||||
task_soft_time_limit=480,
|
||||
task_always_eager=settings.celery_task_always_eager,
|
||||
broker_connection_retry_on_startup=True,
|
||||
task_default_queue="finance",
|
||||
beat_schedule={
|
||||
"purge-due-spaces-hourly": {
|
||||
"task": "spaces.purge_due",
|
||||
"schedule": 3600.0,
|
||||
}
|
||||
},
|
||||
)
|
||||
return app
|
||||
|
||||
|
||||
celery_app = create_celery_app()
|
||||
@@ -0,0 +1,58 @@
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
|
||||
from cryptography.exceptions import InvalidTag
|
||||
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
|
||||
|
||||
from app.config import get_settings
|
||||
from app.errors import AppError
|
||||
|
||||
_DEK_AAD = b"finance-import-dek-v1"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class EncryptedFile:
|
||||
ciphertext: bytes
|
||||
content_nonce: bytes
|
||||
dek_wrapped: bytes
|
||||
wrap_nonce: bytes
|
||||
|
||||
|
||||
def encrypt_file(plaintext: bytes) -> EncryptedFile:
|
||||
"""Per-file random DEK; DEK wrapped with the KEK from the secret manager."""
|
||||
kek = get_settings().kek
|
||||
dek = os.urandom(32)
|
||||
content_nonce = os.urandom(12)
|
||||
ciphertext = AESGCM(dek).encrypt(content_nonce, plaintext, None)
|
||||
wrap_nonce = os.urandom(12)
|
||||
dek_wrapped = AESGCM(kek).encrypt(wrap_nonce, dek, _DEK_AAD)
|
||||
return EncryptedFile(
|
||||
ciphertext=ciphertext,
|
||||
content_nonce=content_nonce,
|
||||
dek_wrapped=dek_wrapped,
|
||||
wrap_nonce=wrap_nonce,
|
||||
)
|
||||
|
||||
|
||||
def decrypt_file(
|
||||
*,
|
||||
ciphertext: bytes,
|
||||
content_nonce: bytes,
|
||||
dek_wrapped: bytes,
|
||||
wrap_nonce: bytes,
|
||||
) -> bytes:
|
||||
kek = get_settings().kek
|
||||
try:
|
||||
dek = AESGCM(kek).decrypt(wrap_nonce, dek_wrapped, _DEK_AAD)
|
||||
return AESGCM(dek).decrypt(content_nonce, ciphertext, None)
|
||||
except InvalidTag as exc:
|
||||
raise AppError(
|
||||
"Encrypted document failed integrity verification",
|
||||
code="IMPORT_DECRYPT_FAILED",
|
||||
status_code=409,
|
||||
) from exc
|
||||
|
||||
|
||||
def shred_dek_material() -> None:
|
||||
"""Crypto-shred hook: deleting wrapped DEK rows in DB plus blobs removes access."""
|
||||
return None
|
||||
@@ -0,0 +1,211 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import (
|
||||
JSON,
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
LargeBinary,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin
|
||||
|
||||
DOCUMENT_STATUSES = (
|
||||
"uploaded",
|
||||
"queued",
|
||||
"processing",
|
||||
"ready",
|
||||
"review_required",
|
||||
"confirmed",
|
||||
"failed",
|
||||
"deleted",
|
||||
)
|
||||
RUN_STATUSES = ("pending", "running", "succeeded", "failed")
|
||||
CANDIDATE_KINDS = ("duplicate", "transfer")
|
||||
CANDIDATE_CONFIDENCES = ("unique", "possible")
|
||||
CANDIDATE_RESOLUTIONS = ("pending", "is_duplicate", "is_new")
|
||||
|
||||
|
||||
class StatementDocument(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "statement_documents"
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "account_id"],
|
||||
["accounts.space_id", "accounts.id"],
|
||||
name="fk_documents_account_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "content_sha256", name="uq_documents_space_sha"),
|
||||
UniqueConstraint("space_id", "id", name="uq_documents_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
|
||||
original_filename: Mapped[str] = mapped_column(String(300), nullable=False)
|
||||
size_bytes: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
content_sha256: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
object_key: Mapped[str] = mapped_column(String(64), nullable=False, unique=True)
|
||||
dek_wrapped: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
dek_wrap_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
content_nonce: Mapped[bytes | None] = mapped_column(LargeBinary, nullable=True)
|
||||
adapter_id: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
account_fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), nullable=False, default="uploaded", index=True)
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
reconciliation: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
needs_account_mapping: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
active_parse_run_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
uploaded_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
|
||||
|
||||
class ParseRun(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "parse_runs"
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "document_id"],
|
||||
["statement_documents.space_id", "statement_documents.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_parse_runs_document_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_parse_runs_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
document_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
adapter_id: Mapped[str | None] = mapped_column(String(48), nullable=True)
|
||||
parser_version: Mapped[str] = mapped_column(String(16), nullable=False, default="1")
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
error_code: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
draft: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
|
||||
|
||||
class SourceRecord(Base, PkMixin, TimestampMixin):
|
||||
"""Immutable extracted bank fact with document origin coordinates."""
|
||||
|
||||
__tablename__ = "source_records"
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "document_id"],
|
||||
["statement_documents.space_id", "statement_documents.id"],
|
||||
name="fk_source_records_document_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "parse_run_id"],
|
||||
["parse_runs.space_id", "parse_runs.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_source_records_run_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "duplicate_operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
name="fk_source_records_duplicate_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_source_records_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
document_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
|
||||
parse_run_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
external_id: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
occurred_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
posted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
direction: Mapped[str] = mapped_column(String(8), nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
bank_category: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
location: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
row_ref: Mapped[str] = mapped_column(String(120), nullable=False)
|
||||
field_warnings: Mapped[dict[str, Any] | None] = mapped_column(JSON, nullable=True)
|
||||
critical: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
record_fp: Mapped[str] = mapped_column(String(64), nullable=False, index=True)
|
||||
review_decision: Mapped[str] = mapped_column(String(16), nullable=False, default="create")
|
||||
duplicate_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
|
||||
|
||||
class SourceRecordLink(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "source_record_links"
|
||||
__table_args__ = (
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "source_record_id"],
|
||||
["source_records.space_id", "source_records.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_links_source_record_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
name="fk_links_operation_tenant",
|
||||
),
|
||||
UniqueConstraint("source_record_id", name="uq_links_source_record"),
|
||||
UniqueConstraint("space_id", "id", name="uq_links_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
source_record_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False)
|
||||
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
|
||||
|
||||
class DedupCandidate(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "dedup_candidates"
|
||||
__table_args__ = (
|
||||
CheckConstraint("candidate_kind IN ('duplicate', 'transfer')", name="kind_valid"),
|
||||
CheckConstraint(
|
||||
"resolution IN ('pending', 'is_duplicate', 'is_new', 'accepted_transfer')",
|
||||
name="resolution_valid",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "source_record_id"],
|
||||
["source_records.space_id", "source_records.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_candidates_source_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "existing_operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
name="fk_candidates_operation_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "existing_source_record_id"],
|
||||
["source_records.space_id", "source_records.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_candidates_existing_source_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_candidates_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
source_record_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
candidate_kind: Mapped[str] = mapped_column(String(16), nullable=False, default="duplicate")
|
||||
existing_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
|
||||
existing_source_record_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, nullable=True, index=True
|
||||
)
|
||||
confidence: Mapped[str] = mapped_column(String(16), nullable=False, default="possible")
|
||||
resolution: Mapped[str] = mapped_column(String(16), nullable=False, default="pending")
|
||||
replacement_snapshot: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
decided_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
@@ -0,0 +1,57 @@
|
||||
import io
|
||||
import zipfile
|
||||
from collections.abc import Iterator
|
||||
from contextlib import contextmanager
|
||||
|
||||
import defusedxml # noqa: F401 (hardened XML stack must be importable for openpyxl)
|
||||
import pdfplumber
|
||||
from openpyxl import load_workbook
|
||||
|
||||
from app.config import get_settings
|
||||
from app.errors import AppError
|
||||
|
||||
|
||||
def _quota_error() -> AppError:
|
||||
return AppError(
|
||||
"Document exceeds parser resource quotas",
|
||||
code="IMPORT_QUOTA_EXCEEDED",
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_xlsx(raw: bytes) -> Iterator:
|
||||
settings = get_settings()
|
||||
try:
|
||||
with zipfile.ZipFile(io.BytesIO(raw)) as zf:
|
||||
total_uncompressed = 0
|
||||
for info in zf.infolist():
|
||||
if info.flag_bits & 0x1:
|
||||
raise AppError(
|
||||
"Encrypted archives are not supported",
|
||||
code="IMPORT_ENCRYPTED_ARCHIVE",
|
||||
status_code=422,
|
||||
)
|
||||
total_uncompressed += info.file_size
|
||||
if total_uncompressed > settings.max_archive_uncompressed_bytes:
|
||||
raise _quota_error()
|
||||
except zipfile.BadZipFile as exc:
|
||||
raise AppError(
|
||||
"File is not a valid XLSX container",
|
||||
code="IMPORT_FILE_CORRUPT",
|
||||
status_code=422,
|
||||
) from exc
|
||||
workbook = load_workbook(io.BytesIO(raw), read_only=True, data_only=True, keep_links=False)
|
||||
try:
|
||||
yield workbook
|
||||
finally:
|
||||
workbook.close()
|
||||
|
||||
|
||||
@contextmanager
|
||||
def open_pdf(raw: bytes) -> Iterator:
|
||||
settings = get_settings()
|
||||
with pdfplumber.open(io.BytesIO(raw)) as pdf:
|
||||
if len(pdf.pages) > settings.max_pdf_pages:
|
||||
raise _quota_error()
|
||||
yield pdf
|
||||
@@ -0,0 +1,248 @@
|
||||
import uuid
|
||||
from typing import Annotated, Any
|
||||
|
||||
from fastapi import APIRouter, File, Form, UploadFile
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.dependencies import Context, Csrf, Db
|
||||
from app.imports import service
|
||||
from app.imports.tasks import enqueue_process_statement
|
||||
|
||||
router = APIRouter(prefix="/imports", tags=["imports"])
|
||||
|
||||
|
||||
class ReviewBody(BaseModel):
|
||||
account_id: uuid.UUID
|
||||
confirmed_without_full_reconciliation: bool = False
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
class DecisionBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
decision: str
|
||||
duplicate_operation_id: uuid.UUID | None = Field(default=None, alias="duplicateOfOperationId")
|
||||
|
||||
|
||||
class ConfirmBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
confirmed_without_full_reconciliation: bool = Field(
|
||||
default=False, alias="confirmedWithoutFullReconciliation"
|
||||
)
|
||||
reason: str | None = None
|
||||
|
||||
|
||||
def summary(document: Any) -> dict:
|
||||
return {
|
||||
"id": document.id,
|
||||
"fileName": document.original_filename,
|
||||
"status": document.status,
|
||||
"bankAdapter": document.adapter_id or "detecting",
|
||||
"accountId": document.account_id,
|
||||
"accountName": None,
|
||||
"periodFrom": None,
|
||||
"periodTo": None,
|
||||
"needsAccountMapping": document.needs_account_mapping,
|
||||
"errorCode": document.error_code,
|
||||
"createdAt": document.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("")
|
||||
def imports(db: Db, context: Context) -> list[dict]:
|
||||
return [summary(item) for item in service.list_documents(db, space_id=context.space.id)]
|
||||
|
||||
|
||||
@router.post("", status_code=202)
|
||||
async def upload(
|
||||
file: Annotated[UploadFile, File()],
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
account_id: Annotated[uuid.UUID | None, Form(alias="accountId")] = None,
|
||||
) -> dict:
|
||||
from app.config import get_settings
|
||||
|
||||
raw = await file.read(get_settings().max_upload_bytes + 1)
|
||||
document, created = service.create_document(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
filename=file.filename or "statement",
|
||||
raw=raw,
|
||||
account_id=account_id,
|
||||
)
|
||||
if created:
|
||||
db.commit()
|
||||
try:
|
||||
enqueue_process_statement(document.id, context.space.id)
|
||||
except Exception as exc:
|
||||
from app.db.session import set_space_context
|
||||
from app.errors import AppError
|
||||
|
||||
set_space_context(db, context.space.id)
|
||||
document = service._document(db, space_id=context.space.id, document_id=document.id)
|
||||
document.status = "failed"
|
||||
document.error_code = "IMPORT_QUEUE_FAILED"
|
||||
db.commit()
|
||||
raise AppError(
|
||||
"Import queue is unavailable",
|
||||
code="IMPORT_QUEUE_FAILED",
|
||||
status_code=503,
|
||||
) from exc
|
||||
return summary(document)
|
||||
|
||||
|
||||
@router.get("/{document_id}")
|
||||
def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
|
||||
raw = service.document_detail(db, space_id=context.space.id, document_id=document_id)
|
||||
document = service._document(db, space_id=context.space.id, document_id=document_id)
|
||||
base = summary(document)
|
||||
reconciliation = raw.get("reconciliation") or {}
|
||||
rows = [
|
||||
{
|
||||
"id": item["id"],
|
||||
"externalId": item["external_id"],
|
||||
"occurredOn": item["occurred_on"],
|
||||
"amountKopecks": item["amount_kopecks"],
|
||||
"direction": item["direction"],
|
||||
"description": item["description"],
|
||||
"payeeHint": None,
|
||||
"warnings": (item["warnings"] or {}).get("codes", []),
|
||||
"decision": "pending" if item["critical"] else item["review_decision"],
|
||||
"duplicateOfOperationId": item["duplicate_operation_id"],
|
||||
}
|
||||
for item in raw["records"]
|
||||
]
|
||||
blockers = [
|
||||
{"code": "IMPORT_FIELD_UNRESOLVED", "message": None, "rowId": str(item["id"])}
|
||||
for item in raw["records"]
|
||||
if item["critical"]
|
||||
]
|
||||
status = (
|
||||
"matched"
|
||||
if reconciliation.get("full")
|
||||
else "confirmed_without"
|
||||
if reconciliation.get("confirmed_without_full_reconciliation")
|
||||
else "not_available"
|
||||
)
|
||||
return {
|
||||
**base,
|
||||
"failureReason": raw["error_code"],
|
||||
"blockers": blockers,
|
||||
"warnings": [],
|
||||
"reconciliation": {
|
||||
"status": status,
|
||||
"openingBalanceKopecks": reconciliation.get("opening_balance_kopecks"),
|
||||
"closingBalanceKopecks": reconciliation.get("closing_balance_kopecks"),
|
||||
"computedClosingKopecks": None,
|
||||
"reason": reconciliation.get("reason"),
|
||||
},
|
||||
"rows": rows,
|
||||
"totals": {
|
||||
"total": len(rows),
|
||||
"create": sum(row["decision"] == "create" for row in rows),
|
||||
"discard": sum(row["decision"] == "discard" for row in rows),
|
||||
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
|
||||
"transfer": sum(row["decision"] == "transfer" for row in rows),
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@router.post("/{document_id}/review")
|
||||
def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
document = service.set_review_confirmation(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
document_id=document_id,
|
||||
**body.model_dump(),
|
||||
)
|
||||
return summary(document)
|
||||
|
||||
|
||||
@router.patch("/{document_id}/rows/{row_id}")
|
||||
def decide_row(
|
||||
document_id: uuid.UUID,
|
||||
row_id: uuid.UUID,
|
||||
body: DecisionBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> dict:
|
||||
service.decide_source_record(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
document_id=document_id,
|
||||
source_record_id=row_id,
|
||||
user=context.user,
|
||||
**body.model_dump(by_alias=False),
|
||||
)
|
||||
return detail(document_id, db, context)
|
||||
|
||||
|
||||
@router.post("/{document_id}/confirm")
|
||||
def confirm(
|
||||
document_id: uuid.UUID,
|
||||
body: ConfirmBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> dict:
|
||||
document = service._document(db, space_id=context.space.id, document_id=document_id)
|
||||
if body.confirmed_without_full_reconciliation and document.account_id:
|
||||
service.set_review_confirmation(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
document_id=document_id,
|
||||
account_id=document.account_id,
|
||||
confirmed_without_full_reconciliation=True,
|
||||
reason=body.reason,
|
||||
)
|
||||
document = service.confirm_document(
|
||||
db, space_id=context.space.id, document_id=document_id, user=context.user
|
||||
)
|
||||
return detail(document_id, db, context)
|
||||
|
||||
|
||||
@router.post("/{document_id}/reparse", status_code=202)
|
||||
def reparse(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
document = service.prepare_reparse(
|
||||
db, space_id=context.space.id, document_id=document_id, user=context.user
|
||||
)
|
||||
db.commit()
|
||||
from app.db.session import set_space_context
|
||||
|
||||
try:
|
||||
enqueue_process_statement(document.id, context.space.id)
|
||||
except Exception as exc:
|
||||
from app.errors import AppError
|
||||
|
||||
set_space_context(db, context.space.id)
|
||||
document = service._document(db, space_id=context.space.id, document_id=document.id)
|
||||
document.status = "failed"
|
||||
document.error_code = "IMPORT_QUEUE_FAILED"
|
||||
db.commit()
|
||||
raise AppError(
|
||||
"Import queue is unavailable",
|
||||
code="IMPORT_QUEUE_FAILED",
|
||||
status_code=503,
|
||||
) from exc
|
||||
set_space_context(db, context.space.id)
|
||||
return detail(document_id, db, context)
|
||||
|
||||
|
||||
@router.post("/{document_id}/revert")
|
||||
def revert(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
service.revert_document(
|
||||
db, space_id=context.space.id, document_id=document_id, user=context.user
|
||||
)
|
||||
return detail(document_id, db, context)
|
||||
|
||||
|
||||
@router.delete("/{document_id}/source")
|
||||
def delete_source(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
document = service.delete_source_file(
|
||||
db, space_id=context.space.id, document_id=document_id, user=context.user
|
||||
)
|
||||
return summary(document)
|
||||
@@ -0,0 +1,921 @@
|
||||
import hashlib
|
||||
import uuid
|
||||
from dataclasses import asdict
|
||||
from datetime import date, timedelta
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.config import get_settings
|
||||
from app.db.session import session_scope
|
||||
from app.errors import AppError, conflict, not_found
|
||||
from app.identity.models import User
|
||||
from app.imports.adapters import detect_adapter, magic_kind
|
||||
from app.imports.adapters.base import SourceRow, StatementDraft, record_fingerprint
|
||||
from app.imports.blob import get_blob_store, new_object_key
|
||||
from app.imports.crypto import decrypt_file, encrypt_file
|
||||
from app.imports.models import (
|
||||
DedupCandidate,
|
||||
ParseRun,
|
||||
SourceRecord,
|
||||
SourceRecordLink,
|
||||
StatementDocument,
|
||||
)
|
||||
from app.ledger.models import Account, Operation
|
||||
from app.ledger.service import (
|
||||
create_expense,
|
||||
create_income,
|
||||
create_transfer,
|
||||
ensure_period_open_for_date,
|
||||
)
|
||||
|
||||
|
||||
def _document(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> StatementDocument:
|
||||
document = (
|
||||
db.query(StatementDocument).filter_by(id=document_id, space_id=space_id).one_or_none()
|
||||
)
|
||||
if document is None:
|
||||
raise not_found("IMPORT_NOT_FOUND", "Statement import not found")
|
||||
return document
|
||||
|
||||
|
||||
def create_document(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
filename: str,
|
||||
raw: bytes,
|
||||
account_id: uuid.UUID | None,
|
||||
) -> tuple[StatementDocument, bool]:
|
||||
settings = get_settings()
|
||||
if not raw or len(raw) > settings.max_upload_bytes:
|
||||
raise AppError(
|
||||
"File size is outside the allowed range", code="IMPORT_SIZE_INVALID", status_code=413
|
||||
)
|
||||
kind = magic_kind(raw)
|
||||
if kind is None:
|
||||
raise AppError(
|
||||
"Only XLSX and text-layer PDF statements are supported",
|
||||
code="IMPORT_TYPE_INVALID",
|
||||
status_code=415,
|
||||
)
|
||||
suffix = Path(filename).suffix.casefold()
|
||||
if suffix not in ({".xlsx"} if kind == "xlsx" else {".pdf"}):
|
||||
raise AppError(
|
||||
"File extension does not match its content",
|
||||
code="IMPORT_TYPE_MISMATCH",
|
||||
status_code=422,
|
||||
)
|
||||
if account_id is not None:
|
||||
account = db.query(Account.id).filter_by(id=account_id, space_id=space_id).one_or_none()
|
||||
if account is None:
|
||||
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
|
||||
digest = hashlib.sha256(raw).hexdigest()
|
||||
existing = (
|
||||
db.query(StatementDocument)
|
||||
.filter_by(space_id=space_id, content_sha256=digest)
|
||||
.one_or_none()
|
||||
)
|
||||
if existing is not None:
|
||||
return existing, False
|
||||
encrypted = encrypt_file(raw)
|
||||
object_key = new_object_key()
|
||||
get_blob_store().put(object_key, encrypted.ciphertext)
|
||||
try:
|
||||
document = StatementDocument(
|
||||
space_id=space_id,
|
||||
account_id=account_id,
|
||||
original_filename=Path(filename).name[:300],
|
||||
size_bytes=len(raw),
|
||||
content_sha256=digest,
|
||||
object_key=object_key,
|
||||
dek_wrapped=encrypted.dek_wrapped,
|
||||
dek_wrap_nonce=encrypted.wrap_nonce,
|
||||
content_nonce=encrypted.content_nonce,
|
||||
status="queued",
|
||||
uploaded_by_user_id=user.id,
|
||||
)
|
||||
db.add(document)
|
||||
db.flush()
|
||||
except Exception:
|
||||
get_blob_store().delete(object_key)
|
||||
raise
|
||||
audit.record(
|
||||
db,
|
||||
event_type="import.uploaded",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"document_id": str(document.id), "size_bytes": len(raw)},
|
||||
)
|
||||
return document, True
|
||||
|
||||
|
||||
def _draft_json(draft: StatementDraft) -> dict[str, Any]:
|
||||
value = asdict(draft)
|
||||
for key in ("period_start", "period_end"):
|
||||
item = value[key]
|
||||
value[key] = item.isoformat() if item else None
|
||||
for row in value["rows"]:
|
||||
for key in ("occurred_on", "posted_on"):
|
||||
item = row[key]
|
||||
row[key] = item.isoformat() if item else None
|
||||
return value
|
||||
|
||||
|
||||
def _source_from_row(
|
||||
*, space_id: uuid.UUID, document_id: uuid.UUID, run_id: uuid.UUID, row: SourceRow
|
||||
) -> SourceRecord:
|
||||
critical_warnings = {"occurred_date_missing", "amount_unparseable"}
|
||||
warnings = list(row.warnings)
|
||||
if row.direction not in {"in", "out"}:
|
||||
warnings.append("direction_invalid")
|
||||
if row.amount_kopecks == 0:
|
||||
warnings.append("amount_zero")
|
||||
return SourceRecord(
|
||||
space_id=space_id,
|
||||
document_id=document_id,
|
||||
parse_run_id=run_id,
|
||||
external_id=row.external_id,
|
||||
occurred_on=row.occurred_on,
|
||||
posted_on=row.posted_on,
|
||||
amount_kopecks=abs(row.amount_kopecks),
|
||||
direction=row.direction,
|
||||
description=row.description,
|
||||
mcc=row.mcc,
|
||||
bank_category=row.bank_category,
|
||||
location=row.location,
|
||||
row_ref=row.row_ref,
|
||||
field_warnings={"codes": sorted(set(warnings))} if warnings else None,
|
||||
critical=(
|
||||
bool(set(warnings) & critical_warnings)
|
||||
or row.amount_kopecks == 0
|
||||
or row.direction not in {"in", "out"}
|
||||
),
|
||||
record_fp=record_fingerprint(row.occurred_on, abs(row.amount_kopecks), row.description),
|
||||
)
|
||||
|
||||
|
||||
def _build_candidates(db: Session, *, document: StatementDocument, run: ParseRun) -> bool:
|
||||
pending = False
|
||||
for source in db.query(SourceRecord).filter_by(parse_run_id=run.id).all():
|
||||
duplicate = None
|
||||
if source.external_id:
|
||||
matches = (
|
||||
db.query(SourceRecord)
|
||||
.join(ParseRun, ParseRun.id == SourceRecord.parse_run_id)
|
||||
.filter(
|
||||
SourceRecord.space_id == document.space_id,
|
||||
SourceRecord.id != source.id,
|
||||
SourceRecord.external_id == source.external_id,
|
||||
ParseRun.adapter_id == run.adapter_id,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
duplicate = matches[0] if len(matches) == 1 else None
|
||||
if duplicate is None:
|
||||
matches = (
|
||||
db.query(SourceRecord)
|
||||
.filter(
|
||||
SourceRecord.space_id == document.space_id,
|
||||
SourceRecord.id != source.id,
|
||||
SourceRecord.record_fp == source.record_fp,
|
||||
SourceRecord.direction == source.direction,
|
||||
)
|
||||
.all()
|
||||
)
|
||||
duplicate = matches[0] if len(matches) == 1 else None
|
||||
if duplicate is not None:
|
||||
link = db.query(SourceRecordLink).filter_by(source_record_id=duplicate.id).one_or_none()
|
||||
db.add(
|
||||
DedupCandidate(
|
||||
space_id=document.space_id,
|
||||
source_record_id=source.id,
|
||||
candidate_kind="duplicate",
|
||||
existing_source_record_id=duplicate.id,
|
||||
existing_operation_id=link.operation_id if link else None,
|
||||
confidence="strong" if source.external_id else "possible",
|
||||
)
|
||||
)
|
||||
pending = True
|
||||
continue
|
||||
if source.occurred_on is None:
|
||||
continue
|
||||
opposite = "in" if source.direction == "out" else "out"
|
||||
transfer_matches = (
|
||||
db.query(SourceRecord)
|
||||
.join(SourceRecordLink, SourceRecordLink.source_record_id == SourceRecord.id)
|
||||
.filter(
|
||||
SourceRecord.space_id == document.space_id,
|
||||
SourceRecord.id != source.id,
|
||||
SourceRecord.amount_kopecks == source.amount_kopecks,
|
||||
SourceRecord.direction == opposite,
|
||||
SourceRecord.occurred_on >= source.occurred_on - timedelta(days=1),
|
||||
SourceRecord.occurred_on <= source.occurred_on + timedelta(days=1),
|
||||
)
|
||||
.all()
|
||||
)
|
||||
if len(transfer_matches) == 1:
|
||||
counterpart = transfer_matches[0]
|
||||
link = db.query(SourceRecordLink).filter_by(source_record_id=counterpart.id).one()
|
||||
db.add(
|
||||
DedupCandidate(
|
||||
space_id=document.space_id,
|
||||
source_record_id=source.id,
|
||||
candidate_kind="transfer",
|
||||
existing_source_record_id=counterpart.id,
|
||||
existing_operation_id=link.operation_id,
|
||||
confidence="possible",
|
||||
)
|
||||
)
|
||||
pending = True
|
||||
return pending
|
||||
|
||||
|
||||
def process_statement_document(document_id: uuid.UUID, space_id: uuid.UUID) -> str:
|
||||
with session_scope(space_id) as db:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
if document.status in {"confirmed", "deleted"}:
|
||||
return document.status
|
||||
document.status = "processing"
|
||||
run = ParseRun(space_id=space_id, document_id=document.id, status="running")
|
||||
db.add(run)
|
||||
db.flush()
|
||||
try:
|
||||
if not all((document.dek_wrapped, document.dek_wrap_nonce, document.content_nonce)):
|
||||
raise AppError(
|
||||
"Document key material is missing",
|
||||
code="IMPORT_DECRYPT_FAILED",
|
||||
status_code=409,
|
||||
)
|
||||
assert document.dek_wrapped is not None
|
||||
assert document.dek_wrap_nonce is not None
|
||||
assert document.content_nonce is not None
|
||||
raw = decrypt_file(
|
||||
ciphertext=get_blob_store().get(document.object_key),
|
||||
content_nonce=document.content_nonce,
|
||||
dek_wrapped=document.dek_wrapped,
|
||||
wrap_nonce=document.dek_wrap_nonce,
|
||||
)
|
||||
kind = magic_kind(raw)
|
||||
if kind is None:
|
||||
raise AppError(
|
||||
"Unsupported document content", code="IMPORT_TYPE_INVALID", status_code=415
|
||||
)
|
||||
adapter = detect_adapter(raw, kind)
|
||||
draft = adapter.parse(raw)
|
||||
run.adapter_id = adapter.adapter_id
|
||||
run.parser_version = draft.parser_version
|
||||
run.draft = _draft_json(draft)
|
||||
run.status = "succeeded"
|
||||
document.adapter_id = adapter.adapter_id
|
||||
document.account_fingerprint = draft.account_fingerprint
|
||||
document.active_parse_run_id = run.id
|
||||
document.reconciliation = {
|
||||
"currency": draft.currency,
|
||||
"opening_balance_kopecks": draft.opening_balance_kopecks,
|
||||
"closing_balance_kopecks": draft.closing_balance_kopecks,
|
||||
"full": draft.opening_balance_kopecks is not None
|
||||
and draft.closing_balance_kopecks is not None,
|
||||
"confirmed_without_full_reconciliation": False,
|
||||
}
|
||||
if document.reconciliation["full"]:
|
||||
signed_total = sum(
|
||||
row.amount_kopecks if row.direction == "in" else -row.amount_kopecks
|
||||
for row in draft.rows
|
||||
)
|
||||
computed = int(draft.opening_balance_kopecks or 0) + signed_total
|
||||
document.reconciliation["computed_closing_kopecks"] = computed
|
||||
document.reconciliation["matched"] = computed == draft.closing_balance_kopecks
|
||||
if draft.account_fingerprint and document.account_id is None:
|
||||
matches = (
|
||||
db.query(Account)
|
||||
.filter_by(space_id=space_id, fingerprint=draft.account_fingerprint)
|
||||
.all()
|
||||
)
|
||||
if len(matches) == 1:
|
||||
document.account_id = matches[0].id
|
||||
for row in draft.rows:
|
||||
db.add(
|
||||
_source_from_row(
|
||||
space_id=space_id, document_id=document.id, run_id=run.id, row=row
|
||||
)
|
||||
)
|
||||
db.flush()
|
||||
has_candidates = _build_candidates(db, document=document, run=run)
|
||||
critical = (
|
||||
db.query(SourceRecord.id).filter_by(parse_run_id=run.id, critical=True).first()
|
||||
)
|
||||
document.needs_account_mapping = document.account_id is None
|
||||
needs_manual_reconciliation = not document.reconciliation["full"]
|
||||
document.status = (
|
||||
"review_required"
|
||||
if critical
|
||||
or document.needs_account_mapping
|
||||
or needs_manual_reconciliation
|
||||
or has_candidates
|
||||
or document.reconciliation.get("matched") is False
|
||||
else "ready"
|
||||
)
|
||||
except AppError as exc:
|
||||
run.status = "failed"
|
||||
run.error_code = exc.code
|
||||
document.status = "failed"
|
||||
document.error_code = exc.code
|
||||
except Exception:
|
||||
run.status = "failed"
|
||||
run.error_code = "IMPORT_PARSE_FAILED"
|
||||
document.status = "failed"
|
||||
document.error_code = "IMPORT_PARSE_FAILED"
|
||||
return document.status
|
||||
|
||||
|
||||
def list_documents(db: Session, *, space_id: uuid.UUID) -> list[StatementDocument]:
|
||||
return (
|
||||
db.query(StatementDocument)
|
||||
.filter_by(space_id=space_id)
|
||||
.order_by(StatementDocument.created_at.desc())
|
||||
.all()
|
||||
)
|
||||
|
||||
|
||||
def document_detail(db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID) -> dict:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
records = []
|
||||
if document.active_parse_run_id:
|
||||
records = (
|
||||
db.query(SourceRecord)
|
||||
.filter_by(parse_run_id=document.active_parse_run_id)
|
||||
.order_by(SourceRecord.created_at)
|
||||
.all()
|
||||
)
|
||||
return {
|
||||
"id": document.id,
|
||||
"status": document.status,
|
||||
"adapter_id": document.adapter_id,
|
||||
"account_id": document.account_id,
|
||||
"needs_account_mapping": document.needs_account_mapping,
|
||||
"error_code": document.error_code,
|
||||
"reconciliation": document.reconciliation,
|
||||
"records": [
|
||||
{
|
||||
"id": item.id,
|
||||
"external_id": item.external_id,
|
||||
"occurred_on": item.occurred_on,
|
||||
"posted_on": item.posted_on,
|
||||
"amount_kopecks": item.amount_kopecks,
|
||||
"direction": item.direction,
|
||||
"description": item.description,
|
||||
"critical": item.critical,
|
||||
"warnings": item.field_warnings,
|
||||
"review_decision": item.review_decision,
|
||||
"duplicate_operation_id": item.duplicate_operation_id,
|
||||
}
|
||||
for item in records
|
||||
],
|
||||
}
|
||||
|
||||
|
||||
def decide_source_record(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
document_id: uuid.UUID,
|
||||
source_record_id: uuid.UUID,
|
||||
decision: str,
|
||||
duplicate_operation_id: uuid.UUID | None,
|
||||
user: User,
|
||||
) -> None:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
source = (
|
||||
db.query(SourceRecord)
|
||||
.filter_by(
|
||||
id=source_record_id,
|
||||
space_id=space_id,
|
||||
parse_run_id=document.active_parse_run_id,
|
||||
)
|
||||
.one_or_none()
|
||||
)
|
||||
if source is None:
|
||||
raise not_found("IMPORT_SOURCE_RECORD_NOT_FOUND", "Import row not found")
|
||||
if decision not in {"create", "discard", "duplicate", "transfer"}:
|
||||
raise conflict("IMPORT_DECISION_INVALID", "Unsupported row decision")
|
||||
candidates = db.query(DedupCandidate).filter_by(source_record_id=source.id).all()
|
||||
matching = [item for item in candidates if item.candidate_kind == decision]
|
||||
if decision == "duplicate" and duplicate_operation_id is None and len(matching) == 1:
|
||||
duplicate_operation_id = matching[0].existing_operation_id
|
||||
if decision in {"duplicate", "transfer"} and len(matching) != 1:
|
||||
raise conflict("IMPORT_CANDIDATE_AMBIGUOUS", "A unique matching candidate is required")
|
||||
if decision == "duplicate":
|
||||
operation = (
|
||||
db.query(Operation)
|
||||
.filter_by(id=duplicate_operation_id, space_id=space_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if operation is None:
|
||||
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Duplicate operation not found")
|
||||
source.review_decision = decision
|
||||
source.duplicate_operation_id = duplicate_operation_id
|
||||
for candidate in candidates:
|
||||
if candidate not in matching:
|
||||
candidate.resolution = "is_new"
|
||||
elif decision == "duplicate":
|
||||
candidate.resolution = "is_duplicate"
|
||||
else:
|
||||
candidate.resolution = "accepted_transfer"
|
||||
candidate.decided_by_user_id = user.id
|
||||
if source.critical and decision in {"discard", "duplicate"}:
|
||||
source.critical = False
|
||||
unresolved = (
|
||||
db.query(SourceRecord.id)
|
||||
.filter_by(parse_run_id=document.active_parse_run_id, critical=True)
|
||||
.first()
|
||||
)
|
||||
pending_candidate = (
|
||||
db.query(DedupCandidate.id)
|
||||
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
|
||||
.filter(
|
||||
SourceRecord.parse_run_id == document.active_parse_run_id,
|
||||
DedupCandidate.resolution == "pending",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
reconciliation = document.reconciliation or {}
|
||||
reconciled = reconciliation.get("full") and reconciliation.get("matched") is not False
|
||||
reconciled = reconciled or reconciliation.get("confirmed_without_full_reconciliation")
|
||||
if not unresolved and not pending_candidate and document.account_id and reconciled:
|
||||
document.status = "ready"
|
||||
|
||||
|
||||
def set_review_confirmation(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
document_id: uuid.UUID,
|
||||
account_id: uuid.UUID,
|
||||
confirmed_without_full_reconciliation: bool,
|
||||
reason: str | None,
|
||||
) -> StatementDocument:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
|
||||
if account is None:
|
||||
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
|
||||
if confirmed_without_full_reconciliation and not reason:
|
||||
raise conflict("IMPORT_RECONCILIATION_REASON_REQUIRED", "A confirmation reason is required")
|
||||
document.account_id = account.id
|
||||
document.needs_account_mapping = False
|
||||
reconciliation = dict(document.reconciliation or {})
|
||||
reconciliation["confirmed_without_full_reconciliation"] = confirmed_without_full_reconciliation
|
||||
reconciliation["reason"] = reason
|
||||
document.reconciliation = reconciliation
|
||||
critical = (
|
||||
db.query(SourceRecord.id)
|
||||
.filter_by(parse_run_id=document.active_parse_run_id, critical=True)
|
||||
.first()
|
||||
)
|
||||
pending_candidate = (
|
||||
db.query(DedupCandidate.id)
|
||||
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
|
||||
.filter(
|
||||
SourceRecord.parse_run_id == document.active_parse_run_id,
|
||||
DedupCandidate.resolution == "pending",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if not critical and not pending_candidate and reconciliation.get("matched") is not False:
|
||||
document.status = "ready"
|
||||
return document
|
||||
|
||||
|
||||
def confirm_document(
|
||||
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
|
||||
) -> StatementDocument:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
db.refresh(document, with_for_update=True)
|
||||
if document.status == "confirmed":
|
||||
return document
|
||||
if document.status != "ready" or document.account_id is None:
|
||||
raise conflict("IMPORT_NOT_READY", "Import review is not complete")
|
||||
reconciliation = document.reconciliation or {}
|
||||
if not reconciliation.get("full") and not reconciliation.get(
|
||||
"confirmed_without_full_reconciliation"
|
||||
):
|
||||
raise conflict("IMPORT_RECONCILIATION_REQUIRED", "Reconciliation must be confirmed")
|
||||
if reconciliation.get("full") and reconciliation.get("matched") is False:
|
||||
raise conflict("IMPORT_RECONCILIATION_MISMATCH", "Statement arithmetic does not reconcile")
|
||||
pending = (
|
||||
db.query(DedupCandidate.id)
|
||||
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
|
||||
.filter(
|
||||
SourceRecord.parse_run_id == document.active_parse_run_id,
|
||||
DedupCandidate.resolution == "pending",
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if pending:
|
||||
raise conflict("IMPORT_DECISIONS_REQUIRED", "Duplicate or transfer decisions remain")
|
||||
records = db.query(SourceRecord).filter_by(parse_run_id=document.active_parse_run_id).all()
|
||||
if any(item.critical for item in records):
|
||||
raise conflict("IMPORT_FIELDS_UNRESOLVED", "Critical source fields remain unresolved")
|
||||
for source in records:
|
||||
if source.review_decision == "discard":
|
||||
continue
|
||||
if source.review_decision == "transfer":
|
||||
candidate = (
|
||||
db.query(DedupCandidate)
|
||||
.filter_by(
|
||||
source_record_id=source.id,
|
||||
candidate_kind="transfer",
|
||||
resolution="accepted_transfer",
|
||||
)
|
||||
.one()
|
||||
)
|
||||
assert candidate.existing_source_record_id is not None
|
||||
counterpart = db.get(SourceRecord, candidate.existing_source_record_id)
|
||||
old_link = (
|
||||
db.query(SourceRecordLink)
|
||||
.filter_by(source_record_id=candidate.existing_source_record_id)
|
||||
.one()
|
||||
)
|
||||
old_operation = db.get(Operation, old_link.operation_id)
|
||||
if (
|
||||
counterpart is None
|
||||
or old_operation is None
|
||||
or old_operation.account_id is None
|
||||
or old_operation.type not in {"income", "expense"}
|
||||
):
|
||||
raise conflict(
|
||||
"IMPORT_TRANSFER_TARGET_MISSING",
|
||||
"Transfer counterpart is unavailable",
|
||||
)
|
||||
other_links = (
|
||||
db.query(SourceRecordLink.id)
|
||||
.filter(
|
||||
SourceRecordLink.operation_id == old_operation.id,
|
||||
SourceRecordLink.id != old_link.id,
|
||||
)
|
||||
.first()
|
||||
)
|
||||
if other_links:
|
||||
raise conflict(
|
||||
"IMPORT_TRANSFER_TARGET_AMBIGUOUS",
|
||||
"Counterpart has multiple links",
|
||||
)
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=old_operation.occurred_on)
|
||||
from app.budgeting.models import ExpectedIncomeMatch
|
||||
|
||||
has_income_match = (
|
||||
db.query(ExpectedIncomeMatch.id).filter_by(operation_id=old_operation.id).first()
|
||||
)
|
||||
has_refund = (
|
||||
db.query(Operation.id)
|
||||
.filter_by(parent_operation_id=old_operation.id, status="confirmed")
|
||||
.first()
|
||||
)
|
||||
if has_income_match or has_refund:
|
||||
raise conflict(
|
||||
"IMPORT_TRANSFER_TARGET_HAS_DEPENDENCIES",
|
||||
"Unlink matches and refunds before converting this operation to a transfer",
|
||||
)
|
||||
candidate.replacement_snapshot = {
|
||||
"type": old_operation.type,
|
||||
"account_id": str(old_operation.account_id),
|
||||
"amount_kopecks": old_operation.amount_kopecks,
|
||||
"occurred_on": old_operation.occurred_on.isoformat(),
|
||||
"posted_on": old_operation.posted_on.isoformat()
|
||||
if old_operation.posted_on
|
||||
else None,
|
||||
"category_id": str(old_operation.category_id)
|
||||
if old_operation.category_id
|
||||
else None,
|
||||
"income_source_id": str(old_operation.income_source_id)
|
||||
if old_operation.income_source_id
|
||||
else None,
|
||||
"payee_id": str(old_operation.payee_id) if old_operation.payee_id else None,
|
||||
"description": old_operation.description,
|
||||
"request_key": old_operation.request_key,
|
||||
"link_kind": old_link.kind,
|
||||
}
|
||||
source_account_id = (
|
||||
document.account_id if source.direction == "out" else old_operation.account_id
|
||||
)
|
||||
target_account_id = (
|
||||
old_operation.account_id if source.direction == "out" else document.account_id
|
||||
)
|
||||
db.query(DedupCandidate).filter_by(existing_operation_id=old_operation.id).update(
|
||||
{DedupCandidate.existing_operation_id: None}
|
||||
)
|
||||
db.delete(old_link)
|
||||
db.delete(old_operation)
|
||||
db.flush()
|
||||
assert source.occurred_on is not None
|
||||
operation = create_transfer(
|
||||
db,
|
||||
space_id=space_id,
|
||||
user=user,
|
||||
source_account_id=source_account_id,
|
||||
target_account_id=target_account_id,
|
||||
amount_kopecks=source.amount_kopecks,
|
||||
occurred_on=source.occurred_on,
|
||||
posted_on=source.posted_on,
|
||||
description=source.description,
|
||||
request_key=f"import-transfer:{source.id}",
|
||||
)
|
||||
db.add_all(
|
||||
[
|
||||
SourceRecordLink(
|
||||
space_id=space_id,
|
||||
source_record_id=source.id,
|
||||
operation_id=operation.id,
|
||||
kind="transfer",
|
||||
),
|
||||
SourceRecordLink(
|
||||
space_id=space_id,
|
||||
source_record_id=counterpart.id,
|
||||
operation_id=operation.id,
|
||||
kind="transfer",
|
||||
),
|
||||
]
|
||||
)
|
||||
continue
|
||||
if source.review_decision == "duplicate" and source.duplicate_operation_id:
|
||||
duplicate_operation = (
|
||||
db.query(Operation)
|
||||
.filter_by(id=source.duplicate_operation_id, space_id=space_id)
|
||||
.one_or_none()
|
||||
)
|
||||
if duplicate_operation is None:
|
||||
raise conflict(
|
||||
"IMPORT_DUPLICATE_TARGET_MISSING",
|
||||
"Duplicate target no longer exists",
|
||||
)
|
||||
db.add(
|
||||
SourceRecordLink(
|
||||
space_id=space_id,
|
||||
source_record_id=source.id,
|
||||
operation_id=duplicate_operation.id,
|
||||
kind="duplicate",
|
||||
)
|
||||
)
|
||||
continue
|
||||
existing_links = (
|
||||
db.query(SourceRecordLink)
|
||||
.join(SourceRecord, SourceRecord.id == SourceRecordLink.source_record_id)
|
||||
.filter(
|
||||
SourceRecord.space_id == space_id,
|
||||
SourceRecord.external_id == source.external_id,
|
||||
SourceRecord.external_id.is_not(None),
|
||||
)
|
||||
.all()
|
||||
if source.external_id
|
||||
else []
|
||||
)
|
||||
linked_operation = (
|
||||
db.get(Operation, existing_links[0].operation_id) if len(existing_links) == 1 else None
|
||||
)
|
||||
kind = "duplicate"
|
||||
if linked_operation is None:
|
||||
assert source.occurred_on is not None
|
||||
if source.direction == "in":
|
||||
linked_operation = create_income(
|
||||
db,
|
||||
space_id=space_id,
|
||||
user=user,
|
||||
account_id=document.account_id,
|
||||
amount_kopecks=source.amount_kopecks,
|
||||
occurred_on=source.occurred_on,
|
||||
posted_on=source.posted_on,
|
||||
description=source.description,
|
||||
request_key=f"import:{source.id}",
|
||||
)
|
||||
else:
|
||||
linked_operation = create_expense(
|
||||
db,
|
||||
space_id=space_id,
|
||||
user=user,
|
||||
account_id=document.account_id,
|
||||
amount_kopecks=source.amount_kopecks,
|
||||
occurred_on=source.occurred_on,
|
||||
posted_on=source.posted_on,
|
||||
description=source.description,
|
||||
request_key=f"import:{source.id}",
|
||||
)
|
||||
kind = "created"
|
||||
db.add(
|
||||
SourceRecordLink(
|
||||
space_id=space_id,
|
||||
source_record_id=source.id,
|
||||
operation_id=linked_operation.id,
|
||||
kind=kind,
|
||||
)
|
||||
)
|
||||
document.status = "confirmed"
|
||||
audit.record(
|
||||
db,
|
||||
event_type="import.confirmed",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"document_id": str(document.id), "records": len(records)},
|
||||
)
|
||||
return document
|
||||
|
||||
|
||||
def prepare_reparse(
|
||||
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
|
||||
) -> StatementDocument:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
if document.status == "confirmed":
|
||||
raise conflict(
|
||||
"IMPORT_REPARSE_CONFIRMED",
|
||||
"Revert the confirmed import before reparsing its source",
|
||||
)
|
||||
if document.status == "deleted":
|
||||
raise conflict("IMPORT_SOURCE_DELETED", "Deleted source files cannot be reparsed")
|
||||
if document.status in {"queued", "processing"}:
|
||||
return document
|
||||
document.status = "queued"
|
||||
document.error_code = None
|
||||
audit.record(
|
||||
db,
|
||||
event_type="import.reparse_queued",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"document_id": str(document.id)},
|
||||
)
|
||||
return document
|
||||
|
||||
|
||||
def revert_document(
|
||||
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
|
||||
) -> StatementDocument:
|
||||
from app.budgeting.models import BudgetPeriod
|
||||
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
if document.status != "confirmed":
|
||||
return document
|
||||
links = (
|
||||
db.query(SourceRecordLink)
|
||||
.join(SourceRecord, SourceRecord.id == SourceRecordLink.source_record_id)
|
||||
.filter(SourceRecord.parse_run_id == document.active_parse_run_id)
|
||||
.all()
|
||||
)
|
||||
transfer_candidates = (
|
||||
db.query(DedupCandidate)
|
||||
.join(SourceRecord, SourceRecord.id == DedupCandidate.source_record_id)
|
||||
.filter(
|
||||
SourceRecord.parse_run_id == document.active_parse_run_id,
|
||||
DedupCandidate.candidate_kind == "transfer",
|
||||
DedupCandidate.resolution == "accepted_transfer",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
created = [db.get(Operation, link.operation_id) for link in links if link.kind == "created"]
|
||||
transfer_operations = [
|
||||
db.get(Operation, link.operation_id) for link in links if link.kind == "transfer"
|
||||
]
|
||||
affected = {
|
||||
(operation.occurred_on.year, operation.occurred_on.month)
|
||||
for operation in created + transfer_operations
|
||||
if operation is not None
|
||||
}
|
||||
for candidate in transfer_candidates:
|
||||
candidate_snapshot = candidate.replacement_snapshot or {}
|
||||
if candidate_snapshot.get("occurred_on"):
|
||||
original_day = date.fromisoformat(candidate_snapshot["occurred_on"])
|
||||
affected.add((original_day.year, original_day.month))
|
||||
closed = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter(
|
||||
BudgetPeriod.space_id == space_id,
|
||||
BudgetPeriod.status == "closed",
|
||||
)
|
||||
.all()
|
||||
)
|
||||
impacted = [item for item in closed if (item.year, item.month) in affected]
|
||||
if impacted:
|
||||
raise conflict(
|
||||
"IMPORT_REVERT_REQUIRES_REOPEN",
|
||||
"Affected budget periods must be reopened before reverting",
|
||||
details={"period_ids": [str(item.id) for item in impacted]},
|
||||
)
|
||||
created_ids = [
|
||||
operation.id for operation in created + transfer_operations if operation is not None
|
||||
]
|
||||
dependent_refund = (
|
||||
db.query(Operation.id)
|
||||
.filter(Operation.parent_operation_id.in_(created_ids), Operation.status == "confirmed")
|
||||
.first()
|
||||
if created_ids
|
||||
else None
|
||||
)
|
||||
if dependent_refund:
|
||||
raise conflict(
|
||||
"IMPORT_REVERT_HAS_DEPENDENCIES",
|
||||
"Void dependent refunds before reverting this import",
|
||||
)
|
||||
handled_link_ids: set[uuid.UUID] = set()
|
||||
for candidate in transfer_candidates:
|
||||
snapshot = candidate.replacement_snapshot
|
||||
if not snapshot or candidate.existing_source_record_id is None:
|
||||
raise conflict(
|
||||
"IMPORT_REVERT_SNAPSHOT_MISSING",
|
||||
"Transfer replacement metadata is unavailable",
|
||||
)
|
||||
current_link = (
|
||||
db.query(SourceRecordLink)
|
||||
.filter_by(source_record_id=candidate.source_record_id, kind="transfer")
|
||||
.one()
|
||||
)
|
||||
transfer_operation = db.get(Operation, current_link.operation_id)
|
||||
if transfer_operation is None:
|
||||
raise conflict("IMPORT_REVERT_OPERATION_MISSING", "Transfer operation is unavailable")
|
||||
transfer_links = (
|
||||
db.query(SourceRecordLink)
|
||||
.filter_by(operation_id=transfer_operation.id, kind="transfer")
|
||||
.all()
|
||||
)
|
||||
handled_link_ids.update(link.id for link in transfer_links)
|
||||
for transfer_link in transfer_links:
|
||||
db.delete(transfer_link)
|
||||
db.delete(transfer_operation)
|
||||
db.flush()
|
||||
account_id = uuid.UUID(snapshot["account_id"])
|
||||
occurred_on = date.fromisoformat(snapshot["occurred_on"])
|
||||
posted_on = date.fromisoformat(snapshot["posted_on"]) if snapshot["posted_on"] else None
|
||||
if snapshot["type"] == "income":
|
||||
restored = create_income(
|
||||
db,
|
||||
space_id=space_id,
|
||||
user=user,
|
||||
account_id=account_id,
|
||||
amount_kopecks=int(snapshot["amount_kopecks"]),
|
||||
occurred_on=occurred_on,
|
||||
posted_on=posted_on,
|
||||
income_source_id=(
|
||||
uuid.UUID(snapshot["income_source_id"])
|
||||
if snapshot["income_source_id"]
|
||||
else None
|
||||
),
|
||||
description=snapshot["description"],
|
||||
request_key=snapshot["request_key"],
|
||||
)
|
||||
else:
|
||||
restored = create_expense(
|
||||
db,
|
||||
space_id=space_id,
|
||||
user=user,
|
||||
account_id=account_id,
|
||||
amount_kopecks=int(snapshot["amount_kopecks"]),
|
||||
occurred_on=occurred_on,
|
||||
posted_on=posted_on,
|
||||
category_id=(
|
||||
uuid.UUID(snapshot["category_id"]) if snapshot["category_id"] else None
|
||||
),
|
||||
payee_id=uuid.UUID(snapshot["payee_id"]) if snapshot["payee_id"] else None,
|
||||
description=snapshot["description"],
|
||||
request_key=snapshot["request_key"],
|
||||
)
|
||||
db.add(
|
||||
SourceRecordLink(
|
||||
space_id=space_id,
|
||||
source_record_id=candidate.existing_source_record_id,
|
||||
operation_id=restored.id,
|
||||
kind=snapshot["link_kind"],
|
||||
)
|
||||
)
|
||||
for link in links:
|
||||
if link.id in handled_link_ids:
|
||||
continue
|
||||
operation = db.get(Operation, link.operation_id)
|
||||
db.delete(link)
|
||||
if link.kind == "created" and operation is not None:
|
||||
db.delete(operation)
|
||||
document.status = "ready"
|
||||
audit.record(
|
||||
db,
|
||||
event_type="import.reverted",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"document_id": str(document.id)},
|
||||
)
|
||||
return document
|
||||
|
||||
|
||||
def delete_source_file(
|
||||
db: Session, *, space_id: uuid.UUID, document_id: uuid.UUID, user: User
|
||||
) -> StatementDocument:
|
||||
document = _document(db, space_id=space_id, document_id=document_id)
|
||||
document.dek_wrapped = None
|
||||
document.dek_wrap_nonce = None
|
||||
document.content_nonce = None
|
||||
document.status = "deleted"
|
||||
audit.record(
|
||||
db,
|
||||
event_type="import.source_deleted",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"document_id": str(document.id)},
|
||||
)
|
||||
return document
|
||||
@@ -0,0 +1,22 @@
|
||||
import uuid
|
||||
|
||||
from app.imports.celery_app import celery_app
|
||||
|
||||
|
||||
@celery_app.task(name="imports.process_statement", bind=True, max_retries=3, default_retry_delay=10)
|
||||
def process_statement_task(self, document_id: str, space_id: str) -> str:
|
||||
from app.imports.service import process_statement_document
|
||||
|
||||
try:
|
||||
status = process_statement_document(uuid.UUID(document_id), uuid.UUID(space_id))
|
||||
if status == "failed":
|
||||
raise RuntimeError("statement parsing failed")
|
||||
except Exception as exc:
|
||||
if self.request.retries < 3:
|
||||
raise self.retry(exc=exc) from exc
|
||||
raise
|
||||
return document_id
|
||||
|
||||
|
||||
def enqueue_process_statement(document_id: uuid.UUID, space_id: uuid.UUID) -> None:
|
||||
process_statement_task.delay(str(document_id), str(space_id))
|
||||
@@ -0,0 +1,181 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
CheckConstraint,
|
||||
Date,
|
||||
ForeignKey,
|
||||
ForeignKeyConstraint,
|
||||
Integer,
|
||||
String,
|
||||
UniqueConstraint,
|
||||
Uuid,
|
||||
)
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin
|
||||
|
||||
ACCOUNT_KINDS = ("cash", "debit", "credit")
|
||||
ACCOUNT_STATUSES = ("active", "archived")
|
||||
OPERATION_TYPES = ("expense", "income", "transfer", "refund")
|
||||
OPERATION_STATUSES = ("confirmed", "voided")
|
||||
|
||||
|
||||
class Account(Base, PkMixin, TimestampMixin):
|
||||
"""Financial account. Balance is always derived: opening + confirmed postings."""
|
||||
|
||||
__tablename__ = "accounts"
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('cash', 'debit', 'credit')", name="kind_valid"),
|
||||
CheckConstraint("status IN ('active', 'archived')", name="status_valid"),
|
||||
CheckConstraint("currency = 'RUB'", name="currency_rub_only"),
|
||||
UniqueConstraint("space_id", "id", name="uq_accounts_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
currency: Mapped[str] = mapped_column(String(3), nullable=False, default="RUB")
|
||||
opening_amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False, default=0)
|
||||
opened_on: Mapped[date] = mapped_column(Date, nullable=False)
|
||||
bank_code: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
masked_identifier: Mapped[str | None] = mapped_column(String(32), nullable=True)
|
||||
fingerprint: Mapped[str | None] = mapped_column(String(128), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
|
||||
|
||||
class Operation(Base, PkMixin, TimestampMixin):
|
||||
"""User financial fact. amount_kopecks is always positive; direction lives in postings."""
|
||||
|
||||
__tablename__ = "operations"
|
||||
__table_args__ = (
|
||||
CheckConstraint("type IN ('expense', 'income', 'transfer', 'refund')", name="type_valid"),
|
||||
CheckConstraint("status IN ('confirmed', 'voided')", name="status_valid"),
|
||||
CheckConstraint("amount_kopecks > 0", name="amount_positive"),
|
||||
CheckConstraint(
|
||||
"NOT (type = 'transfer' AND counter_account_id IS NULL)",
|
||||
name="transfer_requires_counter",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "account_id"],
|
||||
["accounts.space_id", "accounts.id"],
|
||||
name="fk_operations_account_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "counter_account_id"],
|
||||
["accounts.space_id", "accounts.id"],
|
||||
name="fk_operations_counter_account_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "category_id"],
|
||||
["categories.space_id", "categories.id"],
|
||||
name="fk_operations_category_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "income_source_id"],
|
||||
["income_sources.space_id", "income_sources.id"],
|
||||
name="fk_operations_income_source_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "payee_id"],
|
||||
["payees.space_id", "payees.id"],
|
||||
name="fk_operations_payee_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "parent_operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
name="fk_operations_parent_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "request_key", name="uq_operations_space_request_key"),
|
||||
UniqueConstraint("space_id", "id", name="uq_operations_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
type: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="confirmed")
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
counter_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
category_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True, index=True)
|
||||
income_source_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
payee_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
parent_operation_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
occurred_on: Mapped[date] = mapped_column(Date, nullable=False, index=True)
|
||||
posted_on: Mapped[date | None] = mapped_column(Date, nullable=True)
|
||||
description: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
void_reason: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
request_key: Mapped[str | None] = mapped_column(String(120), nullable=True)
|
||||
created_by_user_id: Mapped[uuid.UUID | None] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="SET NULL"), nullable=True
|
||||
)
|
||||
version: Mapped[int] = mapped_column(Integer, nullable=False, default=1)
|
||||
|
||||
__mapper_args__ = {"version_id_col": version}
|
||||
|
||||
|
||||
class Posting(Base, PkMixin, TimestampMixin):
|
||||
"""Signed balance change of one account within one operation.
|
||||
|
||||
Sign convention (uniform for asset and credit accounts, from the account's
|
||||
perspective): income and refunds post positive; expenses post negative;
|
||||
transfers post negative on the source and positive on the destination.
|
||||
Credit accounts normally carry a non-positive balance; debt = -balance.
|
||||
"""
|
||||
|
||||
__tablename__ = "postings"
|
||||
__table_args__ = (
|
||||
CheckConstraint("amount_kopecks <> 0", name="amount_nonzero"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "operation_id"],
|
||||
["operations.space_id", "operations.id"],
|
||||
ondelete="CASCADE",
|
||||
name="fk_postings_operation_tenant",
|
||||
),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "account_id"],
|
||||
["accounts.space_id", "accounts.id"],
|
||||
name="fk_postings_account_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_postings_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
operation_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
account_id: Mapped[uuid.UUID] = mapped_column(Uuid, nullable=False, index=True)
|
||||
amount_kopecks: Mapped[int] = mapped_column(BigInteger, nullable=False)
|
||||
|
||||
|
||||
class Category(Base, PkMixin, TimestampMixin):
|
||||
"""Budget category. Kept in ledger module to avoid circular imports."""
|
||||
|
||||
__tablename__ = "categories"
|
||||
__table_args__ = (
|
||||
CheckConstraint("kind IN ('monthly', 'savings_envelope')", name="kind_valid"),
|
||||
ForeignKeyConstraint(
|
||||
["space_id", "linked_account_id"],
|
||||
["accounts.space_id", "accounts.id"],
|
||||
name="fk_categories_linked_account_tenant",
|
||||
),
|
||||
UniqueConstraint("space_id", "id", name="uq_categories_space_id"),
|
||||
)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
kind: Mapped[str] = mapped_column(String(16), nullable=False)
|
||||
system_key: Mapped[str | None] = mapped_column(String(64), nullable=True)
|
||||
name_override: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
custom_name: Mapped[str | None] = mapped_column(String(200), nullable=True)
|
||||
linked_account_id: Mapped[uuid.UUID | None] = mapped_column(Uuid, nullable=True)
|
||||
archived: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False)
|
||||
@@ -0,0 +1,278 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from typing import Annotated, Any, Literal
|
||||
|
||||
from fastapi import APIRouter, Header, Query
|
||||
from pydantic import BaseModel, ConfigDict, Field
|
||||
|
||||
from app.categorization import service as categorization
|
||||
from app.categorization.models import IncomeSource, Payee
|
||||
from app.dependencies import Context, Csrf, Db
|
||||
from app.ledger import service
|
||||
from app.ledger.models import Category
|
||||
|
||||
router = APIRouter(tags=["ledger"])
|
||||
|
||||
|
||||
class AccountBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
name: str = Field(min_length=1, max_length=200)
|
||||
kind: Literal["cash", "debit", "credit"] = Field(alias="type")
|
||||
opened_on: date = Field(alias="openedOn")
|
||||
opening_amount_kopecks: int = Field(default=0, alias="openingBalanceKopecks")
|
||||
bank_code: str | None = Field(default=None, alias="bankCode", max_length=32)
|
||||
masked_identifier: str | None = Field(default=None, alias="maskedNumber", max_length=32)
|
||||
|
||||
|
||||
class OperationBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
operation_type: Literal["expense", "income", "transfer", "refund"] = Field(alias="kind")
|
||||
amount_kopecks: int = Field(alias="amountKopecks", gt=0)
|
||||
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
|
||||
source_account_id: uuid.UUID | None = Field(default=None, alias="fromAccountId")
|
||||
counter_account_id: uuid.UUID | None = Field(default=None, alias="toAccountId")
|
||||
parent_operation_id: uuid.UUID | None = Field(default=None, alias="parentOperationId")
|
||||
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
|
||||
income_source_name: str | None = Field(default=None, alias="incomeSourceName")
|
||||
payee_name: str | None = Field(default=None, alias="payeeName")
|
||||
occurred_on: date = Field(alias="occurredOn")
|
||||
posted_on: date | None = Field(default=None, alias="postedOn")
|
||||
description: str | None = Field(default=None, max_length=500)
|
||||
|
||||
|
||||
class VoidBody(BaseModel):
|
||||
reason: str = Field(min_length=1, max_length=500)
|
||||
|
||||
|
||||
class OperationPatchBody(BaseModel):
|
||||
model_config = ConfigDict(populate_by_name=True)
|
||||
|
||||
category_id: uuid.UUID | None = Field(default=None, alias="categoryId")
|
||||
|
||||
|
||||
def account_json(db: Db, account: Any) -> dict:
|
||||
return {
|
||||
"id": account.id,
|
||||
"name": account.name,
|
||||
"type": account.kind,
|
||||
"currency": account.currency,
|
||||
"openingBalanceKopecks": account.opening_amount_kopecks,
|
||||
"openedOn": account.opened_on,
|
||||
"bankCode": account.bank_code,
|
||||
"maskedNumber": account.masked_identifier,
|
||||
"status": account.status,
|
||||
"balanceKopecks": service.account_balance(db, account),
|
||||
"version": account.version,
|
||||
}
|
||||
|
||||
|
||||
def operation_json(db: Db, operation: Any) -> dict:
|
||||
category = db.get(Category, operation.category_id) if operation.category_id else None
|
||||
payee = db.get(Payee, operation.payee_id) if operation.payee_id else None
|
||||
income_source = (
|
||||
db.get(IncomeSource, operation.income_source_id) if operation.income_source_id else None
|
||||
)
|
||||
return {
|
||||
"id": operation.id,
|
||||
"kind": operation.type,
|
||||
"status": operation.status,
|
||||
"amountKopecks": operation.amount_kopecks,
|
||||
"accountId": operation.account_id,
|
||||
"counterpartyAccountId": operation.counter_account_id,
|
||||
"categoryId": operation.category_id,
|
||||
"categoryName": categorization.resolve_category_name(category) if category else None,
|
||||
"categoryKey": category.system_key if category else None,
|
||||
"payeeName": payee.name if payee else None,
|
||||
"incomeSourceName": income_source.name if income_source else None,
|
||||
"parentOperationId": operation.parent_operation_id,
|
||||
"occurredOn": operation.occurred_on,
|
||||
"postedOn": operation.posted_on,
|
||||
"description": operation.description,
|
||||
"voidReason": operation.void_reason,
|
||||
"version": operation.version,
|
||||
"createdAt": operation.created_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/accounts")
|
||||
def accounts(db: Db, context: Context) -> list[dict]:
|
||||
return [account_json(db, item) for item in service.list_accounts(db, space_id=context.space.id)]
|
||||
|
||||
|
||||
@router.post("/accounts", status_code=201)
|
||||
def create_account(body: AccountBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
account = service.create_account(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
**body.model_dump(by_alias=False),
|
||||
)
|
||||
return account_json(db, account)
|
||||
|
||||
|
||||
@router.post("/accounts/{account_id}/archive")
|
||||
def archive_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
account = service.archive_account(
|
||||
db, space_id=context.space.id, user=context.user, account_id=account_id
|
||||
)
|
||||
return account_json(db, account)
|
||||
|
||||
|
||||
@router.patch("/accounts/{account_id}")
|
||||
def patch_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
account = service.archive_account(
|
||||
db, space_id=context.space.id, user=context.user, account_id=account_id
|
||||
)
|
||||
return account_json(db, account)
|
||||
|
||||
|
||||
@router.delete("/accounts/{account_id}", status_code=204)
|
||||
def delete_account(account_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> None:
|
||||
service.delete_account(db, space_id=context.space.id, user=context.user, account_id=account_id)
|
||||
|
||||
|
||||
@router.get("/operations")
|
||||
def operations(
|
||||
db: Db,
|
||||
context: Context,
|
||||
account_id: Annotated[uuid.UUID | None, Query(alias="accountId")] = None,
|
||||
operation_type: Annotated[str | None, Query(alias="kind")] = None,
|
||||
category_id: Annotated[uuid.UUID | None, Query(alias="categoryId")] = None,
|
||||
status: str | None = None,
|
||||
date_from: Annotated[date | None, Query(alias="occurredFrom")] = None,
|
||||
date_to: Annotated[date | None, Query(alias="occurredTo")] = None,
|
||||
search: str | None = None,
|
||||
uncategorized: bool = False,
|
||||
limit: Annotated[int, Query(ge=1, le=200)] = 50,
|
||||
cursor: str | None = None,
|
||||
) -> dict:
|
||||
offset = int(cursor) if cursor and cursor.isdigit() else 0
|
||||
items, total = service.list_operations(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
account_id=account_id,
|
||||
operation_type=operation_type,
|
||||
category_id=category_id,
|
||||
status=status,
|
||||
date_from=date_from,
|
||||
date_to=date_to,
|
||||
search=search,
|
||||
uncategorized=uncategorized,
|
||||
limit=limit,
|
||||
offset=offset,
|
||||
)
|
||||
next_offset = offset + len(items)
|
||||
return {
|
||||
"items": [operation_json(db, item) for item in items],
|
||||
"nextCursor": str(next_offset) if next_offset < total else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/operations", status_code=201)
|
||||
def create_operation(
|
||||
body: OperationBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
idempotency_key: Annotated[str | None, Header(alias="Idempotency-Key")] = None,
|
||||
) -> dict:
|
||||
if body.operation_type == "expense" and body.account_id:
|
||||
payee_id = None
|
||||
if body.payee_name:
|
||||
payee_id = categorization.get_or_create_payee(
|
||||
db, space_id=context.space.id, name=body.payee_name, alias=body.payee_name
|
||||
).id
|
||||
operation = service.create_expense(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
account_id=body.account_id,
|
||||
amount_kopecks=body.amount_kopecks,
|
||||
occurred_on=body.occurred_on,
|
||||
category_id=body.category_id,
|
||||
payee_id=payee_id,
|
||||
description=body.description,
|
||||
posted_on=body.posted_on,
|
||||
request_key=idempotency_key,
|
||||
)
|
||||
elif body.operation_type == "income" and body.account_id:
|
||||
income_source_id = None
|
||||
if body.income_source_name:
|
||||
income_source_id = categorization.ensure_income_source(
|
||||
db, space_id=context.space.id, name=body.income_source_name
|
||||
).id
|
||||
operation = service.create_income(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
account_id=body.account_id,
|
||||
amount_kopecks=body.amount_kopecks,
|
||||
occurred_on=body.occurred_on,
|
||||
income_source_id=income_source_id,
|
||||
description=body.description,
|
||||
posted_on=body.posted_on,
|
||||
request_key=idempotency_key,
|
||||
)
|
||||
elif body.operation_type == "transfer" and body.source_account_id and body.counter_account_id:
|
||||
operation = service.create_transfer(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
source_account_id=body.source_account_id,
|
||||
target_account_id=body.counter_account_id,
|
||||
amount_kopecks=body.amount_kopecks,
|
||||
occurred_on=body.occurred_on,
|
||||
description=body.description,
|
||||
posted_on=body.posted_on,
|
||||
request_key=idempotency_key,
|
||||
)
|
||||
elif body.operation_type == "refund" and body.parent_operation_id:
|
||||
operation = service.create_refund(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
parent_operation_id=body.parent_operation_id,
|
||||
amount_kopecks=body.amount_kopecks,
|
||||
occurred_on=body.occurred_on,
|
||||
description=body.description,
|
||||
request_key=idempotency_key,
|
||||
)
|
||||
else:
|
||||
from app.errors import bad_request
|
||||
|
||||
raise bad_request("LEDGER_OPERATION_FIELDS_INVALID", "Required account fields are missing")
|
||||
return operation_json(db, operation)
|
||||
|
||||
|
||||
@router.post("/operations/{operation_id}/void")
|
||||
def void_operation(
|
||||
operation_id: uuid.UUID, body: VoidBody, db: Db, context: Context, _csrf: Csrf
|
||||
) -> dict:
|
||||
operation = service.void_operation(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
operation_id=operation_id,
|
||||
reason=body.reason,
|
||||
)
|
||||
return operation_json(db, operation)
|
||||
|
||||
|
||||
@router.patch("/operations/{operation_id}")
|
||||
def patch_operation(
|
||||
operation_id: uuid.UUID,
|
||||
body: OperationPatchBody,
|
||||
db: Db,
|
||||
context: Context,
|
||||
_csrf: Csrf,
|
||||
) -> dict:
|
||||
operation = service.update_operation(
|
||||
db,
|
||||
space_id=context.space.id,
|
||||
user=context.user,
|
||||
operation_id=operation_id,
|
||||
category_id=body.category_id,
|
||||
)
|
||||
return operation_json(db, operation)
|
||||
@@ -0,0 +1,552 @@
|
||||
import uuid
|
||||
from datetime import date
|
||||
from decimal import Decimal, InvalidOperation
|
||||
from typing import Any
|
||||
|
||||
from sqlalchemy import func
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.categorization import service as categorization
|
||||
from app.errors import bad_request, conflict, not_found
|
||||
from app.identity.models import User
|
||||
from app.ledger.models import ACCOUNT_KINDS, Account, Operation, Posting
|
||||
|
||||
MAX_PAGE_SIZE = 200
|
||||
UNSET = object()
|
||||
|
||||
|
||||
def to_kopecks(value: int | str | Decimal) -> int:
|
||||
"""Convert a decimal ruble amount to integer kopecks. Floats are rejected upstream."""
|
||||
if isinstance(value, int):
|
||||
return value
|
||||
try:
|
||||
dec = Decimal(str(value)).quantize(Decimal("0.01"))
|
||||
except (InvalidOperation, ValueError) as exc:
|
||||
raise bad_request("MONEY_INVALID_AMOUNT", f"Invalid amount: {value!r}") from exc
|
||||
return int(dec.scaleb(2))
|
||||
|
||||
|
||||
def get_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
|
||||
account = db.query(Account).filter_by(id=account_id, space_id=space_id).one_or_none()
|
||||
if account is None:
|
||||
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
|
||||
return account
|
||||
|
||||
|
||||
def get_operation(db: Session, *, space_id: uuid.UUID, operation_id: uuid.UUID) -> Operation:
|
||||
operation = db.query(Operation).filter_by(id=operation_id, space_id=space_id).one_or_none()
|
||||
if operation is None:
|
||||
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
|
||||
return operation
|
||||
|
||||
|
||||
def _require_active_account(db: Session, *, space_id: uuid.UUID, account_id: uuid.UUID) -> Account:
|
||||
account = get_account(db, space_id=space_id, account_id=account_id)
|
||||
if account.status != "active":
|
||||
raise conflict("LEDGER_ACCOUNT_ARCHIVED", "Account is archived")
|
||||
return account
|
||||
|
||||
|
||||
def _validate_category(db: Session, *, space_id: uuid.UUID, category_id: uuid.UUID | None) -> None:
|
||||
if category_id is None:
|
||||
return
|
||||
from app.ledger.models import Category
|
||||
|
||||
category = db.query(Category).filter_by(id=category_id, space_id=space_id).one_or_none()
|
||||
if category is None:
|
||||
raise not_found("BUDGET_CATEGORY_NOT_FOUND", "Category not found")
|
||||
if category.archived:
|
||||
raise conflict("BUDGET_CATEGORY_ARCHIVED", "Category is archived")
|
||||
|
||||
|
||||
def account_balance(db: Session, account: Account) -> int:
|
||||
total = (
|
||||
db.query(func.coalesce(func.sum(Posting.amount_kopecks), 0))
|
||||
.join(Operation, Operation.id == Posting.operation_id)
|
||||
.filter(
|
||||
Posting.account_id == account.id,
|
||||
Operation.status == "confirmed",
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return account.opening_amount_kopecks + int(total or 0)
|
||||
|
||||
|
||||
def create_account(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
name: str,
|
||||
kind: str,
|
||||
opened_on: date,
|
||||
opening_amount_kopecks: int = 0,
|
||||
bank_code: str | None = None,
|
||||
masked_identifier: str | None = None,
|
||||
) -> Account:
|
||||
if kind not in ACCOUNT_KINDS:
|
||||
raise bad_request("LEDGER_ACCOUNT_KIND_INVALID", "Unknown account kind")
|
||||
account = Account(
|
||||
space_id=space_id,
|
||||
name=name,
|
||||
kind=kind,
|
||||
opened_on=opened_on,
|
||||
opening_amount_kopecks=int(opening_amount_kopecks),
|
||||
bank_code=bank_code,
|
||||
masked_identifier=masked_identifier,
|
||||
)
|
||||
db.add(account)
|
||||
db.flush()
|
||||
audit.record(
|
||||
db,
|
||||
event_type="account.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"account_id": str(account.id), "kind": kind},
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
def _existing_by_request_key(
|
||||
db: Session, *, space_id: uuid.UUID, request_key: str | None
|
||||
) -> Operation | None:
|
||||
if request_key is None:
|
||||
return None
|
||||
return db.query(Operation).filter_by(space_id=space_id, request_key=request_key).one_or_none()
|
||||
|
||||
|
||||
def _apply_suggestion(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
operation: Operation,
|
||||
description: str | None,
|
||||
) -> None:
|
||||
"""Precedence: explicit choice (already set) > confirmed payee rule."""
|
||||
if operation.category_id is not None or not description:
|
||||
return
|
||||
payee = categorization.find_payee_by_description(db, space_id=space_id, description=description)
|
||||
if payee is None:
|
||||
return
|
||||
operation.payee_id = payee.id
|
||||
rule = categorization.rule_category_for_payee(db, space_id=space_id, payee_id=payee.id)
|
||||
if rule is not None:
|
||||
operation.category_id = rule.category_id
|
||||
|
||||
|
||||
def create_expense(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
account_id: uuid.UUID,
|
||||
amount_kopecks: int,
|
||||
occurred_on: date,
|
||||
category_id: uuid.UUID | None = None,
|
||||
payee_id: uuid.UUID | None = None,
|
||||
description: str | None = None,
|
||||
posted_on: date | None = None,
|
||||
request_key: str | None = None,
|
||||
) -> Operation:
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
account = _require_active_account(db, space_id=space_id, account_id=account_id)
|
||||
_validate_category(db, space_id=space_id, category_id=category_id)
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
||||
operation = Operation(
|
||||
space_id=space_id,
|
||||
type="expense",
|
||||
amount_kopecks=amount_kopecks,
|
||||
account_id=account.id,
|
||||
category_id=category_id,
|
||||
payee_id=payee_id,
|
||||
occurred_on=occurred_on,
|
||||
posted_on=posted_on,
|
||||
description=description,
|
||||
request_key=request_key,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(operation)
|
||||
db.flush()
|
||||
_apply_suggestion(db, space_id=space_id, operation=operation, description=description)
|
||||
db.add(
|
||||
Posting(
|
||||
space_id=space_id,
|
||||
operation_id=operation.id,
|
||||
account_id=account.id,
|
||||
amount_kopecks=-amount_kopecks,
|
||||
)
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id), "type": "expense"},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def create_income(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
account_id: uuid.UUID,
|
||||
amount_kopecks: int,
|
||||
occurred_on: date,
|
||||
income_source_id: uuid.UUID | None = None,
|
||||
description: str | None = None,
|
||||
posted_on: date | None = None,
|
||||
request_key: str | None = None,
|
||||
) -> Operation:
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
account = _require_active_account(db, space_id=space_id, account_id=account_id)
|
||||
if income_source_id is not None:
|
||||
categorization.get_income_source(db, space_id=space_id, income_source_id=income_source_id)
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
||||
operation = Operation(
|
||||
space_id=space_id,
|
||||
type="income",
|
||||
amount_kopecks=amount_kopecks,
|
||||
account_id=account.id,
|
||||
income_source_id=income_source_id,
|
||||
occurred_on=occurred_on,
|
||||
posted_on=posted_on,
|
||||
description=description,
|
||||
request_key=request_key,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(operation)
|
||||
db.flush()
|
||||
db.add(
|
||||
Posting(
|
||||
space_id=space_id,
|
||||
operation_id=operation.id,
|
||||
account_id=account.id,
|
||||
amount_kopecks=amount_kopecks,
|
||||
)
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id), "type": "income"},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def create_transfer(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
source_account_id: uuid.UUID,
|
||||
target_account_id: uuid.UUID,
|
||||
amount_kopecks: int,
|
||||
occurred_on: date,
|
||||
description: str | None = None,
|
||||
posted_on: date | None = None,
|
||||
request_key: str | None = None,
|
||||
) -> Operation:
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
if source_account_id == target_account_id:
|
||||
raise bad_request("LEDGER_TRANSFER_SAME_ACCOUNT", "Transfer requires two distinct accounts")
|
||||
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
source = _require_active_account(db, space_id=space_id, account_id=source_account_id)
|
||||
target = _require_active_account(db, space_id=space_id, account_id=target_account_id)
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
||||
operation = Operation(
|
||||
space_id=space_id,
|
||||
type="transfer",
|
||||
amount_kopecks=amount_kopecks,
|
||||
account_id=source.id,
|
||||
counter_account_id=target.id,
|
||||
occurred_on=occurred_on,
|
||||
posted_on=posted_on,
|
||||
description=description,
|
||||
request_key=request_key,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(operation)
|
||||
db.flush()
|
||||
db.add(
|
||||
Posting(
|
||||
space_id=space_id,
|
||||
operation_id=operation.id,
|
||||
account_id=source.id,
|
||||
amount_kopecks=-amount_kopecks,
|
||||
)
|
||||
)
|
||||
db.add(
|
||||
Posting(
|
||||
space_id=space_id,
|
||||
operation_id=operation.id,
|
||||
account_id=target.id,
|
||||
amount_kopecks=amount_kopecks,
|
||||
)
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id), "type": "transfer"},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def refunded_kopecks(db: Session, *, parent_operation_id: uuid.UUID) -> int:
|
||||
total = (
|
||||
db.query(func.coalesce(func.sum(Operation.amount_kopecks), 0))
|
||||
.filter(
|
||||
Operation.parent_operation_id == parent_operation_id,
|
||||
Operation.type == "refund",
|
||||
Operation.status == "confirmed",
|
||||
)
|
||||
.scalar()
|
||||
)
|
||||
return int(total or 0)
|
||||
|
||||
|
||||
def create_refund(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
parent_operation_id: uuid.UUID,
|
||||
amount_kopecks: int,
|
||||
occurred_on: date,
|
||||
description: str | None = None,
|
||||
request_key: str | None = None,
|
||||
) -> Operation:
|
||||
if amount_kopecks <= 0:
|
||||
raise bad_request("MONEY_AMOUNT_MUST_BE_POSITIVE", "Amount must be positive")
|
||||
existing = _existing_by_request_key(db, space_id=space_id, request_key=request_key)
|
||||
if existing is not None:
|
||||
return existing
|
||||
parent = (
|
||||
db.query(Operation)
|
||||
.filter_by(id=parent_operation_id, space_id=space_id)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if parent is None:
|
||||
raise not_found("LEDGER_OPERATION_NOT_FOUND", "Operation not found")
|
||||
if parent.type != "expense" or parent.status != "confirmed":
|
||||
raise conflict("LEDGER_REFUND_PARENT_INVALID", "Refund parent must be a confirmed expense")
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=occurred_on)
|
||||
remaining = parent.amount_kopecks - refunded_kopecks(db, parent_operation_id=parent.id)
|
||||
if amount_kopecks > remaining:
|
||||
raise conflict(
|
||||
"LEDGER_REFUND_EXCEEDS_PARENT",
|
||||
"Refund exceeds the unrefunded remainder of the original expense",
|
||||
details={"remaining_kopecks": remaining},
|
||||
)
|
||||
operation = Operation(
|
||||
space_id=space_id,
|
||||
type="refund",
|
||||
amount_kopecks=amount_kopecks,
|
||||
account_id=parent.account_id,
|
||||
parent_operation_id=parent.id,
|
||||
category_id=parent.category_id,
|
||||
occurred_on=occurred_on,
|
||||
description=description or parent.description,
|
||||
request_key=request_key,
|
||||
created_by_user_id=user.id,
|
||||
)
|
||||
db.add(operation)
|
||||
db.flush()
|
||||
db.add(
|
||||
Posting(
|
||||
space_id=space_id,
|
||||
operation_id=operation.id,
|
||||
account_id=parent.account_id,
|
||||
amount_kopecks=amount_kopecks,
|
||||
)
|
||||
)
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.created",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id), "type": "refund"},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def ensure_period_open_for_date(db: Session, *, space_id: uuid.UUID, day: date) -> None:
|
||||
from app.budgeting.models import BudgetPeriod
|
||||
|
||||
period = (
|
||||
db.query(BudgetPeriod)
|
||||
.filter(
|
||||
BudgetPeriod.space_id == space_id,
|
||||
BudgetPeriod.year == day.year,
|
||||
BudgetPeriod.month == day.month,
|
||||
)
|
||||
.with_for_update()
|
||||
.one_or_none()
|
||||
)
|
||||
if period is not None and period.status == "closed":
|
||||
raise conflict(
|
||||
"BUDGET_PERIOD_CLOSED",
|
||||
"Period is closed; reopen it before changing its operations",
|
||||
details={"period_id": str(period.id), "year": period.year, "month": period.month},
|
||||
)
|
||||
|
||||
|
||||
def void_operation(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, operation_id: uuid.UUID, reason: str
|
||||
) -> Operation:
|
||||
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
|
||||
if operation.status == "voided":
|
||||
return operation
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
|
||||
operation.status = "voided"
|
||||
operation.void_reason = reason
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.voided",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id)},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def update_operation(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
user: User,
|
||||
operation_id: uuid.UUID,
|
||||
category_id: Any = UNSET,
|
||||
payee_id: Any = UNSET,
|
||||
description: Any = UNSET,
|
||||
) -> Operation:
|
||||
operation = get_operation(db, space_id=space_id, operation_id=operation_id)
|
||||
if operation.status == "voided":
|
||||
raise conflict("LEDGER_OPERATION_VOIDED", "Voided operations cannot be edited")
|
||||
ensure_period_open_for_date(db, space_id=space_id, day=operation.occurred_on)
|
||||
if category_id is not UNSET:
|
||||
if category_id is not None:
|
||||
_validate_category(db, space_id=space_id, category_id=category_id)
|
||||
operation.category_id = category_id
|
||||
if payee_id is not UNSET:
|
||||
operation.payee_id = payee_id
|
||||
if description is not UNSET:
|
||||
operation.description = description
|
||||
audit.record(
|
||||
db,
|
||||
event_type="operation.updated",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"operation_id": str(operation.id)},
|
||||
)
|
||||
return operation
|
||||
|
||||
|
||||
def archive_account(
|
||||
db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID
|
||||
) -> Account:
|
||||
account = get_account(db, space_id=space_id, account_id=account_id)
|
||||
if account.status == "archived":
|
||||
return account
|
||||
if account_balance(db, account) != 0:
|
||||
raise conflict(
|
||||
"LEDGER_ACCOUNT_BALANCE_NOT_ZERO",
|
||||
"Only accounts with zero balance can be archived",
|
||||
)
|
||||
account.status = "archived"
|
||||
audit.record(
|
||||
db,
|
||||
event_type="account.archived",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"account_id": str(account.id)},
|
||||
)
|
||||
return account
|
||||
|
||||
|
||||
def delete_account(db: Session, *, space_id: uuid.UUID, user: User, account_id: uuid.UUID) -> None:
|
||||
account = get_account(db, space_id=space_id, account_id=account_id)
|
||||
used = (
|
||||
db.query(Operation.id)
|
||||
.filter((Operation.account_id == account.id) | (Operation.counter_account_id == account.id))
|
||||
.first()
|
||||
)
|
||||
if used is not None:
|
||||
raise conflict(
|
||||
"LEDGER_ACCOUNT_IN_USE",
|
||||
"Only never-used accounts can be deleted; archive instead",
|
||||
)
|
||||
db.delete(account)
|
||||
audit.record(
|
||||
db,
|
||||
event_type="account.deleted",
|
||||
space_id=space_id,
|
||||
user_id=user.id,
|
||||
detail={"account_id": str(account.id)},
|
||||
)
|
||||
|
||||
|
||||
def list_operations(
|
||||
db: Session,
|
||||
*,
|
||||
space_id: uuid.UUID,
|
||||
account_id: uuid.UUID | None = None,
|
||||
operation_type: str | None = None,
|
||||
category_id: uuid.UUID | None = None,
|
||||
status: str | None = None,
|
||||
date_from: date | None = None,
|
||||
date_to: date | None = None,
|
||||
search: str | None = None,
|
||||
uncategorized: bool = False,
|
||||
limit: int = 50,
|
||||
offset: int = 0,
|
||||
) -> tuple[list[Operation], int]:
|
||||
query = db.query(Operation).filter(Operation.space_id == space_id)
|
||||
if account_id is not None:
|
||||
query = query.filter(
|
||||
(Operation.account_id == account_id) | (Operation.counter_account_id == account_id)
|
||||
)
|
||||
if operation_type is not None:
|
||||
query = query.filter(Operation.type == operation_type)
|
||||
if category_id is not None:
|
||||
query = query.filter(Operation.category_id == category_id)
|
||||
if status is not None:
|
||||
query = query.filter(Operation.status == status)
|
||||
if date_from is not None:
|
||||
query = query.filter(Operation.occurred_on >= date_from)
|
||||
if date_to is not None:
|
||||
query = query.filter(Operation.occurred_on <= date_to)
|
||||
if search:
|
||||
escaped = search.replace("\\", "\\\\").replace("%", "\\%").replace("_", "\\_")
|
||||
query = query.filter(Operation.description.ilike(f"%{escaped}%", escape="\\"))
|
||||
if uncategorized:
|
||||
query = query.filter(Operation.type == "expense", Operation.category_id.is_(None))
|
||||
total = query.count()
|
||||
items = (
|
||||
query.order_by(Operation.occurred_on.desc(), Operation.created_at.desc())
|
||||
.offset(offset)
|
||||
.limit(min(limit, MAX_PAGE_SIZE))
|
||||
.all()
|
||||
)
|
||||
return items, total
|
||||
|
||||
|
||||
def list_accounts(db: Session, *, space_id: uuid.UUID) -> list[Account]:
|
||||
return db.query(Account).filter_by(space_id=space_id).order_by(Account.created_at).all()
|
||||
@@ -0,0 +1,79 @@
|
||||
from collections.abc import Awaitable, Callable
|
||||
|
||||
from fastapi import FastAPI, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
from sqlalchemy import text
|
||||
from sqlalchemy.orm.exc import StaleDataError
|
||||
|
||||
from app.analytics.router import router as analytics_router
|
||||
from app.budgeting.router import router as budgeting_router
|
||||
from app.db.session import get_engine
|
||||
from app.errors import AppError
|
||||
from app.exports.router import router as exports_router
|
||||
from app.identity.router import router as identity_router
|
||||
from app.imports.router import router as imports_router
|
||||
from app.ledger.router import router as ledger_router
|
||||
from app.spaces.router import router as spaces_router
|
||||
|
||||
app = FastAPI(
|
||||
title="Finance Tracker API",
|
||||
version="0.1.0",
|
||||
docs_url="/api/docs",
|
||||
openapi_url="/api/v1/openapi.json",
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(AppError)
|
||||
async def app_error_handler(_request: Request, exc: AppError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=exc.status_code,
|
||||
content={"error": {"code": exc.code, "message": exc.message, "details": exc.details}},
|
||||
)
|
||||
|
||||
|
||||
@app.exception_handler(StaleDataError)
|
||||
async def stale_data_handler(_request: Request, _exc: StaleDataError) -> JSONResponse:
|
||||
return JSONResponse(
|
||||
status_code=409,
|
||||
content={"error": {"code": "VERSION_CONFLICT", "message": "Resource changed"}},
|
||||
)
|
||||
|
||||
|
||||
@app.middleware("http")
|
||||
async def security_headers(request: Request, call_next: Callable[[Request], Awaitable]):
|
||||
response = await call_next(request)
|
||||
response.headers["X-Content-Type-Options"] = "nosniff"
|
||||
response.headers["X-Frame-Options"] = "DENY"
|
||||
response.headers["Referrer-Policy"] = "no-referrer"
|
||||
response.headers["Permissions-Policy"] = "camera=(), microphone=(), geolocation=()"
|
||||
response.headers["Content-Security-Policy"] = "default-src 'none'; frame-ancestors 'none'"
|
||||
return response
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/healthz", include_in_schema=False)
|
||||
def healthz() -> dict:
|
||||
return health()
|
||||
|
||||
|
||||
@app.get("/ready")
|
||||
def readiness() -> dict:
|
||||
with get_engine().connect() as connection:
|
||||
connection.execute(text("select 1"))
|
||||
return {"status": "ready"}
|
||||
|
||||
|
||||
for api_router in (
|
||||
identity_router,
|
||||
spaces_router,
|
||||
ledger_router,
|
||||
budgeting_router,
|
||||
imports_router,
|
||||
analytics_router,
|
||||
exports_router,
|
||||
):
|
||||
app.include_router(api_router, prefix="/api/v1")
|
||||
@@ -0,0 +1,42 @@
|
||||
import hashlib
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
from redis import Redis
|
||||
from redis.exceptions import RedisError
|
||||
|
||||
from app.config import get_settings
|
||||
|
||||
_lock = threading.Lock()
|
||||
_hits: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
def allow(key: str, limit: int, window_seconds: float) -> bool:
|
||||
"""Distributed fixed-window limiter with a local fail-safe fallback."""
|
||||
digest = hashlib.sha256(key.encode()).hexdigest()
|
||||
window = int(time.time() // window_seconds)
|
||||
try:
|
||||
client = Redis.from_url(
|
||||
get_settings().redis_url,
|
||||
socket_connect_timeout=0.2,
|
||||
socket_timeout=0.2,
|
||||
)
|
||||
redis_key = f"rate:{digest}:{window}"
|
||||
with client.pipeline() as pipeline:
|
||||
pipeline.incr(redis_key)
|
||||
pipeline.expire(redis_key, int(window_seconds) + 1)
|
||||
count, _ = pipeline.execute()
|
||||
return int(count) <= limit
|
||||
except RedisError:
|
||||
pass
|
||||
|
||||
now = time.monotonic()
|
||||
with _lock:
|
||||
bucket = _hits[digest]
|
||||
while bucket and bucket[0] <= now - window_seconds:
|
||||
bucket.popleft()
|
||||
if len(bucket) >= limit:
|
||||
return False
|
||||
bucket.append(now)
|
||||
return True
|
||||
@@ -0,0 +1,30 @@
|
||||
import hashlib
|
||||
import secrets
|
||||
|
||||
from argon2 import PasswordHasher
|
||||
from argon2.exceptions import InvalidHashError, VerificationError, VerifyMismatchError
|
||||
|
||||
_password_hasher = PasswordHasher()
|
||||
|
||||
|
||||
def hash_password(password: str) -> str:
|
||||
return _password_hasher.hash(password)
|
||||
|
||||
|
||||
def verify_password(password_hash: str, password: str) -> bool:
|
||||
try:
|
||||
return _password_hasher.verify(password_hash, password)
|
||||
except (VerifyMismatchError, VerificationError, InvalidHashError):
|
||||
return False
|
||||
|
||||
|
||||
def new_token() -> str:
|
||||
return secrets.token_urlsafe(32)
|
||||
|
||||
|
||||
def token_digest(token: str) -> str:
|
||||
return hashlib.sha256(token.encode("utf-8")).hexdigest()
|
||||
|
||||
|
||||
def constant_time_equals(left: str, right: str) -> bool:
|
||||
return secrets.compare_digest(left.encode("utf-8"), right.encode("utf-8"))
|
||||
@@ -0,0 +1,28 @@
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import ForeignKey, String, UniqueConstraint, Uuid
|
||||
from sqlalchemy.orm import Mapped, mapped_column
|
||||
|
||||
from app.db.base import Base, PkMixin, TimestampMixin, UTCDateTime
|
||||
|
||||
|
||||
class FinancialSpace(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "financial_spaces"
|
||||
|
||||
name: Mapped[str] = mapped_column(String(200), nullable=False)
|
||||
status: Mapped[str] = mapped_column(String(16), nullable=False, default="active")
|
||||
deletion_scheduled_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
|
||||
|
||||
class Membership(Base, PkMixin, TimestampMixin):
|
||||
__tablename__ = "memberships"
|
||||
__table_args__ = (UniqueConstraint("space_id", "user_id", name="uq_memberships_space_user"),)
|
||||
|
||||
space_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("financial_spaces.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
user_id: Mapped[uuid.UUID] = mapped_column(
|
||||
Uuid, ForeignKey("users.id", ondelete="CASCADE"), nullable=False, index=True
|
||||
)
|
||||
role: Mapped[str] = mapped_column(String(16), nullable=False, default="member")
|
||||
@@ -0,0 +1,91 @@
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Header
|
||||
from pydantic import BaseModel, EmailStr, Field
|
||||
|
||||
from app.dependencies import Context, Csrf, CurrentSession, Db
|
||||
from app.errors import bad_request, forbidden
|
||||
from app.exports import service as exports_service
|
||||
from app.identity import service as identity_service
|
||||
from app.identity.mailer import send_token_email
|
||||
from app.security import constant_time_equals
|
||||
from app.spaces.service import list_spaces_for_user, require_owner
|
||||
|
||||
router = APIRouter(prefix="/spaces/current", tags=["spaces"])
|
||||
|
||||
|
||||
class DeletionBody(BaseModel):
|
||||
confirmation_phrase: str = Field(alias="confirmationPhrase")
|
||||
|
||||
|
||||
class MemberInviteBody(BaseModel):
|
||||
email: EmailStr
|
||||
|
||||
|
||||
@router.get("")
|
||||
def current(context: Context) -> dict:
|
||||
return {
|
||||
"id": context.space.id,
|
||||
"name": context.space.name,
|
||||
"status": context.space.status,
|
||||
"role": context.membership.role,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/invitations", status_code=201)
|
||||
def invite_member(body: MemberInviteBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
require_owner(context.membership)
|
||||
invitation, token = identity_service.create_invitation(
|
||||
db,
|
||||
email=str(body.email),
|
||||
invited_by_user_id=context.user.id,
|
||||
space_id=context.space.id,
|
||||
)
|
||||
db.commit()
|
||||
send_token_email(recipient=invitation.email, token=token, kind="invite")
|
||||
return {"id": invitation.id, "expiresAt": invitation.expires_at}
|
||||
|
||||
|
||||
@router.post("/deletion", status_code=202)
|
||||
def schedule_deletion(body: DeletionBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
||||
if body.confirmation_phrase != "DELETE":
|
||||
raise bad_request("SPACE_DELETION_CONFIRMATION_INVALID", "Confirmation phrase is invalid")
|
||||
require_owner(context.membership)
|
||||
scheduled_at = exports_service.schedule_deletion(db, space=context.space)
|
||||
return {
|
||||
"status": "pending_deletion",
|
||||
"scheduledAt": scheduled_at,
|
||||
"cancelsUntil": scheduled_at,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/deletion")
|
||||
def deletion_status(db: Db, auth: CurrentSession) -> dict:
|
||||
user, _ = auth
|
||||
spaces = list_spaces_for_user(db, user=user)
|
||||
if not spaces:
|
||||
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
|
||||
space, _membership = spaces[0]
|
||||
return {
|
||||
"status": "pending_deletion" if space.status == "blocked" else "active",
|
||||
"scheduledAt": space.deletion_scheduled_at,
|
||||
"cancelsUntil": space.deletion_scheduled_at,
|
||||
}
|
||||
|
||||
|
||||
@router.delete("/deletion")
|
||||
def cancel_deletion(
|
||||
db: Db,
|
||||
auth: CurrentSession,
|
||||
csrf_header: Annotated[str | None, Header(alias="X-CSRF-Token")] = None,
|
||||
) -> dict:
|
||||
user, session = auth
|
||||
if not csrf_header or not constant_time_equals(session.csrf_token, csrf_header):
|
||||
raise forbidden("IDENTITY_CSRF_INVALID", "CSRF token is missing or invalid")
|
||||
spaces = list_spaces_for_user(db, user=user)
|
||||
if not spaces:
|
||||
raise forbidden("SPACE_MEMBERSHIP_REQUIRED", "No financial space is available")
|
||||
space, membership = spaces[0]
|
||||
require_owner(membership)
|
||||
exports_service.cancel_deletion(db, space=space)
|
||||
return {"status": "active", "scheduledAt": None, "cancelsUntil": None}
|
||||
@@ -0,0 +1,57 @@
|
||||
import uuid
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.audit import service as audit
|
||||
from app.db.session import set_space_context
|
||||
from app.errors import conflict, forbidden, not_found
|
||||
from app.identity.models import User
|
||||
from app.spaces.models import FinancialSpace, Membership
|
||||
|
||||
|
||||
def create_space(db: Session, *, user: User, name: str) -> FinancialSpace:
|
||||
space = FinancialSpace(name=name)
|
||||
db.add(space)
|
||||
db.flush()
|
||||
set_space_context(db, space.id)
|
||||
db.add(Membership(space_id=space.id, user_id=user.id, role="owner"))
|
||||
audit.record(db, event_type="space.created", space_id=space.id, user_id=user.id)
|
||||
return space
|
||||
|
||||
|
||||
def get_membership(db: Session, *, space_id: uuid.UUID, user_id: uuid.UUID) -> Membership:
|
||||
membership = db.query(Membership).filter_by(space_id=space_id, user_id=user_id).first()
|
||||
if membership is None:
|
||||
raise not_found("SPACE_NOT_FOUND", "Financial space not found")
|
||||
return membership
|
||||
|
||||
|
||||
def get_space_for_member(
|
||||
db: Session, *, space_id: uuid.UUID, user: User
|
||||
) -> tuple[FinancialSpace, Membership]:
|
||||
space = db.get(FinancialSpace, space_id)
|
||||
if space is None:
|
||||
raise not_found("SPACE_NOT_FOUND", "Financial space not found")
|
||||
membership = get_membership(db, space_id=space_id, user_id=user.id)
|
||||
if space.status == "blocked":
|
||||
raise conflict(
|
||||
"SPACE_BLOCKED",
|
||||
"Financial space is blocked pending deletion",
|
||||
)
|
||||
return space, membership
|
||||
|
||||
|
||||
def require_owner(membership: Membership) -> None:
|
||||
if membership.role != "owner":
|
||||
raise forbidden("SPACE_OWNER_REQUIRED", "Owner role required")
|
||||
|
||||
|
||||
def list_spaces_for_user(db: Session, *, user: User) -> list[tuple[FinancialSpace, Membership]]:
|
||||
rows = (
|
||||
db.query(FinancialSpace, Membership)
|
||||
.join(Membership, Membership.space_id == FinancialSpace.id)
|
||||
.filter(Membership.user_id == user.id)
|
||||
.order_by(FinancialSpace.created_at)
|
||||
.all()
|
||||
)
|
||||
return [(space, membership) for space, membership in rows]
|
||||
@@ -0,0 +1,5 @@
|
||||
from app.imports.celery_app import celery_app
|
||||
|
||||
celery = celery_app
|
||||
|
||||
__all__ = ["celery", "celery_app"]
|
||||
@@ -0,0 +1,59 @@
|
||||
[project]
|
||||
name = "finance-backend"
|
||||
version = "0.1.0"
|
||||
description = "FastAPI backend for the personal zero-based budget tracker MVP"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.30",
|
||||
"pydantic>=2.7",
|
||||
"pydantic-settings>=2.3",
|
||||
"email-validator>=2.1",
|
||||
"sqlalchemy>=2.0.30",
|
||||
"psycopg[binary]>=3.2",
|
||||
"alembic>=1.13",
|
||||
"celery[redis]>=5.4",
|
||||
"redis>=5.0",
|
||||
"redis>=5.0",
|
||||
"argon2-cffi>=23.1",
|
||||
"cryptography>=42.0",
|
||||
"openpyxl>=3.1",
|
||||
"defusedxml>=0.7",
|
||||
"pdfplumber>=0.11",
|
||||
"python-multipart>=0.0.9",
|
||||
"boto3>=1.35",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.5",
|
||||
"mypy>=1.10",
|
||||
"hypothesis>=6.112",
|
||||
]
|
||||
|
||||
[tool.uv]
|
||||
package = false
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
pythonpath = ["."]
|
||||
testpaths = ["tests"]
|
||||
filterwarnings = ["ignore::DeprecationWarning"]
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py312"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "I", "UP", "B"]
|
||||
ignore = ["B008"]
|
||||
|
||||
[tool.mypy]
|
||||
python_version = "3.12"
|
||||
ignore_missing_imports = true
|
||||
check_untyped_defs = true
|
||||
no_implicit_optional = true
|
||||
warn_redundant_casts = true
|
||||
disable_error_code = ["import-untyped"]
|
||||
exclude = ["alembic/", "tests/"]
|
||||
@@ -0,0 +1,60 @@
|
||||
import os
|
||||
from collections.abc import Iterator
|
||||
|
||||
os.environ.setdefault("ADMIN_BOOTSTRAP_TOKEN", "test-admin-token")
|
||||
os.environ.setdefault("COOKIE_SECURE", "false")
|
||||
os.environ.setdefault("IMPORTS_KEK_B64", "MDAxMTIyMzM0NDU1NjY3Nzg4ODlhYWJiY2NkZGVlZg==")
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
from sqlalchemy import create_engine, event
|
||||
from sqlalchemy.orm import Session, sessionmaker
|
||||
from sqlalchemy.pool import StaticPool
|
||||
|
||||
from app.db import models as _models # noqa: F401
|
||||
from app.db.base import Base
|
||||
from app.db.session import get_db
|
||||
from app.main import app
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db_factory() -> Iterator[sessionmaker[Session]]:
|
||||
engine = create_engine(
|
||||
"sqlite://",
|
||||
connect_args={"check_same_thread": False},
|
||||
poolclass=StaticPool,
|
||||
)
|
||||
|
||||
@event.listens_for(engine, "connect")
|
||||
def foreign_keys(connection, _record) -> None:
|
||||
connection.execute("PRAGMA foreign_keys=ON")
|
||||
|
||||
Base.metadata.create_all(engine)
|
||||
factory = sessionmaker(bind=engine, expire_on_commit=False)
|
||||
yield factory
|
||||
Base.metadata.drop_all(engine)
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def db(db_factory: sessionmaker[Session]) -> Iterator[Session]:
|
||||
with db_factory() as session:
|
||||
yield session
|
||||
session.rollback()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client(db_factory: sessionmaker[Session]) -> Iterator[TestClient]:
|
||||
def override_db() -> Iterator[Session]:
|
||||
with db_factory() as session:
|
||||
try:
|
||||
yield session
|
||||
session.commit()
|
||||
except Exception:
|
||||
session.rollback()
|
||||
raise
|
||||
|
||||
app.dependency_overrides[get_db] = override_db
|
||||
with TestClient(app) as test_client:
|
||||
yield test_client
|
||||
app.dependency_overrides.clear()
|
||||
@@ -0,0 +1,45 @@
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
|
||||
def test_invite_login_and_authenticated_space(client: TestClient) -> None:
|
||||
invite = client.post(
|
||||
"/api/v1/admin/invitations",
|
||||
headers={"X-Admin-Token": "test-admin-token"},
|
||||
json={"email": "Person@Example.com"},
|
||||
)
|
||||
assert invite.status_code == 201, invite.text
|
||||
accepted = client.post(
|
||||
"/api/v1/auth/invitations/accept",
|
||||
json={
|
||||
"token": invite.json()["token"],
|
||||
"password": "a-long-password-for-tests",
|
||||
"fullName": "Test Person",
|
||||
"locale": "en",
|
||||
},
|
||||
)
|
||||
assert accepted.status_code == 201, accepted.text
|
||||
assert accepted.json()["email"] == "person@example.com"
|
||||
assert client.get("/api/v1/spaces/current").status_code == 200
|
||||
|
||||
csrf = client.cookies["csrf_token"]
|
||||
account = client.post(
|
||||
"/api/v1/accounts",
|
||||
headers={"X-CSRF-Token": csrf},
|
||||
json={
|
||||
"name": "Wallet",
|
||||
"type": "cash",
|
||||
"openedOn": "2026-09-01",
|
||||
"openingBalanceKopecks": 12_500,
|
||||
},
|
||||
)
|
||||
assert account.status_code == 201, account.text
|
||||
assert account.json()["balanceKopecks"] == 12_500
|
||||
|
||||
|
||||
def test_openapi_contains_critical_paths(client: TestClient) -> None:
|
||||
schema = client.get("/api/v1/openapi.json").json()
|
||||
paths = schema["paths"]
|
||||
assert "/api/v1/operations" in paths
|
||||
assert "/api/v1/imports/{document_id}/confirm" in paths
|
||||
assert "/api/v1/budget/close" in paths
|
||||
assert "/api/v1/analytics/balances" in paths
|
||||
@@ -0,0 +1,100 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.budgeting.models import PeriodClosure
|
||||
from app.budgeting.service import (
|
||||
close_period,
|
||||
compute_period_state,
|
||||
copy_previous_month,
|
||||
create_category,
|
||||
get_or_create_period,
|
||||
reopen_period,
|
||||
set_assignments,
|
||||
)
|
||||
from app.errors import AppError
|
||||
from app.identity.models import User
|
||||
from app.ledger.service import create_account, create_expense
|
||||
from app.spaces.service import create_space
|
||||
|
||||
|
||||
def seed(db: Session):
|
||||
user = User(email="budget@example.test", password_hash="unused-in-domain-tests")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
return user, create_space(db, user=user, name="Budget")
|
||||
|
||||
|
||||
def test_uncategorized_expense_blocks_close(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
account = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Cash",
|
||||
kind="cash",
|
||||
opened_on=date(2026, 3, 1),
|
||||
opening_amount_kopecks=10_000,
|
||||
)
|
||||
create_expense(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
account_id=account.id,
|
||||
amount_kopecks=1_000,
|
||||
occurred_on=date(2026, 3, 2),
|
||||
)
|
||||
period = get_or_create_period(db, space_id=space.id, year=2026, month=3)
|
||||
with pytest.raises(AppError) as exc:
|
||||
close_period(db, space_id=space.id, user=user, period=period)
|
||||
assert exc.value.code == "BUDGET_CLOSE_BLOCKED"
|
||||
|
||||
|
||||
def test_copy_previous_month_clamps_expected_day(db: Session) -> None:
|
||||
from app.budgeting.service import add_expected_income
|
||||
|
||||
user, space = seed(db)
|
||||
category = create_category(db, space_id=space.id, user=user, kind="monthly", name="Food")
|
||||
january = get_or_create_period(db, space_id=space.id, year=2026, month=1)
|
||||
february = get_or_create_period(db, space_id=space.id, year=2026, month=2)
|
||||
set_assignments(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
period=january,
|
||||
items=[{"category_id": category.id, "planned_kopecks": 30_000}],
|
||||
)
|
||||
add_expected_income(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
period=january,
|
||||
expected_on=date(2026, 1, 31),
|
||||
amount_kopecks=100_000,
|
||||
source_name="Salary",
|
||||
)
|
||||
result = copy_previous_month(db, space_id=space.id, user=user, period=february)
|
||||
assert result == {"assignments_copied": 1, "expected_incomes_copied": 1}
|
||||
state = compute_period_state(db, space_id=space.id, period=february)
|
||||
assert state["expected_incomes"][0]["expected_on"] == "2026-02-28"
|
||||
|
||||
|
||||
def test_reopen_preserves_immutable_closure_history(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
period = get_or_create_period(db, space_id=space.id, year=2026, month=4)
|
||||
close_period(db, space_id=space.id, user=user, period=period)
|
||||
reopen_period(db, space_id=space.id, user=user, period=period)
|
||||
close_period(db, space_id=space.id, user=user, period=period)
|
||||
assert db.query(PeriodClosure).filter_by(period_id=period.id).count() == 2
|
||||
|
||||
|
||||
def test_periods_must_close_in_calendar_order(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
august = get_or_create_period(db, space_id=space.id, year=2026, month=8)
|
||||
september = get_or_create_period(db, space_id=space.id, year=2026, month=9)
|
||||
with pytest.raises(AppError) as exc:
|
||||
close_period(db, space_id=space.id, user=user, period=september)
|
||||
assert exc.value.code == "BUDGET_CLOSE_OUT_OF_ORDER"
|
||||
close_period(db, space_id=space.id, user=user, period=august)
|
||||
close_period(db, space_id=space.id, user=user, period=september)
|
||||
@@ -0,0 +1,283 @@
|
||||
import uuid
|
||||
from contextlib import contextmanager
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.errors import AppError
|
||||
from app.identity.models import User
|
||||
from app.imports import service
|
||||
from app.imports.adapters.base import SourceRow
|
||||
from app.imports.models import (
|
||||
DedupCandidate,
|
||||
ParseRun,
|
||||
SourceRecord,
|
||||
SourceRecordLink,
|
||||
StatementDocument,
|
||||
)
|
||||
from app.ledger.models import Operation
|
||||
from app.ledger.service import create_account
|
||||
from app.spaces.service import create_space
|
||||
|
||||
|
||||
def seed(db: Session):
|
||||
user = User(email=f"{uuid.uuid4().hex}@example.com", password_hash="unused")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
space = create_space(db, user=user, name="Imports")
|
||||
account = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Debit",
|
||||
kind="debit",
|
||||
opened_on=date(2026, 1, 1),
|
||||
)
|
||||
return user, space, account
|
||||
|
||||
|
||||
def ready_document(db: Session):
|
||||
user, space, account = seed(db)
|
||||
document = StatementDocument(
|
||||
space_id=space.id,
|
||||
account_id=account.id,
|
||||
original_filename="synthetic.xlsx",
|
||||
size_bytes=100,
|
||||
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
object_key=uuid.uuid4().hex,
|
||||
status="ready",
|
||||
reconciliation={"full": True, "matched": True},
|
||||
uploaded_by_user_id=user.id,
|
||||
)
|
||||
db.add(document)
|
||||
db.flush()
|
||||
run = ParseRun(
|
||||
space_id=space.id,
|
||||
document_id=document.id,
|
||||
adapter_id="alfa_debit_xlsx",
|
||||
status="succeeded",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
document.active_parse_run_id = run.id
|
||||
source = SourceRecord(
|
||||
space_id=space.id,
|
||||
document_id=document.id,
|
||||
parse_run_id=run.id,
|
||||
occurred_on=date(2026, 1, 5),
|
||||
amount_kopecks=12_345,
|
||||
direction="out",
|
||||
row_ref="sheet0:row2",
|
||||
record_fp=uuid.uuid4().hex,
|
||||
)
|
||||
db.add(source)
|
||||
db.flush()
|
||||
return user, space, document
|
||||
|
||||
|
||||
def test_confirm_and_revert_are_idempotent(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
db.flush()
|
||||
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
|
||||
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 1
|
||||
|
||||
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
service.revert_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
db.flush()
|
||||
assert db.query(Operation).filter_by(space_id=space.id).count() == 0
|
||||
assert db.query(SourceRecordLink).filter_by(space_id=space.id).count() == 0
|
||||
|
||||
|
||||
def test_confirm_rejects_arithmetic_mismatch(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
document.reconciliation = {"full": True, "matched": False}
|
||||
with pytest.raises(AppError) as exc:
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
assert exc.value.code == "IMPORT_RECONCILIATION_MISMATCH"
|
||||
|
||||
|
||||
def test_confirmed_import_must_be_reverted_before_reparse(db: Session) -> None:
|
||||
user, space, document = ready_document(db)
|
||||
service.confirm_document(db, space_id=space.id, document_id=document.id, user=user)
|
||||
with pytest.raises(AppError) as exc:
|
||||
service.prepare_reparse(db, space_id=space.id, document_id=document.id, user=user)
|
||||
assert exc.value.code == "IMPORT_REPARSE_CONFIRMED"
|
||||
|
||||
|
||||
def test_invalid_direction_is_critical() -> None:
|
||||
source = service._source_from_row(
|
||||
space_id=uuid.uuid4(),
|
||||
document_id=uuid.uuid4(),
|
||||
run_id=uuid.uuid4(),
|
||||
row=SourceRow(
|
||||
external_id=None,
|
||||
occurred_on=date(2026, 1, 1),
|
||||
posted_on=None,
|
||||
amount_kopecks=100,
|
||||
direction="sideways",
|
||||
description=None,
|
||||
mcc=None,
|
||||
bank_category=None,
|
||||
location=None,
|
||||
row_ref="row1",
|
||||
),
|
||||
)
|
||||
assert source.critical is True
|
||||
assert source.field_warnings == {"codes": ["direction_invalid"]}
|
||||
|
||||
|
||||
def test_unexpected_parser_failure_reaches_terminal_state(db: Session, monkeypatch) -> None:
|
||||
_user, space, account = seed(db)
|
||||
document = StatementDocument(
|
||||
space_id=space.id,
|
||||
account_id=account.id,
|
||||
original_filename="synthetic.pdf",
|
||||
size_bytes=10,
|
||||
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
object_key=uuid.uuid4().hex,
|
||||
dek_wrapped=b"key",
|
||||
dek_wrap_nonce=b"nonce",
|
||||
content_nonce=b"nonce",
|
||||
status="queued",
|
||||
)
|
||||
db.add(document)
|
||||
db.flush()
|
||||
|
||||
@contextmanager
|
||||
def existing_session(_space_id):
|
||||
yield db
|
||||
|
||||
class BrokenStore:
|
||||
def get(self, _key):
|
||||
raise RuntimeError("storage unavailable")
|
||||
|
||||
monkeypatch.setattr(service, "session_scope", existing_session)
|
||||
monkeypatch.setattr(service, "get_blob_store", lambda: BrokenStore())
|
||||
service.process_statement_document(document.id, space.id)
|
||||
assert document.status == "failed"
|
||||
assert document.error_code == "IMPORT_PARSE_FAILED"
|
||||
|
||||
|
||||
def test_duplicate_candidate_can_reuse_unique_operation_without_client_id(db: Session) -> None:
|
||||
user, space, first_document = ready_document(db)
|
||||
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
|
||||
first_source = (
|
||||
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
|
||||
)
|
||||
second = StatementDocument(
|
||||
space_id=space.id,
|
||||
account_id=first_document.account_id,
|
||||
original_filename="second.xlsx",
|
||||
size_bytes=100,
|
||||
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
object_key=uuid.uuid4().hex,
|
||||
status="review_required",
|
||||
reconciliation={"full": True, "matched": True},
|
||||
)
|
||||
db.add(second)
|
||||
db.flush()
|
||||
run = ParseRun(
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
adapter_id="alfa_debit_xlsx",
|
||||
status="succeeded",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
second.active_parse_run_id = run.id
|
||||
duplicate_source = SourceRecord(
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
parse_run_id=run.id,
|
||||
occurred_on=first_source.occurred_on,
|
||||
amount_kopecks=first_source.amount_kopecks,
|
||||
direction=first_source.direction,
|
||||
row_ref="sheet0:row9",
|
||||
record_fp=first_source.record_fp,
|
||||
)
|
||||
db.add(duplicate_source)
|
||||
db.flush()
|
||||
assert service._build_candidates(db, document=second, run=run)
|
||||
candidate = db.query(DedupCandidate).filter_by(source_record_id=duplicate_source.id).one()
|
||||
assert candidate.candidate_kind == "duplicate"
|
||||
service.decide_source_record(
|
||||
db,
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
source_record_id=duplicate_source.id,
|
||||
decision="duplicate",
|
||||
duplicate_operation_id=None,
|
||||
user=user,
|
||||
)
|
||||
assert second.status == "ready"
|
||||
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
|
||||
assert db.query(Operation).filter_by(space_id=space.id).count() == 1
|
||||
|
||||
|
||||
def test_transfer_merge_revert_restores_counterpart_operation(db: Session) -> None:
|
||||
user, space, first_document = ready_document(db)
|
||||
service.confirm_document(db, space_id=space.id, document_id=first_document.id, user=user)
|
||||
first_source = (
|
||||
db.query(SourceRecord).filter_by(parse_run_id=first_document.active_parse_run_id).one()
|
||||
)
|
||||
target = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Second debit",
|
||||
kind="debit",
|
||||
opened_on=date(2026, 1, 1),
|
||||
)
|
||||
second = StatementDocument(
|
||||
space_id=space.id,
|
||||
account_id=target.id,
|
||||
original_filename="target.xlsx",
|
||||
size_bytes=100,
|
||||
content_sha256=uuid.uuid4().hex + uuid.uuid4().hex,
|
||||
object_key=uuid.uuid4().hex,
|
||||
status="review_required",
|
||||
reconciliation={"full": True, "matched": True},
|
||||
)
|
||||
db.add(second)
|
||||
db.flush()
|
||||
run = ParseRun(
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
adapter_id="alfa_debit_xlsx",
|
||||
status="succeeded",
|
||||
)
|
||||
db.add(run)
|
||||
db.flush()
|
||||
second.active_parse_run_id = run.id
|
||||
incoming = SourceRecord(
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
parse_run_id=run.id,
|
||||
occurred_on=first_source.occurred_on,
|
||||
amount_kopecks=first_source.amount_kopecks,
|
||||
direction="in",
|
||||
row_ref="sheet0:row10",
|
||||
record_fp=uuid.uuid4().hex,
|
||||
)
|
||||
db.add(incoming)
|
||||
db.flush()
|
||||
assert service._build_candidates(db, document=second, run=run)
|
||||
candidate = db.query(DedupCandidate).filter_by(source_record_id=incoming.id).one()
|
||||
assert candidate.candidate_kind == "transfer"
|
||||
service.decide_source_record(
|
||||
db,
|
||||
space_id=space.id,
|
||||
document_id=second.id,
|
||||
source_record_id=incoming.id,
|
||||
decision="transfer",
|
||||
duplicate_operation_id=None,
|
||||
user=user,
|
||||
)
|
||||
service.confirm_document(db, space_id=space.id, document_id=second.id, user=user)
|
||||
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 1
|
||||
service.revert_document(db, space_id=space.id, document_id=second.id, user=user)
|
||||
assert db.query(Operation).filter_by(space_id=space.id, type="transfer").count() == 0
|
||||
assert db.query(Operation).filter_by(space_id=space.id, type="expense").count() == 1
|
||||
@@ -0,0 +1,155 @@
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from hypothesis import HealthCheck, given, settings
|
||||
from hypothesis import strategies as st
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.errors import AppError
|
||||
from app.identity.models import User
|
||||
from app.ledger.models import Posting
|
||||
from app.ledger.service import (
|
||||
account_balance,
|
||||
create_account,
|
||||
create_expense,
|
||||
create_income,
|
||||
create_refund,
|
||||
create_transfer,
|
||||
get_account,
|
||||
)
|
||||
from app.spaces.service import create_space
|
||||
|
||||
|
||||
def seed(db: Session):
|
||||
user = User(email="owner@example.test", password_hash="unused-in-domain-tests")
|
||||
db.add(user)
|
||||
db.flush()
|
||||
space = create_space(db, user=user, name="Personal")
|
||||
return user, space
|
||||
|
||||
|
||||
@given(amount=st.integers(min_value=1, max_value=10**12))
|
||||
@settings(suppress_health_check=[HealthCheck.function_scoped_fixture])
|
||||
def test_transfer_postings_conserve_money(db: Session, amount: int) -> None:
|
||||
db.rollback()
|
||||
user, space = seed(db)
|
||||
source = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Debit",
|
||||
kind="debit",
|
||||
opened_on=date(2026, 1, 1),
|
||||
opening_amount_kopecks=amount,
|
||||
)
|
||||
target = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Cash",
|
||||
kind="cash",
|
||||
opened_on=date(2026, 1, 1),
|
||||
)
|
||||
operation = create_transfer(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
source_account_id=source.id,
|
||||
target_account_id=target.id,
|
||||
amount_kopecks=amount,
|
||||
occurred_on=date(2026, 1, 2),
|
||||
)
|
||||
db.flush()
|
||||
postings = db.query(Posting).filter_by(operation_id=operation.id).all()
|
||||
assert len(postings) == 2
|
||||
assert sum(item.amount_kopecks for item in postings) == 0
|
||||
assert account_balance(db, source) == 0
|
||||
assert account_balance(db, target) == amount
|
||||
db.rollback()
|
||||
|
||||
|
||||
def test_credit_expense_is_negative_balance_and_refund_is_capped(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
card = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Credit",
|
||||
kind="credit",
|
||||
opened_on=date(2026, 2, 1),
|
||||
)
|
||||
expense = create_expense(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
account_id=card.id,
|
||||
amount_kopecks=12_345,
|
||||
occurred_on=date(2026, 2, 2),
|
||||
)
|
||||
assert account_balance(db, card) == -12_345
|
||||
create_refund(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
parent_operation_id=expense.id,
|
||||
amount_kopecks=2_345,
|
||||
occurred_on=date(2026, 2, 3),
|
||||
)
|
||||
assert account_balance(db, card) == -10_000
|
||||
with pytest.raises(AppError, match="Refund exceeds"):
|
||||
create_refund(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
parent_operation_id=expense.id,
|
||||
amount_kopecks=10_001,
|
||||
occurred_on=date(2026, 2, 4),
|
||||
)
|
||||
|
||||
|
||||
def test_cross_space_lookup_fails_closed(db: Session) -> None:
|
||||
user, first = seed(db)
|
||||
other = create_space(db, user=user, name="Other")
|
||||
account = create_account(
|
||||
db,
|
||||
space_id=first.id,
|
||||
user=user,
|
||||
name="Cash",
|
||||
kind="cash",
|
||||
opened_on=date(2026, 1, 1),
|
||||
)
|
||||
with pytest.raises(AppError) as exc:
|
||||
get_account(db, space_id=other.id, account_id=account.id)
|
||||
assert exc.value.code == "LEDGER_ACCOUNT_NOT_FOUND"
|
||||
|
||||
|
||||
def test_idempotency_key_prevents_duplicate_income(db: Session) -> None:
|
||||
user, space = seed(db)
|
||||
account = create_account(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
name="Debit",
|
||||
kind="debit",
|
||||
opened_on=date(2026, 1, 1),
|
||||
)
|
||||
first = create_income(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
account_id=account.id,
|
||||
amount_kopecks=50_000,
|
||||
occurred_on=date(2026, 1, 5),
|
||||
request_key="retry-1",
|
||||
)
|
||||
second = create_income(
|
||||
db,
|
||||
space_id=space.id,
|
||||
user=user,
|
||||
account_id=account.id,
|
||||
amount_kopecks=50_000,
|
||||
occurred_on=date(2026, 1, 5),
|
||||
request_key="retry-1",
|
||||
)
|
||||
assert first.id == second.id
|
||||
assert account_balance(db, account) == 50_000
|
||||
@@ -0,0 +1,52 @@
|
||||
import os
|
||||
import uuid
|
||||
from datetime import date
|
||||
|
||||
import pytest
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.exc import DBAPIError
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from app.db.session import set_space_context
|
||||
from app.imports.blob import get_blob_store
|
||||
from app.ledger.models import Account
|
||||
from app.spaces.models import FinancialSpace
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("RLS_DATABASE_URL"), reason="requires restricted PostgreSQL role")
|
||||
def test_postgres_rls_requires_matching_transaction_context() -> None:
|
||||
engine = create_engine(os.environ["RLS_DATABASE_URL"])
|
||||
with Session(engine) as db:
|
||||
space = FinancialSpace(name="RLS integration")
|
||||
db.add(space)
|
||||
db.commit()
|
||||
space_id = space.id
|
||||
|
||||
db.add(Account(space_id=space_id, name="Blocked", kind="cash", opened_on=date.today()))
|
||||
with pytest.raises(DBAPIError):
|
||||
db.commit()
|
||||
db.rollback()
|
||||
|
||||
set_space_context(db, space_id)
|
||||
db.add(Account(space_id=space_id, name="Allowed", kind="cash", opened_on=date.today()))
|
||||
db.commit()
|
||||
|
||||
set_space_context(db, uuid.uuid4())
|
||||
assert db.query(Account).count() == 0
|
||||
db.rollback()
|
||||
set_space_context(db, space_id)
|
||||
persisted_space = db.get(FinancialSpace, space_id)
|
||||
assert persisted_space is not None
|
||||
db.delete(persisted_space)
|
||||
db.commit()
|
||||
engine.dispose()
|
||||
|
||||
|
||||
@pytest.mark.skipif(not os.getenv("S3_ENDPOINT_URL"), reason="requires S3-compatible storage")
|
||||
def test_s3_blob_round_trip() -> None:
|
||||
store = get_blob_store()
|
||||
key = uuid.uuid4().hex
|
||||
payload = b"synthetic statement payload"
|
||||
store.put(key, payload)
|
||||
assert store.get(key) == payload
|
||||
store.delete(key)
|
||||
Generated
+2082
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user