d582f15681
- 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.
922 lines
35 KiB
Python
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
|