1396 lines
54 KiB
Python
1396 lines
54 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 import func
|
|
from sqlalchemy.orm import Session
|
|
|
|
from app.audit import service as audit
|
|
from app.config import get_settings
|
|
from app.db.base import utcnow
|
|
from app.db.session import session_scope
|
|
from app.errors import AppError, bad_request, 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,
|
|
HoldResolution,
|
|
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,
|
|
)
|
|
|
|
HOLD_MANUAL_ACTIONS = ("confirm_as_posted", "discard_as_cancelled")
|
|
|
|
|
|
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", "bank_status_invalid"}
|
|
warnings = list(row.warnings)
|
|
if row.direction not in {"in", "out"}:
|
|
warnings.append("direction_invalid")
|
|
if row.amount_kopecks == 0:
|
|
warnings.append("amount_zero")
|
|
bank_status = getattr(row, "bank_status", "posted")
|
|
if bank_status not in {"posted", "hold"}:
|
|
warnings.append("bank_status_invalid")
|
|
bank_status = "posted"
|
|
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,
|
|
bank_status=bank_status,
|
|
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),
|
|
review_decision="savings_transfer" if row.savings_transfer else "create",
|
|
)
|
|
|
|
|
|
def _reconciliation_summary(draft: Any) -> dict[str, Any]:
|
|
"""Reconcile parsed rows against declared bank controls with stable diagnostics.
|
|
|
|
Posted rows are compared with the declared inflow/outflow controls, HOLD
|
|
rows with the declared pending total, and where both balances exist the
|
|
balance equation must hold. Every check keeps an explicit tri-state field:
|
|
True (matched), False (mismatch, review-blocking), None (not declared).
|
|
"""
|
|
rows = list(draft.rows)
|
|
posted = [row for row in rows if getattr(row, "bank_status", "posted") == "posted"]
|
|
holds = [row for row in rows if getattr(row, "bank_status", "posted") == "hold"]
|
|
posted_inflow = sum(row.amount_kopecks for row in posted if row.direction == "in")
|
|
posted_outflow = sum(row.amount_kopecks for row in posted if row.direction == "out")
|
|
hold_total = sum(abs(row.amount_kopecks) for row in holds)
|
|
declared_inflow = getattr(draft, "declared_inflow_kopecks", None)
|
|
declared_outflow = getattr(draft, "declared_outflow_kopecks", None)
|
|
declared_hold = getattr(draft, "declared_hold_kopecks", None)
|
|
full = draft.opening_balance_kopecks is not None and draft.closing_balance_kopecks is not None
|
|
checks: list[bool] = []
|
|
balance_matched: bool | None = None
|
|
computed_closing: int | None = None
|
|
if full:
|
|
computed_closing = int(draft.opening_balance_kopecks) + posted_inflow - posted_outflow
|
|
balance_matched = computed_closing == int(draft.closing_balance_kopecks)
|
|
checks.append(balance_matched)
|
|
posted_totals_matched: bool | None = None
|
|
if declared_inflow is not None or declared_outflow is not None:
|
|
posted_totals_matched = (declared_inflow is None or declared_inflow == posted_inflow) and (
|
|
declared_outflow is None or declared_outflow == posted_outflow
|
|
)
|
|
checks.append(posted_totals_matched)
|
|
hold_totals_matched: bool | None = None
|
|
if declared_hold is not None:
|
|
hold_totals_matched = declared_hold == hold_total
|
|
checks.append(hold_totals_matched)
|
|
return {
|
|
"currency": draft.currency,
|
|
"opening_balance_kopecks": draft.opening_balance_kopecks,
|
|
"closing_balance_kopecks": draft.closing_balance_kopecks,
|
|
"full": full,
|
|
"declared_inflow_kopecks": declared_inflow,
|
|
"declared_outflow_kopecks": declared_outflow,
|
|
"declared_hold_kopecks": declared_hold,
|
|
"parsed_posted_inflow_kopecks": posted_inflow,
|
|
"parsed_posted_outflow_kopecks": posted_outflow,
|
|
"parsed_hold_total_kopecks": hold_total,
|
|
"posted_totals_matched": posted_totals_matched,
|
|
"hold_totals_matched": hold_totals_matched,
|
|
"balance_matched": balance_matched,
|
|
"computed_closing_kopecks": computed_closing,
|
|
"matched": all(checks) if checks else None,
|
|
"confirmed_without_full_reconciliation": False,
|
|
}
|
|
|
|
|
|
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():
|
|
if source.bank_status == "hold":
|
|
continue
|
|
duplicate = None
|
|
if source.external_id:
|
|
match_query = (
|
|
db.query(SourceRecord)
|
|
.join(ParseRun, ParseRun.id == SourceRecord.parse_run_id)
|
|
.join(StatementDocument, StatementDocument.id == SourceRecord.document_id)
|
|
.join(SourceRecordLink, SourceRecordLink.source_record_id == SourceRecord.id)
|
|
.filter(
|
|
SourceRecord.space_id == document.space_id,
|
|
SourceRecord.document_id != document.id,
|
|
SourceRecord.id != source.id,
|
|
SourceRecord.external_id == source.external_id,
|
|
SourceRecord.bank_status == "posted",
|
|
ParseRun.adapter_id == run.adapter_id,
|
|
StatementDocument.status == "confirmed",
|
|
StatementDocument.active_parse_run_id == SourceRecord.parse_run_id,
|
|
)
|
|
)
|
|
if document.account_id is not None:
|
|
match_query = match_query.filter(
|
|
StatementDocument.account_id == document.account_id
|
|
)
|
|
matches = match_query.all()
|
|
duplicate = matches[0] if len(matches) == 1 else None
|
|
if duplicate is None:
|
|
match_query = (
|
|
db.query(SourceRecord)
|
|
.join(ParseRun, ParseRun.id == SourceRecord.parse_run_id)
|
|
.join(StatementDocument, StatementDocument.id == SourceRecord.document_id)
|
|
.join(SourceRecordLink, SourceRecordLink.source_record_id == SourceRecord.id)
|
|
.filter(
|
|
SourceRecord.space_id == document.space_id,
|
|
SourceRecord.document_id != document.id,
|
|
SourceRecord.id != source.id,
|
|
SourceRecord.record_fp == source.record_fp,
|
|
SourceRecord.direction == source.direction,
|
|
SourceRecord.bank_status == "posted",
|
|
ParseRun.adapter_id == run.adapter_id,
|
|
StatementDocument.status == "confirmed",
|
|
StatementDocument.active_parse_run_id == SourceRecord.parse_run_id,
|
|
)
|
|
)
|
|
if document.account_id is not None:
|
|
match_query = match_query.filter(
|
|
StatementDocument.account_id == document.account_id
|
|
)
|
|
matches = match_query.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()
|
|
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,
|
|
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_query = (
|
|
db.query(SourceRecord)
|
|
.join(SourceRecordLink, SourceRecordLink.source_record_id == SourceRecord.id)
|
|
.join(StatementDocument, StatementDocument.id == SourceRecord.document_id)
|
|
.filter(
|
|
SourceRecord.space_id == document.space_id,
|
|
SourceRecord.document_id != document.id,
|
|
SourceRecord.id != source.id,
|
|
SourceRecord.amount_kopecks == source.amount_kopecks,
|
|
SourceRecord.direction == opposite,
|
|
SourceRecord.bank_status == "posted",
|
|
SourceRecord.occurred_on >= source.occurred_on - timedelta(days=1),
|
|
SourceRecord.occurred_on <= source.occurred_on + timedelta(days=1),
|
|
StatementDocument.status == "confirmed",
|
|
StatementDocument.active_parse_run_id == SourceRecord.parse_run_id,
|
|
)
|
|
)
|
|
if document.account_id is not None:
|
|
transfer_query = transfer_query.filter(
|
|
StatementDocument.account_id != document.account_id
|
|
)
|
|
transfer_matches = transfer_query.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 _match_hold_resolutions(
|
|
db: Session, *, document: StatementDocument, run: ParseRun
|
|
) -> dict[uuid.UUID, uuid.UUID]:
|
|
"""Conservatively match unresolved confirmed-doc HOLDs to new posted records.
|
|
|
|
Candidate pairs are searched only within the same space, mapped account,
|
|
and statement contract (adapter). A stable external ID wins; otherwise a
|
|
single unambiguous date/amount/direction/normalized-description
|
|
fingerprint match is required. Any overlap ambiguity leaves every
|
|
involved HOLD unresolved (review-required). Resolutions are written only
|
|
when the new statement is confirmed.
|
|
"""
|
|
if run.adapter_id is None or document.account_id is None:
|
|
return {}
|
|
holds = (
|
|
db.query(SourceRecord)
|
|
.join(
|
|
StatementDocument,
|
|
(StatementDocument.space_id == SourceRecord.space_id)
|
|
& (StatementDocument.id == SourceRecord.document_id),
|
|
)
|
|
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
|
|
.filter(
|
|
SourceRecord.space_id == document.space_id,
|
|
SourceRecord.bank_status == "hold",
|
|
SourceRecord.parse_run_id != run.id,
|
|
StatementDocument.status == "confirmed",
|
|
StatementDocument.active_parse_run_id == SourceRecord.parse_run_id,
|
|
StatementDocument.adapter_id == run.adapter_id,
|
|
StatementDocument.account_id == document.account_id,
|
|
HoldResolution.id.is_(None),
|
|
)
|
|
.all()
|
|
)
|
|
if not holds:
|
|
return {}
|
|
posted = (
|
|
db.query(SourceRecord)
|
|
.filter_by(parse_run_id=run.id, bank_status="posted")
|
|
.filter(SourceRecord.review_decision != "discard")
|
|
.all()
|
|
)
|
|
by_external: dict[str, list[SourceRecord]] = {}
|
|
by_fingerprint: dict[tuple[str, str], list[SourceRecord]] = {}
|
|
for record in posted:
|
|
if record.external_id:
|
|
by_external.setdefault(record.external_id, []).append(record)
|
|
by_fingerprint.setdefault((record.record_fp, record.direction), []).append(record)
|
|
candidates: dict[uuid.UUID, set[uuid.UUID]] = {}
|
|
for hold in holds:
|
|
matched: set[uuid.UUID] = set()
|
|
if hold.external_id:
|
|
matched.update(item.id for item in by_external.get(hold.external_id, []))
|
|
matched.update(item.id for item in by_fingerprint.get((hold.record_fp, hold.direction), []))
|
|
if len(matched) == 1:
|
|
candidates[hold.id] = matched
|
|
claimed: dict[uuid.UUID, int] = {}
|
|
for posted_ids in candidates.values():
|
|
for posted_id in posted_ids:
|
|
claimed[posted_id] = claimed.get(posted_id, 0) + 1
|
|
return {
|
|
hold_id: next(iter(posted_ids))
|
|
for hold_id, posted_ids in candidates.items()
|
|
if all(claimed[posted_id] == 1 for posted_id in posted_ids)
|
|
}
|
|
|
|
|
|
def _apply_hold_matches(
|
|
db: Session,
|
|
*,
|
|
document: StatementDocument,
|
|
run: ParseRun,
|
|
user: User,
|
|
matches: dict[uuid.UUID, uuid.UUID],
|
|
) -> None:
|
|
for hold_id, posted_id in matches.items():
|
|
db.add(
|
|
HoldResolution(
|
|
space_id=document.space_id,
|
|
hold_source_record_id=hold_id,
|
|
resolution="matched_posted",
|
|
resolving_source_record_id=posted_id,
|
|
resolved_at=utcnow(),
|
|
)
|
|
)
|
|
if matches:
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="import.holds_matched_posted",
|
|
space_id=document.space_id,
|
|
user_id=user.id,
|
|
detail={
|
|
"document_id": str(document.id),
|
|
"holds": sorted(str(hold_id) for hold_id in matches),
|
|
},
|
|
)
|
|
|
|
|
|
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 = _reconciliation_summary(draft)
|
|
if document.account_id is None and draft.account_fingerprint:
|
|
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 unresolved_holds(
|
|
db: Session, *, space_id: uuid.UUID, document: StatementDocument
|
|
) -> list[SourceRecord]:
|
|
"""Unresolved HOLD rows of the document's active parse run."""
|
|
if not document.active_parse_run_id:
|
|
return []
|
|
return (
|
|
db.query(SourceRecord)
|
|
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
|
|
.filter(
|
|
SourceRecord.space_id == space_id,
|
|
SourceRecord.parse_run_id == document.active_parse_run_id,
|
|
SourceRecord.bank_status == "hold",
|
|
HoldResolution.id.is_(None),
|
|
)
|
|
.all()
|
|
)
|
|
|
|
|
|
def unresolved_hold_counts(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
document_ids: list[uuid.UUID],
|
|
month: tuple[int, int] | None = None,
|
|
) -> dict[uuid.UUID, int]:
|
|
"""Count unresolved HOLD rows for active parse runs in one query."""
|
|
|
|
if not document_ids:
|
|
return {}
|
|
query = (
|
|
db.query(StatementDocument.id, func.count(SourceRecord.id))
|
|
.join(
|
|
SourceRecord,
|
|
(SourceRecord.document_id == StatementDocument.id)
|
|
& (SourceRecord.parse_run_id == StatementDocument.active_parse_run_id),
|
|
)
|
|
.outerjoin(HoldResolution, HoldResolution.hold_source_record_id == SourceRecord.id)
|
|
.filter(
|
|
StatementDocument.space_id == space_id,
|
|
StatementDocument.id.in_(document_ids),
|
|
SourceRecord.bank_status == "hold",
|
|
HoldResolution.id.is_(None),
|
|
)
|
|
)
|
|
if month is not None:
|
|
year, month_number = month
|
|
start = date(year, month_number, 1)
|
|
end = date(year + 1, 1, 1) if month_number == 12 else date(year, month_number + 1, 1)
|
|
query = query.filter(SourceRecord.occurred_on >= start, SourceRecord.occurred_on < end)
|
|
return {document_id: count for document_id, count in query.group_by(StatementDocument.id)}
|
|
|
|
|
|
def parse_month_filter(value: str | None) -> tuple[int, int] | None:
|
|
if value is None:
|
|
return None
|
|
try:
|
|
year_text, month_text = value.split("-", 1)
|
|
year, month = int(year_text), int(month_text)
|
|
except (ValueError, AttributeError) as exc:
|
|
raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM") from exc
|
|
if not 1 <= month <= 12 or year < 2000:
|
|
raise bad_request("IMPORT_MONTH_INVALID", "Month filter must use YYYY-MM")
|
|
return year, month
|
|
|
|
|
|
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()
|
|
)
|
|
record_ids = [record.id for record in records]
|
|
resolutions: dict[uuid.UUID, dict[str, Any]] = {}
|
|
candidates: dict[uuid.UUID, list[dict[str, Any]]] = {}
|
|
if record_ids:
|
|
for item in (
|
|
db.query(HoldResolution)
|
|
.filter(
|
|
HoldResolution.space_id == space_id,
|
|
HoldResolution.hold_source_record_id.in_(record_ids),
|
|
)
|
|
.all()
|
|
):
|
|
resolutions[item.hold_source_record_id] = {
|
|
"resolution": item.resolution,
|
|
"resolving_source_record_id": item.resolving_source_record_id,
|
|
"operation_id": item.operation_id,
|
|
"resolved_by_user_id": item.resolved_by_user_id,
|
|
"reason": item.reason,
|
|
"resolved_at": item.resolved_at,
|
|
}
|
|
for candidate in (
|
|
db.query(DedupCandidate)
|
|
.filter(
|
|
DedupCandidate.space_id == space_id,
|
|
DedupCandidate.source_record_id.in_(record_ids),
|
|
)
|
|
.order_by(DedupCandidate.created_at)
|
|
.all()
|
|
):
|
|
candidates.setdefault(candidate.source_record_id, []).append(
|
|
{
|
|
"id": candidate.id,
|
|
"kind": candidate.candidate_kind,
|
|
"confidence": candidate.confidence,
|
|
"resolution": candidate.resolution,
|
|
"existing_operation_id": candidate.existing_operation_id,
|
|
}
|
|
)
|
|
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,
|
|
"bank_status": item.bank_status,
|
|
"description": item.description,
|
|
"critical": item.critical,
|
|
"warnings": item.field_warnings,
|
|
"review_decision": item.review_decision,
|
|
"duplicate_operation_id": item.duplicate_operation_id,
|
|
"hold_resolution": resolutions.get(item.id),
|
|
"candidates": candidates.get(item.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", "savings_transfer"}:
|
|
raise conflict("IMPORT_DECISION_INVALID", "Unsupported row decision")
|
|
if source.bank_status == "hold" and decision != "discard":
|
|
raise conflict("IMPORT_DECISION_INVALID", "HOLD rows are resolved via hold resolution")
|
|
if decision == "savings_transfer" and (
|
|
source.bank_status != "posted" or source.direction != "out"
|
|
):
|
|
raise conflict(
|
|
"IMPORT_DECISION_INVALID",
|
|
"Only posted outflows can be transferred to savings",
|
|
)
|
|
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 account.system_key is not None:
|
|
raise conflict(
|
|
"IMPORT_SYSTEM_ACCOUNT_INVALID",
|
|
"System accounts cannot be used as statement accounts",
|
|
)
|
|
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 resolve_hold(
|
|
db: Session,
|
|
*,
|
|
space_id: uuid.UUID,
|
|
document_id: uuid.UUID,
|
|
source_record_id: uuid.UUID,
|
|
user: User,
|
|
action: str,
|
|
reason: str | None,
|
|
account_id: uuid.UUID | None = None,
|
|
) -> HoldResolution:
|
|
"""Manually settle one HOLD: book it as a normal operation or discard it."""
|
|
document = _document(db, space_id=space_id, document_id=document_id)
|
|
if document.status != "confirmed":
|
|
raise conflict(
|
|
"IMPORT_HOLD_DOCUMENT_NOT_CONFIRMED",
|
|
"Holds can only be resolved after the import is confirmed",
|
|
)
|
|
if action not in HOLD_MANUAL_ACTIONS:
|
|
raise bad_request("IMPORT_HOLD_ACTION_INVALID", "Unknown hold resolution action")
|
|
if reason is None or not reason.strip():
|
|
raise conflict("IMPORT_HOLD_REASON_REQUIRED", "A resolution reason is required")
|
|
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_HOLD_NOT_FOUND", "Import row not found")
|
|
if source.bank_status != "hold":
|
|
raise conflict("IMPORT_HOLD_INVALID", "Only hold rows can be resolved manually")
|
|
existing = db.query(HoldResolution.id).filter_by(hold_source_record_id=source.id).one_or_none()
|
|
if existing is not None:
|
|
raise conflict("IMPORT_HOLD_ALREADY_RESOLVED", "Hold is already resolved")
|
|
if source.occurred_on is None:
|
|
raise conflict("IMPORT_HOLD_DATE_MISSING", "Hold has no usable operation date")
|
|
if source.direction not in {"in", "out"}:
|
|
raise conflict("IMPORT_HOLD_DIRECTION_INVALID", "Hold direction is invalid")
|
|
resolution = HoldResolution(
|
|
space_id=space_id,
|
|
hold_source_record_id=source.id,
|
|
resolution="confirmed_as_posted"
|
|
if action == "confirm_as_posted"
|
|
else "discarded_as_cancelled",
|
|
resolved_by_user_id=user.id,
|
|
reason=reason.strip(),
|
|
resolved_at=utcnow(),
|
|
)
|
|
if action == "confirm_as_posted":
|
|
mapped_account_id = account_id or document.account_id
|
|
if mapped_account_id is None:
|
|
raise conflict(
|
|
"IMPORT_HOLD_ACCOUNT_REQUIRED",
|
|
"A mapped account is required to confirm a hold as posted",
|
|
)
|
|
account = db.query(Account).filter_by(id=mapped_account_id, space_id=space_id).one_or_none()
|
|
if account is None:
|
|
raise not_found("LEDGER_ACCOUNT_NOT_FOUND", "Account not found")
|
|
if source.direction == "in":
|
|
operation = create_income(
|
|
db,
|
|
space_id=space_id,
|
|
user=user,
|
|
account_id=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:
|
|
operation = create_expense(
|
|
db,
|
|
space_id=space_id,
|
|
user=user,
|
|
account_id=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}",
|
|
)
|
|
db.add(
|
|
SourceRecordLink(
|
|
space_id=space_id,
|
|
source_record_id=source.id,
|
|
operation_id=operation.id,
|
|
kind="hold_confirmed",
|
|
)
|
|
)
|
|
resolution.operation_id = operation.id
|
|
db.add(resolution)
|
|
db.flush()
|
|
audit.record(
|
|
db,
|
|
event_type="import.hold_confirmed_as_posted"
|
|
if action == "confirm_as_posted"
|
|
else "import.hold_discarded_as_cancelled",
|
|
space_id=space_id,
|
|
user_id=user.id,
|
|
detail={
|
|
"document_id": str(document.id),
|
|
"source_record_id": str(source.id),
|
|
"reason": reason.strip(),
|
|
},
|
|
)
|
|
return resolution
|
|
|
|
|
|
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 not in {"ready", "review_required"} or document.account_id is None:
|
|
raise conflict("IMPORT_NOT_READY", "Import review is not complete")
|
|
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")
|
|
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("matched") is False:
|
|
raise conflict("IMPORT_RECONCILIATION_MISMATCH", "Statement arithmetic does not reconcile")
|
|
if document.status != "ready":
|
|
raise conflict("IMPORT_NOT_READY", "Import review is not complete")
|
|
run = db.get(ParseRun, document.active_parse_run_id)
|
|
if run is not None:
|
|
_apply_hold_matches(
|
|
db,
|
|
document=document,
|
|
run=run,
|
|
user=user,
|
|
matches=_match_hold_resolutions(db, document=document, run=run),
|
|
)
|
|
for source in records:
|
|
if source.bank_status == "hold":
|
|
continue
|
|
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 == "savings_transfer":
|
|
from app.ledger.savings import ensure_savings_resources
|
|
|
|
savings_account, _savings_category = ensure_savings_resources(
|
|
db, space_id=space_id
|
|
)
|
|
if document.account_id == savings_account.id:
|
|
raise conflict(
|
|
"IMPORT_SAVINGS_SOURCE_INVALID",
|
|
"The mapped statement account cannot be the savings account itself",
|
|
)
|
|
assert source.occurred_on is not None
|
|
operation = create_transfer(
|
|
db,
|
|
space_id=space_id,
|
|
user=user,
|
|
source_account_id=document.account_id,
|
|
target_account_id=savings_account.id,
|
|
amount_kopecks=source.amount_kopecks,
|
|
occurred_on=source.occurred_on,
|
|
posted_on=source.posted_on,
|
|
description=source.description,
|
|
request_key=f"import-savings:{source.id}",
|
|
)
|
|
db.add(
|
|
SourceRecordLink(
|
|
space_id=space_id,
|
|
source_record_id=source.id,
|
|
operation_id=operation.id,
|
|
kind="created",
|
|
)
|
|
)
|
|
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
|
|
if document.active_parse_run_id:
|
|
db.query(HoldResolution).filter(
|
|
HoldResolution.space_id == space_id,
|
|
HoldResolution.resolution == "matched_posted",
|
|
HoldResolution.resolving_source_record_id.in_(
|
|
db.query(SourceRecord.id).filter_by(parse_run_id=document.active_parse_run_id)
|
|
),
|
|
).delete(synchronize_session=False)
|
|
links = (
|
|
db.query(SourceRecordLink)
|
|
.join(SourceRecord, SourceRecord.id == SourceRecordLink.source_record_id)
|
|
.filter(
|
|
SourceRecord.parse_run_id == document.active_parse_run_id,
|
|
SourceRecordLink.kind != "hold_confirmed",
|
|
)
|
|
.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
|