You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
335 lines
17 KiB
335 lines
17 KiB
"""Audited review lifecycle. No financial rows exist until an explicit commit.""" |
|
from datetime import date |
|
from functools import wraps |
|
import hashlib |
|
import re |
|
from uuid import UUID, uuid5, NAMESPACE_URL |
|
|
|
from django.core.exceptions import ValidationError |
|
from django.db import IntegrityError, connection, transaction |
|
from django.db.models import Q |
|
|
|
from finance.models import Account, Payee, Category, CardStatement, InstallmentPlan, Transaction, SourceRecord |
|
from finance.services import Actor, DomainError |
|
from finance.services.base import audit, identity, save |
|
from finance.services.posting import record |
|
from ingestion.models import ImportBatch, ImportItem |
|
from ingestion.parsers import parse_document, ParseError |
|
from ingestion.normalization import normalize |
|
from ingestion.duplicates import candidate_signature, transaction_signature |
|
from ingestion.validation import validate |
|
|
|
|
|
REASONS = {"USER_EXCLUDED", "MALFORMED", "UNRECOGNIZED", "ALREADY_IMPORTED", "SECTION_EXCLUDED"} |
|
REFERENCE_FIELDS = {"payee_id": ("payees", Payee), "category_id": ("categories", Category), |
|
"statement_id": ("statements", CardStatement), "installment_plan_id": ("plans", InstallmentPlan)} |
|
|
|
|
|
def operation(name): |
|
def decorate(function): |
|
@wraps(function) |
|
def wrapped(*args, actor, **kwargs): |
|
if not isinstance(actor, Actor) or actor.kind not in {"HUMAN", "APPLICATION", "AGENT"} or not isinstance(actor.identifier, str) or not actor.identifier.strip() or len(actor.identifier) > 200: |
|
raise DomainError("invalid_actor") |
|
if connection.in_atomic_block: |
|
raise DomainError("transaction_boundary") |
|
try: |
|
with transaction.atomic(durable=True): |
|
batch, fields, emit = function(*args, actor=actor, **kwargs) |
|
if emit: |
|
audit(actor, name, "SUCCESS", batch.pk, fields) |
|
return batch |
|
except (DomainError, ParseError, ValidationError, IntegrityError) as error: |
|
code = getattr(error, "code", None) |
|
if not isinstance(code, str): |
|
code = "CONFLICT" if isinstance(error, IntegrityError) else "VALIDATION_ERROR" |
|
with transaction.atomic(durable=True): |
|
audit(actor, name, "FAILURE", error_code=code) |
|
raise DomainError(code) from None |
|
return wrapped |
|
return decorate |
|
|
|
|
|
def _batch(batch_id, revision=None, *, preview=True): |
|
try: |
|
batch = ImportBatch.objects.select_for_update().get(pk=identity(batch_id)) |
|
except ImportBatch.DoesNotExist: |
|
raise DomainError("BATCH_NOT_FOUND") from None |
|
if revision is not None and (type(revision) is not int or revision != batch.revision): |
|
raise DomainError("STALE_REVISION") |
|
if preview and batch.state != "PREVIEW": |
|
raise DomainError("BATCH_NOT_PREVIEW") |
|
return batch |
|
|
|
|
|
def _rows(query, locking): |
|
if locking: |
|
query = query.select_for_update(of=("self",)) |
|
return {str(obj.pk): obj for obj in query.order_by("pk")} |
|
|
|
|
|
def _load(batch, items, locking): |
|
account_ids = [value for value in batch.section_mapping.values() if value] |
|
accounts = _rows(Account.objects.filter(pk__in=account_ids), locking) |
|
refs = {} |
|
for field, (key, model) in REFERENCE_FIELDS.items(): |
|
ids = [item.decision[field] for item in items if item.decision.get(field)] |
|
refs[key] = _rows(model.objects.filter(pk__in=ids), locking) |
|
dates = set() |
|
for item in items: |
|
try: |
|
dates.add(date.fromisoformat(item.candidate.get("posting_date"))) |
|
except (TypeError, ValueError): |
|
pass |
|
target_ids = [item.decision[key] for item in items for key in ("transaction_id", "refund_of_id") if item.decision.get(key)] |
|
query = Transaction.objects.filter(Q(account_id__in=account_ids, posting_date__in=dates) | Q(pk__in=target_ids)) |
|
refs["transactions"] = _rows(query.select_related("account", "installment_plan"), locking) |
|
sources = {row.item_locator: row for row in SourceRecord.objects.filter(document_hash=batch.document_hash).select_related("transaction")} |
|
return accounts, refs, sources |
|
|
|
|
|
def _fingerprint(obj): |
|
# IDs and modification timestamps detect reference annotations/archive changes. |
|
return [str(obj.pk), obj.updated_at.isoformat()] |
|
|
|
|
|
def _evaluate(batch, items, *, locking=False): |
|
accounts, refs, sources = _load(batch, items, locking) |
|
keys = {} |
|
existing = {} |
|
for row in refs["transactions"].values(): |
|
existing.setdefault(transaction_signature(row), []).append(str(row.pk)) |
|
for item in items: |
|
account = accounts.get(batch.section_mapping.get(item.candidate["section"])) |
|
if account and not item.candidate.get("errors"): |
|
try: |
|
keys[item.locator] = candidate_signature(item.candidate, account, item.decision) |
|
except (KeyError, TypeError, ValueError): |
|
pass |
|
results = {} |
|
plan_definitions = {} |
|
plan_memberships = set() |
|
for item in items: |
|
candidate, decision = item.candidate, item.decision |
|
mapped = batch.section_mapping.get(candidate["section"]) |
|
account = accounts.get(mapped) |
|
issues = validate(candidate, decision, account, refs, batch.profile) if mapped else ["SECTION_EXCLUDED"] |
|
key = keys.get(item.locator) |
|
source = sources.get(item.locator) |
|
matches = sorted(existing.get(key, [])) if key is not None else [] |
|
within = sorted(locator for locator, other in keys.items() if locator != item.locator and other == key) if key is not None else [] |
|
duplicate = {"existing": matches, "within": within, |
|
"source": str(source.transaction_id) if source else None, |
|
"source_account": str(source.transaction.account_id) if source else None, |
|
"status": "ALREADY_IMPORTED" if source else None} |
|
if source: |
|
issues.append("ALREADY_IMPORTED" if str(source.transaction.account_id) == mapped else "SOURCE_ACCOUNT_CONFLICT") |
|
if decision.get("new_plan") and decision.get("action") == "CREATE": |
|
name = decision["new_plan"] |
|
definition = (mapped, candidate.get("installment_total")) |
|
if name in plan_definitions and plan_definitions[name] != definition: |
|
issues.append("NEW_PLAN_CONFLICT") |
|
plan_definitions[name] = definition |
|
if decision.get("action") == "CREATE" and candidate.get("installment_number"): |
|
plan_key = decision.get("installment_plan_id") or decision.get("new_plan") |
|
membership = (plan_key, candidate["installment_number"]) |
|
if plan_key and membership in plan_memberships: |
|
issues.append("DUPLICATE_INSTALLMENT") |
|
plan_memberships.add(membership) |
|
results[item.locator] = {"issues": sorted(set(issues)), "duplicates": duplicate} |
|
reference_snapshot = {name: [_fingerprint(obj) for obj in table.values()] for name, table in refs.items() if name != "transactions"} |
|
relevant_ids = {tid for result in results.values() for tid in result["duplicates"]["existing"]} |
|
relevant_ids.update(item.decision[key] for item in items for key in ("transaction_id", "refund_of_id") if item.decision.get(key)) |
|
reference_snapshot["transactions"] = [_fingerprint(obj) for key, obj in refs["transactions"].items() if key in relevant_ids] |
|
return {"accounts": [_fingerprint(obj) for obj in accounts.values()], "references": reference_snapshot, "items": results} |
|
|
|
|
|
def _persist(batch, items, snapshot): |
|
for item in items: |
|
current = snapshot["items"][item.locator] |
|
item.issues, item.duplicates = current["issues"], current["duplicates"] |
|
ImportItem.objects.bulk_update(items, ["issues", "duplicates", "decision"]) |
|
batch.snapshot = snapshot |
|
batch.summary = {**batch.summary, "rows": len(items), "error_rows": sum(bool(item.issues) for item in items), |
|
"decided_rows": sum(bool(item.decision) for item in items)} |
|
batch.save() |
|
|
|
|
|
def _current(batch, items): |
|
snapshot = _evaluate(batch, items, locking=True) |
|
if snapshot != batch.snapshot: |
|
raise DomainError("STALE_PREVIEW") |
|
return snapshot |
|
|
|
|
|
@operation("import_preview") |
|
def create_preview(*, data, profile, section_mapping, context=None, actor): |
|
parsed = parse_document(data, profile, context or {}) |
|
candidates = [candidate.to_dict() for candidate in normalize(parsed)] |
|
if not isinstance(section_mapping, dict) or any(not isinstance(key, str) for key in section_mapping): |
|
raise DomainError("INVALID_SECTION_MAPPING") |
|
sections = {item["section"] for item in candidates} |
|
if set(section_mapping) != sections: |
|
raise DomainError("SECTION_MAPPING_REQUIRED") |
|
mapping = {key: str(identity(value)) if value is not None else None for key, value in section_mapping.items()} |
|
batch = ImportBatch.objects.create(document_hash=hashlib.sha256(data).hexdigest(), |
|
source_format="BANK_CSV" if profile == "BANK_CSV_V1" else "OUROCARD_TXT", |
|
profile=profile, encoding=parsed.encoding, context=context or {}, section_mapping=mapping, |
|
summary={"parser_skipped": dict(parsed.skipped)}, actor_kind=actor.kind, actor_identifier=actor.identifier) |
|
items = [ImportItem(batch=batch, locator=candidate["locator"], candidate=candidate) for candidate in candidates] |
|
ImportItem.objects.bulk_create(items) |
|
_persist(batch, items, _evaluate(batch, items, locking=True)) |
|
return batch, ["preview"], True |
|
|
|
|
|
def export_review(*, batch_id): |
|
# Keep exported revision and item decisions consistent with concurrent editing. |
|
with transaction.atomic(): |
|
batch = _batch(batch_id, preview=False) |
|
items = list(batch.items.order_by("created_at", "locator")) |
|
return {"batch_id": str(batch.pk), "revision": batch.revision, "state": batch.state, |
|
"profile": batch.profile, "encoding": batch.encoding, "summary": batch.summary, |
|
"items": [{"locator": row.locator, "candidate_id": str(uuid5(NAMESPACE_URL, batch.document_hash + ":" + row.locator)), |
|
"candidate": row.candidate, "issues": row.issues, "duplicates": row.duplicates, |
|
"decision": row.decision} for row in items], |
|
"decisions": [{"locator": row.locator, **row.decision} for row in items]} |
|
|
|
|
|
def _decisions(value, locators): |
|
if not isinstance(value, list): |
|
raise DomainError("INVALID_DECISIONS") |
|
result = {} |
|
for supplied in value: |
|
if not isinstance(supplied, dict): |
|
raise DomainError("INVALID_DECISION") |
|
row = dict(supplied) |
|
locator = row.pop("locator", None) |
|
if not isinstance(locator, str) or locator not in locators or locator in result: |
|
raise DomainError("INVALID_LOCATOR") |
|
action = row.get("action") |
|
allowed = {"action"} |
|
if action == "CREATE": |
|
allowed |= {"acknowledge_duplicate", "kind", "payee_id", "category_id", "statement_id", "refund_of_id", "installment_plan_id", "new_plan"} |
|
elif action == "MATCH": |
|
allowed.add("transaction_id") |
|
elif action == "SKIP": |
|
allowed.add("reason") |
|
else: |
|
raise DomainError("INVALID_ACTION") |
|
if set(row) - allowed: |
|
raise DomainError("UNKNOWN_DECISION_FIELD") |
|
if "acknowledge_duplicate" in row and type(row["acknowledge_duplicate"]) is not bool: |
|
raise DomainError("INVALID_ACKNOWLEDGEMENT") |
|
if action == "SKIP" and row.get("reason") not in REASONS: |
|
raise DomainError("SKIP_REASON_REQUIRED") |
|
if action == "MATCH" and not row.get("transaction_id"): |
|
raise DomainError("MATCH_TARGET_REQUIRED") |
|
for field in set(row) & {"transaction_id", "payee_id", "category_id", "statement_id", "refund_of_id", "installment_plan_id"}: |
|
if row[field] is not None: |
|
row[field] = str(identity(row[field])) |
|
if "new_plan" in row and (not isinstance(row["new_plan"], str) or not re.fullmatch(r"[A-Za-z0-9_-]{1,64}", row["new_plan"])): |
|
raise DomainError("INVALID_NEW_PLAN") |
|
if "kind" in row and row["kind"] not in {"INCOME", "EXPENSE", "REFUND", "TRANSFER"}: |
|
raise DomainError("INVALID_KIND") |
|
result[locator] = row |
|
return result |
|
|
|
|
|
@operation("import_decisions") |
|
def apply_decisions(*, batch_id, revision, decisions, actor): |
|
batch = _batch(batch_id, revision) |
|
items = list(batch.items.order_by("locator")) |
|
_current(batch, items) |
|
values = _decisions(decisions, {item.locator for item in items}) |
|
for item in items: |
|
item.decision = values.get(item.locator, {}) |
|
batch.revision += 1 |
|
_persist(batch, items, _evaluate(batch, items, locking=True)) |
|
return batch, ["decisions", "revision"], True |
|
|
|
|
|
@operation("import_refresh") |
|
def refresh_preview(*, batch_id, revision, actor): |
|
batch = _batch(batch_id, revision) |
|
items = list(batch.items.order_by("locator")) |
|
for item in items: |
|
item.decision = {} |
|
batch.revision += 1 |
|
_persist(batch, items, _evaluate(batch, items, locking=True)) |
|
return batch, ["preview", "decisions", "revision"], True |
|
|
|
|
|
@operation("import_cancel") |
|
def cancel_preview(*, batch_id, revision, actor): |
|
batch = _batch(batch_id, revision) |
|
batch.state = "CANCELLED" |
|
batch.save() |
|
return batch, ["state"], True |
|
|
|
|
|
def _check_decision(item): |
|
decision = item.decision |
|
if not decision: |
|
raise DomainError("DECISION_REQUIRED") |
|
if "SOURCE_ACCOUNT_CONFLICT" in item.issues: |
|
raise DomainError("SOURCE_ACCOUNT_CONFLICT") |
|
if decision["action"] == "SKIP": |
|
if item.duplicates.get("source") and decision["reason"] != "ALREADY_IMPORTED": |
|
raise DomainError("ALREADY_IMPORTED") |
|
if "SECTION_EXCLUDED" in item.issues and decision["reason"] != "SECTION_EXCLUDED": |
|
raise DomainError("SECTION_EXCLUDED") |
|
return |
|
if item.duplicates.get("source"): |
|
raise DomainError("ALREADY_IMPORTED") |
|
# MATCH validates the signature rather than requiring a new plan assignment. |
|
issues = [issue for issue in item.issues if not (decision["action"] == "MATCH" and issue == "INSTALLMENT_PLAN_REQUIRED")] |
|
if issues: |
|
raise DomainError("UNRESOLVED_ISSUES") |
|
if decision["action"] == "CREATE" and (item.duplicates["existing"] or item.duplicates["within"]) and not decision.get("acknowledge_duplicate"): |
|
raise DomainError("DUPLICATE_ACKNOWLEDGEMENT_REQUIRED") |
|
if decision["action"] == "MATCH" and decision["transaction_id"] not in item.duplicates["existing"]: |
|
raise DomainError("MATCH_SIGNATURE_MISMATCH") |
|
|
|
|
|
@operation("import_commit") |
|
def commit_preview(*, batch_id, revision, actor): |
|
batch = _batch(batch_id, revision, preview=False) |
|
if batch.state == "COMMITTED": |
|
return batch, [], False |
|
if batch.state != "PREVIEW": |
|
raise DomainError("BATCH_NOT_PREVIEW") |
|
items = list(batch.items.order_by("locator")) |
|
_current(batch, items) |
|
for item in items: |
|
_check_decision(item) |
|
plans = {} |
|
result = {"created": 0, "matched": 0, "skipped": 0, "transaction_ids": [], "parser_skipped": batch.summary.get("parser_skipped", {}), "error_rows": batch.summary.get("error_rows", 0)} |
|
for item in items: |
|
candidate, decision = item.candidate, item.decision |
|
if decision["action"] == "SKIP": |
|
result["skipped"] += 1 |
|
continue |
|
if decision["action"] == "MATCH": |
|
row = Transaction.objects.get(pk=decision["transaction_id"]) |
|
result["matched"] += 1 |
|
else: |
|
account_id = batch.section_mapping[candidate["section"]] |
|
plan_id = decision.get("installment_plan_id") |
|
if decision.get("new_plan"): |
|
name = decision["new_plan"] |
|
if name not in plans: |
|
plans[name] = save(InstallmentPlan(account_id=account_id, description=name, total_installments=candidate["installment_total"])) |
|
plan_id = plans[name].pk |
|
fields = {key: decision.get(key) for key in ("payee_id", "category_id", "statement_id", "refund_of_id")} |
|
row = record(account_id=account_id, amount=candidate["amount"], posting_date=candidate["posting_date"], |
|
occurrence_date=candidate["occurrence_date"], kind=decision.get("kind", candidate["kind"]), |
|
description=candidate["description"], installment_plan_id=plan_id, |
|
installment_number=candidate.get("installment_number"), **fields) |
|
result["created"] += 1 |
|
save(SourceRecord(transaction=row, document_hash=batch.document_hash, source_format=batch.source_format, |
|
parser_version=batch.parser_version, item_locator=item.locator)) |
|
result["transaction_ids"].append(str(row.pk)) |
|
batch.state = "COMMITTED" |
|
batch.result = result |
|
batch.save() |
|
return batch, ["state", "transactions", "source_records", "plans"], True
|
|
|