Files
arrr-erp/app/modules/accounting/templates/accounting/bank_reconciliation.html
T
2026-08-24 18:58:58 +05:30

328 lines
23 KiB
HTML

{% extends "ui/templates/base/layout.html" %}
{% block content %}
<div class="space-y-6">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<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 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">
<a href="/tools/bank-statement-analyzer" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Bank Analyzer</a>
<a href="/tools/accounting/bank-posting{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-slate-300 bg-white px-4 py-2 text-sm font-semibold">Accounting Bank Queue</a>
</div>
</div>
{% if message %}<div class="rounded-xl border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">{{ message }}</div>{% endif %}
{% if error %}<div class="rounded-xl border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ error }}</div>{% endif %}
<section class="rounded-2xl bg-white p-5 shadow-soft">
<form method="get" class="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
<label class="text-sm font-semibold">ERP Client
<select name="client_id" required class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select client</option>
{% for client in clients %}
<option value="{{ client.id }}" {% if selected_client and selected_client.id==client.id %}selected{% endif %}>{{ client.client_code }} · {{ client.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-semibold">Tally Company
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select mapped company</option>
{% 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 xl:col-span-2">
<button class="rounded-xl bg-brand-600 px-5 py-2 text-sm font-semibold text-white">Load Reconciliation Setup</button>
</div>
</form>
</section>
{% 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">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>
<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">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">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 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="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">
<div class="border-b border-slate-200 px-5 py-4">
<h2 class="font-semibold">Recent Reconciliation Runs</h2>
<p class="text-sm text-slate-500">Runs preserve their results and never modify Tally.</p>
</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 / 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"><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' %}
<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>
</tr>
{% else %}
<tr><td colspan="6" class="px-4 py-8 text-center text-slate-500">No reconciliation runs yet.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
</section>
{% endif %}
{% if selected_run %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="flex flex-wrap items-start justify-between gap-3 border-b border-slate-200 px-5 py-4">
<div>
<h2 class="font-semibold">Run #{{ selected_run.id }} · {{ selected_run.bank_ledger_name }}</h2>
<p class="text-sm text-slate-500">{{ selected_run.date_from }} → {{ selected_run.date_to }} · Status {{ selected_run.status|replace('_',' ')|title }}</p>
</div>
{% if selected_run.status=='completed' %}
<form method="get" class="flex gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input type="hidden" name="tally_guid" value="{{ selected_run.tally_guid }}">
<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'),('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>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button>
</form>
{% endif %}
</div>
{% if selected_run.status in ['queued','extracting'] %}
<div class="p-8 text-center text-blue-700">The connected Local Agent is reading Tally vouchers. Refresh this page shortly.</div>
{% 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>
<tbody>
{% for item in items %}
<tr class="border-t border-slate-100 align-top">
<td class="px-3 py-3">
<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 %}">
{{ item.match_status|replace('_',' ')|title }}
</span>
{% if item.confidence %}<div class="mt-1 text-slate-500">{{ item.confidence }}%</div>{% endif %}
</td>
<td class="px-3 py-3">
{% if item.bank_transaction_id %}
<div class="font-semibold">{{ item.bank_date }} · {{ item.bank_direction }} · ₹{{ '%.2f'|format(item.bank_amount) }}</div>
<div class="mt-1">{{ item.bank_reference or '-' }}</div>
<div class="mt-1 max-w-md text-slate-500">{{ item.bank_narration }}</div>
{% else %}—{% endif %}
</td>
<td class="px-3 py-3">
{% if item.tally_voucher_number or item.tally_guid %}
<div class="font-semibold">{{ item.tally_date }} · {{ item.tally_voucher_type }} · ₹{{ '%.2f'|format(item.tally_amount) }}</div>
<div class="mt-1">Voucher {{ item.tally_voucher_number or item.tally_guid }}</div>
<div class="mt-1">{{ item.tally_reference or '-' }}</div>
<div class="mt-1 max-w-md text-slate-500">{{ item.tally_narration }}</div>
{% 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 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="mt-2 inline-block font-semibold text-blue-700 hover:underline">Open Accounting Queue</a>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="px-4 py-8 text-center text-slate-500">No reconciliation rows for this filter.</td></tr>
{% endfor %}
</tbody>
</table>
</div>
{% endif %}
</section>
{% endif %}
</div>
{% endblock %}