Retain original bank statements for 24 hours

This commit is contained in:
A R R R Associates
2026-08-05 10:44:41 +05:30
parent fd2a572d06
commit 9bfc666dad
4 changed files with 75 additions and 23 deletions
+38 -10
View File
@@ -272,16 +272,9 @@ def _process_job(job_id: str) -> None:
"financial_year": job.financial_year or "", "financial_year": job.financial_year or "",
"classification_enabled": job.classification_enabled, "classification_enabled": job.classification_enabled,
} }
for path in paths: # Keep the original uploaded statements until the job expiry time.
try: # This applies equally to completed and failed jobs and allows the
path.unlink(missing_ok=True) # uploader to reproduce parser issues during the 24-hour retention window.
except OSError:
pass
input_dir = Path(job.job_directory) / "Input"
try:
input_dir.rmdir()
except OSError:
pass
job.output_file = str(output) job.output_file = str(output)
job.summary_json = json.dumps(summary) job.summary_json = json.dumps(summary)
job.status = "completed" job.status = "completed"
@@ -399,6 +392,36 @@ def list_user_jobs(user_id: int, limit: int = 25) -> list[BankStatementAnalysisJ
db.close() db.close()
def original_statement_files(job: BankStatementAnalysisJob) -> list[Path]:
"""Return retained input PDFs that still belong to the job directory."""
job_root = Path(job.job_directory).resolve()
input_root = (job_root / "Input").resolve()
try:
configured = [Path(value).resolve() for value in json.loads(job.input_files_json or "[]")]
except (TypeError, ValueError, json.JSONDecodeError):
configured = []
retained: list[Path] = []
for path in configured:
try:
path.relative_to(input_root)
except ValueError:
continue
if path.is_file() and path.suffix.lower() == ".pdf":
retained.append(path)
return retained
def get_owned_original_statement(user_id: int, job_id: str, file_index: int) -> tuple[BankStatementAnalysisJob, Path] | None:
job = get_owned_job(user_id, job_id)
if not job or job.status not in {"completed", "failed"}:
return None
files = original_statement_files(job)
if file_index < 1 or file_index > len(files):
return None
return job, files[file_index - 1]
def job_view(job: BankStatementAnalysisJob) -> dict: def job_view(job: BankStatementAnalysisJob) -> dict:
return { return {
"id": job.id, "id": job.id,
@@ -415,6 +438,11 @@ def job_view(job: BankStatementAnalysisJob) -> dict:
"estimated_wait": estimated_wait(job), "estimated_wait": estimated_wait(job),
"summary": _summary(job), "summary": _summary(job),
"download_ready": job.status == "completed" and bool(job.output_file) and Path(job.output_file).is_file(), "download_ready": job.status == "completed" and bool(job.output_file) and Path(job.output_file).is_file(),
"original_files": [
{"index": index, "name": path.name}
for index, path in enumerate(original_statement_files(job), start=1)
],
"originals_retained": job.status in {"completed", "failed"} and bool(original_statement_files(job)),
} }
@@ -13,21 +13,14 @@
<form action="/tools/bank-statement-analyzer/analyze" method="post" enctype="multipart/form-data" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"> <form action="/tools/bank-statement-analyzer/analyze" method="post" enctype="multipart/form-data" class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"> <input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<div class="grid gap-5 md:grid-cols-2"> <div class="grid gap-5 md:grid-cols-2">
<div> <div><label class="mb-1 block text-sm font-semibold text-slate-700">Bank</label><select name="bank_selection" class="w-full rounded-xl border border-slate-300 bg-white px-3 py-2 text-sm">{% for value, label in bank_options %}<option value="{{ value }}" {% if selected_bank == value %}selected{% endif %}>{{ label }}</option>{% endfor %}</select><p class="mt-1 text-xs text-slate-500">Keep Auto Detect or select a bank for direct parser validation.</p></div>
<input type="hidden" name="bank_selection" value="auto">
<label class="mb-1 block text-sm font-semibold text-slate-700">Bank detection</label>
<div class="rounded-xl border border-slate-200 bg-slate-50 px-4 py-3">
<div class="text-sm font-semibold text-slate-800">Automatic layout detection</div>
<p class="mt-1 text-xs text-slate-500">The validated template engine runs first. Existing bank-specific parsers are used automatically when the layout template cannot be reconciled.</p>
</div>
</div>
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Financial year <span class="font-normal text-slate-400">(optional)</span></label><input name="financial_year" value="{{ financial_year or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Example: 2025-26"></div> <div><label class="mb-1 block text-sm font-semibold text-slate-700">Financial year <span class="font-normal text-slate-400">(optional)</span></label><input name="financial_year" value="{{ financial_year or '' }}" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Example: 2025-26"></div>
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account holder override <span class="font-normal text-slate-400">(optional)</span></label><input name="customer_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div> <div><label class="mb-1 block text-sm font-semibold text-slate-700">Account holder override <span class="font-normal text-slate-400">(optional)</span></label><input name="customer_name" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
<div><label class="mb-1 block text-sm font-semibold text-slate-700">Account number override <span class="font-normal text-slate-400">(optional)</span></label><input name="account_number" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div> <div><label class="mb-1 block text-sm font-semibold text-slate-700">Account number override <span class="font-normal text-slate-400">(optional)</span></label><input name="account_number" class="w-full rounded-xl border border-slate-300 px-3 py-2 text-sm" placeholder="Use only when statement extraction needs correction"></div>
</div> </div>
<div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4"><label class="flex items-start gap-3"><input type="checkbox" name="enable_classification" value="1" {% if classification_enabled %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300"><span><span class="block text-sm font-semibold text-slate-800">Enable narration-based transaction classification</span><span class="mt-1 block text-xs text-slate-500">Adds category, party, review and draft financial helper sheets. Final classification must be verified with books and supporting records.</span></span></label></div> <div class="mt-5 rounded-xl border border-slate-200 bg-slate-50 p-4"><label class="flex items-start gap-3"><input type="checkbox" name="enable_classification" value="1" {% if classification_enabled %}checked{% endif %} class="mt-1 h-4 w-4 rounded border-slate-300"><span><span class="block text-sm font-semibold text-slate-800">Enable narration-based transaction classification</span><span class="mt-1 block text-xs text-slate-500">Adds category, party, review and draft financial helper sheets. Final classification must be verified with books and supporting records.</span></span></label></div>
<div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div> <div class="mt-5"><label class="mb-1 block text-sm font-semibold text-slate-700">PDF bank statements</label><input type="file" name="statements" accept="application/pdf,.pdf" multiple required class="block w-full rounded-xl border border-slate-300 bg-white px-3 py-3 text-sm"></div>
<div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Queue limits</div><div class="mt-1">Maximum three processing jobs across all users. Each user may have up to three queued or processing jobs. Completed workbooks remain available for 24 hours.</div></div> <div class="mt-5 rounded-xl bg-slate-50 p-4 text-sm text-slate-600"><div class="font-semibold text-slate-800">Queue limits</div><div class="mt-1">Maximum three processing jobs across all users. Each user may have up to three queued or processing jobs. Completed workbooks and original uploaded statements remain available for 24 hours. Failed-job statements are also retained for 24 hours for debugging.</div></div>
<div class="mt-6 flex flex-wrap gap-3"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Submit Analysis</button></div> <div class="mt-6 flex flex-wrap gap-3"><button class="rounded-xl bg-brand-600 px-5 py-2.5 text-sm font-semibold text-white hover:bg-brand-700">Submit Analysis</button></div>
</form> </form>
@@ -37,8 +30,8 @@
<div class="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Files</div><div class="mt-1 text-lg font-bold">{{ active_job.file_count }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Queue position</div><div id="queue-position" class="mt-1 text-lg font-bold">{{ active_job.queue_position or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Estimated wait</div><div id="estimated-wait" class="mt-1 text-sm font-bold">{{ active_job.estimated_wait or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Progress</div><div id="progress-text" class="mt-1 text-lg font-bold">{{ active_job.progress_percent }}%</div></div></div> <div class="mt-5 grid gap-4 sm:grid-cols-2 lg:grid-cols-4"><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Files</div><div class="mt-1 text-lg font-bold">{{ active_job.file_count }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Queue position</div><div id="queue-position" class="mt-1 text-lg font-bold">{{ active_job.queue_position or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Estimated wait</div><div id="estimated-wait" class="mt-1 text-sm font-bold">{{ active_job.estimated_wait or '—' }}</div></div><div class="rounded-xl bg-slate-50 p-4"><div class="text-xs text-slate-500">Progress</div><div id="progress-text" class="mt-1 text-lg font-bold">{{ active_job.progress_percent }}%</div></div></div>
<div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"><div id="progress-bar" class="h-full bg-brand-600 transition-all" style="width: {{ active_job.progress_percent }}%"></div></div> <div class="mt-4 h-2 overflow-hidden rounded-full bg-slate-200"><div id="progress-bar" class="h-full bg-brand-600 transition-all" style="width: {{ active_job.progress_percent }}%"></div></div>
{% if active_job.status == 'completed' %} {% if active_job.status == 'completed' %}
<div class="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-5"><h3 class="font-bold text-emerald-900">Analysis completed</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div><div class="text-xs text-emerald-700">Statements</div><div class="font-bold">{{ active_job.summary.statement_count or 0 }}</div></div><div><div class="text-xs text-emerald-700">Transactions extracted</div><div class="font-bold">{{ active_job.summary.rows_extracted or 0 }}</div></div><div><div class="text-xs text-emerald-700">Exact duplicates</div><div class="font-bold">{{ active_job.summary.exact_duplicate_rows or 0 }}</div></div><div><div class="text-xs text-emerald-700">Review items</div><div class="font-bold">{{ active_job.summary.review_items or 0 }}</div></div></div><div class="mt-5 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/download" class="rounded-xl bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white">Download Excel</a><a href="/tools/bank-statement-analyzer" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Analyze Another Bank</a></div><p class="mt-3 text-xs text-emerald-700">The workbook remains available until {{ active_job.expires_at or '24 hours after completion' }}.</p></div> <div class="mt-6 rounded-xl border border-emerald-200 bg-emerald-50 p-5"><h3 class="font-bold text-emerald-900">Analysis completed</h3><div class="mt-4 grid gap-3 sm:grid-cols-2 lg:grid-cols-4"><div><div class="text-xs text-emerald-700">Statements</div><div class="font-bold">{{ active_job.summary.statement_count or 0 }}</div></div><div><div class="text-xs text-emerald-700">Transactions extracted</div><div class="font-bold">{{ active_job.summary.rows_extracted or 0 }}</div></div><div><div class="text-xs text-emerald-700">Exact duplicates</div><div class="font-bold">{{ active_job.summary.exact_duplicate_rows or 0 }}</div></div><div><div class="text-xs text-emerald-700">Review items</div><div class="font-bold">{{ active_job.summary.review_items or 0 }}</div></div></div><div class="mt-5 flex flex-wrap gap-3"><a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/download" class="rounded-xl bg-emerald-700 px-5 py-2.5 text-sm font-semibold text-white">Download Excel</a>{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Download Statement {{ file.index }}</a>{% endfor %}<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-emerald-300 bg-white px-5 py-2.5 text-sm font-semibold text-emerald-800">Analyze Another Bank</a></div><p class="mt-3 text-xs text-emerald-700">The workbook and original statement{{ 's' if active_job.file_count != 1 else '' }} remain available until {{ active_job.expires_at or '24 hours after completion' }}.</p></div>
{% elif active_job.status == 'failed' %}<div class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"><strong>Analysis failed.</strong><div class="mt-1">{{ active_job.error_message }}</div><a href="/tools/bank-statement-analyzer" class="mt-3 inline-block font-semibold underline">Analyze another statement</a></div> {% elif active_job.status == 'failed' %}<div class="mt-5 rounded-xl border border-red-200 bg-red-50 p-4 text-sm text-red-800"><strong>Analysis failed.</strong><div class="mt-1">{{ active_job.error_message }}</div>{% if active_job.original_files %}<div class="mt-4 flex flex-wrap gap-3">{% for file in active_job.original_files %}<a href="/tools/bank-statement-analyzer/jobs/{{ active_job.id }}/statements/{{ file.index }}/download" class="rounded-xl border border-red-300 bg-white px-4 py-2 text-sm font-semibold text-red-800">Download Statement {{ file.index }}</a>{% endfor %}</div><p class="mt-3 text-xs text-red-700">Original statement{{ 's are' if active_job.file_count != 1 else ' is' }} retained until {{ active_job.expires_at or '24 hours after failure' }} for debugging.</p>{% endif %}<a href="/tools/bank-statement-analyzer" class="mt-3 inline-block font-semibold underline">Analyze another statement</a></div>
{% else %}<div id="live-message" class="mt-5 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">{% if active_job.status == 'queued' %}Your job is queued. You may safely leave this page and return through My Analysis Jobs.{% else %}Your statements are being processed.{% endif %}</div>{% endif %} {% else %}<div id="live-message" class="mt-5 rounded-xl border border-blue-200 bg-blue-50 p-4 text-sm text-blue-800">{% if active_job.status == 'queued' %}Your job is queued. You may safely leave this page and return through My Analysis Jobs.{% else %}Your statements are being processed.{% endif %}</div>{% endif %}
</section> </section>
{% endif %} {% endif %}
@@ -1,4 +1,4 @@
{% extends "ui/templates/base/layout.html" %} {% extends "ui/templates/base/layout.html" %}
{% block content %} {% block content %}
<div class="mx-auto max-w-6xl space-y-6"><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"><div class="flex flex-wrap items-center justify-between gap-3"><div><h1 class="text-2xl font-bold text-slate-900">My Analysis Jobs</h1><p class="mt-2 text-sm text-slate-600">Queued, processing, completed and failed bank-statement analyses.</p></div><a href="/tools/bank-statement-analyzer" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">New Analysis</a></div></div><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"><div class="overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="border-b text-left text-slate-500"><th class="py-2 pr-4">Submitted</th><th class="py-2 pr-4">Bank</th><th class="py-2 pr-4">Files</th><th class="py-2 pr-4">Status</th><th class="py-2 pr-4">Estimate / Expiry</th><th class="py-2">Action</th></tr></thead><tbody>{% for item in jobs %}<tr class="border-b border-slate-100"><td class="py-3 pr-4">{{ item.submitted_at }}</td><td class="py-3 pr-4">{{ item.selected_bank|replace('_',' ')|title }}</td><td class="py-3 pr-4">{{ item.file_count }}</td><td class="py-3 pr-4"><span class="font-semibold">{{ item.status|title }}</span>{% if item.queue_position %}<div class="text-xs text-slate-500">Queue position {{ item.queue_position }}</div>{% endif %}</td><td class="py-3 pr-4">{% if item.status in ['queued','processing'] %}{{ item.estimated_wait }}{% elif item.status == 'completed' %}Available for 24 hours{% else %}{{ item.error_message[:80] }}{% endif %}</td><td class="py-3"><div class="flex flex-wrap gap-3"><a class="font-semibold text-brand-700" href="/tools/bank-statement-analyzer?job={{ item.id }}#analysis-status">View</a>{% if item.download_ready %}<a class="font-semibold text-emerald-700" href="/tools/bank-statement-analyzer/jobs/{{ item.id }}/download">Download</a>{% endif %}{% if item.status != 'processing' %}<form method="post" action="/tools/bank-statement-analyzer/jobs/{{ item.id }}/delete" onsubmit="return confirm('Delete this analysis job and its files?')"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="font-semibold text-red-600">Delete</button></form>{% endif %}</div></td></tr>{% else %}<tr><td colspan="6" class="py-10 text-center text-slate-500">No analysis jobs yet.</td></tr>{% endfor %}</tbody></table></div></div></div> <div class="mx-auto max-w-6xl space-y-6"><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"><div class="flex flex-wrap items-center justify-between gap-3"><div><h1 class="text-2xl font-bold text-slate-900">My Analysis Jobs</h1><p class="mt-2 text-sm text-slate-600">Queued, processing, completed and failed bank-statement analyses.</p></div><a href="/tools/bank-statement-analyzer" class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">New Analysis</a></div></div><div class="rounded-2xl border border-slate-200 bg-white p-6 shadow-soft"><div class="overflow-x-auto"><table class="min-w-full text-sm"><thead><tr class="border-b text-left text-slate-500"><th class="py-2 pr-4">Submitted</th><th class="py-2 pr-4">Bank</th><th class="py-2 pr-4">Files</th><th class="py-2 pr-4">Status</th><th class="py-2 pr-4">Estimate / Expiry</th><th class="py-2">Action</th></tr></thead><tbody>{% for item in jobs %}<tr class="border-b border-slate-100"><td class="py-3 pr-4">{{ item.submitted_at }}</td><td class="py-3 pr-4">{{ item.selected_bank|replace('_',' ')|title }}</td><td class="py-3 pr-4">{{ item.file_count }}</td><td class="py-3 pr-4"><span class="font-semibold">{{ item.status|title }}</span>{% if item.queue_position %}<div class="text-xs text-slate-500">Queue position {{ item.queue_position }}</div>{% endif %}</td><td class="py-3 pr-4">{% if item.status in ['queued','processing'] %}{{ item.estimated_wait }}{% elif item.status == 'completed' %}Workbook and statements available until {{ item.expires_at or '24 hours' }}{% elif item.status == 'failed' %}Statements retained until {{ item.expires_at or '24 hours' }}{% else %}{{ item.error_message[:80] }}{% endif %}</td><td class="py-3"><div class="flex flex-wrap gap-3"><a class="font-semibold text-brand-700" href="/tools/bank-statement-analyzer?job={{ item.id }}#analysis-status">View</a>{% if item.download_ready %}<a class="font-semibold text-emerald-700" href="/tools/bank-statement-analyzer/jobs/{{ item.id }}/download">Download Excel</a>{% endif %}{% for file in item.original_files %}<a class="font-semibold text-sky-700" href="/tools/bank-statement-analyzer/jobs/{{ item.id }}/statements/{{ file.index }}/download">Statement {{ file.index }}</a>{% endfor %}{% if item.status != 'processing' %}<form method="post" action="/tools/bank-statement-analyzer/jobs/{{ item.id }}/delete" onsubmit="return confirm('Delete this analysis job and its files?')"><input type="hidden" name="csrf_token" value="{{ csrf_token }}"><button class="font-semibold text-red-600">Delete</button></form>{% endif %}</div></td></tr>{% else %}<tr><td colspan="6" class="py-10 text-center text-slate-500">No analysis jobs yet.</td></tr>{% endfor %}</tbody></table></div></div></div>
{% endblock %} {% endblock %}
+32 -1
View File
@@ -16,7 +16,18 @@ from app.core.templating import templates
from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
from .parsers.registry import BANK_OPTIONS from .parsers.registry import BANK_OPTIONS
from .service import can_use, create_job_folder, delete_owned_job, enqueue_job, ensure_worker_started, get_owned_job, job_view, list_user_jobs, save_uploads from .service import (
can_use,
create_job_folder,
delete_owned_job,
enqueue_job,
ensure_worker_started,
get_owned_job,
get_owned_original_statement,
job_view,
list_user_jobs,
save_uploads,
)
router = APIRouter(prefix="/tools/bank-statement-analyzer", tags=["bank-statement-analyzer-ui"]) router = APIRouter(prefix="/tools/bank-statement-analyzer", tags=["bank-statement-analyzer-ui"])
@@ -186,6 +197,26 @@ def download(job_id: str, request: Request):
db.close() db.close()
@router.get("/jobs/{job_id}/statements/{file_index}/download")
def download_original_statement(job_id: str, file_index: int, request: Request):
db = CommonSessionLocal()
try:
user, roles, denied = _auth(request, db)
if denied:
return denied
resolved = get_owned_original_statement(user.id, job_id, file_index)
if not resolved:
return not_found_response(request, "Original statement is unavailable or has expired.")
_job, path = resolved
return FileResponse(
path=path,
filename=path.name,
media_type="application/pdf",
)
finally:
db.close()
@router.post("/jobs/{job_id}/delete") @router.post("/jobs/{job_id}/delete")
def delete(job_id: str, request: Request, csrf_token: str = Form(...)): def delete(job_id: str, request: Request, csrf_token: str = Form(...)):
db = CommonSessionLocal() db = CommonSessionLocal()