Files
finance/backend/app/imports/service.py
T
k1nq d582f15681 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.
2026-09-14 09:26:33 +05:00

922 lines
35 KiB
Python

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