From 769add8cb82747325a7ac793615827787272fc3c Mon Sep 17 00:00:00 2001 From: A R R R Associates Date: Mon, 24 Aug 2026 15:23:49 +0530 Subject: [PATCH] Add Phase 19 bank accounting and reconciliation workflow --- ...lyzer_accounting_reconciliation_phase19.py | 156 ++++++ app/modules/accounting/bank_models.py | 10 + .../accounting/bank_reconciliation_models.py | 64 +++ .../accounting/bank_reconciliation_service.py | 512 ++++++++++++++++++ .../accounting/bank_reconciliation_ui.py | 218 ++++++++ app/modules/accounting/bank_service.py | 13 + .../templates/accounting/bank_posting.html | 5 +- .../accounting/bank_reconciliation.html | 214 ++++++++ .../templates/accounting/tally.html | 1 + app/modules/bank_statement_analyzer/models.py | 6 + .../bank_statement_analyzer/service.py | 51 +- .../bank_statement_analyzer/index.html | 28 +- app/modules/bank_statement_analyzer/ui.py | 34 +- app/modules/documents/agent_package.py | 2 +- .../erp_local_agent/__init__.py | 2 +- .../erp_local_agent/commands.py | 55 +- app/ui/app.py | 2 + 17 files changed, 1363 insertions(+), 10 deletions(-) create mode 100644 alembic/versions/20260824_bank_analyzer_accounting_reconciliation_phase19.py create mode 100644 app/modules/accounting/bank_reconciliation_models.py create mode 100644 app/modules/accounting/bank_reconciliation_service.py create mode 100644 app/modules/accounting/bank_reconciliation_ui.py create mode 100644 app/modules/accounting/templates/accounting/bank_reconciliation.html diff --git a/alembic/versions/20260824_bank_analyzer_accounting_reconciliation_phase19.py b/alembic/versions/20260824_bank_analyzer_accounting_reconciliation_phase19.py new file mode 100644 index 0000000..0a51800 --- /dev/null +++ b/alembic/versions/20260824_bank_analyzer_accounting_reconciliation_phase19.py @@ -0,0 +1,156 @@ +"""Phase 19 Bank Analyzer Accounting + Reconciliation. + +Revision ID: 20260824_bank_reconciliation_p19 +Revises: 20260823_auto_stock_voucher_p18 +""" +from alembic import op +import sqlalchemy as sa + + +revision = "20260824_bank_reconciliation_p19" +down_revision = "20260823_auto_stock_voucher_p18" +branch_labels = None +depends_on = None + + +def upgrade(): + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("purpose", sa.String(30), nullable=False, server_default="analyze_only"), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("accounting_import_status", sa.String(30), nullable=False, server_default="not_requested"), + ) + op.add_column( + "bank_statement_analysis_jobs", + sa.Column("accounting_import_json", sa.Text(), nullable=True), + ) + op.create_index( + "ix_bank_statement_analysis_jobs_purpose", + "bank_statement_analysis_jobs", + ["purpose"], + ) + op.create_index( + "ix_bank_statement_analysis_jobs_accounting_import_status", + "bank_statement_analysis_jobs", + ["accounting_import_status"], + ) + + op.create_table( + "accounting_bank_reconciliation_runs", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("tenant_id", sa.Integer(), sa.ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False), + sa.Column("client_id", sa.Integer(), sa.ForeignKey("clients.id", ondelete="CASCADE"), nullable=False), + sa.Column("source_job_id", sa.String(32), sa.ForeignKey("bank_statement_analysis_jobs.id", ondelete="CASCADE"), nullable=False), + sa.Column("tally_guid", sa.String(120), nullable=False), + sa.Column("company_name", sa.String(255), nullable=False, server_default=""), + sa.Column("bank_ledger_name", sa.String(255), nullable=False), + sa.Column("date_from", sa.String(10), nullable=False), + sa.Column("date_to", sa.String(10), nullable=False), + sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="RESTRICT"), nullable=False), + sa.Column("agent_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("status", sa.String(30), nullable=False, server_default="queued"), + sa.Column("summary_json", sa.Text(), nullable=False, server_default="{}"), + sa.Column("last_error", sa.Text(), nullable=False, server_default=""), + sa.Column("created_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("completed_at_utc", sa.DateTime(timezone=True), nullable=True), + ) + for name in ( + "tenant_id", + "client_id", + "source_job_id", + "tally_guid", + "workstation_agent_id", + "agent_job_id", + "status", + "created_at_utc", + ): + op.create_index( + f"ix_accounting_bank_reconciliation_runs_{name}", + "accounting_bank_reconciliation_runs", + [name], + ) + + op.add_column( + "accounting_bank_transactions", + sa.Column("reconciliation_status", sa.String(30), nullable=False, server_default="not_tested"), + ) + op.add_column( + "accounting_bank_transactions", + sa.Column( + "last_reconciliation_run_id", + sa.Integer(), + sa.ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="SET NULL"), + nullable=True, + ), + ) + op.create_index( + "ix_accounting_bank_transactions_reconciliation_status", + "accounting_bank_transactions", + ["reconciliation_status"], + ) + op.create_index( + "ix_accounting_bank_transactions_last_reconciliation_run_id", + "accounting_bank_transactions", + ["last_reconciliation_run_id"], + ) + + op.create_table( + "accounting_bank_reconciliation_items", + sa.Column("id", sa.Integer(), primary_key=True), + sa.Column("run_id", sa.Integer(), sa.ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="CASCADE"), nullable=False), + sa.Column("bank_transaction_id", sa.Integer(), sa.ForeignKey("accounting_bank_transactions.id", ondelete="SET NULL"), nullable=True), + sa.Column("match_status", sa.String(30), nullable=False), + sa.Column("confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("match_reason", sa.Text(), nullable=False, server_default=""), + sa.Column("bank_date", sa.String(10), nullable=False, server_default=""), + sa.Column("bank_direction", sa.String(10), nullable=False, server_default=""), + sa.Column("bank_amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("bank_reference", sa.String(180), nullable=False, server_default=""), + sa.Column("bank_narration", sa.Text(), nullable=False, server_default=""), + sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""), + sa.Column("tally_voucher_type", sa.String(60), nullable=False, server_default=""), + sa.Column("tally_date", sa.String(10), nullable=False, server_default=""), + sa.Column("tally_reference", sa.String(180), nullable=False, server_default=""), + sa.Column("tally_narration", sa.Text(), nullable=False, server_default=""), + sa.Column("tally_amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("tally_direction", sa.String(10), nullable=False, server_default=""), + ) + for name in ("run_id", "bank_transaction_id", "match_status"): + op.create_index( + f"ix_accounting_bank_reconciliation_items_{name}", + "accounting_bank_reconciliation_items", + [name], + ) + + +def downgrade(): + op.drop_table("accounting_bank_reconciliation_items") + + op.drop_index( + "ix_accounting_bank_transactions_last_reconciliation_run_id", + table_name="accounting_bank_transactions", + ) + op.drop_index( + "ix_accounting_bank_transactions_reconciliation_status", + table_name="accounting_bank_transactions", + ) + op.drop_column("accounting_bank_transactions", "last_reconciliation_run_id") + op.drop_column("accounting_bank_transactions", "reconciliation_status") + + op.drop_table("accounting_bank_reconciliation_runs") + + op.drop_index( + "ix_bank_statement_analysis_jobs_accounting_import_status", + table_name="bank_statement_analysis_jobs", + ) + op.drop_index( + "ix_bank_statement_analysis_jobs_purpose", + table_name="bank_statement_analysis_jobs", + ) + op.drop_column("bank_statement_analysis_jobs", "accounting_import_json") + op.drop_column("bank_statement_analysis_jobs", "accounting_import_status") + op.drop_column("bank_statement_analysis_jobs", "purpose") diff --git a/app/modules/accounting/bank_models.py b/app/modules/accounting/bank_models.py index df71bc8..820ceb2 100644 --- a/app/modules/accounting/bank_models.py +++ b/app/modules/accounting/bank_models.py @@ -69,6 +69,16 @@ class AccountingBankTransaction(CommonBase): preflight_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) posting_result_json: Mapped[str | None] = mapped_column(Text, nullable=True) posting_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_started", index=True) + + # Phase 19 reconciliation state. This does not alter the existing posting + # workflow; it tells Accounting whether the bank row already exists in Tally. + reconciliation_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_tested", index=True) + last_reconciliation_run_id: Mapped[int | None] = mapped_column( + ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="SET NULL"), + nullable=True, + index=True, + ) + last_error: Mapped[str | None] = mapped_column(Text, nullable=True) tally_voucher_id: Mapped[str] = mapped_column(String(120), nullable=False, default="") tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="") diff --git a/app/modules/accounting/bank_reconciliation_models.py b/app/modules/accounting/bank_reconciliation_models.py new file mode 100644 index 0000000..858276a --- /dev/null +++ b/app/modules/accounting/bank_reconciliation_models.py @@ -0,0 +1,64 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +def _utcnow(): + return datetime.now(timezone.utc) + + +class BankReconciliationRun(CommonBase): + __tablename__ = "accounting_bank_reconciliation_runs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + tenant_id: Mapped[int] = mapped_column(ForeignKey("tenants.id", ondelete="CASCADE"), nullable=False, index=True) + client_id: Mapped[int] = mapped_column(ForeignKey("clients.id", ondelete="CASCADE"), nullable=False, index=True) + source_job_id: Mapped[str] = mapped_column(ForeignKey("bank_statement_analysis_jobs.id", ondelete="CASCADE"), nullable=False, index=True) + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, index=True) + company_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False) + date_from: Mapped[str] = mapped_column(String(10), nullable=False) + date_to: Mapped[str] = mapped_column(String(10), nullable=False) + + workstation_agent_id: Mapped[int] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="RESTRICT"), nullable=False, index=True) + agent_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + + status: Mapped[str] = mapped_column(String(30), nullable=False, default="queued", index=True) + summary_json: Mapped[str] = mapped_column(Text, nullable=False, default="{}") + last_error: Mapped[str] = mapped_column(Text, nullable=False, default="") + + created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow, index=True) + completed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + +class BankReconciliationItem(CommonBase): + __tablename__ = "accounting_bank_reconciliation_items" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + run_id: Mapped[int] = mapped_column(ForeignKey("accounting_bank_reconciliation_runs.id", ondelete="CASCADE"), nullable=False, index=True) + bank_transaction_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_bank_transactions.id", ondelete="SET NULL"), nullable=True, index=True) + + match_status: Mapped[str] = mapped_column(String(30), nullable=False, index=True) + confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + match_reason: Mapped[str] = mapped_column(Text, nullable=False, default="") + + bank_date: Mapped[str] = mapped_column(String(10), nullable=False, default="") + bank_direction: Mapped[str] = mapped_column(String(10), nullable=False, default="") + bank_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + bank_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="") + bank_narration: Mapped[str] = mapped_column(Text, nullable=False, default="") + + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="") + tally_voucher_number: Mapped[str] = mapped_column(String(160), nullable=False, default="") + tally_voucher_type: Mapped[str] = mapped_column(String(60), nullable=False, default="") + tally_date: Mapped[str] = mapped_column(String(10), nullable=False, default="") + tally_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="") + tally_narration: Mapped[str] = mapped_column(Text, nullable=False, default="") + tally_amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + tally_direction: Mapped[str] = mapped_column(String(10), nullable=False, default="") diff --git a/app/modules/accounting/bank_reconciliation_service.py b/app/modules/accounting/bank_reconciliation_service.py new file mode 100644 index 0000000..d0ae477 --- /dev/null +++ b/app/modules/accounting/bank_reconciliation_service.py @@ -0,0 +1,512 @@ +from __future__ import annotations + +import json +import re +from datetime import date, datetime, timezone +from difflib import SequenceMatcher + +from sqlalchemy import delete, func, select + +from app.modules.accounting.bank_models import AccountingBankTransaction +from app.modules.accounting.bank_reconciliation_models import ( + BankReconciliationItem, + BankReconciliationRun, +) +from app.modules.accounting.chart_models import AccountingChartLedger +from app.modules.accounting.chart_service import effective_role +from app.modules.bank_statement_analyzer.models import BankStatementAnalysisJob +from app.modules.documents.agent_jobs import enqueue_agent_job +from app.modules.documents.models import ERPAgentJob, ERPWorkstationAgent + +ACTION = "accounting_bank_reconciliation_extract" + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value): + return str(value or "").strip() + + +def _loads(value, default=None): + try: + return json.loads(value or "") + except Exception: + return {} if default is None else default + + +def _norm_ref(value): + return re.sub(r"[^A-Z0-9]", "", _s(value).upper()) + + +def _norm_text(value): + return re.sub(r"[^A-Z0-9]+", " ", _s(value).upper()).strip() + + +def _date_obj(value): + try: + return date.fromisoformat(_s(value)[:10]) + except Exception: + return None + + +def visible_bank_ledgers(db, *, tenant_id: int, client_id: int, tally_guid: str): + rows = list( + db.execute( + select(AccountingChartLedger) + .where( + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + AccountingChartLedger.tally_guid == _s(tally_guid), + ) + .order_by(AccountingChartLedger.name) + ).scalars().all() + ) + # Prefer the semantic role, but also trust Tally's immediate parent group. + # This keeps reconciliation usable even before a previously mis-resolved + # Phase 16 root hierarchy is manually refreshed. + return [ + row + for row in rows + if effective_role(row) == "BANK" + or _norm_text(row.parent_group_name) == "BANK ACCOUNTS" + or _norm_text(row.root_group_name) == "BANK ACCOUNTS" + ] + + +def visible_workstations(db, *, tenant_id: int, branch_id: int | None = None): + stmt = select(ERPWorkstationAgent).where( + ERPWorkstationAgent.tenant_id == int(tenant_id), + ERPWorkstationAgent.is_active.is_(True), + ERPWorkstationAgent.tally_connected.is_(True), + ) + if branch_id is not None: + stmt = stmt.where(ERPWorkstationAgent.branch_id == int(branch_id)) + return list( + db.execute( + stmt.order_by( + ERPWorkstationAgent.last_seen_at_utc.desc(), + ERPWorkstationAgent.id.desc(), + ) + ).scalars().all() + ) + + +def completed_client_jobs(db, *, tenant_id: int, client_id: int, limit: int = 50): + return list( + db.execute( + select(BankStatementAnalysisJob) + .where( + BankStatementAnalysisJob.tenant_id == int(tenant_id), + BankStatementAnalysisJob.client_id == int(client_id), + BankStatementAnalysisJob.status == "completed", + BankStatementAnalysisJob.ownership_status == "confirmed", + ) + .order_by(BankStatementAnalysisJob.completed_at_utc.desc()) + .limit(limit) + ).scalars().all() + ) + + +def _job_period(db, *, tenant_id: int, client_id: int, job_id: str): + values = list( + db.execute( + select(AccountingBankTransaction.transaction_date) + .where( + AccountingBankTransaction.tenant_id == int(tenant_id), + AccountingBankTransaction.client_id == int(client_id), + AccountingBankTransaction.source_job_id == job_id, + ) + .order_by(AccountingBankTransaction.transaction_date) + ).scalars().all() + ) + values = [v for v in values if _date_obj(v)] + if not values: + raise ValueError( + "No imported bank transactions were found for this Bank Analyzer job. " + "Use a Bank Reconciliation purpose job or import the completed job into Accounting first." + ) + return values[0][:10], values[-1][:10] + + +def queue_reconciliation( + db, + *, + tenant_id: int, + client_id: int, + source_job_id: str, + tally_guid: str, + company_name: str, + bank_ledger_name: str, + workstation_id: int, + user_id: int, +): + source_job = db.get(BankStatementAnalysisJob, source_job_id) + if ( + not source_job + or source_job.status != "completed" + or int(source_job.client_id or 0) != int(client_id) + or int(source_job.tenant_id or 0) != int(tenant_id) + or source_job.ownership_status != "confirmed" + ): + raise ValueError("The selected completed Bank Analyzer job is not valid for this client.") + + if source_job.accounting_import_status != "completed": + raise ValueError( + "The selected Bank Analyzer job has not been imported successfully into the Accounting bank queue." + ) + + date_from, date_to = _job_period( + db, + tenant_id=tenant_id, + client_id=client_id, + job_id=source_job_id, + ) + + allowed = { + row.name + for row in visible_bank_ledgers( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + ) + } + if bank_ledger_name not in allowed: + raise ValueError("Select a Bank ledger from the client's synchronized Chart of Accounts.") + + ws = db.get(ERPWorkstationAgent, int(workstation_id)) + if ( + not ws + or not ws.is_active + or not ws.tally_connected + or int(ws.tenant_id) != int(tenant_id) + ): + raise ValueError("Selected workstation is unavailable or Tally is not connected.") + + run = BankReconciliationRun( + tenant_id=int(tenant_id), + client_id=int(client_id), + source_job_id=source_job_id, + tally_guid=_s(tally_guid), + company_name=_s(company_name), + bank_ledger_name=bank_ledger_name, + date_from=date_from, + date_to=date_to, + workstation_agent_id=ws.id, + status="queued", + created_by_user_id=int(user_id), + ) + db.add(run) + db.commit() + db.refresh(run) + + job = enqueue_agent_job( + db, + workstation_agent_id=ws.id, + action=ACTION, + payload={ + "tenant_id": int(tenant_id), + "client_id": int(client_id), + "tally_guid": _s(tally_guid), + "company_name": _s(company_name), + "bank_ledger_name": bank_ledger_name, + "date_from": date_from, + "date_to": date_to, + "reconciliation_run_id": run.id, + }, + idempotency_key=f"bank-recon:{tenant_id}:{client_id}:{source_job_id}:{tally_guid}:{bank_ledger_name}:{run.id}", + priority=8, + max_attempts=2, + created_by_user_id=user_id, + ) + run.agent_job_id = job.id + db.add(run) + db.commit() + return run + + +def _tally_side(voucher: dict, bank_ledger_name: str): + bank_key = bank_ledger_name.casefold() + entries = list(voucher.get("ledger_entries") or []) + bank_entries = [ + row for row in entries + if _s(row.get("ledger_name")).casefold() == bank_key + ] + if not bank_entries: + return None + + # Tally exports in this project use negative amount for a Debit ledger and + # positive amount for Credit. Bank statement direction is opposite from the + # bank ledger accounting side: bank Debit statement = bank ledger Credit. + amount = round(abs(float(bank_entries[0].get("amount") or 0)), 2) + signed = float(bank_entries[0].get("amount") or 0) + direction = "DEBIT" if signed > 0 else "CREDIT" + + return { + "guid": _s(voucher.get("guid")), + "voucher_number": _s(voucher.get("voucher_number")), + "voucher_type": _s(voucher.get("voucher_type_name")), + "date": _s(voucher.get("date"))[:10], + "reference": _s(voucher.get("reference")), + "narration": _s(voucher.get("narration")), + "amount": amount, + "direction": direction, + } + + +def _pair_score(bank: AccountingBankTransaction, tally: dict): + if round(float(bank.amount or 0), 2) != round(float(tally["amount"] or 0), 2): + return 0, "" + if _s(bank.direction).upper() != _s(tally["direction"]).upper(): + return 0, "" + + bd = _date_obj(bank.transaction_date) + td = _date_obj(tally["date"]) + if not bd or not td: + return 0, "" + + gap = abs((bd - td).days) + if gap > 7: + return 0, "" + + bank_ref = _norm_ref(bank.transfer_reference or bank.reference_no) + tally_ref = _norm_ref(tally.get("reference")) + ref_exact = bool(bank_ref and tally_ref and bank_ref == tally_ref) + + bank_text = _norm_text(f"{bank.auto_party} {bank.narration}") + tally_text = _norm_text(tally.get("narration")) + similarity = int(round(SequenceMatcher(None, bank_text, tally_text).ratio() * 100)) if bank_text and tally_text else 0 + + score = 60 + reasons = ["same amount", "same bank direction"] + + if gap == 0: + score += 22 + reasons.append("same date") + elif gap <= 2: + score += 12 + reasons.append(f"{gap}-day timing difference") + elif gap <= 4: + score += 7 + reasons.append(f"{gap}-day timing difference") + else: + score += 2 + reasons.append(f"{gap}-day timing difference") + + if ref_exact: + score += 18 + reasons.append("same reference") + elif similarity >= 85: + score += 10 + reasons.append("high narration similarity") + elif similarity >= 65: + score += 5 + reasons.append("narration similarity") + + return min(100, score), ", ".join(reasons) + + +def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]): + db.execute(delete(BankReconciliationItem).where(BankReconciliationItem.run_id == run.id)) + db.commit() + + bank_rows = list( + db.execute( + select(AccountingBankTransaction) + .where( + AccountingBankTransaction.tenant_id == run.tenant_id, + AccountingBankTransaction.client_id == run.client_id, + AccountingBankTransaction.source_job_id == run.source_job_id, + ) + .order_by( + AccountingBankTransaction.transaction_date, + AccountingBankTransaction.id, + ) + ).scalars().all() + ) + + tally_rows = [] + for voucher in vouchers: + if _s(voucher.get("is_cancelled")).lower() in {"yes", "true", "1"}: + continue + row = _tally_side(voucher, run.bank_ledger_name) + if row and row["amount"] > 0: + tally_rows.append(row) + + candidates = {} + for bank in bank_rows: + scored = [] + for index, tally in enumerate(tally_rows): + score, reason = _pair_score(bank, tally) + if score: + scored.append((score, index, reason)) + scored.sort(key=lambda item: (-item[0], item[1])) + candidates[bank.id] = scored + + used_tally = set() + exact = probable = bank_only = duplicates = 0 + + for bank in bank_rows: + options = [ + item for item in candidates.get(bank.id, []) + if item[1] not in used_tally + ] + + if not options: + status = "bank_only" + confidence = 0 + reason = "No Tally bank-ledger voucher matched amount, direction and permitted date window." + tally = None + bank_only += 1 + else: + top_score, tally_index, reason = options[0] + tied = [item for item in options if item[0] == top_score] + if len(tied) > 1 and top_score < 95: + status = "duplicate_candidate" + confidence = top_score + tally = tally_rows[tally_index] + duplicates += 1 + else: + used_tally.add(tally_index) + tally = tally_rows[tally_index] + confidence = top_score + status = "matched" if top_score >= 90 else "probable_match" + if status == "matched": + exact += 1 + else: + probable += 1 + + item = BankReconciliationItem( + run_id=run.id, + bank_transaction_id=bank.id, + match_status=status, + confidence=int(confidence), + match_reason=reason, + bank_date=_s(bank.transaction_date)[:10], + bank_direction=_s(bank.direction).upper(), + bank_amount=float(bank.amount or 0), + bank_reference=_s(bank.transfer_reference or bank.reference_no), + bank_narration=_s(bank.narration), + ) + if tally: + item.tally_guid = tally["guid"] + item.tally_voucher_number = tally["voucher_number"] + item.tally_voucher_type = tally["voucher_type"] + item.tally_date = tally["date"] + item.tally_reference = tally["reference"] + item.tally_narration = tally["narration"] + item.tally_amount = tally["amount"] + item.tally_direction = tally["direction"] + + bank.reconciliation_status = status + bank.last_reconciliation_run_id = run.id + db.add(bank) + db.add(item) + + books_only = 0 + for index, tally in enumerate(tally_rows): + if index in used_tally: + continue + books_only += 1 + db.add( + BankReconciliationItem( + run_id=run.id, + bank_transaction_id=None, + match_status="books_only", + confidence=0, + match_reason="Tally bank-ledger voucher has no matching transaction in the uploaded bank statement set.", + tally_guid=tally["guid"], + tally_voucher_number=tally["voucher_number"], + tally_voucher_type=tally["voucher_type"], + tally_date=tally["date"], + tally_reference=tally["reference"], + tally_narration=tally["narration"], + tally_amount=tally["amount"], + tally_direction=tally["direction"], + ) + ) + + run.summary_json = json.dumps( + { + "bank_transactions": len(bank_rows), + "tally_bank_vouchers": len(tally_rows), + "matched": exact, + "probable_match": probable, + "bank_only": bank_only, + "books_only": books_only, + "duplicate_candidate": duplicates, + }, + ensure_ascii=False, + ) + run.status = "completed" + run.completed_at_utc = _utcnow() + run.last_error = "" + db.add(run) + db.commit() + return run + + +def sync_run(db, run: BankReconciliationRun): + if not run.agent_job_id or run.status == "completed": + return run + job = db.get(ERPAgentJob, int(run.agent_job_id)) + if not job: + return run + + if job.status in {"queued", "claimed"}: + next_status = "extracting" if job.status == "claimed" else "queued" + if run.status != next_status: + run.status = next_status + db.add(run) + db.commit() + return run + + if job.status == "succeeded": + result = _loads(job.result_json, {}) + vouchers = list(result.get("vouchers") or []) + return build_reconciliation(db, run, vouchers) + + if job.status in {"failed", "cancelled"}: + run.status = "failed" + run.last_error = _s(job.last_error) or f"Local Agent reconciliation extraction {job.status}." + run.completed_at_utc = _utcnow() + db.add(run) + db.commit() + + return run + + +def list_runs(db, *, tenant_id: int, client_id: int, limit: int = 30): + rows = list( + db.execute( + select(BankReconciliationRun) + .where( + BankReconciliationRun.tenant_id == int(tenant_id), + BankReconciliationRun.client_id == int(client_id), + ) + .order_by(BankReconciliationRun.id.desc()) + .limit(limit) + ).scalars().all() + ) + for row in rows: + sync_run(db, row) + return rows + + +def run_items(db, *, run_id: int, status: str = ""): + stmt = select(BankReconciliationItem).where( + BankReconciliationItem.run_id == int(run_id) + ) + if _s(status): + stmt = stmt.where(BankReconciliationItem.match_status == _s(status)) + return list( + db.execute( + stmt.order_by( + BankReconciliationItem.bank_date, + BankReconciliationItem.tally_date, + BankReconciliationItem.id, + ) + ).scalars().all() + ) diff --git a/app/modules/accounting/bank_reconciliation_ui.py b/app/modules/accounting/bank_reconciliation_ui.py new file mode 100644 index 0000000..286dfe1 --- /dev/null +++ b/app/modules/accounting/bank_reconciliation_ui.py @@ -0,0 +1,218 @@ +from __future__ import annotations + +import json +from urllib.parse import urlencode + +from fastapi import APIRouter, Form, Request +from fastapi.responses import RedirectResponse + +from app.core.db.common import CommonSessionLocal +from app.core.security.csrf import get_or_create_csrf_token, validate_csrf +from app.core.templating import templates +from app.modules.accounting.bank_reconciliation_service import ( + completed_client_jobs, + list_runs, + queue_reconciliation, + run_items, + visible_bank_ledgers, + visible_workstations, +) +from app.modules.accounting.chart_models import AccountingChartLedger +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from sqlalchemy import select + +router = APIRouter( + prefix="/tools/accounting/bank-reconciliation", + tags=["accounting-bank-reconciliation-ui"], +) + + +def _go(client_id=0, message="", error="", **extra): + params = {} + if client_id: + params["client_id"] = client_id + if message: + params["message"] = message[:350] + if error: + params["error"] = error[:350] + for key, value in extra.items(): + if value not in ("", None): + params[key] = value + return RedirectResponse( + "/tools/accounting/bank-reconciliation" + + ("?" + urlencode(params) if params else ""), + status_code=303, + ) + + +def _company_options(db, tenant_id, client_id): + rows = list( + db.execute( + select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name) + .where( + AccountingChartLedger.tenant_id == int(tenant_id), + AccountingChartLedger.client_id == int(client_id), + ) + .distinct() + .order_by(AccountingChartLedger.company_name) + ).all() + ) + return [{"guid": guid, "name": name or guid} for guid, name in rows if guid] + + +@router.get("") +def page( + request: Request, + client_id: int | None = None, + job_id: str = "", + tally_guid: str = "", + run_id: int | None = None, + status: str = "", + message: str = "", + error: str = "", +): + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.view") + if denied: + return denied + + clients, scope = _visible_clients(db, request, user) + selected = next( + (c for c in clients if client_id and int(c.id) == int(client_id)), + None, + ) + + jobs = [] + companies = [] + bank_ledgers = [] + workstations = [] + runs = [] + items = [] + selected_run = None + + if selected: + jobs = completed_client_jobs( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + limit=50, + ) + companies = _company_options(db, scope.tenant_id, selected.id) + if not tally_guid and len(companies) == 1: + tally_guid = companies[0]["guid"] + if tally_guid: + bank_ledgers = visible_bank_ledgers( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + tally_guid=tally_guid, + ) + workstations = visible_workstations( + db, + tenant_id=scope.tenant_id, + branch_id=getattr(user, "branch_id", None), + ) + runs = list_runs( + db, + tenant_id=scope.tenant_id, + client_id=selected.id, + limit=30, + ) + + if run_id: + selected_run = next( + (row for row in runs if int(row.id) == int(run_id)), + None, + ) + elif runs: + selected_run = runs[0] + + if selected_run and selected_run.status == "completed": + items = run_items( + db, + run_id=selected_run.id, + status=status, + ) + + return templates.TemplateResponse( + "modules/accounting/templates/accounting/bank_reconciliation.html", + { + "request": request, + "current_user": user, + "current_user_roles": get_user_roles(db, user.id), + "current_user_permissions": get_user_permissions(db, user.id), + "csrf_token": get_or_create_csrf_token(request), + "title": "Bank Reconciliation", + "clients": clients, + "selected_client": selected, + "jobs": jobs, + "selected_job_id": job_id, + "companies": companies, + "selected_tally_guid": tally_guid, + "bank_ledgers": bank_ledgers, + "workstations": workstations, + "runs": runs, + "selected_run": selected_run, + "items": items, + "status_filter": status, + "message": message, + "error": error, + }, + ) + finally: + db.close() + + +@router.post("/run") +def create_run( + request: Request, + client_id: int = Form(...), + source_job_id: str = Form(...), + tally_guid: str = Form(...), + company_name: str = Form(...), + bank_ledger_name: str = Form(...), + workstation_id: int = Form(...), + csrf_token: str = Form(...), +): + validate_csrf(request, csrf_token) + db = CommonSessionLocal() + try: + user, denied = _require_partner(request, db, "accounting.learning.manage") + if denied: + return denied + + client, _clients, scope = _find_visible_client(db, request, user, client_id) + if not client: + return _go(error="Client is not visible.") + + run = queue_reconciliation( + db, + tenant_id=scope.tenant_id, + client_id=client.id, + source_job_id=source_job_id, + tally_guid=tally_guid, + company_name=company_name, + bank_ledger_name=bank_ledger_name, + workstation_id=workstation_id, + user_id=user.id, + ) + return _go( + client.id, + run_id=run.id, + tally_guid=tally_guid, + message=( + f"Bank reconciliation run #{run.id} queued. " + "The Local Agent will read the selected Tally bank ledger; no voucher is posted." + ), + ) + except Exception as exc: + db.rollback() + return _go( + client_id, + tally_guid=tally_guid, + error=str(exc), + ) + finally: + db.close() diff --git a/app/modules/accounting/bank_service.py b/app/modules/accounting/bank_service.py index ecdf3f2..60eb058 100644 --- a/app/modules/accounting/bank_service.py +++ b/app/modules/accounting/bank_service.py @@ -293,6 +293,19 @@ def _ensure_reviewed(tx): if tx.posting_status == "posted": raise ValueError("This bank transaction is already posted.") + # Phase 19: reconciliation is a duplicate-safety control. Rows already + # matched/probably matched to Tally must not be posted as new vouchers. + if getattr(tx, "reconciliation_status", "not_tested") in { + "matched", + "probable_match", + "duplicate_candidate", + }: + raise ValueError( + "Bank Reconciliation indicates that this transaction already has, " + "or probably has, a corresponding Tally voucher. Resolve the " + "reconciliation item instead of posting a duplicate entry." + ) + def queue_preflight(db, *, tx_id, tenant_id, client_id, workstation_id, user_id): tx = db.get(AccountingBankTransaction, tx_id) diff --git a/app/modules/accounting/templates/accounting/bank_posting.html b/app/modules/accounting/templates/accounting/bank_posting.html index afd8d06..f988240 100644 --- a/app/modules/accounting/templates/accounting/bank_posting.html +++ b/app/modules/accounting/templates/accounting/bank_posting.html @@ -17,7 +17,8 @@ - {% if selected_client %} +
{% if selected_client %}Open Bank Reconciliation{% endif %}
+{% if selected_client %}

