Add Phase 20 stored bank reuse and richer reconciliation

This commit is contained in:
A R R R Associates
2026-08-24 18:58:58 +05:30
parent 769add8cb8
commit b8fbaf55ec
12 changed files with 1419 additions and 78 deletions
@@ -2,7 +2,7 @@ from __future__ import annotations
from datetime import datetime, timezone
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text
from sqlalchemy import DateTime, Float, ForeignKey, Integer, String, Text, UniqueConstraint
from sqlalchemy.orm import Mapped, mapped_column
from app.core.db.common import CommonBase
@@ -22,8 +22,10 @@ class BankReconciliationRun(CommonBase):
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)
account_number: Mapped[str] = mapped_column(String(100), nullable=False, default="", index=True)
date_from: Mapped[str] = mapped_column(String(10), nullable=False)
date_to: Mapped[str] = mapped_column(String(10), nullable=False)
date_tolerance_days: Mapped[int] = mapped_column(Integer, nullable=False, default=15)
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)
@@ -62,3 +64,34 @@ class BankReconciliationItem(CommonBase):
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="")
resolution_status: Mapped[str] = mapped_column(String(30), nullable=False, default="unresolved", index=True)
resolution_note: Mapped[str] = mapped_column(Text, nullable=False, default="")
resolved_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
resolved_at_utc: Mapped[datetime | None] = mapped_column(DateTime(timezone=True), nullable=True)
class AccountingBankLedgerMapping(CommonBase):
__tablename__ = "accounting_bank_ledger_mappings"
__table_args__ = (
UniqueConstraint(
"tenant_id",
"client_id",
"account_number",
"tally_guid",
name="uq_accounting_bank_ledger_mapping",
),
)
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)
account_number: Mapped[str] = mapped_column(String(100), nullable=False, index=True)
bank_name: Mapped[str] = mapped_column(String(160), nullable=False, default="")
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)
created_by_user_id: Mapped[int | None] = mapped_column(ForeignKey("users.id", ondelete="SET NULL"), nullable=True)
updated_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)
updated_at_utc: Mapped[datetime] = mapped_column(DateTime(timezone=True), nullable=False, default=_utcnow)
@@ -9,6 +9,7 @@ from sqlalchemy import delete, func, select
from app.modules.accounting.bank_models import AccountingBankTransaction
from app.modules.accounting.bank_reconciliation_models import (
AccountingBankLedgerMapping,
BankReconciliationItem,
BankReconciliationRun,
)
@@ -109,22 +110,131 @@ def completed_client_jobs(db, *, tenant_id: int, client_id: int, limit: int = 50
)
def _job_period(db, *, tenant_id: int, client_id: int, job_id: str):
values = list(
def source_accounts(db, *, tenant_id: int, client_id: int, job_id: str):
rows = list(
db.execute(
select(AccountingBankTransaction.transaction_date)
select(
AccountingBankTransaction.account_number,
AccountingBankTransaction.bank_name,
func.count(AccountingBankTransaction.id),
)
.where(
AccountingBankTransaction.tenant_id == int(tenant_id),
AccountingBankTransaction.client_id == int(client_id),
AccountingBankTransaction.source_job_id == job_id,
)
.order_by(AccountingBankTransaction.transaction_date)
.group_by(
AccountingBankTransaction.account_number,
AccountingBankTransaction.bank_name,
)
.order_by(
AccountingBankTransaction.bank_name,
AccountingBankTransaction.account_number,
)
).all()
)
return [
{
"account_number": _s(account),
"bank_name": _s(bank),
"transaction_count": int(count or 0),
}
for account, bank, count in rows
if _s(account)
]
def list_bank_mappings(db, *, tenant_id: int, client_id: int, tally_guid: str = ""):
stmt = select(AccountingBankLedgerMapping).where(
AccountingBankLedgerMapping.tenant_id == int(tenant_id),
AccountingBankLedgerMapping.client_id == int(client_id),
)
if _s(tally_guid):
stmt = stmt.where(AccountingBankLedgerMapping.tally_guid == _s(tally_guid))
return list(
db.execute(
stmt.order_by(
AccountingBankLedgerMapping.bank_name,
AccountingBankLedgerMapping.account_number,
)
).scalars().all()
)
def save_bank_mapping(
db,
*,
tenant_id: int,
client_id: int,
account_number: str,
bank_name: str,
tally_guid: str,
company_name: str,
bank_ledger_name: str,
user_id: int,
):
account_number = _s(account_number)
if not account_number:
raise ValueError("Bank account number is required.")
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 valid Tally Bank ledger from Chart of Accounts.")
mapping = db.execute(
select(AccountingBankLedgerMapping).where(
AccountingBankLedgerMapping.tenant_id == int(tenant_id),
AccountingBankLedgerMapping.client_id == int(client_id),
AccountingBankLedgerMapping.account_number == account_number,
AccountingBankLedgerMapping.tally_guid == _s(tally_guid),
)
).scalar_one_or_none()
if mapping is None:
mapping = AccountingBankLedgerMapping(
tenant_id=int(tenant_id),
client_id=int(client_id),
account_number=account_number,
tally_guid=_s(tally_guid),
created_by_user_id=int(user_id),
)
mapping.bank_name = _s(bank_name)
mapping.company_name = _s(company_name)
mapping.bank_ledger_name = _s(bank_ledger_name)
mapping.updated_by_user_id = int(user_id)
mapping.updated_at_utc = _utcnow()
db.add(mapping)
db.commit()
db.refresh(mapping)
return mapping
def _job_period(db, *, tenant_id: int, client_id: int, job_id: str, account_number: str = ""):
stmt = select(AccountingBankTransaction.transaction_date).where(
AccountingBankTransaction.tenant_id == int(tenant_id),
AccountingBankTransaction.client_id == int(client_id),
AccountingBankTransaction.source_job_id == job_id,
)
if _s(account_number):
stmt = stmt.where(AccountingBankTransaction.account_number == _s(account_number))
values = list(
db.execute(
stmt.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. "
"No imported bank transactions were found for this Bank Analyzer job/account. "
"Use a Bank Reconciliation purpose job or import the completed job into Accounting first."
)
return values[0][:10], values[-1][:10]
@@ -141,6 +251,8 @@ def queue_reconciliation(
bank_ledger_name: str,
workstation_id: int,
user_id: int,
account_number: str = "",
date_tolerance_days: int = 15,
):
source_job = db.get(BankStatementAnalysisJob, source_job_id)
if (
@@ -157,11 +269,15 @@ def queue_reconciliation(
"The selected Bank Analyzer job has not been imported successfully into the Accounting bank queue."
)
account_number = _s(account_number)
date_tolerance_days = max(0, min(90, int(date_tolerance_days or 15)))
date_from, date_to = _job_period(
db,
tenant_id=tenant_id,
client_id=client_id,
job_id=source_job_id,
account_number=account_number,
)
allowed = {
@@ -192,8 +308,10 @@ def queue_reconciliation(
tally_guid=_s(tally_guid),
company_name=_s(company_name),
bank_ledger_name=bank_ledger_name,
account_number=account_number,
date_from=date_from,
date_to=date_to,
date_tolerance_days=date_tolerance_days,
workstation_agent_id=ws.id,
status="queued",
created_by_user_id=int(user_id),
@@ -212,11 +330,12 @@ def queue_reconciliation(
"tally_guid": _s(tally_guid),
"company_name": _s(company_name),
"bank_ledger_name": bank_ledger_name,
"account_number": account_number,
"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}",
idempotency_key=f"bank-recon:{tenant_id}:{client_id}:{source_job_id}:{account_number}:{tally_guid}:{bank_ledger_name}:{run.id}",
priority=8,
max_attempts=2,
created_by_user_id=user_id,
@@ -227,6 +346,74 @@ def queue_reconciliation(
return run
def queue_all_mapped_reconciliations(
db,
*,
tenant_id: int,
client_id: int,
source_job_id: str,
tally_guid: str,
company_name: str,
workstation_id: int,
user_id: int,
date_tolerance_days: int = 15,
):
accounts = source_accounts(
db,
tenant_id=tenant_id,
client_id=client_id,
job_id=source_job_id,
)
if not accounts:
raise ValueError("No bank accounts were imported from the selected Bank Analyzer job.")
mappings = {
row.account_number: row
for row in list_bank_mappings(
db,
tenant_id=tenant_id,
client_id=client_id,
tally_guid=tally_guid,
)
}
missing = [
row
for row in accounts
if row["account_number"] not in mappings
]
if missing:
labels = ", ".join(
f"{row['bank_name']} {row['account_number']}"
for row in missing
)
raise ValueError(
"Save Tally bank-ledger mapping for every bank account before "
f"running all accounts together. Missing: {labels}"
)
runs = []
for account in accounts:
mapping = mappings[account["account_number"]]
runs.append(
queue_reconciliation(
db,
tenant_id=tenant_id,
client_id=client_id,
source_job_id=source_job_id,
tally_guid=tally_guid,
company_name=company_name,
bank_ledger_name=mapping.bank_ledger_name,
workstation_id=workstation_id,
user_id=user_id,
account_number=account["account_number"],
date_tolerance_days=date_tolerance_days,
)
)
return runs
def _tally_side(voucher: dict, bank_ledger_name: str):
bank_key = bank_ledger_name.casefold()
entries = list(voucher.get("ledger_entries") or [])
@@ -256,7 +443,7 @@ def _tally_side(voucher: dict, bank_ledger_name: str):
}
def _pair_score(bank: AccountingBankTransaction, tally: dict):
def _pair_score(bank: AccountingBankTransaction, tally: dict, tolerance_days: int = 15):
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():
@@ -268,7 +455,8 @@ def _pair_score(bank: AccountingBankTransaction, tally: dict):
return 0, ""
gap = abs((bd - td).days)
if gap > 7:
tolerance_days = max(0, min(90, int(tolerance_days or 15)))
if gap > tolerance_days:
return 0, ""
bank_ref = _norm_ref(bank.transfer_reference or bank.reference_no)
@@ -291,9 +479,14 @@ def _pair_score(bank: AccountingBankTransaction, tally: dict):
elif gap <= 4:
score += 7
reasons.append(f"{gap}-day timing difference")
else:
elif gap <= 7:
score += 2
reasons.append(f"{gap}-day timing difference")
else:
# Long timing differences remain match candidates only because amount
# and bank direction are exact. They are surfaced separately rather
# than silently treated as a normal probable match.
reasons.append(f"{gap}-day timing difference")
if ref_exact:
score += 18
@@ -319,6 +512,11 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
AccountingBankTransaction.tenant_id == run.tenant_id,
AccountingBankTransaction.client_id == run.client_id,
AccountingBankTransaction.source_job_id == run.source_job_id,
*(
[AccountingBankTransaction.account_number == run.account_number]
if _s(run.account_number)
else []
),
)
.order_by(
AccountingBankTransaction.transaction_date,
@@ -339,14 +537,14 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
for bank in bank_rows:
scored = []
for index, tally in enumerate(tally_rows):
score, reason = _pair_score(bank, tally)
score, reason = _pair_score(bank, tally, run.date_tolerance_days)
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
exact = probable = timing = bank_only = duplicates = 0
for bank in bank_rows:
options = [
@@ -372,10 +570,17 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
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":
bank_date = _date_obj(bank.transaction_date)
tally_date = _date_obj(tally["date"])
gap = abs((bank_date - tally_date).days) if bank_date and tally_date else 999
if top_score >= 90:
status = "matched"
exact += 1
elif gap > 7:
status = "timing_difference"
timing += 1
else:
status = "probable_match"
probable += 1
item = BankReconciliationItem(
@@ -428,18 +633,74 @@ def build_reconciliation(db, run: BankReconciliationRun, vouchers: list[dict]):
)
)
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,
bank_only_rows = [
row for row in db.execute(
select(BankReconciliationItem).where(
BankReconciliationItem.run_id == run.id,
BankReconciliationItem.match_status == "bank_only",
)
).scalars().all()
]
books_only_rows = [
row for row in db.execute(
select(BankReconciliationItem).where(
BankReconciliationItem.run_id == run.id,
BankReconciliationItem.match_status == "books_only",
)
).scalars().all()
]
def _direction_totals(rows, prefix):
result = {
f"{prefix}_debit_amount": 0.0,
f"{prefix}_credit_amount": 0.0,
}
for row in rows:
direction = (
row.bank_direction if prefix == "bank_only"
else row.tally_direction
)
amount = (
row.bank_amount if prefix == "bank_only"
else row.tally_amount
)
key = (
f"{prefix}_debit_amount"
if _s(direction).upper() == "DEBIT"
else f"{prefix}_credit_amount"
)
result[key] = round(result[key] + float(amount or 0), 2)
return result
summary = {
"bank_transactions": len(bank_rows),
"tally_bank_vouchers": len(tally_rows),
"matched": exact,
"probable_match": probable,
"timing_difference": timing,
"bank_only": bank_only,
"books_only": books_only,
"duplicate_candidate": duplicates,
"account_number": run.account_number,
"bank_ledger_name": run.bank_ledger_name,
"date_tolerance_days": int(run.date_tolerance_days or 15),
}
summary.update(_direction_totals(bank_only_rows, "bank_only"))
summary.update(_direction_totals(books_only_rows, "books_only"))
summary["bank_only_net"] = round(
summary["bank_only_credit_amount"] - summary["bank_only_debit_amount"],
2,
)
summary["books_only_net"] = round(
summary["books_only_credit_amount"] - summary["books_only_debit_amount"],
2,
)
summary["unreconciled_net_difference"] = round(
summary["bank_only_net"] - summary["books_only_net"],
2,
)
run.summary_json = json.dumps(summary, ensure_ascii=False)
run.status = "completed"
run.completed_at_utc = _utcnow()
run.last_error = ""
@@ -478,6 +739,64 @@ def sync_run(db, run: BankReconciliationRun):
return run
def resolve_reconciliation_item(
db,
*,
run_id: int,
item_id: int,
action: str,
note: str,
user_id: int,
):
item = db.execute(
select(BankReconciliationItem).where(
BankReconciliationItem.id == int(item_id),
BankReconciliationItem.run_id == int(run_id),
)
).scalar_one_or_none()
if not item:
raise ValueError("Reconciliation item was not found.")
action = _s(action)
allowed = {
"confirm_match",
"confirm_timing",
"reject_match_bank_only",
"confirm_bank_only",
"confirm_books_only",
"needs_follow_up",
"reopen",
}
if action not in allowed:
raise ValueError("Unsupported reconciliation resolution.")
item.resolution_status = action
item.resolution_note = _s(note)
item.resolved_by_user_id = int(user_id)
item.resolved_at_utc = _utcnow()
if item.bank_transaction_id:
tx = db.get(AccountingBankTransaction, int(item.bank_transaction_id))
if tx:
if action == "confirm_match":
tx.reconciliation_status = "matched"
elif action == "confirm_timing":
tx.reconciliation_status = "timing_difference_confirmed"
elif action in {"reject_match_bank_only", "confirm_bank_only"}:
tx.reconciliation_status = "bank_only"
elif action == "needs_follow_up":
tx.reconciliation_status = "needs_review"
elif action == "reopen":
tx.reconciliation_status = item.match_status
tx.last_reconciliation_run_id = int(run_id)
db.add(tx)
db.add(item)
db.commit()
return item
def list_runs(db, *, tenant_id: int, client_id: int, limit: int = 30):
rows = list(
db.execute(
@@ -11,9 +11,14 @@ 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_bank_mappings,
list_runs,
queue_all_mapped_reconciliations,
queue_reconciliation,
resolve_reconciliation_item,
run_items,
save_bank_mapping,
source_accounts,
visible_bank_ledgers,
visible_workstations,
)
@@ -91,6 +96,9 @@ def page(
runs = []
items = []
selected_run = None
accounts = []
mappings = []
run_summaries = {}
if selected:
jobs = completed_client_jobs(
@@ -99,6 +107,13 @@ def page(
client_id=selected.id,
limit=50,
)
if job_id:
accounts = source_accounts(
db,
tenant_id=scope.tenant_id,
client_id=selected.id,
job_id=job_id,
)
companies = _company_options(db, scope.tenant_id, selected.id)
if not tally_guid and len(companies) == 1:
tally_guid = companies[0]["guid"]
@@ -109,6 +124,12 @@ def page(
client_id=selected.id,
tally_guid=tally_guid,
)
mappings = list_bank_mappings(
db,
tenant_id=scope.tenant_id,
client_id=selected.id,
tally_guid=tally_guid,
)
workstations = visible_workstations(
db,
tenant_id=scope.tenant_id,
@@ -136,6 +157,12 @@ def page(
status=status,
)
for row in runs:
try:
run_summaries[int(row.id)] = json.loads(row.summary_json or "{}")
except Exception:
run_summaries[int(row.id)] = {}
return templates.TemplateResponse(
"modules/accounting/templates/accounting/bank_reconciliation.html",
{
@@ -157,6 +184,9 @@ def page(
"selected_run": selected_run,
"items": items,
"status_filter": status,
"source_accounts": accounts,
"bank_mappings": mappings,
"run_summaries": run_summaries,
"message": message,
"error": error,
},
@@ -173,6 +203,8 @@ def create_run(
tally_guid: str = Form(...),
company_name: str = Form(...),
bank_ledger_name: str = Form(...),
account_number: str = Form(""),
date_tolerance_days: int = Form(15),
workstation_id: int = Form(...),
csrf_token: str = Form(...),
):
@@ -197,6 +229,8 @@ def create_run(
bank_ledger_name=bank_ledger_name,
workstation_id=workstation_id,
user_id=user.id,
account_number=account_number,
date_tolerance_days=date_tolerance_days,
)
return _go(
client.id,
@@ -216,3 +250,153 @@ def create_run(
)
finally:
db.close()
@router.post("/mapping")
def save_mapping(
request: Request,
client_id: int = Form(...),
source_job_id: str = Form(""),
tally_guid: str = Form(...),
company_name: str = Form(...),
account_number: str = Form(...),
bank_name: str = Form(""),
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, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not visible.")
save_bank_mapping(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
account_number=account_number,
bank_name=bank_name,
tally_guid=tally_guid,
company_name=company_name,
bank_ledger_name=bank_ledger_name,
user_id=user.id,
)
return _go(
client.id,
job_id=source_job_id,
tally_guid=tally_guid,
message=f"Bank account {account_number} mapped to Tally ledger '{bank_ledger_name}'.",
)
except Exception as exc:
db.rollback()
return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc))
finally:
db.close()
@router.post("/run-all")
def create_all_runs(
request: Request,
client_id: int = Form(...),
source_job_id: str = Form(...),
tally_guid: str = Form(...),
company_name: str = Form(...),
date_tolerance_days: int = Form(15),
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.")
runs = queue_all_mapped_reconciliations(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
source_job_id=source_job_id,
tally_guid=tally_guid,
company_name=company_name,
workstation_id=workstation_id,
user_id=user.id,
date_tolerance_days=date_tolerance_days,
)
return _go(
client.id,
job_id=source_job_id,
tally_guid=tally_guid,
run_id=(runs[0].id if runs else None),
message=f"Queued {len(runs)} reconciliation run(s), one for each mapped bank account.",
)
except Exception as exc:
db.rollback()
return _go(client_id, job_id=source_job_id, tally_guid=tally_guid, error=str(exc))
finally:
db.close()
@router.post("/runs/{run_id}/items/{item_id}/resolve")
def resolve_item(
request: Request,
run_id: int,
item_id: int,
client_id: int = Form(...),
tally_guid: str = Form(""),
action: str = Form(...),
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, _clients, scope = _find_visible_client(db, request, user, client_id)
if not client:
return _go(error="Client is not visible.")
run = next(
(
row
for row in list_runs(
db,
tenant_id=scope.tenant_id,
client_id=client.id,
limit=100,
)
if int(row.id) == int(run_id)
),
None,
)
if not run:
raise ValueError("Reconciliation run was not found.")
resolve_reconciliation_item(
db,
run_id=run.id,
item_id=item_id,
action=action,
note=note,
user_id=user.id,
)
return _go(
client.id,
tally_guid=(tally_guid or run.tally_guid),
run_id=run.id,
message="Reconciliation review decision saved.",
)
except Exception as exc:
db.rollback()
return _go(client_id, tally_guid=tally_guid, run_id=run_id, error=str(exc))
finally:
db.close()
+3
View File
@@ -299,6 +299,9 @@ def _ensure_reviewed(tx):
"matched",
"probable_match",
"duplicate_candidate",
"timing_difference",
"timing_difference_confirmed",
"needs_review",
}:
raise ValueError(
"Bank Reconciliation indicates that this transaction already has, "
@@ -6,7 +6,7 @@
<p class="text-xs font-semibold uppercase tracking-[0.16em] text-brand-600">Accounting · Bank Reconciliation</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Bank Reconciliation</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
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.
Compare normalized Bank Analyzer transactions with Tally Bank ledgers. Phase 20 remembers each client bank-account ↔ Tally-ledger mapping, can reconcile every mapped account in one action, supports wider cheque/clearing timing windows, and keeps reviewer resolutions. Tally access remains read-only.
</p>
</div>
<div class="flex flex-wrap gap-2">
@@ -47,54 +47,140 @@
{% if selected_client and selected_tally_guid %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold">Start a reconciliation run</h2>
<p class="text-sm text-slate-500">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.</p>
<h2 class="font-semibold">1. Select the Bank Analyzer job</h2>
<p class="text-sm text-slate-500">Choose the completed client-bound analysis whose imported bank transactions you want to reconcile. The job can contain any number of bank accounts.</p>
</div>
<form method="get" class="grid gap-3 md:grid-cols-[1fr_1fr_auto]">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<label class="text-xs font-semibold">Bank Analyzer Job
<select name="job_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select completed job</option>
{% for job in jobs %}
<option value="{{ job.id }}" {% if selected_job_id==job.id %}selected{% endif %}>{{ job.completed_at_utc or job.submitted_at_utc }} · {{ job.file_count }} statement(s) · {{ job.purpose|replace('_',' ')|title }} · Import {{ job.accounting_import_status|replace('_',' ')|title }}</option>
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Company
<select name="tally_guid" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for company in companies %}
<option value="{{ company.guid }}" {% if selected_tally_guid==company.guid %}selected{% endif %}>{{ company.name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-lg border border-slate-300 px-4 py-2 text-sm font-semibold">Load Accounts</button></div>
</form>
</section>
{% if selected_job_id %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold">2. Map client bank accounts to Tally Bank ledgers</h2>
<p class="text-sm text-slate-500">Mappings are remembered client-wise and company-wise. Once all accounts are mapped, future reconciliation runs do not require selecting the bank ledger again.</p>
</div>
<form method="post" action="/tools/accounting/bank-reconciliation/run" class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<div class="space-y-3">
{% for account in source_accounts %}
{% set existing = namespace(value=None) %}
{% for mapping in bank_mappings %}
{% if mapping.account_number == account.account_number %}{% set existing.value = mapping %}{% endif %}
{% endfor %}
<form method="post" action="/tools/accounting/bank-reconciliation/mapping" class="grid gap-3 rounded-xl border border-slate-200 p-4 md:grid-cols-[1fr_1.4fr_auto]">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
<input type="hidden" name="account_number" value="{{ account.account_number }}">
<input type="hidden" name="bank_name" value="{{ account.bank_name }}">
<div>
<div class="text-xs text-slate-500">Statement account</div>
<div class="font-semibold">{{ account.bank_name or 'Bank' }} · {{ account.account_number }}</div>
<div class="text-xs text-slate-500">{{ account.transaction_count }} imported transaction(s)</div>
</div>
<label class="text-xs font-semibold">Tally Bank Ledger
<select name="bank_ledger_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select bank ledger</option>
{% for ledger in bank_ledgers %}
<option value="{{ ledger.name }}" {% if existing.value and existing.value.bank_ledger_name==ledger.name %}selected{% endif %}>{{ ledger.name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white">{% if existing.value %}Update Mapping{% else %}Save Mapping{% endif %}</button></div>
</form>
{% else %}
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">No imported bank accounts were found for this job. Ensure its Accounting import status is Completed.</div>
{% endfor %}
</div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold">3. Reconcile mapped bank accounts</h2>
<p class="text-sm text-slate-500">Use a wider timing tolerance for cheque issue/presentation or deposit/clearing differences. Amount and bank direction must still match exactly before the engine considers a Tally voucher as a candidate.</p>
</div>
<form method="post" action="/tools/accounting/bank-reconciliation/run-all" class="grid gap-4 md:grid-cols-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
<label class="text-xs font-semibold xl:col-span-2">Completed Bank Analyzer Job
<select name="source_job_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select job</option>
{% for job in jobs %}
<option value="{{ job.id }}" {% if selected_job_id==job.id %}selected{% endif %}>
{{ job.completed_at_utc or job.submitted_at_utc }} · {{ job.file_count }} statement(s) · {{ job.purpose|replace('_',' ')|title }} · Import {{ job.accounting_import_status|replace('_',' ')|title }}
</option>
{% endfor %}
<label class="text-xs font-semibold">Timing tolerance
<select name="date_tolerance_days" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="7">7 days</option>
<option value="15" selected>15 days</option>
<option value="30">30 days</option>
<option value="45">45 days</option>
<option value="60">60 days</option>
<option value="90">90 days</option>
</select>
</label>
<label class="text-xs font-semibold">Tally Company Name
<select name="company_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for company in companies %}
{% if company.guid==selected_tally_guid %}<option value="{{ company.name }}">{{ company.name }}</option>{% endif %}
{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Bank Ledger
<select name="bank_ledger_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select bank ledger</option>
{% for ledger in bank_ledgers %}<option value="{{ ledger.name }}">{{ ledger.name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Workstation
<label class="text-xs font-semibold">Connected Workstation
<select name="workstation_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select connected workstation</option>
<option value="">Select workstation</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
</select>
</label>
<div class="xl:col-span-5 flex justify-end">
<button class="rounded-xl bg-emerald-700 px-5 py-2 text-sm font-semibold text-white">Read Tally & Reconcile</button>
</div>
<div class="flex items-end"><button class="w-full rounded-xl bg-emerald-700 px-5 py-2 text-sm font-semibold text-white">Reconcile All Mapped Accounts</button></div>
</form>
<details class="mt-4 rounded-xl border border-slate-200 bg-slate-50 p-4">
<summary class="cursor-pointer text-sm font-semibold">Run one account manually</summary>
<form method="post" action="/tools/accounting/bank-reconciliation/run" class="mt-4 grid gap-3 md:grid-cols-3">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="source_job_id" value="{{ selected_job_id }}">
<input type="hidden" name="tally_guid" value="{{ selected_tally_guid }}">
{% for company in companies %}{% if company.guid==selected_tally_guid %}<input type="hidden" name="company_name" value="{{ company.name }}">{% endif %}{% endfor %}
<label class="text-xs font-semibold">Statement Account
<select name="account_number" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">All imported rows (legacy / account number unavailable)</option>
{% for account in source_accounts %}<option value="{{ account.account_number }}">{{ account.bank_name }} · {{ account.account_number }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Bank Ledger
<select name="bank_ledger_name" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select bank ledger</option>
{% for ledger in bank_ledgers %}<option value="{{ ledger.name }}">{{ ledger.name }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Timing tolerance
<input type="number" name="date_tolerance_days" value="15" min="0" max="90" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold md:col-span-2">Workstation
<select name="workstation_id" required class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="">Select workstation</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-lg bg-slate-900 px-4 py-2 text-xs font-semibold text-white">Run One Account</button></div>
</form>
</details>
</section>
{% endif %}
{% endif %}
{% if selected_client %}
<section class="rounded-2xl bg-white shadow-soft">
@@ -104,19 +190,21 @@
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Run</th><th class="px-4 py-3">Bank Ledger</th><th class="px-4 py-3">Period</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Result</th><th class="px-4 py-3"></th></tr></thead>
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-4 py-3">Run</th><th class="px-4 py-3">Bank / Account</th><th class="px-4 py-3">Period</th><th class="px-4 py-3">Status</th><th class="px-4 py-3">Result</th><th class="px-4 py-3"></th></tr></thead>
<tbody>
{% for run in runs %}
{% set summary = run.summary_json|default('{}') %}
<tr class="border-t border-slate-100">
<td class="px-4 py-3 font-semibold">#{{ run.id }}</td>
<td class="px-4 py-3">{{ run.bank_ledger_name }}</td>
<td class="px-4 py-3"><div class="font-semibold">{{ run.bank_ledger_name }}</div><div class="text-xs text-slate-500">{{ run.account_number or "All imported accounts" }}</div></td>
<td class="px-4 py-3">{{ run.date_from }} → {{ run.date_to }}</td>
<td class="px-4 py-3">{{ run.status|replace('_',' ')|title }}{% if run.last_error %}<div class="text-xs text-red-700">{{ run.last_error }}</div>{% endif %}</td>
<td class="px-4 py-3 text-xs">
{% set s = run_summaries.get(run.id, {}) %}
{% if run.status=='completed' %}
{% set s = run.summary_json|from_json if false else none %}
<span class="text-slate-500">Open run to view matched / probable / bank-only / books-only detail.</span>
<div>Matched {{ s.get('matched',0) }} · Probable {{ s.get('probable_match',0) }} · Timing {{ s.get('timing_difference',0) }}</div>
<div class="mt-1">Bank only {{ s.get('bank_only',0) }} · Books only {{ s.get('books_only',0) }} · Duplicate {{ s.get('duplicate_candidate',0) }}</div>
<div class="mt-1 text-slate-500">Unreconciled net difference ₹{{ '%.2f'|format(s.get('unreconciled_net_difference',0)) }}</div>
{% else %}—{% endif %}
</td>
<td class="px-4 py-3 text-right"><a href="?client_id={{ selected_client.id }}&tally_guid={{ run.tally_guid }}&run_id={{ run.id }}" class="font-semibold text-brand-700 hover:underline">Open</a></td>
@@ -144,7 +232,7 @@
<input type="hidden" name="run_id" value="{{ selected_run.id }}">
<select name="status" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All results</option>
{% for value,label in [('matched','Matched'),('probable_match','Probable Match'),('bank_only','Bank Only'),('books_only','Books Only'),('duplicate_candidate','Duplicate Candidate')] %}
{% for value,label in [('matched','Matched'),('probable_match','Probable Match'),('timing_difference','Timing Difference'),('bank_only','Bank Only'),('books_only','Books Only'),('duplicate_candidate','Duplicate Candidate')] %}
<option value="{{ value }}" {% if status_filter==value %}selected{% endif %}>{{ label }}</option>
{% endfor %}
</select>
@@ -158,6 +246,15 @@
{% elif selected_run.status=='failed' %}
<div class="p-8 text-center text-red-700">{{ selected_run.last_error }}</div>
{% elif selected_run.status=='completed' %}
{% set rs = run_summaries.get(selected_run.id, {}) %}
<div class="grid gap-3 border-b border-slate-200 p-4 sm:grid-cols-2 lg:grid-cols-6">
<div class="rounded-xl bg-emerald-50 p-3"><div class="text-xs text-emerald-700">Matched</div><div class="text-xl font-bold">{{ rs.get('matched',0) }}</div></div>
<div class="rounded-xl bg-amber-50 p-3"><div class="text-xs text-amber-700">Probable</div><div class="text-xl font-bold">{{ rs.get('probable_match',0) }}</div></div>
<div class="rounded-xl bg-orange-50 p-3"><div class="text-xs text-orange-700">Timing differences</div><div class="text-xl font-bold">{{ rs.get('timing_difference',0) }}</div></div>
<div class="rounded-xl bg-blue-50 p-3"><div class="text-xs text-blue-700">Bank only</div><div class="text-xl font-bold">{{ rs.get('bank_only',0) }}</div><div class="text-[11px]">Dr ₹{{ '%.2f'|format(rs.get('bank_only_debit_amount',0)) }} · Cr ₹{{ '%.2f'|format(rs.get('bank_only_credit_amount',0)) }}</div></div>
<div class="rounded-xl bg-violet-50 p-3"><div class="text-xs text-violet-700">Books only</div><div class="text-xl font-bold">{{ rs.get('books_only',0) }}</div><div class="text-[11px]">Dr ₹{{ '%.2f'|format(rs.get('books_only_debit_amount',0)) }} · Cr ₹{{ '%.2f'|format(rs.get('books_only_credit_amount',0)) }}</div></div>
<div class="rounded-xl bg-slate-100 p-3"><div class="text-xs text-slate-600">Unreconciled net</div><div class="text-xl font-bold">₹{{ '%.2f'|format(rs.get('unreconciled_net_difference',0)) }}</div><div class="text-[11px]">Tolerance {{ rs.get('date_tolerance_days',15) }} days</div></div>
</div>
<div class="overflow-x-auto">
<table class="min-w-full text-xs">
<thead><tr class="bg-slate-50 text-left uppercase text-slate-500"><th class="px-3 py-2">Result</th><th class="px-3 py-2">Bank Statement</th><th class="px-3 py-2">Tally</th><th class="px-3 py-2">Why</th><th class="px-3 py-2">Action</th></tr></thead>
@@ -168,6 +265,7 @@
<span class="rounded-full px-2 py-1 font-semibold
{% if item.match_status=='matched' %}bg-emerald-100 text-emerald-800
{% elif item.match_status=='probable_match' %}bg-amber-100 text-amber-800
{% elif item.match_status=='timing_difference' %}bg-orange-100 text-orange-800
{% elif item.match_status=='bank_only' %}bg-blue-100 text-blue-800
{% elif item.match_status=='books_only' %}bg-violet-100 text-violet-800
{% else %}bg-red-100 text-red-800{% endif %}">
@@ -191,13 +289,28 @@
{% else %}—{% endif %}
</td>
<td class="px-3 py-3 max-w-xs text-slate-600">{{ item.match_reason }}</td>
<td class="px-3 py-3">
<td class="px-3 py-3 min-w-[260px]">
{% if item.resolution_status and item.resolution_status != 'unresolved' %}
<div class="mb-2 rounded-lg bg-slate-100 p-2"><strong>Reviewed:</strong> {{ item.resolution_status|replace('_',' ')|title }}{% if item.resolution_note %}<div class="mt-1 text-slate-500">{{ item.resolution_note }}</div>{% endif %}</div>
{% endif %}
<form method="post" action="/tools/accounting/bank-reconciliation/runs/{{ selected_run.id }}/items/{{ item.id }}/resolve" class="space-y-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_run.tally_guid }}">
<select name="action" required class="w-full rounded-lg border border-slate-300 bg-white px-2 py-2">
<option value="">Reviewer decision</option>
{% if item.match_status in ['matched','probable_match','duplicate_candidate'] %}<option value="confirm_match">Confirm as matched</option><option value="reject_match_bank_only">Reject match — treat as Bank Only</option>{% endif %}
{% if item.match_status=='timing_difference' %}<option value="confirm_timing">Confirm timing difference</option><option value="reject_match_bank_only">Reject match — treat as Bank Only</option>{% endif %}
{% if item.match_status=='bank_only' %}<option value="confirm_bank_only">Confirm Bank Only / missing in books</option>{% endif %}
{% if item.match_status=='books_only' %}<option value="confirm_books_only">Confirm Books Only / outstanding item</option>{% endif %}
<option value="needs_follow_up">Needs follow-up</option>
<option value="reopen">Reopen system result</option>
</select>
<input name="note" placeholder="Review note (optional)" class="w-full rounded-lg border border-slate-300 px-2 py-2">
<button class="rounded-lg border border-slate-300 bg-white px-3 py-2 font-semibold">Save Decision</button>
</form>
{% if item.match_status=='bank_only' and item.bank_transaction_id %}
<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="font-semibold text-blue-700 hover:underline">Review in Accounting Queue</a>
{% elif item.match_status=='books_only' %}
<span class="text-slate-500">Investigate timing / statement coverage.</span>
{% else %}
<span class="text-slate-500">Review if needed.</span>
<a href="/tools/accounting/bank-posting?client_id={{ selected_client.id }}" class="mt-2 inline-block font-semibold text-blue-700 hover:underline">Open Accounting Queue</a>
{% endif %}
</td>
</tr>