63d065011b
- Updated timestamps and AST hashes in manifest.json for various backend and frontend files to reflect recent changes. - Added new files related to MCC categories and optional hold reasons in the manifest. - Modified the README in the infra directory to correct the linting tool from 'mypy' to 'ty' for type checking in the backend project.
359 lines
12 KiB
Python
359 lines
12 KiB
Python
import uuid
|
|
from typing import Annotated, Any
|
|
|
|
from fastapi import APIRouter, File, Form, Query, UploadFile
|
|
from pydantic import BaseModel, ConfigDict, Field
|
|
|
|
from app.dependencies import Context, Csrf, Db
|
|
from app.imports import service
|
|
from app.imports.tasks import enqueue_process_statement
|
|
|
|
router = APIRouter(prefix="/imports", tags=["imports"])
|
|
|
|
|
|
class ReviewBody(BaseModel):
|
|
account_id: uuid.UUID
|
|
confirmed_without_full_reconciliation: bool = False
|
|
reason: str | None = None
|
|
|
|
|
|
class DecisionBody(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
decision: str
|
|
duplicate_operation_id: uuid.UUID | None = Field(default=None, alias="duplicateOfOperationId")
|
|
|
|
|
|
class ConfirmBody(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
confirmed_without_full_reconciliation: bool = Field(
|
|
default=False, alias="confirmedWithoutFullReconciliation"
|
|
)
|
|
|
|
|
|
class HoldResolveBody(BaseModel):
|
|
model_config = ConfigDict(populate_by_name=True)
|
|
|
|
action: str
|
|
reason: str | None = None
|
|
account_id: uuid.UUID | None = Field(default=None, alias="accountId")
|
|
|
|
|
|
def summary(document: Any, *, unresolved_hold_count: int = 0) -> dict:
|
|
return {
|
|
"id": document.id,
|
|
"fileName": document.original_filename,
|
|
"status": document.status,
|
|
"bankAdapter": document.adapter_id or "detecting",
|
|
"accountId": document.account_id,
|
|
"accountName": None,
|
|
"periodFrom": None,
|
|
"periodTo": None,
|
|
"needsAccountMapping": document.needs_account_mapping,
|
|
"unresolvedHoldCount": unresolved_hold_count,
|
|
"errorCode": document.error_code,
|
|
"createdAt": document.created_at,
|
|
}
|
|
|
|
|
|
@router.get("")
|
|
def imports(
|
|
db: Db,
|
|
context: Context,
|
|
month: Annotated[str | None, Query()] = None,
|
|
) -> list[dict]:
|
|
month_filter = service.parse_month_filter(month)
|
|
documents = service.list_documents(db, space_id=context.space.id)
|
|
hold_counts = service.unresolved_hold_counts(
|
|
db,
|
|
space_id=context.space.id,
|
|
document_ids=[item.id for item in documents],
|
|
month=month_filter,
|
|
)
|
|
result = []
|
|
for item in documents:
|
|
hold_count = hold_counts.get(item.id, 0)
|
|
if month_filter is not None and item.status == "confirmed" and hold_count == 0:
|
|
continue
|
|
result.append(summary(item, unresolved_hold_count=hold_count))
|
|
return result
|
|
|
|
|
|
@router.post("", status_code=202)
|
|
async def upload(
|
|
file: Annotated[UploadFile, File()],
|
|
db: Db,
|
|
context: Context,
|
|
_csrf: Csrf,
|
|
account_id: Annotated[uuid.UUID | None, Form(alias="accountId")] = None,
|
|
) -> dict:
|
|
from app.config import get_settings
|
|
|
|
raw = await file.read(get_settings().max_upload_bytes + 1)
|
|
document, created = service.create_document(
|
|
db,
|
|
space_id=context.space.id,
|
|
user=context.user,
|
|
filename=file.filename or "statement",
|
|
raw=raw,
|
|
account_id=account_id,
|
|
)
|
|
if created:
|
|
db.commit()
|
|
try:
|
|
enqueue_process_statement(document.id, context.space.id)
|
|
except Exception as exc:
|
|
from app.db.session import set_space_context
|
|
from app.errors import AppError
|
|
|
|
set_space_context(db, context.space.id)
|
|
document = service._document(db, space_id=context.space.id, document_id=document.id)
|
|
document.status = "failed"
|
|
document.error_code = "IMPORT_QUEUE_FAILED"
|
|
db.commit()
|
|
raise AppError(
|
|
"Import queue is unavailable",
|
|
code="IMPORT_QUEUE_FAILED",
|
|
status_code=503,
|
|
) from exc
|
|
return summary(document)
|
|
|
|
|
|
@router.get("/{document_id}")
|
|
def detail(document_id: uuid.UUID, db: Db, context: Context) -> dict:
|
|
raw = service.document_detail(db, space_id=context.space.id, document_id=document_id)
|
|
document = service._document(db, space_id=context.space.id, document_id=document_id)
|
|
base = summary(
|
|
document,
|
|
unresolved_hold_count=len(
|
|
service.unresolved_holds(db, space_id=context.space.id, document=document)
|
|
),
|
|
)
|
|
reconciliation = raw.get("reconciliation") or {}
|
|
rows = []
|
|
pending_candidate_row_ids: set[uuid.UUID] = set()
|
|
for item in raw["records"]:
|
|
hold = item.get("hold_resolution")
|
|
candidates = [
|
|
{
|
|
"id": candidate["id"],
|
|
"kind": candidate["kind"],
|
|
"confidence": candidate["confidence"],
|
|
"resolution": candidate["resolution"],
|
|
"existingOperationId": candidate["existing_operation_id"],
|
|
}
|
|
for candidate in item.get("candidates", [])
|
|
]
|
|
has_pending_candidate = any(
|
|
candidate["resolution"] == "pending" for candidate in candidates
|
|
)
|
|
if has_pending_candidate:
|
|
pending_candidate_row_ids.add(item["id"])
|
|
rows.append(
|
|
{
|
|
"id": item["id"],
|
|
"externalId": item["external_id"],
|
|
"occurredOn": item["occurred_on"],
|
|
"amountKopecks": item["amount_kopecks"],
|
|
"direction": item["direction"],
|
|
"bankStatus": item.get("bank_status", "posted"),
|
|
"description": item["description"],
|
|
"mcc": item["mcc"],
|
|
"mccName": item["mcc_name"],
|
|
"payeeHint": None,
|
|
"warnings": (item["warnings"] or {}).get("codes", []),
|
|
"decision": "pending"
|
|
if item["critical"] or has_pending_candidate
|
|
else item["review_decision"],
|
|
"duplicateOfOperationId": item["duplicate_operation_id"],
|
|
"candidates": candidates,
|
|
"holdResolution": None
|
|
if hold is None
|
|
else {
|
|
"resolution": hold["resolution"],
|
|
"resolvingSourceRecordId": hold["resolving_source_record_id"],
|
|
"operationId": hold["operation_id"],
|
|
"resolvedByUserId": hold["resolved_by_user_id"],
|
|
"reason": hold["reason"],
|
|
"resolvedAt": hold["resolved_at"],
|
|
},
|
|
}
|
|
)
|
|
blockers = [
|
|
{"code": "IMPORT_FIELD_UNRESOLVED", "message": None, "rowId": str(item["id"])}
|
|
for item in raw["records"]
|
|
if item["critical"]
|
|
]
|
|
blockers.extend(
|
|
{
|
|
"code": "IMPORT_DECISION_REQUIRED",
|
|
"message": "Possible duplicate or transfer requires a decision",
|
|
"rowId": str(item["id"]),
|
|
}
|
|
for item in raw["records"]
|
|
if item["id"] in pending_candidate_row_ids
|
|
)
|
|
status = (
|
|
"matched"
|
|
if reconciliation.get("full")
|
|
else "confirmed_without"
|
|
if reconciliation.get("confirmed_without_full_reconciliation")
|
|
else "not_available"
|
|
)
|
|
return {
|
|
**base,
|
|
"failureReason": raw["error_code"],
|
|
"blockers": blockers,
|
|
"warnings": [],
|
|
"reconciliation": {
|
|
"status": status,
|
|
"openingBalanceKopecks": reconciliation.get("opening_balance_kopecks"),
|
|
"closingBalanceKopecks": reconciliation.get("closing_balance_kopecks"),
|
|
"computedClosingKopecks": reconciliation.get("computed_closing_kopecks"),
|
|
"declaredInflowKopecks": reconciliation.get("declared_inflow_kopecks"),
|
|
"declaredOutflowKopecks": reconciliation.get("declared_outflow_kopecks"),
|
|
"declaredHoldKopecks": reconciliation.get("declared_hold_kopecks"),
|
|
"parsedPostedInflowKopecks": reconciliation.get("parsed_posted_inflow_kopecks"),
|
|
"parsedPostedOutflowKopecks": reconciliation.get("parsed_posted_outflow_kopecks"),
|
|
"parsedHoldTotalKopecks": reconciliation.get("parsed_hold_total_kopecks"),
|
|
"postedTotalsMatched": reconciliation.get("posted_totals_matched"),
|
|
"holdTotalsMatched": reconciliation.get("hold_totals_matched"),
|
|
"balanceMatched": reconciliation.get("balance_matched"),
|
|
"reason": reconciliation.get("reason"),
|
|
},
|
|
"rows": rows,
|
|
"totals": {
|
|
"total": len(rows),
|
|
"create": sum(row["decision"] == "create" for row in rows),
|
|
"discard": sum(row["decision"] == "discard" for row in rows),
|
|
"duplicate": sum(row["decision"] == "duplicate" for row in rows),
|
|
"transfer": sum(row["decision"] == "transfer" for row in rows),
|
|
"savingsTransfer": sum(row["decision"] == "savings_transfer" for row in rows),
|
|
"pending": sum(row["decision"] == "pending" for row in rows),
|
|
},
|
|
}
|
|
|
|
|
|
@router.post("/{document_id}/review")
|
|
def review(document_id: uuid.UUID, body: ReviewBody, db: Db, context: Context, _csrf: Csrf) -> dict:
|
|
document = service.set_review_confirmation(
|
|
db,
|
|
space_id=context.space.id,
|
|
document_id=document_id,
|
|
**body.model_dump(),
|
|
)
|
|
return summary(
|
|
document,
|
|
unresolved_hold_count=len(
|
|
service.unresolved_holds(db, space_id=context.space.id, document=document)
|
|
),
|
|
)
|
|
|
|
|
|
@router.patch("/{document_id}/rows/{row_id}")
|
|
def decide_row(
|
|
document_id: uuid.UUID,
|
|
row_id: uuid.UUID,
|
|
body: DecisionBody,
|
|
db: Db,
|
|
context: Context,
|
|
_csrf: Csrf,
|
|
) -> dict:
|
|
service.decide_source_record(
|
|
db,
|
|
space_id=context.space.id,
|
|
document_id=document_id,
|
|
source_record_id=row_id,
|
|
user=context.user,
|
|
**body.model_dump(by_alias=False),
|
|
)
|
|
return detail(document_id, db, context)
|
|
|
|
|
|
@router.post("/{document_id}/rows/{row_id}/resolve")
|
|
def resolve_hold(
|
|
document_id: uuid.UUID,
|
|
row_id: uuid.UUID,
|
|
body: HoldResolveBody,
|
|
db: Db,
|
|
context: Context,
|
|
_csrf: Csrf,
|
|
) -> dict:
|
|
service.resolve_hold(
|
|
db,
|
|
space_id=context.space.id,
|
|
document_id=document_id,
|
|
source_record_id=row_id,
|
|
user=context.user,
|
|
**body.model_dump(by_alias=False),
|
|
)
|
|
return detail(document_id, db, context)
|
|
|
|
|
|
@router.post("/{document_id}/confirm")
|
|
def confirm(
|
|
document_id: uuid.UUID,
|
|
body: ConfirmBody,
|
|
db: Db,
|
|
context: Context,
|
|
_csrf: Csrf,
|
|
) -> dict:
|
|
document = service._document(db, space_id=context.space.id, document_id=document_id)
|
|
if body.confirmed_without_full_reconciliation and document.account_id:
|
|
service.set_review_confirmation(
|
|
db,
|
|
space_id=context.space.id,
|
|
document_id=document_id,
|
|
account_id=document.account_id,
|
|
confirmed_without_full_reconciliation=True,
|
|
reason=None,
|
|
)
|
|
document = service.confirm_document(
|
|
db, space_id=context.space.id, document_id=document_id, user=context.user
|
|
)
|
|
return detail(document_id, db, context)
|
|
|
|
|
|
@router.post("/{document_id}/reparse", status_code=202)
|
|
def reparse(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
|
document = service.prepare_reparse(
|
|
db, space_id=context.space.id, document_id=document_id, user=context.user
|
|
)
|
|
db.commit()
|
|
from app.db.session import set_space_context
|
|
|
|
try:
|
|
enqueue_process_statement(document.id, context.space.id)
|
|
except Exception as exc:
|
|
from app.errors import AppError
|
|
|
|
set_space_context(db, context.space.id)
|
|
document = service._document(db, space_id=context.space.id, document_id=document.id)
|
|
document.status = "failed"
|
|
document.error_code = "IMPORT_QUEUE_FAILED"
|
|
db.commit()
|
|
raise AppError(
|
|
"Import queue is unavailable",
|
|
code="IMPORT_QUEUE_FAILED",
|
|
status_code=503,
|
|
) from exc
|
|
set_space_context(db, context.space.id)
|
|
return detail(document_id, db, context)
|
|
|
|
|
|
@router.post("/{document_id}/revert")
|
|
def revert(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
|
service.revert_document(
|
|
db, space_id=context.space.id, document_id=document_id, user=context.user
|
|
)
|
|
return detail(document_id, db, context)
|
|
|
|
|
|
@router.delete("/{document_id}/source")
|
|
def delete_source(document_id: uuid.UUID, db: Db, context: Context, _csrf: Csrf) -> dict:
|
|
document = service.delete_source_file(
|
|
db, space_id=context.space.id, document_id=document_id, user=context.user
|
|
)
|
|
return summary(document)
|