{{ selected_client.client_name }}

{{ total }} bank transaction(s). Matched multi-bank contra rows are visibly linked by pair ID.

@@ -25,7 +26,7 @@ {% set choice = choices.get(tx.id) %}
-
{{ tx.transaction_date }} · {{ tx.bank_name }}
{{ tx.account_number }}
{{ tx.narration }}
₹{{ '%.2f'|format(tx.amount) }} · {{ tx.direction }}
{% if tx.contra_pair_id %}
{{ tx.contra_pair_id }} · {{ tx.contra_counter_bank }} / {{ tx.contra_counter_account }} · {{ '%.0f'|format(tx.contra_confidence) }}%
{% endif %}
+
{{ tx.transaction_date }} · {{ tx.bank_name }}
{{ tx.account_number }}
{{ tx.narration }}
₹{{ '%.2f'|format(tx.amount) }} · {{ tx.direction }}
{% if tx.reconciliation_status and tx.reconciliation_status != 'not_tested' %}
Reconciliation: {{ tx.reconciliation_status|replace('_',' ')|title }}{% if tx.last_reconciliation_run_id %} · Run #{{ tx.last_reconciliation_run_id }}{% endif %}
{% endif %}{% if tx.contra_pair_id %}
{{ tx.contra_pair_id }} · {{ tx.contra_counter_bank }} / {{ tx.contra_counter_account }} · {{ '%.0f'|format(tx.contra_confidence) }}%
{% endif %}
Suggestion
{{ tx.suggested_voucher_type }}
{{ tx.auto_party or '—' }}
{{ tx.suggested_ledger_name or tx.analyzer_ledger or 'Review ledger' }}
Confidence {{ tx.suggested_confidence }}%
{% if tx.review_status != 'reviewed' %} diff --git a/app/modules/accounting/templates/accounting/bank_reconciliation.html b/app/modules/accounting/templates/accounting/bank_reconciliation.html new file mode 100644 index 0000000..0741e54 --- /dev/null +++ b/app/modules/accounting/templates/accounting/bank_reconciliation.html @@ -0,0 +1,214 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+
+

