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 %}
{{ 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.
+
+
+
+ | Run | Bank Ledger | Period | Status | Result | |
+
+ {% for run in runs %}
+ {% set summary = run.summary_json|default('{}') %}
+
+ | #{{ 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 |
+
+ {% else %}
+ | No reconciliation runs yet. |
+ {% endfor %}
+
+
+
+
+ {% 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' %}
+
+
+ | Result | Bank Statement | Tally | Why | Action |
+
+ {% for item in items %}
+
+ |
+
+ {{ 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 %}
+ |
+
+ {% else %}
+ | No reconciliation rows for this filter. |
+ {% endfor %}
+
+
+
+ {% 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 @@