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:
k1nq
2026-09-14 09:26:33 +05:00
parent d8b938adfb
commit d582f15681
164 changed files with 25553 additions and 1 deletions
+7
View File
@@ -0,0 +1,7 @@
.venv/
var/
__pycache__/
*.pyc
.pytest_cache/
.mypy_cache/
.ruff_cache/
+1
View File
@@ -0,0 +1 @@
3.12
+39
View File
@@ -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
+45
View File
@@ -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()
+24
View File
@@ -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()")
View File
View File
+109
View File
@@ -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
],
}
+105
View File
@@ -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
View File
+25
View File
@@ -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)
+23
View File
@@ -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,
)
)
View File
+188
View File
@@ -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
)
+296
View File
@@ -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)
+868
View File
@@ -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}
+113
View File
@@ -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)
+97
View File
@@ -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
+68
View File
@@ -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()
View File
+52
View File
@@ -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
)
+63
View File
@@ -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",
]
+71
View File
@@ -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()
+67
View File
@@ -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)]
+48
View File
@@ -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)
View File
+36
View File
@@ -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)
+60
View File
@@ -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"'},
)
+194
View File
@@ -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
+27
View File
@@ -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)
View File
+22
View File
@@ -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)
+65
View File
@@ -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)
+164
View File
@@ -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="/")
+188
View File
@@ -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
View File
+38
View File
@@ -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'}"
)
+210
View File
@@ -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 = ("кредит", "кредитная")
+96
View File
@@ -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()
+134
View File
@@ -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 = ("дебетов", "дебетовая")
+77
View File
@@ -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()
+34
View File
@@ -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()
+58
View File
@@ -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
+211
View File
@@ -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
)
+57
View File
@@ -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
+248
View File
@@ -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)
+921
View File
@@ -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
+22
View File
@@ -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))
View File
+181
View File
@@ -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)
+278
View File
@@ -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)
+552
View File
@@ -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()
+79
View File
@@ -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")
+42
View File
@@ -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
+30
View File
@@ -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"))
View File
+28
View File
@@ -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")
+91
View File
@@ -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}
+57
View File
@@ -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]
+5
View File
@@ -0,0 +1,5 @@
from app.imports.celery_app import celery_app
celery = celery_app
__all__ = ["celery", "celery_app"]
+59
View File
@@ -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/"]
+60
View File
@@ -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()
+45
View File
@@ -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
+100
View File
@@ -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)
+283
View File
@@ -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
+155
View File
@@ -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
+52
View File
@@ -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)
+2082
View File
File diff suppressed because it is too large Load Diff