Accounting · Bank Reconciliation

+

Bank Reconciliation

+

+ Compare the normalized transactions from a completed client-bound Bank Analyzer job with one selected Tally Bank ledger. This screen is read-only against Tally; unmatched bank rows can later be handled through the existing Accounting Bank Queue. +

+
+ +
+ + {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + + + +
+ +
+
+
+ + {% if selected_client and selected_tally_guid %} +
+
+

Start a reconciliation run

+

The Local Agent only reads vouchers from the selected Tally company/date range and filters them to the selected Bank ledger. No accounting entry is posted by this action.

+
+ +
+ + + + + + + + + + + + +
+ +
+
+
+ {% endif %} + + {% if selected_client %} +
+
+

Recent Reconciliation Runs

+

Runs preserve their results and never modify Tally.

+
+
+ + + + {% for run in runs %} + {% set summary = run.summary_json|default('{}') %} + + + + + + + + + {% else %} + + {% endfor %} + +
RunBank LedgerPeriodStatusResult
#{{ run.id }}{{ run.bank_ledger_name }}{{ run.date_from }} → {{ run.date_to }}{{ run.status|replace('_',' ')|title }}{% if run.last_error %}
{{ run.last_error }}
{% endif %}
+ {% if run.status=='completed' %} + {% set s = run.summary_json|from_json if false else none %} + Open run to view matched / probable / bank-only / books-only detail. + {% else %}—{% endif %} + Open
No reconciliation runs yet.
+
+
+ {% endif %} + + {% if selected_run %} +
+
+
+

Run #{{ selected_run.id }} · {{ selected_run.bank_ledger_name }}

+

{{ selected_run.date_from }} → {{ selected_run.date_to }} · Status {{ selected_run.status|replace('_',' ')|title }}

+
+ {% if selected_run.status=='completed' %} +
+ + + + + +
+ {% endif %} +
+ + {% if selected_run.status in ['queued','extracting'] %} +
The connected Local Agent is reading Tally vouchers. Refresh this page shortly.
+ {% elif selected_run.status=='failed' %} +
{{ selected_run.last_error }}
+ {% elif selected_run.status=='completed' %} +
+ + + + {% for item in items %} + + + + + + + + {% else %} + + {% endfor %} + +
ResultBank StatementTallyWhyAction
+ + {{ item.match_status|replace('_',' ')|title }} + + {% if item.confidence %}
{{ item.confidence }}%
{% endif %} +
+ {% if item.bank_transaction_id %} +
{{ item.bank_date }} · {{ item.bank_direction }} · ₹{{ '%.2f'|format(item.bank_amount) }}
+
{{ item.bank_reference or '-' }}
+
{{ item.bank_narration }}
+ {% else %}—{% endif %} +
+ {% if item.tally_voucher_number or item.tally_guid %} +
{{ item.tally_date }} · {{ item.tally_voucher_type }} · ₹{{ '%.2f'|format(item.tally_amount) }}
+
Voucher {{ item.tally_voucher_number or item.tally_guid }}
+
{{ item.tally_reference or '-' }}
+
{{ item.tally_narration }}
+ {% else %}—{% endif %} +
{{ item.match_reason }} + {% if item.match_status=='bank_only' and item.bank_transaction_id %} + Review in Accounting Queue + {% elif item.match_status=='books_only' %} + Investigate timing / statement coverage. + {% else %} + Review if needed. + {% endif %} +
No reconciliation rows for this filter.
+
+ {% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index 906331f..ae92b8c 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -41,6 +41,7 @@ Purchase Review Purchase → Tally Bank Expenses / Payments + Bank Reconciliation Depreciation (IT) Accounting AI Internal Model diff --git a/app/modules/bank_statement_analyzer/models.py b/app/modules/bank_statement_analyzer/models.py index 90d35a6..d1be60f 100644 --- a/app/modules/bank_statement_analyzer/models.py +++ b/app/modules/bank_statement_analyzer/models.py @@ -33,6 +33,12 @@ class BankStatementAnalysisJob(CommonBase): account_override: Mapped[str | None] = mapped_column(String(100), nullable=True) classification_enabled: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + # Phase 19: one analyzer, three controlled uses. Existing jobs default to + # analyze_only and preserve the historical Bank Analyzer behaviour. + purpose: Mapped[str] = mapped_column(String(30), nullable=False, default="analyze_only", index=True) + accounting_import_status: Mapped[str] = mapped_column(String(30), nullable=False, default="not_requested", index=True) + accounting_import_json: Mapped[str | None] = mapped_column(Text, nullable=True) + status: Mapped[str] = mapped_column(String(20), nullable=False, default="queued", index=True) progress_percent: Mapped[int] = mapped_column(Integer, nullable=False, default=0) file_count: Mapped[int] = mapped_column(Integer, nullable=False, default=0) diff --git a/app/modules/bank_statement_analyzer/service.py b/app/modules/bank_statement_analyzer/service.py index cb19fef..23cc46b 100644 --- a/app/modules/bank_statement_analyzer/service.py +++ b/app/modules/bank_statement_analyzer/service.py @@ -162,7 +162,7 @@ def pending_count_for_user(user_id: int) -> int: db.close() -def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool, client_id: int | None = None, engagement_id: int | None = None, ownership_confirmation: bool = False) -> BankStatementAnalysisJob: +def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], job_dir: Path, bank_selection: str, financial_year: str, customer_override: str, account_override: str, classification_enabled: bool, client_id: int | None = None, engagement_id: int | None = None, ownership_confirmation: bool = False, purpose: str = "analyze_only") -> BankStatementAnalysisJob: if pending_count_for_user(int(user.id)) >= MAX_PENDING_PER_USER: shutil.rmtree(job_dir, ignore_errors=True) raise ValueError("You already have three queued or processing analyses. Please wait for one to complete before submitting another.") @@ -186,6 +186,12 @@ def enqueue_job(*, user, roles: Iterable[str], job_id: str, paths: list[Path], j customer_override=(customer_override or "").strip() or None, account_override=(account_override or "").strip() or None, classification_enabled=bool(classification_enabled), + purpose=(purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only"), + accounting_import_status=( + "pending" + if purpose in {"accounting_entries", "bank_reconciliation"} + else "not_requested" + ), status="queued", progress_percent=0, file_count=len(paths), @@ -333,6 +339,46 @@ def _process_job(job_id: str) -> None: job.completed_at_utc = _now() job.expires_at_utc = _now() + timedelta(hours=COMPLETED_RETENTION_HOURS) db.commit() + + # Phase 19 reuses the existing AccountingBankTransaction import path. + # Analyze-only remains unchanged. Accounting/Reconciliation jobs import + # only after ownership has passed and the workbook has completed. + if job.purpose in {"accounting_entries", "bank_reconciliation"}: + try: + if not job.client_id: + raise ValueError( + "Accounting / Bank Reconciliation purpose requires an ERP client." + ) + from app.modules.accounting.bank_service import import_completed_job + + added, skipped = import_completed_job( + db, + tenant_id=int(job.tenant_id or 0), + client_id=int(job.client_id), + job_id=job.id, + user_id=int(job.user_id), + ) + job = db.get(BankStatementAnalysisJob, job.id) + job.accounting_import_status = "completed" + job.accounting_import_json = json.dumps( + { + "added": int(added), + "skipped_duplicate_fingerprints": int(skipped), + "purpose": job.purpose, + }, + ensure_ascii=False, + ) + db.commit() + except Exception as import_exc: + db.rollback() + job = db.get(BankStatementAnalysisJob, job_id) + if job: + job.accounting_import_status = "failed" + job.accounting_import_json = json.dumps( + {"error": str(import_exc)[:2000]}, + ensure_ascii=False, + ) + db.commit() except Exception as exc: db.rollback() job = db.get(BankStatementAnalysisJob, job_id) @@ -481,6 +527,9 @@ def job_view(job: BankStatementAnalysisJob) -> dict: "file_count": job.file_count, "selected_bank": job.selected_bank, "financial_year": job.financial_year or "", + "purpose": getattr(job, "purpose", "analyze_only"), + "accounting_import_status": getattr(job, "accounting_import_status", "not_requested"), + "accounting_import": json.loads(job.accounting_import_json) if getattr(job, "accounting_import_json", None) else {}, "submitted_at": job.submitted_at_utc, "completed_at": job.completed_at_utc, "expires_at": job.expires_at_utc, diff --git a/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html b/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html index f436a3f..a40c421 100644 --- a/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html +++ b/app/modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html @@ -3,7 +3,7 @@
-

Bank Statement Analyzer

Upload one or several PDF statements for the same client. Multiple bank accounts can be analysed together so equal-and-opposite inter-bank transfers can be identified as probable contra. Up to three analyses run globally at one time; additional jobs are queued safely.

+

Bank Statement Analyzer

Upload any number of PDF statements for the same client and choose how the same Bank Analyzer should be used: Analyze Only, Prepare Accounting Entries, or Bank Reconciliation. Multi-bank statements are analysed together for conservative contra detection; existing analysis/export behaviour remains available.

My Analysis Jobs
@@ -13,6 +13,30 @@
+
+
Purpose of this bank run
+
+ + + +
+

Accounting Entries and Bank Reconciliation require an ERP client. Analyze Only can still be used as a standalone job.

+
{% if analyzer_clients %}
@@ -64,7 +88,7 @@

Analysis completed

Statements
{{ active_job.summary.statement_count or 0 }}
Transactions extracted
{{ active_job.summary.rows_extracted or 0 }}
Exact duplicates
{{ active_job.summary.exact_duplicate_rows or 0 }}
Review items
{{ active_job.summary.review_items or 0 }}
Inter-bank contra pairs
{{ active_job.summary.contra_pairs or 0 }}
Download Excel{% for file in active_job.original_files %}Download Statement {{ file.index }}{% endfor %}Analyze Another Bank

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' }}.

{% elif active_job.status == 'failed' %}
Analysis failed.
{{ active_job.error_message }}
{% if active_job.original_files %}
{% for file in active_job.original_files %}Download Statement {{ file.index }}{% endfor %}

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.

{% endif %}Analyze another statement
{% else %}
{% 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 %}
{% endif %} -
+ {% if active_job.status == 'completed' and active_job.purpose in ['accounting_entries','bank_reconciliation'] %}
Accounting import: {{ active_job.accounting_import_status|replace('_',' ')|title }}{% if active_job.summary.client_id %} · Open accounting queue{% endif %}
{% endif %} {% endif %} {% if recent_jobs %}

Recent Analyses

View all
{% for item in recent_jobs %}{% endfor %}
SubmittedBankFilesStatusAction
{{ item.submitted_at }}{{ item.selected_bank|replace('_',' ')|title }}{{ item.file_count }}{{ item.status|title }}{% if item.queue_position %} · Position {{ item.queue_position }}{% endif %}View
{% endif %} diff --git a/app/modules/bank_statement_analyzer/ui.py b/app/modules/bank_statement_analyzer/ui.py index 16b3fe1..c532bfc 100644 --- a/app/modules/bank_statement_analyzer/ui.py +++ b/app/modules/bank_statement_analyzer/ui.py @@ -86,6 +86,7 @@ def _ctx(request, db, user, **extra): "selected_bank": "auto", "financial_year": "", "classification_enabled": True, + "purpose": "analyze_only", "analyzer_clients": [], "analyzer_engagements": [], "selected_client_id": None, @@ -137,7 +138,7 @@ def index(request: Request, job: str | None = None): @router.post("/analyze") -async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: str = Form("auto"), financial_year: str = Form(""), customer_name: str = Form(""), account_number: str = Form(""), client_id: str = Form(""), engagement_id: str = Form(""), confirm_same_client: str | None = Form(None), enable_classification: str | None = Form(None), statements: list[UploadFile] = File(...)): +async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: str = Form("auto"), financial_year: str = Form(""), customer_name: str = Form(""), account_number: str = Form(""), client_id: str = Form(""), engagement_id: str = Form(""), confirm_same_client: str | None = Form(None), enable_classification: str | None = Form(None), purpose: str = Form("analyze_only"), statements: list[UploadFile] = File(...)): db = CommonSessionLocal() selected_bank = bank_selection if bank_selection in dict(BANK_OPTIONS) else "auto" classification_enabled = enable_classification == "1" @@ -147,8 +148,16 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: if denied: return denied validate_csrf(request, csrf_token) + purpose = purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only" resolved_client_id = int(client_id) if client_id.strip() else None resolved_engagement_id = int(engagement_id) if engagement_id.strip() else None + + if purpose in {"accounting_entries", "bank_reconciliation"} and not resolved_client_id: + raise ValueError( + "Prepare Accounting Entries and Bank Reconciliation require an ERP client. " + "Use Analyze Only for standalone analysis." + ) + validate_selected_client_and_engagement( db, request=request, @@ -175,6 +184,7 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: client_id=resolved_client_id, engagement_id=resolved_engagement_id, ownership_confirmation=(confirm_same_client == "1"), + purpose=purpose, ) return RedirectResponse(f"/tools/bank-statement-analyzer?job={job_id}#analysis-status", status_code=303) except Exception as exc: @@ -187,7 +197,27 @@ async def analyze(request: Request, csrf_token: str = Form(...), bank_selection: timezone_name = _display_timezone_name(request, db, user) recent = [_localised_job_view(item, timezone_name) for item in list_user_jobs(user.id, limit=8)] client_context = analyzer_client_context(db, request=request, user=user, roles=get_user_roles(db, user.id)) - return templates.TemplateResponse("modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", _ctx(request, db, user, error=str(exc), active_job=None, recent_jobs=recent, selected_bank=selected_bank, financial_year=financial_year, classification_enabled=classification_enabled, display_timezone=timezone_name), status_code=400) + return templates.TemplateResponse( + "modules/bank_statement_analyzer/templates/bank_statement_analyzer/index.html", + _ctx( + request, + db, + user, + error=str(exc), + active_job=None, + recent_jobs=recent, + selected_bank=selected_bank, + financial_year=financial_year, + classification_enabled=classification_enabled, + purpose=purpose if purpose in {"analyze_only", "accounting_entries", "bank_reconciliation"} else "analyze_only", + display_timezone=timezone_name, + analyzer_clients=client_context["clients"], + analyzer_engagements=client_context["engagements"], + selected_client_id=(int(client_id) if str(client_id).strip().isdigit() else None), + selected_engagement_id=(int(engagement_id) if str(engagement_id).strip().isdigit() else None), + ), + status_code=400, + ) finally: db.close() diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 4cef9e6..c36631d 100644 --- a/app/modules/documents/agent_package.py +++ b/app/modules/documents/agent_package.py @@ -4,7 +4,7 @@ import io from pathlib import Path import zipfile -ERP_LOCAL_AGENT_VERSION = "1.18.0" +ERP_LOCAL_AGENT_VERSION = "1.19.0" ERP_LOCAL_AGENT_NAME = "ERP Local Agent" RUNTIME_ROOT = Path(__file__).resolve().parent / "local_agent_runtime" _DETERMINISTIC_ZIP_TIMESTAMP = (2026, 1, 1, 0, 0, 0) diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py index 01556e5..4be0995 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/__init__.py @@ -1,2 +1,2 @@ -__version__ = "1.18.0" +__version__ = "1.19.0" AGENT_NAME = "ERP Local Agent" diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py index e67c82d..ee2cc05 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/commands.py @@ -48,6 +48,8 @@ class AgentCommandProcessor: result = self._post_purchase_voucher(payload) elif action == "accounting_bank_posting_preflight": result = self._bank_posting_preflight(payload) + elif action == "accounting_bank_reconciliation_extract": + result = self._bank_reconciliation_extract(payload) elif action == "accounting_post_bank_voucher": result = self._post_bank_voucher(payload) elif action == "accounting_native_voucher_preflight": @@ -83,7 +85,7 @@ class AgentCommandProcessor: return { "name": "ERP Local Agent", "version": __version__, "tally_capability": True, "accounting_act_capability": True, - "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, + "tally_mapping_capability": True, "tally_master_sync_capability": True, "chart_of_accounts_capability": True, "native_voucher_engine_capability": True, "stock_item_native_voucher_capability": True, "bank_reconciliation_read_capability": True, "tally_transaction_sync_capability": True, "historical_learning_read_capability": True, "purchase_posting_preflight_capability": True, @@ -492,6 +494,57 @@ class AgentCommandProcessor: } + def _bank_reconciliation_extract(self, payload: dict[str, Any]) -> dict[str, Any]: + company, company_name = self._resolve_open_company(payload) + + bank_ledger_name = str(payload.get("bank_ledger_name") or "").strip() + if not bank_ledger_name: + raise ValueError("Bank ledger is required for reconciliation.") + + ledgers = self.tally.bank_posting_ledgers(company_name) + bank_names = { + str(row.get("name") or "").strip().casefold() + for row in (ledgers.get("bank_ledgers") or []) + if str(row.get("name") or "").strip() + } + if bank_ledger_name.casefold() not in bank_names: + raise ValueError( + f"'{bank_ledger_name}' is not a Tally Bank Accounts ledger in the open company." + ) + + date_from = str(payload.get("date_from") or "").strip() + date_to = str(payload.get("date_to") or "").strip() + vouchers = self.tally.export_vouchers(company_name, date_from, date_to) + + filtered = [] + target = bank_ledger_name.casefold() + for voucher in vouchers: + entries = list(voucher.get("ledger_entries") or []) + if not any( + str(entry.get("ledger_name") or "").strip().casefold() == target + for entry in entries + ): + continue + filtered.append( + { + key: value + for key, value in voucher.items() + if key != "raw_xml" + } + ) + + return { + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "bank_ledger_name": bank_ledger_name, + "date_from": date_from, + "date_to": date_to, + "voucher_count": len(filtered), + "vouchers": filtered, + "read_only": True, + "agent": self._agent_info(), + } + def _bank_posting_preflight(self, payload: dict[str, Any]) -> dict[str, Any]: company, company_name = self._resolve_open_company(payload) ledgers = self.tally.bank_posting_ledgers(company_name) diff --git a/app/ui/app.py b/app/ui/app.py index 0964e25..feb4958 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -45,6 +45,7 @@ from app.modules.accounting.purchase_enrichment_ui import router as accounting_p from app.modules.accounting.purchase_review_ui import router as accounting_purchase_review_ui_router from app.modules.accounting.purchase_posting_ui import router as accounting_purchase_posting_ui_router from app.modules.accounting.bank_ui import router as accounting_bank_ui_router +from app.modules.accounting.bank_reconciliation_ui import router as accounting_bank_reconciliation_ui_router from app.modules.accounting.ai_ui import router as accounting_ai_ui_router from app.modules.accounting.internal_model_ui import router as accounting_internal_model_ui_router from app.modules.accounting.sales_ui import router as accounting_sales_income_ui_router @@ -84,6 +85,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_purchase_review_ui_router) app.include_router(accounting_purchase_posting_ui_router) app.include_router(accounting_bank_ui_router) + app.include_router(accounting_bank_reconciliation_ui_router) app.include_router(accounting_ai_ui_router) app.include_router(accounting_internal_model_ui_router) app.include_router(accounting_sales_income_ui_router)