diff --git a/alembic/versions/20260822_bank_to_tally_phase11.py b/alembic/versions/20260822_bank_to_tally_phase11.py new file mode 100644 index 0000000..ec8733e --- /dev/null +++ b/alembic/versions/20260822_bank_to_tally_phase11.py @@ -0,0 +1,81 @@ +"""Phase 11 Bank to Tally + multi-bank contra. + +Revision ID: 20260822_bank_to_tally_p11 +Revises: 20260822_purchase_posting_p10 +""" +from alembic import op +import sqlalchemy as sa + +revision = "20260822_bank_to_tally_p11" +down_revision = "20260822_purchase_posting_p10" +branch_labels = None +depends_on = None + + +def upgrade(): + op.create_table( + "accounting_bank_transactions", + 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="SET NULL"), nullable=True), + sa.Column("fingerprint", sa.String(80), nullable=False), + sa.Column("statement_id", sa.String(40), nullable=False, server_default=""), + sa.Column("bank_name", sa.String(160), nullable=False, server_default=""), + sa.Column("account_number", sa.String(100), nullable=False, server_default=""), + sa.Column("customer_name", sa.String(255), nullable=False, server_default=""), + sa.Column("transaction_date", sa.String(20), nullable=False, server_default=""), + sa.Column("value_date", sa.String(20), nullable=False, server_default=""), + sa.Column("narration", sa.Text(), nullable=False, server_default=""), + sa.Column("reference_no", sa.String(180), nullable=False, server_default=""), + sa.Column("transfer_reference", sa.String(180), nullable=False, server_default=""), + sa.Column("direction", sa.String(10), nullable=False, server_default=""), + sa.Column("debit", sa.Float(), nullable=False, server_default="0"), + sa.Column("credit", sa.Float(), nullable=False, server_default="0"), + sa.Column("amount", sa.Float(), nullable=False, server_default="0"), + sa.Column("auto_party", sa.String(255), nullable=False, server_default=""), + sa.Column("analyzer_category", sa.String(255), nullable=False, server_default=""), + sa.Column("analyzer_nature", sa.String(160), nullable=False, server_default=""), + sa.Column("analyzer_ledger", sa.String(255), nullable=False, server_default=""), + sa.Column("contra_pair_id", sa.String(60), nullable=False, server_default=""), + sa.Column("contra_counter_bank", sa.String(160), nullable=False, server_default=""), + sa.Column("contra_counter_account", sa.String(100), nullable=False, server_default=""), + sa.Column("contra_confidence", sa.Float(), nullable=False, server_default="0"), + sa.Column("contra_reason", sa.Text(), nullable=True), + sa.Column("tally_guid", sa.String(120), nullable=False, server_default=""), + sa.Column("suggested_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("suggested_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("suggested_confidence", sa.Integer(), nullable=False, server_default="0"), + sa.Column("suggestion_reason_json", sa.Text(), nullable=True), + sa.Column("suggested_voucher_type", sa.String(20), nullable=False, server_default=""), + sa.Column("review_status", sa.String(20), nullable=False, server_default="pending"), + sa.Column("final_nature_id", sa.Integer(), sa.ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True), + sa.Column("final_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("final_party_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("final_bank_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("final_other_bank_ledger_name", sa.String(255), nullable=False, server_default=""), + sa.Column("final_voucher_type", sa.String(20), nullable=False, server_default=""), + sa.Column("review_note", sa.Text(), nullable=True), + sa.Column("reviewed_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("reviewed_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("workstation_agent_id", sa.Integer(), sa.ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True), + sa.Column("preflight_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("posting_job_id", sa.Integer(), sa.ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True), + sa.Column("preflight_result_json", sa.Text(), nullable=True), + sa.Column("posting_result_json", sa.Text(), nullable=True), + sa.Column("posting_status", sa.String(30), nullable=False, server_default="not_started"), + sa.Column("last_error", sa.Text(), nullable=True), + sa.Column("tally_voucher_id", sa.String(120), nullable=False, server_default=""), + sa.Column("tally_voucher_number", sa.String(160), nullable=False, server_default=""), + sa.Column("posted_by_user_id", sa.Integer(), sa.ForeignKey("users.id", ondelete="SET NULL"), nullable=True), + sa.Column("posted_at_utc", sa.DateTime(timezone=True), nullable=True), + sa.Column("created_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.Column("updated_at_utc", sa.DateTime(timezone=True), nullable=False, server_default=sa.func.now()), + sa.UniqueConstraint("tenant_id", "client_id", "fingerprint", name="uq_accounting_bank_tx_fingerprint"), + ) + for name in ("tenant_id","client_id","source_job_id","fingerprint","transaction_date","contra_pair_id","review_status","posting_status","created_at_utc"): + op.create_index(f"ix_accounting_bank_transactions_{name}", "accounting_bank_transactions", [name]) + + +def downgrade(): + op.drop_table("accounting_bank_transactions") diff --git a/app/modules/accounting/bank_models.py b/app/modules/accounting/bank_models.py new file mode 100644 index 0000000..df71bc8 --- /dev/null +++ b/app/modules/accounting/bank_models.py @@ -0,0 +1,79 @@ +from __future__ import annotations + +from datetime import datetime, timezone + +from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db.common import CommonBase + + +class AccountingBankTransaction(CommonBase): + __tablename__ = "accounting_bank_transactions" + __table_args__ = ( + UniqueConstraint("tenant_id", "client_id", "fingerprint", name="uq_accounting_bank_tx_fingerprint"), + ) + + 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 | None] = mapped_column(ForeignKey("bank_statement_analysis_jobs.id", ondelete="SET NULL"), nullable=True, index=True) + fingerprint: Mapped[str] = mapped_column(String(80), nullable=False, index=True) + + statement_id: Mapped[str] = mapped_column(String(40), nullable=False, default="") + bank_name: Mapped[str] = mapped_column(String(160), nullable=False, default="") + account_number: Mapped[str] = mapped_column(String(100), nullable=False, default="") + customer_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + transaction_date: Mapped[str] = mapped_column(String(20), nullable=False, default="", index=True) + value_date: Mapped[str] = mapped_column(String(20), nullable=False, default="") + narration: Mapped[str] = mapped_column(Text, nullable=False, default="") + reference_no: Mapped[str] = mapped_column(String(180), nullable=False, default="") + transfer_reference: Mapped[str] = mapped_column(String(180), nullable=False, default="") + direction: Mapped[str] = mapped_column(String(10), nullable=False, default="") + debit: Mapped[float] = mapped_column(Float, nullable=False, default=0) + credit: Mapped[float] = mapped_column(Float, nullable=False, default=0) + amount: Mapped[float] = mapped_column(Float, nullable=False, default=0) + + auto_party: Mapped[str] = mapped_column(String(255), nullable=False, default="") + analyzer_category: Mapped[str] = mapped_column(String(255), nullable=False, default="") + analyzer_nature: Mapped[str] = mapped_column(String(160), nullable=False, default="") + analyzer_ledger: Mapped[str] = mapped_column(String(255), nullable=False, default="") + + contra_pair_id: Mapped[str] = mapped_column(String(60), nullable=False, default="", index=True) + contra_counter_bank: Mapped[str] = mapped_column(String(160), nullable=False, default="") + contra_counter_account: Mapped[str] = mapped_column(String(100), nullable=False, default="") + contra_confidence: Mapped[float] = mapped_column(Float, nullable=False, default=0) + contra_reason: Mapped[str | None] = mapped_column(Text, nullable=True) + + tally_guid: Mapped[str] = mapped_column(String(120), nullable=False, default="") + suggested_nature_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True) + suggested_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + suggested_confidence: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + suggestion_reason_json: Mapped[str | None] = mapped_column(Text, nullable=True) + suggested_voucher_type: Mapped[str] = mapped_column(String(20), nullable=False, default="") + + review_status: Mapped[str] = mapped_column(String(20), nullable=False, default="pending", index=True) + final_nature_id: Mapped[int | None] = mapped_column(ForeignKey("accounting_natures.id", ondelete="SET NULL"), nullable=True) + final_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + final_party_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + final_bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + final_other_bank_ledger_name: Mapped[str] = mapped_column(String(255), nullable=False, default="") + final_voucher_type: Mapped[str] = mapped_column(String(20), nullable=False, default="") + review_note: Mapped[str | None] = mapped_column(Text, nullable=True) + reviewed_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + reviewed_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + workstation_agent_id: Mapped[int | None] = mapped_column(ForeignKey("erp_workstation_agents.id", ondelete="SET NULL"), nullable=True) + preflight_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True) + posting_job_id: Mapped[int | None] = mapped_column(ForeignKey("erp_agent_jobs.id", ondelete="SET NULL"), nullable=True) + 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) + 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="") + posted_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True) + posted_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True) + + created_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), index=True) + updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=lambda: datetime.now(timezone.utc), onupdate=lambda: datetime.now(timezone.utc)) diff --git a/app/modules/accounting/bank_service.py b/app/modules/accounting/bank_service.py new file mode 100644 index 0000000..15bba97 --- /dev/null +++ b/app/modules/accounting/bank_service.py @@ -0,0 +1,412 @@ +from __future__ import annotations + +import hashlib +import json +from datetime import datetime, timezone +from pathlib import Path + +import pandas as pd +from sqlalchemy import func, select + +from app.modules.accounting.bank_models import AccountingBankTransaction +from app.modules.accounting.ledger_learning_service import ( + active_natures, + available_tally_guids, + rank_suggestions, + record_review, +) +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 + +PREFLIGHT_ACTION = "accounting_bank_posting_preflight" +POST_ACTION = "accounting_post_bank_voucher" + + +def _utcnow(): + return datetime.now(timezone.utc) + + +def _s(value): + if value is None: + return "" + try: + if pd.isna(value): + return "" + except Exception: + pass + return str(value).strip() + + +def _f(value): + try: + if pd.isna(value): + return 0.0 + return round(float(value or 0), 2) + except Exception: + return 0.0 + + +def _date(value): + dt = pd.to_datetime(value, errors="coerce") + return "" if pd.isna(dt) else dt.strftime("%Y-%m-%d") + + +def _fingerprint(client_id, row): + raw = "|".join([ + str(client_id), + _s(row.get("bank_name")), + _s(row.get("account_number")), + _date(row.get("transaction_date")), + "D" if _f(row.get("debit")) > 0 else "C", + f"{max(_f(row.get('debit')), _f(row.get('credit'))):.2f}", + _s(row.get("reference_no")), + _s(row.get("transfer_reference")), + _s(row.get("narration")).upper(), + ]) + return hashlib.sha256(raw.encode("utf-8", "ignore")).hexdigest() + + +def _voucher_type(row): + if _s(row.get("contra_pair_id")): + return "Contra" + return "Payment" if _f(row.get("debit")) > 0 else "Receipt" + + +def import_completed_job(db, *, tenant_id, client_id, job_id, user_id): + job = db.get(BankStatementAnalysisJob, job_id) + if not job or job.status != "completed" or not job.output_file or not Path(job.output_file).is_file(): + raise ValueError("Completed Bank Analyzer workbook was not found.") + if job.tenant_id not in (None, tenant_id): + raise ValueError("Bank Analyzer job belongs to a different firm.") + + frame = pd.read_excel(job.output_file, sheet_name="Transaction Classification") + tally_options = available_tally_guids(db, tenant_id, client_id) + tally_guid = tally_options[0][0] if len(tally_options) == 1 else "" + + inserted = 0 + skipped = 0 + for _, row in frame.iterrows(): + fp = _fingerprint(client_id, row) + exists = db.execute(select(AccountingBankTransaction.id).where( + AccountingBankTransaction.tenant_id == tenant_id, + AccountingBankTransaction.client_id == client_id, + AccountingBankTransaction.fingerprint == fp, + )).scalar_one_or_none() + if exists: + skipped += 1 + continue + + contra_pair = _s(row.get("contra_pair_id")) + suggestions = [] + if not contra_pair: + suggestions = rank_suggestions( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tally_guid, + supplier_name=_s(row.get("auto_party")), + supplier_gstin="", + hsn_code="", + description=_s(row.get("narration")), + amount=max(_f(row.get("debit")), _f(row.get("credit"))), + ) + top = suggestions[0] if suggestions else None + + tx = AccountingBankTransaction( + tenant_id=tenant_id, + client_id=client_id, + source_job_id=job.id, + fingerprint=fp, + statement_id=_s(row.get("statement_id")), + bank_name=_s(row.get("bank_name")), + account_number=_s(row.get("account_number")), + customer_name=_s(row.get("customer_name")), + transaction_date=_date(row.get("transaction_date")), + value_date=_date(row.get("value_date")), + narration=_s(row.get("narration")), + reference_no=_s(row.get("reference_no")), + transfer_reference=_s(row.get("transfer_reference")), + direction=_s(row.get("direction")), + debit=_f(row.get("debit")), + credit=_f(row.get("credit")), + amount=max(_f(row.get("debit")), _f(row.get("credit"))), + auto_party=_s(row.get("auto_party")), + analyzer_category=_s(row.get("auto_category")), + analyzer_nature=_s(row.get("auto_nature")), + analyzer_ledger=_s(row.get("suggested_ledger")), + contra_pair_id=contra_pair, + contra_counter_bank=_s(row.get("contra_counter_bank")), + contra_counter_account=_s(row.get("contra_counter_account")), + contra_confidence=_f(row.get("contra_confidence")), + contra_reason=_s(row.get("contra_reason")) or None, + tally_guid=tally_guid, + suggested_nature_id=(top["nature"].id if top else None), + suggested_ledger_name=(top["suggested_ledger"] if top else ""), + suggested_confidence=(int(top["confidence"]) if top else int(_f(row.get("contra_confidence")))), + suggestion_reason_json=json.dumps(top["reasons"] if top else ([f"Matched multi-bank contra pair {contra_pair}."] if contra_pair else []), ensure_ascii=False), + suggested_voucher_type=_voucher_type(row), + final_voucher_type=_voucher_type(row), + ) + db.add(tx) + inserted += 1 + + db.commit() + return inserted, skipped + + +def queue_rows(db, *, tenant_id, client_id, status="", page=1, per_page=25): + stmt = select(AccountingBankTransaction).where( + AccountingBankTransaction.tenant_id == tenant_id, + AccountingBankTransaction.client_id == client_id, + ) + count_stmt = select(func.count()).select_from(AccountingBankTransaction).where( + AccountingBankTransaction.tenant_id == tenant_id, + AccountingBankTransaction.client_id == client_id, + ) + if status: + stmt = stmt.where(AccountingBankTransaction.review_status == status) + count_stmt = count_stmt.where(AccountingBankTransaction.review_status == status) + total = int(db.scalar(count_stmt) or 0) + pages = max(1, (total + per_page - 1) // per_page) + page = max(1, min(page, pages)) + rows = list(db.execute( + stmt.order_by(AccountingBankTransaction.transaction_date.desc(), AccountingBankTransaction.id.desc()) + .offset((page - 1) * per_page).limit(per_page) + ).scalars().all()) + return rows, total, page, pages + + +def confirm_review(db, *, tx_id, tenant_id, client_id, nature_id, ledger_name, voucher_type, user_id, note=""): + tx = db.get(AccountingBankTransaction, tx_id) + if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id: + raise ValueError("Bank transaction was not found.") + + voucher_type = (voucher_type or tx.suggested_voucher_type or "").title() + if voucher_type not in {"Payment", "Receipt", "Contra"}: + raise ValueError("Voucher type must be Payment, Receipt or Contra.") + + tx.final_voucher_type = voucher_type + tx.review_note = (note or "").strip() or None + tx.reviewed_by_user_id = user_id + tx.reviewed_at_utc = _utcnow() + tx.review_status = "reviewed" + + if voucher_type == "Contra": + if not tx.contra_pair_id: + raise ValueError("Contra treatment requires a matched multi-bank contra pair.") + tx.final_nature_id = None + tx.final_ledger_name = "" + else: + if not nature_id: + raise ValueError("Select the final accounting nature.") + tx.final_nature_id = int(nature_id) + tx.final_ledger_name = (ledger_name or "").strip() + record_review( + db, + tenant_id=tenant_id, + client_id=client_id, + tally_guid=tx.tally_guid or "", + supplier_name=tx.auto_party, + supplier_gstin="", + hsn_code="", + description=tx.narration, + amount=tx.amount, + suggested_nature_id=tx.suggested_nature_id, + suggested_ledger_name=tx.suggested_ledger_name, + suggested_confidence=tx.suggested_confidence, + final_nature_id=int(nature_id), + final_ledger_name=tx.final_ledger_name, + user_id=user_id, + explanation=json.loads(tx.suggestion_reason_json or "[]"), + ) + # record_review commits; refresh before final mutation persistence + tx = db.get(AccountingBankTransaction, tx_id) + tx.final_voucher_type = voucher_type + tx.review_note = (note or "").strip() or None + tx.reviewed_by_user_id = user_id + tx.reviewed_at_utc = _utcnow() + tx.review_status = "reviewed" + tx.final_nature_id = int(nature_id) + tx.final_ledger_name = (ledger_name or "").strip() + + db.add(tx) + db.commit() + return tx + + +def visible_workstations(db, tenant_id, branch_id=None): + stmt = select(ERPWorkstationAgent).where( + ERPWorkstationAgent.tenant_id == tenant_id, + ERPWorkstationAgent.is_active.is_(True), + ) + if branch_id is not None: + stmt = stmt.where(ERPWorkstationAgent.branch_id == branch_id) + return list(db.execute(stmt.order_by(ERPWorkstationAgent.tally_connected.desc(), ERPWorkstationAgent.id)).scalars().all()) + + +def _ensure_reviewed(tx): + if tx.review_status != "reviewed": + raise ValueError("Review the bank transaction before Tally posting.") + if tx.posting_status == "posted": + raise ValueError("This bank transaction is already posted.") + + +def queue_preflight(db, *, tx_id, tenant_id, client_id, workstation_id, user_id): + tx = db.get(AccountingBankTransaction, tx_id) + if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id: + raise ValueError("Bank transaction was not found.") + _ensure_reviewed(tx) + ws = db.get(ERPWorkstationAgent, int(workstation_id)) + if not ws or ws.tenant_id != tenant_id or not ws.is_active or not ws.tally_connected: + raise ValueError("Selected workstation/Tally connection is unavailable.") + if not tx.tally_guid: + raise ValueError("Map the client to a Tally company before bank posting.") + + payload = { + "tenant_id": tenant_id, + "client_id": client_id, + "tally_guid": tx.tally_guid, + "bank_name": tx.bank_name, + "account_number": tx.account_number, + "counter_bank": tx.contra_counter_bank, + "counter_account": tx.contra_counter_account, + "party_hint": tx.auto_party, + "counter_ledger_hint": tx.final_ledger_name, + "voucher_type": tx.final_voucher_type, + "transaction_date": tx.transaction_date, + "amount": tx.amount, + "reference": tx.transfer_reference or tx.reference_no, + "erp_bank_transaction_id": tx.id, + } + job = enqueue_agent_job( + db, + workstation_agent_id=ws.id, + action=PREFLIGHT_ACTION, + payload=payload, + idempotency_key=f"bank:{client_id}:{tx.id}:preflight:{ws.id}", + priority=9, + max_attempts=2, + created_by_user_id=user_id, + ) + tx.workstation_agent_id = ws.id + tx.preflight_job_id = job.id + tx.posting_status = "preflight_queued" + tx.last_error = None + db.add(tx) + db.commit() + return tx + + +def _loads(value): + try: + return json.loads(value or "{}") + except Exception: + return {} + + +def sync_posting(db, tx): + changed = False + if tx.preflight_job_id and tx.posting_status.startswith("preflight"): + job = db.get(ERPAgentJob, tx.preflight_job_id) + if job: + if job.status == "claimed": + tx.posting_status = "preflight_claimed"; changed = True + elif job.status == "succeeded": + tx.preflight_result_json = job.result_json + tx.posting_status = "preflight_ready"; tx.last_error = None; changed = True + elif job.status in {"failed", "cancelled"}: + tx.posting_status = "preflight_failed"; tx.last_error = job.last_error or job.status; changed = True + + if tx.posting_job_id and tx.posting_status.startswith("posting"): + job = db.get(ERPAgentJob, tx.posting_job_id) + if job: + if job.status == "claimed": + tx.posting_status = "posting_claimed"; changed = True + elif job.status == "succeeded": + result = _loads(job.result_json) + tally = result.get("tally_result") or result + tx.posting_result_json = job.result_json + tx.tally_voucher_id = str(tally.get("last_voucher_id") or tally.get("voucher_id") or "") + tx.tally_voucher_number = str(tally.get("voucher_number") or tally.get("last_voucher_id") or "") + tx.posting_status = "posted" + tx.posted_at_utc = job.completed_at_utc or _utcnow() + tx.last_error = None + changed = True + elif job.status == "failed": + err = job.last_error or "Tally bank posting failed." + tx.posting_status = "posting_indeterminate" if "Verify Tally before retrying" in err else "posting_failed" + tx.last_error = err; changed = True + elif job.status == "cancelled": + tx.posting_status = "posting_failed"; tx.last_error = "Posting job cancelled."; changed = True + if changed: + tx.updated_at_utc = _utcnow() + db.add(tx); db.commit() + return tx + + +def preflight_choices(tx): + return _loads(tx.preflight_result_json) + + +def queue_post(db, *, tx_id, tenant_id, client_id, bank_ledger_name, counter_ledger_name, other_bank_ledger_name, user_id): + tx = db.get(AccountingBankTransaction, tx_id) + if not tx or tx.tenant_id != tenant_id or tx.client_id != client_id: + raise ValueError("Bank transaction was not found.") + tx = sync_posting(db, tx) + if tx.posting_status != "preflight_ready": + raise ValueError("Successful workstation preflight is required.") + choices = preflight_choices(tx) + banks = {str(x.get("name") or "") for x in choices.get("bank_ledgers") or []} + ledgers = {str(x.get("name") or "") for x in choices.get("all_ledgers") or []} + + bank_ledger_name = (bank_ledger_name or "").strip() + counter_ledger_name = (counter_ledger_name or "").strip() + other_bank_ledger_name = (other_bank_ledger_name or "").strip() + + if bank_ledger_name not in banks: + raise ValueError("Select a bank ledger returned by the current Tally preflight.") + if tx.final_voucher_type == "Contra": + if other_bank_ledger_name not in banks or other_bank_ledger_name == bank_ledger_name: + raise ValueError("Select the other bank ledger for this contra pair.") + else: + if counter_ledger_name not in ledgers: + raise ValueError("Select a valid Tally counter ledger.") + + if choices.get("duplicate_candidates"): + raise ValueError("Possible duplicate bank voucher exists in Tally. Verify before posting.") + + payload = { + "tenant_id": tenant_id, + "client_id": client_id, + "tally_guid": tx.tally_guid, + "erp_bank_transaction_id": tx.id, + "voucher_type": tx.final_voucher_type, + "transaction_date": tx.transaction_date, + "bank_ledger_name": bank_ledger_name, + "counter_ledger_name": counter_ledger_name, + "other_bank_ledger_name": other_bank_ledger_name, + "direction": tx.direction, + "amount": tx.amount, + "reference": tx.transfer_reference or tx.reference_no or f"ERP-BANK-{tx.id}", + "narration": f"ERP Bank Analyzer #{tx.id}: {tx.narration}"[:1000], + } + ws = db.get(ERPWorkstationAgent, tx.workstation_agent_id) + job = enqueue_agent_job( + db, + workstation_agent_id=ws.id, + action=POST_ACTION, + payload=payload, + idempotency_key=f"bank:{client_id}:{tx.id}:post:{ws.id}", + priority=10, + max_attempts=1, + created_by_user_id=user_id, + ) + tx.final_bank_ledger_name = bank_ledger_name + tx.final_party_ledger_name = counter_ledger_name + tx.final_other_bank_ledger_name = other_bank_ledger_name + tx.posting_job_id = job.id + tx.posted_by_user_id = user_id + tx.posting_status = "posting_queued" + db.add(tx); db.commit() + return tx diff --git a/app/modules/accounting/bank_ui.py b/app/modules/accounting/bank_ui.py new file mode 100644 index 0000000..bee99d2 --- /dev/null +++ b/app/modules/accounting/bank_ui.py @@ -0,0 +1,123 @@ +from __future__ import annotations + +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_service import ( + confirm_review, import_completed_job, preflight_choices, queue_post, queue_preflight, + queue_rows, sync_posting, visible_workstations, +) +from app.modules.accounting.ledger_learning_service import active_natures +from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients +from app.modules.bank_statement_analyzer.models import BankStatementAnalysisJob +from app.modules.core.rbac.deps import get_user_permissions, get_user_roles +from sqlalchemy import select + +router = APIRouter(prefix="/tools/accounting/bank-posting", tags=["accounting-bank-posting-ui"]) + + +def _go(client_id=0, message="", error=""): + q = {"client_id": client_id} if client_id else {} + if message: q["message"] = message[:250] + if error: q["error"] = error[:250] + return RedirectResponse("/tools/accounting/bank-posting" + ("?" + urlencode(q) if q else ""), status_code=303) + + +@router.get("") +def page(request: Request, client_id: int | None = None, status: str = "", page: int = 1, per_page: int = 25, 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) + rows = []; total = 0; pages = 1 + choices = {}; workstations = [] + if selected: + rows, total, page, pages = queue_rows(db, tenant_id=scope.tenant_id, client_id=selected.id, status=status, page=page, per_page=per_page) + for row in rows: + sync_posting(db, row) + choices = {r.id: preflight_choices(r) for r in rows if r.preflight_result_json} + workstations = visible_workstations(db, scope.tenant_id, getattr(user, "branch_id", None)) + jobs = list(db.execute(select(BankStatementAnalysisJob).where( + BankStatementAnalysisJob.user_id == user.id, + BankStatementAnalysisJob.status == "completed", + ).order_by(BankStatementAnalysisJob.completed_at_utc.desc()).limit(30)).scalars().all()) + return templates.TemplateResponse("modules/accounting/templates/accounting/bank_posting.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 to Tally", "clients": clients, "selected_client": selected, + "rows": rows, "total": total, "page": page, "pages": pages, "per_page": per_page, + "status_filter": status, "jobs": jobs, "natures": active_natures(db, scope.tenant_id), + "choices": choices, "workstations": workstations, "message": message, "error": error, + }) + finally: + db.close() + + +@router.post("/import") +def import_job(request: Request, client_id: int = Form(...), job_id: str = 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, _, scope = _find_visible_client(db, request, user, client_id) + if not client: return _go(error="Client is not visible.") + added, skipped = import_completed_job(db, tenant_id=scope.tenant_id, client_id=client.id, job_id=job_id, user_id=user.id) + return _go(client.id, message=f"Imported {added} bank transaction(s); {skipped} duplicate fingerprint(s) skipped.") + except Exception as exc: + db.rollback(); return _go(client_id, error=str(exc)) + finally: db.close() + + +@router.post("/{tx_id}/review") +def review(request: Request, tx_id: int, client_id: int = Form(...), nature_id: int | None = Form(None), ledger_name: str = Form(""), voucher_type: str = Form(...), review_note: str = 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, _, scope = _find_visible_client(db, request, user, client_id) + confirm_review(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, nature_id=nature_id, ledger_name=ledger_name, voucher_type=voucher_type, user_id=user.id, note=review_note) + return _go(client.id, message="Bank transaction review saved.") + except Exception as exc: + db.rollback(); return _go(client_id, error=str(exc)) + finally: db.close() + + +@router.post("/{tx_id}/preflight") +def preflight(request: Request, tx_id: int, client_id: int = 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, _, scope = _find_visible_client(db, request, user, client_id) + queue_preflight(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, workstation_id=workstation_id, user_id=user.id) + return _go(client.id, message="Bank posting preflight queued.") + except Exception as exc: + db.rollback(); return _go(client_id, error=str(exc)) + finally: db.close() + + +@router.post("/{tx_id}/post") +def post(request: Request, tx_id: int, client_id: int = Form(...), bank_ledger_name: str = Form(...), counter_ledger_name: str = Form(""), other_bank_ledger_name: str = 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, _, scope = _find_visible_client(db, request, user, client_id) + queue_post(db, tx_id=tx_id, tenant_id=scope.tenant_id, client_id=client.id, bank_ledger_name=bank_ledger_name, counter_ledger_name=counter_ledger_name, other_bank_ledger_name=other_bank_ledger_name, user_id=user.id) + return _go(client.id, message="Controlled bank voucher job queued.") + except Exception as exc: + db.rollback(); return _go(client_id, error=str(exc)) + finally: db.close() diff --git a/app/modules/accounting/templates/accounting/bank_posting.html b/app/modules/accounting/templates/accounting/bank_posting.html new file mode 100644 index 0000000..3cd83a9 --- /dev/null +++ b/app/modules/accounting/templates/accounting/bank_posting.html @@ -0,0 +1,76 @@ +{% extends "ui/templates/base/layout.html" %} +{% block content %} +
+
+

Accounting Intelligence · Phase 11

Bank → Tally Review

Import a completed Bank Analyzer job, reuse the common ledger-learning engine for non-contra transactions, and post only reviewed Payment / Receipt / Contra vouchers through the durable Local Agent job system.

+ Bank Analyzer +
+ {% if message %}
{{ message }}
{% endif %} + {% if error %}
{{ error }}
{% endif %} + +
+
+ + + +
+
+
+ + {% if selected_client %} +
+

{{ selected_client.client_name }}

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

+
+ {% for tx in rows %} + {% 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 %}
+
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' %} +
+ + + + + + +
+ {% elif tx.posting_status in ['not_started','preflight_failed','posting_failed'] %} +
+ +
Reviewed: {{ tx.final_voucher_type }}{% if tx.final_ledger_name %} · {{ tx.final_ledger_name }}{% endif %}
+ + + {% if tx.last_error %}
{{ tx.last_error }}
{% endif %} +
+ {% elif tx.posting_status in ['preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %} +
{{ tx.posting_status|replace('_',' ')|title }}. Refresh the page after the workstation processes the durable job.
+ {% elif tx.posting_status == 'preflight_ready' and choice %} +
+ + {% if choice.duplicate_candidates %}
Possible duplicate exists in Tally. Posting is blocked until resolved.
{% endif %} + + {% if tx.final_voucher_type == 'Contra' %} + + {% else %} + + {% endif %} + +
+ {% elif tx.posting_status == 'posted' %} +
Posted. Voucher {{ tx.tally_voucher_number or tx.tally_voucher_id or 'created' }}.
+ {% elif tx.posting_status == 'posting_indeterminate' %} +
Manual verification required — do not retry. {{ tx.last_error }}
+ {% endif %} +
+
+
+ {% else %}
No imported bank transactions.
{% endfor %} +
+ {% if pages > 1 %}
Page {{ page }} of {{ pages }}
{% if page>1 %}Previous{% endif %}{% if pageNext{% endif %}
{% endif %} +
+ {% endif %} +
+{% endblock %} diff --git a/app/modules/accounting/templates/accounting/tally.html b/app/modules/accounting/templates/accounting/tally.html index ca6d674..4ad7d1c 100644 --- a/app/modules/accounting/templates/accounting/tally.html +++ b/app/modules/accounting/templates/accounting/tally.html @@ -20,6 +20,7 @@ {% if selected_client %}E-Invoice / E-Way Bill{% endif %} {% if selected_client %}Purchase Review{% endif %} {% if selected_client %}Post to Tally{% endif %} + {% if selected_client %}Bank → Tally{% endif %} {% if selected_client %}Depreciation (IT){% endif %} Refresh Tally Companies diff --git a/app/modules/bank_statement_analyzer/analyzer.py b/app/modules/bank_statement_analyzer/analyzer.py index 9fbd2a0..0feb7af 100644 --- a/app/modules/bank_statement_analyzer/analyzer.py +++ b/app/modules/bank_statement_analyzer/analyzer.py @@ -23,6 +23,8 @@ ANALYSIS_COLUMNS = [ "auto_category", "auto_nature", "auto_group", "matched_rule_id", "matched_keyword", "suggested_ledger", "rule_confidence", "review_required", "review_note", + "contra_pair_id", "contra_status", "contra_counter_statement_id", + "contra_counter_bank", "contra_counter_account", "contra_confidence", "contra_reason", "category", "counterparty", ] @@ -428,6 +430,186 @@ def enrich(df, classification_enabled: bool = True): return _ensure_analysis_columns(x) + +def _contra_normalized_ref(row) -> str: + values = [ + row.get("transfer_reference"), + row.get("reference_no"), + ] + for value in values: + value = re.sub(r"[^A-Z0-9]", "", _text(value).upper()) + if len(value) >= 6: + return value + return "" + + +def _account_tail(value: str) -> str: + digits = re.sub(r"\D", "", _text(value)) + return digits[-6:] if len(digits) >= 4 else digits + + +def _contra_candidate_score(left, right) -> tuple[int, list[str]]: + if _direction(left) == _direction(right): + return 0, [] + left_account = _text(left.get("account_number")) + right_account = _text(right.get("account_number")) + if not left_account or not right_account or clean_key(left_account) == clean_key(right_account): + return 0, [] + + left_amount = round(_amount(left), 2) + right_amount = round(_amount(right), 2) + if left_amount <= 0 or abs(left_amount - right_amount) > 0.01: + return 0, [] + + left_date = pd.to_datetime(left.get("transaction_date"), errors="coerce") + right_date = pd.to_datetime(right.get("transaction_date"), errors="coerce") + if pd.isna(left_date) or pd.isna(right_date): + return 0, [] + days = abs((left_date.normalize() - right_date.normalize()).days) + if days > 2: + return 0, [] + + score = 55 + reasons = ["equal and opposite amount across different bank accounts"] + if days == 0: + score += 18 + reasons.append("same transaction date") + elif days == 1: + score += 13 + reasons.append("one-day settlement difference") + else: + score += 8 + reasons.append("two-day settlement difference") + + lref = _contra_normalized_ref(left) + rref = _contra_normalized_ref(right) + if lref and rref and lref == rref: + score += 30 + reasons.append("same bank transfer reference") + + ln = _text(left.get("narration")).upper() + rn = _text(right.get("narration")).upper() + own_words = ("SELF", "OWN ACCOUNT", "OWN A/C", "TRANSFER TO", "TRANSFER FROM", "INTERNAL TRANSFER") + if any(word in ln for word in own_words) or any(word in rn for word in own_words): + score += 10 + reasons.append("own-account transfer wording") + + ltail = _account_tail(left_account) + rtail = _account_tail(right_account) + if (ltail and ltail in rn) or (rtail and rtail in ln): + score += 18 + reasons.append("counter bank account suffix appears in narration") + + # Without a transfer reference or direct account evidence, do not call a generic + # same-amount movement contra merely because dates happen to align. + strong_identity = bool((lref and rref and lref == rref) or ((ltail and ltail in rn) or (rtail and rtail in ln))) + if not strong_identity and not any(word in ln for word in own_words) and not any(word in rn for word in own_words): + return 0, [] + + return min(100, score), reasons + + +def detect_interbank_contra_pairs(df: pd.DataFrame) -> pd.DataFrame: + """Conservatively pair transfers between different uploaded accounts. + + The function only pairs equal-and-opposite movements across distinct account + numbers within two days and requires transfer-reference, account-suffix or + explicit own-account wording evidence. Each transaction is used in at most + one pair. + """ + x = _ensure_analysis_columns(df) + if x.empty: + return x + + for col, default in ( + ("contra_pair_id", ""), + ("contra_status", ""), + ("contra_counter_statement_id", ""), + ("contra_counter_bank", ""), + ("contra_counter_account", ""), + ("contra_confidence", 0.0), + ("contra_reason", ""), + ): + if col not in x.columns: + x[col] = default + + candidates = [] + rows = list(x.iterrows()) + for pos, (li, left) in enumerate(rows): + for ri, right in rows[pos + 1:]: + score, reasons = _contra_candidate_score(left, right) + if score >= 85: + candidates.append((score, li, ri, reasons)) + + candidates.sort(key=lambda item: (-item[0], item[1], item[2])) + used = set() + pair_no = 0 + + for score, li, ri, reasons in candidates: + if li in used or ri in used: + continue + used.add(li) + used.add(ri) + pair_no += 1 + pair_id = f"CONTRA-{pair_no:04d}" + + for current, other in ((li, ri), (ri, li)): + x.at[current, "contra_pair_id"] = pair_id + x.at[current, "contra_status"] = "Matched" + x.at[current, "contra_counter_statement_id"] = _text(x.at[other, "statement_id"]) + x.at[current, "contra_counter_bank"] = _text(x.at[other, "bank_name"]) + x.at[current, "contra_counter_account"] = _text(x.at[other, "account_number"]) + x.at[current, "contra_confidence"] = float(score) + x.at[current, "contra_reason"] = "; ".join(reasons) + + x.at[current, "auto_party"] = "Own Bank Transfer" + x.at[current, "auto_category"] = "Self Transfer / Contra" + x.at[current, "auto_nature"] = "Contra" + x.at[current, "auto_group"] = "Contra / Balance Sheet" + x.at[current, "suggested_ledger"] = "Other Bank Account" + x.at[current, "rule_confidence"] = float(score) + if score >= 95: + x.at[current, "review_required"] = False + x.at[current, "review_note"] = "" + else: + x.at[current, "review_required"] = True + x.at[current, "review_note"] = "Probable inter-bank contra; verify both bank accounts before posting." + + return x + + +def interbank_contra_summary(df: pd.DataFrame) -> pd.DataFrame: + if df is None or df.empty or "contra_pair_id" not in df.columns: + return pd.DataFrame(columns=[ + "contra_pair_id", "confidence", "debit_bank", "debit_account", + "credit_bank", "credit_account", "amount", "date_from", "date_to", "reason", + ]) + matched = df[df["contra_pair_id"].fillna("").ne("")].copy() + rows = [] + for pair_id, part in matched.groupby("contra_pair_id", sort=True): + if len(part) != 2: + continue + debit = part[pd.to_numeric(part["debit"], errors="coerce").fillna(0).gt(0)] + credit = part[pd.to_numeric(part["credit"], errors="coerce").fillna(0).gt(0)] + if debit.empty or credit.empty: + continue + d = debit.iloc[0] + c = credit.iloc[0] + dates = pd.to_datetime(part["transaction_date"], errors="coerce").dropna() + rows.append({ + "contra_pair_id": pair_id, + "confidence": float(part["contra_confidence"].max() or 0), + "debit_bank": d.get("bank_name", ""), + "debit_account": d.get("account_number", ""), + "credit_bank": c.get("bank_name", ""), + "credit_account": c.get("account_number", ""), + "amount": round(float(d.get("debit") or 0), 2), + "date_from": dates.min() if not dates.empty else None, + "date_to": dates.max() if not dates.empty else None, + "reason": d.get("contra_reason", ""), + }) + return pd.DataFrame(rows) + def analyze_files(paths, customer_override="", account_override="", bank_hint="auto", classification_enabled=True): metas = [] frames = [] @@ -447,6 +629,7 @@ def analyze_files(paths, customer_override="", account_override="", bank_hint="a frames.append(df) combined = pd.concat(frames, ignore_index=True) if frames else pd.DataFrame() all_df = _ensure_analysis_columns(enrich(combined, classification_enabled)) + all_df = detect_interbank_contra_pairs(all_df) # IMPORTANT ACCOUNTING CONTROL: # Duplicate detection is advisory only. A bank may legitimately contain two @@ -598,7 +781,10 @@ def _workbook_columns(df): "statement_id", "transaction_date", "value_date", "narration", "transfer_bank_code", "transfer_reference", "transfer_comment", "auto_party", "party_match_method", "party_match_confidence", "auto_category", "auto_nature", "auto_group", "matched_rule_id", "matched_keyword", - "suggested_ledger", "rule_confidence", "review_required", "mode", "direction", "debit", "credit", + "suggested_ledger", "rule_confidence", "review_required", + "contra_pair_id", "contra_status", "contra_counter_statement_id", "contra_counter_bank", + "contra_counter_account", "contra_confidence", "contra_reason", + "mode", "direction", "debit", "credit", "balance", "reference_no", "bank_name", "customer_name", "account_number", "source_file", "source_page", "parser_name", "exact_duplicate", "possible_duplicate", "duplicate_group_id", "duplicate_reason", "duplicate_confidence", "review_note", @@ -900,6 +1086,7 @@ def export_excel(output, metas, all_df, unique_df, financial_year="", selected_b exact_export.to_excel(writer, sheet_name="Exact Duplicates", index=False) possible_export.to_excel(writer, sheet_name="Possible Duplicates", index=False) duplicate_summary(all_df).to_excel(writer, sheet_name="Duplicate Summary", index=False) + interbank_contra_summary(unique_df).to_excel(writer, sheet_name="Interbank Contra Matches", index=False) # Masters first so validation ranges exist. max_master = max(len(categories), len(parties), len(natures), len(groups), 1) diff --git a/app/modules/bank_statement_analyzer/service.py b/app/modules/bank_statement_analyzer/service.py index b7e59d9..982fd03 100644 --- a/app/modules/bank_statement_analyzer/service.py +++ b/app/modules/bank_statement_analyzer/service.py @@ -265,6 +265,7 @@ def _process_job(job_id: str) -> None: "exact_duplicate_rows": int(all_df["exact_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "exact_duplicate" in all_df.columns else 0, "possible_duplicate_rows": int(all_df["possible_duplicate"].fillna(False).astype(bool).sum()) if not all_df.empty and "possible_duplicate" in all_df.columns else 0, "review_items": int(unique_df.review_note.fillna("").ne("").sum()) if not unique_df.empty and "review_note" in unique_df.columns else 0, + "contra_pairs": len({str(v) for v in unique_df["contra_pair_id"].fillna("") if str(v).strip()}) if not unique_df.empty and "contra_pair_id" in unique_df.columns else 0, "categories": int(unique_df.category.nunique()) if not unique_df.empty and "category" in unique_df.columns else 0, "banks": sorted({meta.bank_name for meta in metas}), "customer_name": next((meta.customer_name for meta in metas if meta.customer_name), ""), 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 02a6f93..66a679f 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 supported PDF statements. Up to three analyses run globally at one time; additional jobs are queued safely.

+

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.

My Analysis Jobs
@@ -19,7 +19,7 @@
-
+
Multi-bank contra detection: when the client has more than one bank account, upload all relevant statements in the same analysis job. The analyzer pairs only conservative equal-and-opposite transfers across different account numbers and keeps every pair reviewable.
Queue limits
Maximum three processing jobs across all users. Each user may have up to three queued or processing jobs. Completed workbooks and original uploaded statements remain available for 24 hours. Failed-job statements are also retained for 24 hours for debugging.
@@ -30,7 +30,7 @@
Files
{{ active_job.file_count }}
Queue position
{{ active_job.queue_position or '—' }}
Estimated wait
{{ active_job.estimated_wait or '—' }}
Progress
{{ active_job.progress_percent }}%
{% if active_job.status == 'completed' %} -

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

+

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 %} diff --git a/app/modules/documents/agent_package.py b/app/modules/documents/agent_package.py index 200aa82..df14643 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.13.1" +ERP_LOCAL_AGENT_VERSION = "1.14.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/ERPAgentDashboard.pyw b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw index b6fdf86..2e27295 100644 --- a/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw +++ b/app/modules/documents/local_agent_runtime/ERPAgentDashboard.pyw @@ -35,42 +35,24 @@ def _popup(title: str, message: str, error: bool = False) -> None: def _pid_alive(pid: int) -> bool: if pid <= 0: return False - if os.name == "nt": try: import ctypes - PROCESS_QUERY_LIMITED_INFORMATION = 0x1000 STILL_ACTIVE = 259 - kernel32 = ctypes.windll.kernel32 - - handle = kernel32.OpenProcess( - PROCESS_QUERY_LIMITED_INFORMATION, - False, - int(pid), - ) - + handle = kernel32.OpenProcess(PROCESS_QUERY_LIMITED_INFORMATION, False, int(pid)) if not handle: return False - try: exit_code = ctypes.c_ulong() - - if not kernel32.GetExitCodeProcess( - handle, - ctypes.byref(exit_code), - ): + if not kernel32.GetExitCodeProcess(handle, ctypes.byref(exit_code)): return False - return exit_code.value == STILL_ACTIVE - finally: kernel32.CloseHandle(handle) - except Exception: return False - try: os.kill(pid, 0) return True diff --git a/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs b/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs index bd65f3f..fe389a7 100644 --- a/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs +++ b/app/modules/documents/local_agent_runtime/Open ERP Local Agent Dashboard.vbs @@ -15,9 +15,9 @@ If Not fso.FileExists(launcher) Then End If If fso.FileExists(pyw) Then - cmd = Chr(34) & pyw & Chr(34) & " " & Chr(34) & launcher & Chr(34) + cmd = """" & pyw & """ """ & launcher & """" ElseIf fso.FileExists(py) Then - cmd = Chr(34) & py & Chr(34) & " " & Chr(34) & launcher & Chr(34) + cmd = """" & py & """ """ & launcher & """" Else MsgBox "ERP Local Agent Python environment was not found.", 16, "ERP Local Agent" WScript.Quit 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 0f0c4ce..5b70321 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.13.1" +__version__ = "1.14.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 29ea135..5e6ee66 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 @@ -43,6 +43,10 @@ class AgentCommandProcessor: result = self._purchase_posting_preflight(payload) elif action == "accounting_post_purchase_voucher": result = self._post_purchase_voucher(payload) + elif action == "accounting_bank_posting_preflight": + result = self._bank_posting_preflight(payload) + elif action == "accounting_post_bank_voucher": + result = self._post_bank_voucher(payload) elif action == "accounting_depreciation_preview": result = self._depreciation_preview(payload) elif action == "accounting_calculate_it_depreciation": @@ -369,6 +373,75 @@ class AgentCommandProcessor: "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) + duplicates = self.tally.find_bank_voucher_duplicates( + company_name, + voucher_date=str(payload.get("transaction_date") or ""), + voucher_type=str(payload.get("voucher_type") or ""), + reference=str(payload.get("reference") or ""), + amount=float(payload.get("amount") or 0), + ) + return { + **ledgers, + "duplicate_candidates": duplicates, + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "agent": self._agent_info(), + } + + def _post_bank_voucher(self, payload: dict[str, Any]) -> dict[str, Any]: + company, company_name = self._resolve_open_company(payload) + ledgers = self.tally.bank_posting_ledgers(company_name) + all_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("all_ledgers") or []} + bank_names = {str(x.get("name") or "").strip().casefold() for x in ledgers.get("bank_ledgers") or []} + + bank = str(payload.get("bank_ledger_name") or "").strip() + counter = str(payload.get("counter_ledger_name") or "").strip() + other = str(payload.get("other_bank_ledger_name") or "").strip() + vtype = str(payload.get("voucher_type") or "").title() + + if bank.casefold() not in bank_names: + raise ValueError("Selected bank ledger is not present under Tally bank accounts.") + if vtype == "Contra": + if other.casefold() not in bank_names or other.casefold() == bank.casefold(): + raise ValueError("The other bank ledger is invalid for Contra.") + elif counter.casefold() not in all_names: + raise ValueError("Selected counter ledger is not present in Tally.") + + duplicates = self.tally.find_bank_voucher_duplicates( + company_name, + voucher_date=str(payload.get("transaction_date") or ""), + voucher_type=vtype, + reference=str(payload.get("reference") or ""), + amount=float(payload.get("amount") or 0), + ) + if duplicates: + raise ValueError("Possible duplicate bank voucher already exists in Tally. Posting blocked.") + + result = self.tally.post_bank_voucher( + company_name, + voucher_type=vtype, + voucher_date=str(payload.get("transaction_date") or ""), + bank_ledger_name=bank, + counter_ledger_name=counter, + other_bank_ledger_name=other, + direction=str(payload.get("direction") or ""), + amount=float(payload.get("amount") or 0), + narration=str(payload.get("narration") or ""), + reference=str(payload.get("reference") or ""), + ) + return { + "posted": True, + "company_name": company_name, + "company_guid": str(company.get("guid") or ""), + "reference": str(payload.get("reference") or ""), + "tally_result": result, + "agent": self._agent_info(), + } + def _depreciation_preview(self, payload: dict[str, Any]) -> dict[str, Any]: client_id = int(payload.get("client_id")) tally_guid = str(payload.get("tally_guid") or "").strip() diff --git a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py index 0ef7fb0..23f027f 100644 --- a/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py +++ b/app/modules/documents/local_agent_runtime/erp_local_agent/tally.py @@ -838,6 +838,101 @@ class TallyLiveConnector: "request_xml": request_xml, } + + def bank_posting_ledgers(self, company_name: str) -> dict: + rows = self.purchase_posting_ledgers(company_name) + bank_rows = [ + row for row in rows + if "BANK" in str(row.get("parent") or "").upper() + or "BANK ACCOUNT" in str(row.get("parent") or "").upper() + ] + return {"bank_ledgers": bank_rows, "all_ledgers": rows} + + def find_bank_voucher_duplicates(self, company_name: str, *, voucher_date: str, voucher_type: str, reference: str, amount: float) -> list[dict]: + target_ref = re.sub(r"[^A-Z0-9]", "", str(reference or "").upper()) + expected = round(abs(float(amount or 0)), 2) + result = [] + for voucher in self.export_vouchers(company_name, voucher_date, voucher_date): + if str(voucher.get("voucher_type_name") or "").strip().casefold() != str(voucher_type or "").strip().casefold(): + continue + ref = re.sub(r"[^A-Z0-9]", "", str(voucher.get("reference") or "").upper()) + if target_ref and ref and ref != target_ref: + continue + values = [abs(float(x.get("amount") or 0)) for x in voucher.get("ledger_entries") or []] + if expected and values and all(abs(v-expected) > max(1.0, expected*0.002) for v in values): + continue + result.append({ + "guid": voucher.get("guid") or "", + "voucher_number": voucher.get("voucher_number") or "", + "reference": voucher.get("reference") or "", + "date": voucher.get("date") or "", + }) + return result[:20] + + def build_bank_voucher_xml(self, company_name: str, *, voucher_type: str, voucher_date: str, bank_ledger_name: str, counter_ledger_name: str = "", other_bank_ledger_name: str = "", direction: str = "", amount: float, narration: str = "", reference: str = "") -> str: + vtype = str(voucher_type or "").title() + if vtype not in {"Payment", "Receipt", "Contra"}: + raise ValueError("Unsupported bank voucher type.") + company = str(company_name or "").strip() + bank = str(bank_ledger_name or "").strip() + counter = str(counter_ledger_name or "").strip() + other_bank = str(other_bank_ledger_name or "").strip() + value = round(abs(float(amount or 0)), 2) + if not company or not bank or value <= 0: + raise ValueError("Company, bank ledger and positive amount are required.") + if vtype == "Contra" and not other_bank: + raise ValueError("Other bank ledger is required for Contra.") + if vtype != "Contra" and not counter: + raise ValueError("Counter ledger is required for Payment/Receipt.") + + if vtype == "Payment": + debit_ledger, credit_ledger = counter, bank + elif vtype == "Receipt": + debit_ledger, credit_ledger = bank, counter + else: + # Row direction describes movement in the uploaded bank account. + if str(direction or "").lower() == "debit": + debit_ledger, credit_ledger = other_bank, bank + else: + debit_ledger, credit_ledger = bank, other_bank + + ref_xml = f"{self._xml_escape(reference)}" if reference else "" + return f""" +
1ImportDataVouchers
+ {self._xml_escape(company)} + + + {_tally_date(voucher_date)} + {vtype} + Accounting Voucher View + {ref_xml} + {self._xml_escape(narration)} + {self._xml_escape(debit_ledger)}Yes-{value:.2f} + {self._xml_escape(credit_ledger)}No{value:.2f} + + +
""" + + def post_bank_voucher(self, company_name: str, **kwargs) -> dict: + request_xml = self.build_bank_voucher_xml(company_name, **kwargs) + response_xml = self._post_xml(request_xml) + root = ET.fromstring(_clean_xml_response(response_xml).encode("utf-8")) + def _i(tag): + try: return int(float(_first_text(root, [tag]) or 0)) + except Exception: return 0 + errors = _i("ERRORS") + line_error = _first_text(root, ["LINEERROR"]) + created = _i("CREATED") + if errors or line_error: + raise ValueError(line_error or f"Tally reported {errors} bank voucher error(s).") + if created < 1 and _i("ALTERED") < 1: + raise ValueError("Tally did not confirm creation of the bank voucher.") + return { + "created": created, + "errors": errors, + "last_voucher_id": str(_first_text(root, ["LASTVCHID","LASTMID","LASTVOUCHERID"]) or ""), + } + @staticmethod def _parse_vouchers(xml_text: str) -> list[dict]: cleaned = _clean_xml_response(xml_text) diff --git a/app/ui/app.py b/app/ui/app.py index 5698375..8fa3fed 100644 --- a/app/ui/app.py +++ b/app/ui/app.py @@ -44,6 +44,7 @@ from app.modules.accounting.gstr2b_ui import router as accounting_gstr2b_ui_rout from app.modules.accounting.purchase_enrichment_ui import router as accounting_purchase_enrichment_ui_router 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.registrations.ui import router as registrations_ui_router from app.modules.credential_vault.ui import router as credential_vault_ui_router from app.modules.client_identity.ui import router as client_identity_ui_router @@ -74,6 +75,7 @@ def mount_ui(app: FastAPI) -> None: app.include_router(accounting_purchase_enrichment_ui_router) 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(work_tracker_ui_router) app.include_router(billing_ui_router) app.include_router(platform_billing_ui_router)