Add Phase 17 common native Tally voucher engine

This commit is contained in:
A R R R Associates
2026-08-23 15:26:37 +05:30
parent 7d11236eed
commit 1111a52917
11 changed files with 1809 additions and 3 deletions
@@ -20,6 +20,7 @@
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/chart-of-accounts{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl bg-blue-700 px-3 py-2 text-sm font-semibold text-white">Chart of Accounts - Groups & Ledgers</a>
<a href="/tools/accounting/voucher-engine{% if selected_client %}?client_id={{ selected_client.id }}{% endif %}" class="rounded-xl border border-blue-300 bg-white px-3 py-2 text-sm font-semibold text-blue-800">Accounting Voucher Engine</a>
</div>
</div>
@@ -0,0 +1,339 @@
{% 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 Engine · Phase 17</p>
<h1 class="mt-1 text-2xl font-semibold text-slate-900">Common Native Tally Voucher Engine</h1>
<p class="mt-1 max-w-5xl text-sm text-slate-500">
Prepare one balanced accounting transaction and post it with the correct native Tally voucher type: Purchase, Sales, Receipt, Payment, Contra or Journal. Existing specialized accounting tools remain unchanged and can progressively feed this common engine.
</p>
</div>
<div class="flex flex-wrap gap-2">
<a href="/tools/accounting/chart-of-accounts{% 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">Chart of Accounts</a>
<a href="/tools/tally{% 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">Back to Accounting</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 lg:grid-cols-[1fr_1fr_auto]">
<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 c in clients %}
<option value="{{ c.id }}" {% if selected_client and selected_client.id==c.id %}selected{% endif %}>{{ c.client_name }}</option>
{% endfor %}
</select>
</label>
<label class="text-sm font-semibold">Tally Company / Chart of Accounts
<select name="tally_guid" class="mt-1 w-full rounded-xl border border-slate-300 bg-white px-3 py-2">
<option value="">Select company</option>
{% for c in company_options %}
<option value="{{ c.guid }}" {% if selected_tally_guid==c.guid %}selected{% endif %}>{{ c.name }}</option>
{% endfor %}
</select>
</label>
<div class="flex items-end"><button class="rounded-xl bg-brand-600 px-4 py-2 text-sm font-semibold text-white">Open Engine</button></div>
</form>
</section>
{% if selected_client and selected_tally_guid %}
<section class="rounded-2xl border border-blue-200 bg-blue-50/40 p-5">
<h2 class="font-semibold text-slate-900">Native Tally voucher selection</h2>
<div class="mt-3 grid gap-3 sm:grid-cols-2 lg:grid-cols-3 text-sm">
<div class="rounded-xl bg-white p-3"><b>Purchase</b><br><span class="text-slate-500">Supplier bills, purchases, direct/indirect expenses and asset bills.</span></div>
<div class="rounded-xl bg-white p-3"><b>Sales</b><br><span class="text-slate-500">Customer invoices, sales and billed direct/indirect income.</span></div>
<div class="rounded-xl bg-white p-3"><b>Receipt</b><br><span class="text-slate-500">Money received into Bank/Cash from customers, income, loans or capital.</span></div>
<div class="rounded-xl bg-white p-3"><b>Payment</b><br><span class="text-slate-500">Money paid from Bank/Cash to suppliers, expenses, assets or loans.</span></div>
<div class="rounded-xl bg-white p-3"><b>Contra</b><br><span class="text-slate-500">Bank-to-bank, bank-to-cash and cash-to-bank transfers.</span></div>
<div class="rounded-xl bg-white p-3"><b>Journal</b><br><span class="text-slate-500">Accruals, provisions, depreciation, reclassification and adjustments.</span></div>
</div>
</section>
<section class="rounded-2xl bg-white p-5 shadow-soft">
<div class="mb-4">
<h2 class="font-semibold text-slate-900">Create Voucher Draft</h2>
<p class="text-sm text-slate-500">Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.</p>
</div>
<form method="post" action="/tools/accounting/voucher-engine/draft" id="voucherDraftForm" class="space-y-5">
<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_tally_guid }}">
<div class="grid gap-4 md:grid-cols-2 xl:grid-cols-5">
<label class="text-xs font-semibold">Business Nature
<select name="business_nature" id="businessNature" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for code,label in business_natures %}<option value="{{ code }}">{{ label }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Tally Voucher Type
<select name="voucher_type" id="voucherType" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
{% for v in voucher_types %}<option value="{{ v }}">{{ v }}</option>{% endfor %}
</select>
</label>
<label class="text-xs font-semibold">Voucher Date
<input type="date" name="voucher_date" required class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold">Reference
<input name="reference" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2">
</label>
<label class="text-xs font-semibold">Source
<select name="source_type" class="mt-1 w-full rounded-lg border border-slate-300 bg-white px-3 py-2">
<option value="manual">Manual</option>
<option value="bank">Bank Analyzer</option>
<option value="gstr2b">GSTR-2B</option>
<option value="sales">Sales / E-Invoice / GSTR-1</option>
<option value="audit_adjustment">Audit Adjustment</option>
</select>
</label>
</div>
<label class="block text-xs font-semibold">Narration
<textarea name="narration" rows="2" class="mt-1 w-full rounded-lg border border-slate-300 px-3 py-2"></textarea>
</label>
<div>
<div class="mb-2 flex items-center justify-between">
<div>
<h3 class="font-semibold">Voucher Lines</h3>
<p class="text-xs text-slate-500">Add as many balanced Debit/Credit lines as needed.</p>
</div>
<button type="button" id="addLine" class="rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold">Add Line</button>
</div>
<div id="lineRows" class="space-y-2">
{% for index in range(2) %}
<div class="voucher-line grid gap-2 rounded-xl border border-slate-200 p-3 lg:grid-cols-[1fr_100px_160px_1fr_auto]">
<select name="ledger_name" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">Select ledger</option>
{% for ledger in ledgers %}
{% set role = ledger.manual_role_code or ledger.suggested_role_code %}
<option value="{{ ledger.name }}">{{ ledger.name }} · {{ role_labels.get(role, role) }}</option>
{% endfor %}
</select>
<select name="dr_cr" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="DR" {% if index==0 %}selected{% endif %}>Debit</option>
<option value="CR" {% if index==1 %}selected{% endif %}>Credit</option>
</select>
<input name="amount" type="number" min="0.01" step="0.01" required placeholder="Amount" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<input name="line_note" placeholder="Line note (optional)" class="rounded-lg border border-slate-300 px-3 py-2 text-sm">
<button type="button" class="remove-line rounded-lg border border-red-200 px-3 py-2 text-xs font-semibold text-red-700">Remove</button>
</div>
{% endfor %}
</div>
<div class="mt-3 flex flex-wrap justify-end gap-5 text-sm font-semibold">
<span>Debit: ₹<span id="debitTotal">0.00</span></span>
<span>Credit: ₹<span id="creditTotal">0.00</span></span>
<span id="balanceState" class="text-amber-700">Not balanced</span>
</div>
</div>
<button class="w-full rounded-xl bg-slate-900 px-4 py-3 text-sm font-semibold text-white">Create Balanced Voucher Draft</button>
</form>
</section>
{% elif selected_client %}
<div class="rounded-xl border border-amber-200 bg-amber-50 p-4 text-sm text-amber-800">
Refresh the client's Chart of Accounts and select a mapped Tally company before creating vouchers.
</div>
{% endif %}
{% if selected_client %}
<section class="rounded-2xl bg-white shadow-soft">
<div class="border-b border-slate-200 px-5 py-4">
<div class="flex flex-wrap items-center justify-between gap-3">
<div>
<h2 class="font-semibold">Voucher Review & Posting Queue</h2>
<p class="text-sm text-slate-500">{{ total }} draft(s). Controlled flow: Draft → Reviewed → Approved → Tally Preflight → Post.</p>
</div>
<form method="get" class="flex gap-2">
<input type="hidden" name="client_id" value="{{ selected_client.id }}">
<select name="status" class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-sm">
<option value="">All statuses</option>
{% for s in ['draft','reviewed','approved','preflight_queued','preflight_claimed','preflight_ready','preflight_failed','posting_queued','posting_claimed','posted','posting_failed','void'] %}
<option value="{{ s }}" {% if status_filter==s %}selected{% endif %}>{{ s|replace('_',' ')|title }}</option>
{% endfor %}
</select>
<button class="rounded-lg border border-slate-300 px-3 py-2 text-sm font-semibold">Filter</button>
</form>
</div>
</div>
<div class="divide-y divide-slate-100">
{% for draft in drafts %}
<article class="p-5">
<div class="flex flex-wrap items-start justify-between gap-3">
<div>
<div class="flex flex-wrap items-center gap-2">
<span class="rounded-full bg-slate-900 px-2 py-1 text-xs font-semibold text-white">{{ draft.voucher_type }}</span>
<span class="rounded-full bg-slate-100 px-2 py-1 text-xs font-semibold">{{ draft.status|replace('_',' ')|title }}</span>
<span class="text-xs text-slate-500">Draft #{{ draft.id }}</span>
</div>
<div class="mt-2 font-semibold">{{ draft.company_name }} · {{ draft.voucher_date }}{% if draft.reference %} · Ref {{ draft.reference }}{% endif %}</div>
<div class="mt-1 text-sm text-slate-500">Nature: {{ draft.business_nature|replace('_',' ')|title }} · Source: {{ draft.source_type }} · Audit use: {{ draft.audit_area or 'General Ledger' }}</div>
{% if draft.narration %}<div class="mt-1 text-sm text-slate-500">{{ draft.narration }}</div>{% endif %}
</div>
<div class="text-right">
<div class="text-lg font-semibold">₹{{ '%.2f'|format(draft.total_debit) }}</div>
{% if draft.tally_voucher_number %}<div class="text-xs text-emerald-700">Tally voucher {{ draft.tally_voucher_number }}</div>{% endif %}
</div>
</div>
<div class="mt-4 overflow-x-auto rounded-xl border border-slate-200">
<table class="min-w-full text-sm">
<thead><tr class="bg-slate-50 text-left text-xs uppercase text-slate-500"><th class="px-3 py-2">Ledger</th><th class="px-3 py-2">Role</th><th class="px-3 py-2">Dr/Cr</th><th class="px-3 py-2 text-right">Amount</th></tr></thead>
<tbody>
{% for line in draft.lines %}
<tr class="border-t border-slate-100">
<td class="px-3 py-2 font-semibold">{{ line.ledger_name }}</td>
<td class="px-3 py-2">{{ role_labels.get(line.ledger_role_code, line.ledger_role_code) }}</td>
<td class="px-3 py-2">{{ line.dr_cr }}</td>
<td class="px-3 py-2 text-right">₹{{ '%.2f'|format(line.amount) }}</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% if draft.last_error %}<div class="mt-3 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-800">{{ draft.last_error }}</div>{% endif %}
{% if draft.status == 'preflight_ready' %}<div class="mt-3 rounded-lg border border-emerald-200 bg-emerald-50 p-3 text-sm text-emerald-800">Tally preflight completed. Ledger existence, balancing and duplicate checks completed.</div>{% endif %}
<div class="mt-4 flex flex-wrap gap-2">
{% if draft.status == 'draft' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/review" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<input name="note" placeholder="Review note" class="rounded-lg border border-slate-300 px-3 py-2 text-xs">
<button class="rounded-lg bg-blue-700 px-3 py-2 text-xs font-semibold text-white">Mark Reviewed</button>
</form>
{% endif %}
{% if draft.status == 'reviewed' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/approve">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg bg-emerald-700 px-3 py-2 text-xs font-semibold text-white">Approve</button>
</form>
{% endif %}
{% if draft.status in ['approved','preflight_failed'] %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/preflight" class="flex gap-2">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<select name="workstation_id" required class="rounded-lg border border-slate-300 bg-white px-3 py-2 text-xs">
<option value="">Select workstation</option>
{% for ws in workstations %}<option value="{{ ws.id }}">{{ ws.machine_name }} · Agent {{ ws.agent_version or '-' }}</option>{% endfor %}
</select>
<button class="rounded-lg bg-slate-900 px-3 py-2 text-xs font-semibold text-white">Run Tally Preflight</button>
</form>
{% endif %}
{% if draft.status == 'preflight_ready' %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/post">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg bg-red-700 px-3 py-2 text-xs font-semibold text-white" onclick="return confirm('Post this approved {{ draft.voucher_type }} voucher to Tally?')">Post {{ draft.voucher_type }} to Tally</button>
</form>
{% endif %}
{% if draft.status not in ['posted','void','preflight_queued','preflight_claimed','posting_queued','posting_claimed'] %}
<form method="post" action="/tools/accounting/voucher-engine/draft/{{ draft.id }}/void">
<input type="hidden" name="csrf_token" value="{{ csrf_token }}"><input type="hidden" name="client_id" value="{{ selected_client.id }}">
<button class="rounded-lg border border-slate-300 px-3 py-2 text-xs font-semibold">Void Draft</button>
</form>
{% endif %}
</div>
</article>
{% else %}
<div class="p-10 text-center text-slate-500">No voucher drafts for this client yet.</div>
{% endfor %}
</div>
{% if pages > 1 %}
<div class="flex items-center justify-between border-t border-slate-200 p-4 text-sm">
<span>Page {{ page }} of {{ pages }}</span>
<div class="flex gap-2">
{% if page > 1 %}<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&page={{ page-1 }}&per_page={{ per_page }}">Previous</a>{% endif %}
{% if page < pages %}<a class="rounded-lg border border-slate-300 px-3 py-1" href="?client_id={{ selected_client.id }}&status={{ status_filter }}&page={{ page+1 }}&per_page={{ per_page }}">Next</a>{% endif %}
</div>
</div>
{% endif %}
</section>
{% endif %}
</div>
{% if selected_client and selected_tally_guid %}
<script>
(function () {
const container = document.getElementById("lineRows");
const addButton = document.getElementById("addLine");
const nature = document.getElementById("businessNature");
const voucherType = document.getElementById("voucherType");
const debitTotal = document.getElementById("debitTotal");
const creditTotal = document.getElementById("creditTotal");
const balanceState = document.getElementById("balanceState");
const voucherMap = {
PURCHASE: "Purchase",
DIRECT_EXPENSE: "Purchase",
INDIRECT_EXPENSE: "Purchase",
SALES: "Sales",
DIRECT_INCOME: "Sales",
INDIRECT_INCOME: "Sales",
RECEIPT: "Receipt",
PAYMENT: "Payment",
CONTRA: "Contra",
ADJUSTMENT: "Journal"
};
function updateTotals() {
let dr = 0, cr = 0;
container.querySelectorAll(".voucher-line").forEach(function (row) {
const side = row.querySelector('[name="dr_cr"]').value;
const amount = Number(row.querySelector('[name="amount"]').value || 0);
if (side === "DR") dr += amount; else if (side === "CR") cr += amount;
});
debitTotal.textContent = dr.toFixed(2);
creditTotal.textContent = cr.toFixed(2);
const ok = dr > 0 && cr > 0 && Math.abs(dr - cr) < 0.01;
balanceState.textContent = ok ? "Balanced" : "Not balanced";
balanceState.className = ok ? "text-emerald-700" : "text-amber-700";
}
function wire(row) {
row.querySelectorAll("input,select").forEach(function (el) {
el.addEventListener("input", updateTotals);
el.addEventListener("change", updateTotals);
});
row.querySelector(".remove-line").addEventListener("click", function () {
if (container.querySelectorAll(".voucher-line").length <= 2) return;
row.remove();
updateTotals();
});
}
container.querySelectorAll(".voucher-line").forEach(wire);
addButton.addEventListener("click", function () {
const source = container.querySelector(".voucher-line");
const clone = source.cloneNode(true);
clone.querySelector('[name="ledger_name"]').value = "";
clone.querySelector('[name="dr_cr"]').value = "DR";
clone.querySelector('[name="amount"]').value = "";
clone.querySelector('[name="line_note"]').value = "";
container.appendChild(clone);
wire(clone);
updateTotals();
});
nature.addEventListener("change", function () {
voucherType.value = voucherMap[nature.value] || "Journal";
});
updateTotals();
})();
</script>
{% endif %}
{% endblock %}