From 67ebd587e9334cf1994c4f4e10d55b56baf92999 Mon Sep 17 00:00:00 2001
From: A R R R Associates
Date: Sun, 23 Aug 2026 18:45:21 +0530
Subject: [PATCH] Add Phase 18 automatic stock item voucher preparation
---
...23_automatic_stock_item_voucher_phase18.py | 56 +++
.../accounting/automatic_voucher_service.py | 376 ++++++++++++++++++
.../accounting/automatic_voucher_ui.py | 351 ++++++++++++++++
.../accounting/source_adapter_service.py | 370 +++++++++++++++++
.../accounting/automatic_voucher_sources.html | 244 ++++++++++++
.../templates/accounting/voucher_engine.html | 27 +-
.../accounting/voucher_engine_models.py | 34 ++
.../accounting/voucher_engine_service.py | 19 +-
app/modules/documents/agent_package.py | 2 +-
.../erp_local_agent/__init__.py | 2 +-
.../erp_local_agent/commands.py | 5 +-
.../erp_local_agent/native_voucher_engine.py | 248 ++++++++++--
app/ui/app.py | 2 +
13 files changed, 1705 insertions(+), 31 deletions(-)
create mode 100644 alembic/versions/20260823_automatic_stock_item_voucher_phase18.py
create mode 100644 app/modules/accounting/automatic_voucher_service.py
create mode 100644 app/modules/accounting/automatic_voucher_ui.py
create mode 100644 app/modules/accounting/source_adapter_service.py
create mode 100644 app/modules/accounting/templates/accounting/automatic_voucher_sources.html
diff --git a/alembic/versions/20260823_automatic_stock_item_voucher_phase18.py b/alembic/versions/20260823_automatic_stock_item_voucher_phase18.py
new file mode 100644
index 0000000..b034a4d
--- /dev/null
+++ b/alembic/versions/20260823_automatic_stock_item_voucher_phase18.py
@@ -0,0 +1,56 @@
+"""Phase 18 Automatic Source Driven Stock Item Voucher Engine.
+
+Revision ID: 20260823_auto_stock_voucher_p18
+Revises: 20260823_native_voucher_engine_p17
+"""
+from alembic import op
+import sqlalchemy as sa
+
+
+revision = "20260823_auto_stock_voucher_p18"
+down_revision = "20260823_native_voucher_engine_p17"
+branch_labels = None
+depends_on = None
+
+
+def upgrade():
+ op.create_table(
+ "accounting_voucher_item_lines",
+ sa.Column("id", sa.Integer(), primary_key=True),
+ sa.Column(
+ "draft_id",
+ sa.Integer(),
+ sa.ForeignKey("accounting_voucher_drafts.id", ondelete="CASCADE"),
+ nullable=False,
+ ),
+ sa.Column("line_no", sa.Integer(), nullable=False, server_default="1"),
+ sa.Column("stock_item_name", sa.String(255), nullable=False),
+ sa.Column("hsn_sac", sa.String(30), nullable=False, server_default=""),
+ sa.Column("quantity", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("unit", sa.String(40), nullable=False, server_default=""),
+ sa.Column("rate", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("amount", sa.Float(), nullable=False, server_default="0"),
+ sa.Column("dr_cr", sa.String(2), nullable=False),
+ sa.Column("accounting_ledger_name", sa.String(255), nullable=False, server_default=""),
+ sa.Column("godown_name", sa.String(255), nullable=False, server_default=""),
+ sa.Column("source_item_json", sa.Text(), nullable=False, server_default="{}"),
+ )
+ op.create_index(
+ "ix_accounting_voucher_item_lines_draft_id",
+ "accounting_voucher_item_lines",
+ ["draft_id"],
+ )
+ op.create_index(
+ "ix_accounting_voucher_item_lines_stock_item_name",
+ "accounting_voucher_item_lines",
+ ["stock_item_name"],
+ )
+ op.create_index(
+ "ix_accounting_voucher_item_lines_hsn_sac",
+ "accounting_voucher_item_lines",
+ ["hsn_sac"],
+ )
+
+
+def downgrade():
+ op.drop_table("accounting_voucher_item_lines")
diff --git a/app/modules/accounting/automatic_voucher_service.py b/app/modules/accounting/automatic_voucher_service.py
new file mode 100644
index 0000000..9245700
--- /dev/null
+++ b/app/modules/accounting/automatic_voucher_service.py
@@ -0,0 +1,376 @@
+from __future__ import annotations
+
+import json
+from datetime import datetime, timezone
+
+from sqlalchemy import select
+
+from app.modules.accounting.chart_models import AccountingChartLedger
+from app.modules.accounting.chart_service import effective_role
+from app.modules.accounting.voucher_engine_models import (
+ AccountingVoucherDraft,
+ AccountingVoucherItemLine,
+ AccountingVoucherLine,
+)
+
+
+def _s(value):
+ return str(value or "").strip()
+
+
+def _utcnow():
+ return datetime.now(timezone.utc)
+
+
+def _ledger_candidates(db, *, tenant_id: int, client_id: int, tally_guid: str):
+ rows = list(
+ db.execute(
+ select(AccountingChartLedger).where(
+ AccountingChartLedger.tenant_id == int(tenant_id),
+ AccountingChartLedger.client_id == int(client_id),
+ AccountingChartLedger.tally_guid == _s(tally_guid),
+ )
+ ).scalars().all()
+ )
+ return rows
+
+
+def _match_by_role(rows, roles, name_hint=""):
+ roles = set(roles)
+ hint = _s(name_hint).casefold()
+
+ ranked = []
+ for row in rows:
+ role = effective_role(row)
+ if role not in roles:
+ continue
+ score = 100
+ if hint:
+ name = row.name.casefold()
+ if hint == name:
+ score += 100
+ elif hint in name or name in hint:
+ score += 40
+ for token in [t for t in hint.replace("-", " ").split() if len(t) >= 4]:
+ if token in name:
+ score += 5
+ ranked.append((score, row))
+
+ ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
+ return ranked[0][1] if ranked else None
+
+
+def _name_match(rows, text, allowed_roles=None):
+ target = _s(text).casefold()
+ if not target:
+ return None
+
+ ranked = []
+ for row in rows:
+ role = effective_role(row)
+ if allowed_roles and role not in set(allowed_roles):
+ continue
+ name = row.name.casefold()
+ score = 0
+ if target == name:
+ score = 300
+ elif target in name or name in target:
+ score = 180
+ else:
+ tokens = [t for t in target.replace("-", " ").replace(".", " ").split() if len(t) >= 4]
+ score = sum(12 for token in tokens if token in name)
+ if score:
+ ranked.append((score, row))
+
+ ranked.sort(key=lambda pair: (-pair[0], pair[1].name))
+ return ranked[0][1] if ranked else None
+
+
+def _append_line(draft, line_no, ledger, side, amount, note=""):
+ if amount <= 0 or ledger is None:
+ return line_no
+ draft.lines.append(
+ AccountingVoucherLine(
+ line_no=line_no,
+ ledger_name=ledger.name,
+ ledger_role_code=effective_role(ledger),
+ dr_cr=side,
+ amount=round(float(amount), 2),
+ line_note=note,
+ )
+ )
+ return line_no + 1
+
+
+def _audit_area(voucher_type, roles):
+ areas = []
+ if roles & {"SALES", "DIRECT_INCOME", "OTHER_INCOME"}:
+ areas.append("Revenue & Other Income")
+ if roles & {"PURCHASE", "DIRECT_EXPENSE", "INDIRECT_EXPENSE", "SUPPLIER"}:
+ areas.append("Purchases & Expenses")
+ if roles & {"BANK", "CASH"}:
+ areas.append("Cash & Bank")
+ if roles & {"GST_INPUT", "GST_OUTPUT", "TAX_OTHER"}:
+ areas.append("GST & Taxes")
+ if roles & {"CUSTOMER"}:
+ areas.append("Trade Receivables")
+ if not areas:
+ areas.append("General Ledger")
+ return " · ".join(dict.fromkeys(areas))
+
+
+def create_bank_draft(
+ db,
+ *,
+ tenant_id: int,
+ client_id: int,
+ tally_guid: str,
+ company_name: str,
+ source_row: dict,
+ bank_ledger_name: str,
+ user_id: int,
+):
+ rows = _ledger_candidates(
+ db,
+ tenant_id=tenant_id,
+ client_id=client_id,
+ tally_guid=tally_guid,
+ )
+
+ bank = _name_match(rows, bank_ledger_name, {"BANK", "CASH"})
+ if bank is None:
+ raise ValueError(
+ f"Bank/Cash ledger '{bank_ledger_name}' was not found in the client's Chart of Accounts."
+ )
+
+ direction = _s(source_row.get("direction")).upper()
+ amount = round(abs(float(source_row.get("amount") or 0)), 2)
+ party_hint = source_row.get("party") or source_row.get("narration") or ""
+ category = _s(source_row.get("category")).lower()
+ narration = _s(source_row.get("narration"))
+
+ contra_hint = _s(source_row.get("contra_hint"))
+ counter = None
+ voucher_type = ""
+
+ if contra_hint:
+ counter = _name_match(rows, party_hint, {"BANK", "CASH"})
+ if counter:
+ voucher_type = "Contra"
+
+ if not counter:
+ if direction == "DEBIT":
+ voucher_type = "Payment"
+ role_order = (
+ {"SUPPLIER"},
+ {"DIRECT_EXPENSE", "INDIRECT_EXPENSE"},
+ {"FIXED_ASSET", "CURRENT_ASSET"},
+ {"LOAN"},
+ {"CAPITAL"},
+ )
+ else:
+ voucher_type = "Receipt"
+ role_order = (
+ {"CUSTOMER"},
+ {"DIRECT_INCOME", "OTHER_INCOME", "SALES"},
+ {"LOAN"},
+ {"CAPITAL"},
+ )
+
+ for roles in role_order:
+ counter = _name_match(rows, party_hint, roles)
+ if counter:
+ break
+
+ if not counter and category:
+ if direction == "DEBIT":
+ counter = _name_match(rows, category, {"DIRECT_EXPENSE", "INDIRECT_EXPENSE", "PURCHASE"})
+ else:
+ counter = _name_match(rows, category, {"DIRECT_INCOME", "OTHER_INCOME", "SALES"})
+
+ if not counter:
+ raise ValueError(
+ "Counter-ledger could not be resolved confidently from the bank transaction. "
+ "Review the Chart of Accounts / historical learning before auto-creating this voucher."
+ )
+
+ draft = AccountingVoucherDraft(
+ tenant_id=int(tenant_id),
+ client_id=int(client_id),
+ tally_guid=tally_guid,
+ company_name=company_name,
+ voucher_type=voucher_type,
+ business_nature=voucher_type.upper(),
+ voucher_date=source_row.get("date") or datetime.now().date().isoformat(),
+ reference=source_row.get("reference") or "",
+ narration=narration,
+ source_type="bank",
+ source_record_type=source_row.get("source_table") or "bank",
+ source_record_id=source_row.get("source_id") or "",
+ source_evidence_json=json.dumps(source_row, ensure_ascii=False, default=str),
+ total_debit=amount,
+ total_credit=amount,
+ status="draft",
+ created_by_user_id=int(user_id),
+ updated_at_utc=_utcnow(),
+ )
+
+ if voucher_type == "Contra":
+ if direction == "DEBIT":
+ _append_line(draft, 1, counter, "DR", amount, "Matched own-bank transfer")
+ _append_line(draft, 2, bank, "CR", amount, narration)
+ else:
+ _append_line(draft, 1, bank, "DR", amount, narration)
+ _append_line(draft, 2, counter, "CR", amount, "Matched own-bank transfer")
+ elif voucher_type == "Payment":
+ _append_line(draft, 1, counter, "DR", amount, narration)
+ _append_line(draft, 2, bank, "CR", amount, narration)
+ else:
+ _append_line(draft, 1, bank, "DR", amount, narration)
+ _append_line(draft, 2, counter, "CR", amount, narration)
+
+ roles = {line.ledger_role_code for line in draft.lines}
+ draft.audit_area = _audit_area(voucher_type, roles)
+
+ db.add(draft)
+ db.commit()
+ db.refresh(draft)
+ return draft
+
+
+def create_eway_invoice_draft(
+ db,
+ *,
+ tenant_id: int,
+ client_id: int,
+ tally_guid: str,
+ company_name: str,
+ invoice: dict,
+ user_id: int,
+):
+ rows = _ledger_candidates(
+ db,
+ tenant_id=tenant_id,
+ client_id=client_id,
+ tally_guid=tally_guid,
+ )
+
+ direction = _s(invoice.get("direction")).upper()
+ if direction not in {"PURCHASE", "SALES"}:
+ raise ValueError("E-Way Bill direction could not be identified as Purchase or Sales.")
+
+ party_name = invoice.get("party_name") or ""
+ if direction == "PURCHASE":
+ party = _name_match(rows, party_name, {"SUPPLIER"})
+ main = _match_by_role(rows, {"PURCHASE"})
+ input_cgst = _name_match(rows, "input cgst", {"GST_INPUT"})
+ input_sgst = _name_match(rows, "input sgst", {"GST_INPUT"})
+ input_igst = _name_match(rows, "input igst", {"GST_INPUT"})
+ cess_ledger = _name_match(rows, "input cess", {"GST_INPUT", "TAX_OTHER"})
+ voucher_type = "Purchase"
+ item_side = "DR"
+ party_side = "CR"
+ else:
+ party = _name_match(rows, party_name, {"CUSTOMER"})
+ main = _match_by_role(rows, {"SALES"})
+ input_cgst = _name_match(rows, "output cgst", {"GST_OUTPUT"})
+ input_sgst = _name_match(rows, "output sgst", {"GST_OUTPUT"})
+ input_igst = _name_match(rows, "output igst", {"GST_OUTPUT"})
+ cess_ledger = _name_match(rows, "output cess", {"GST_OUTPUT", "TAX_OTHER"})
+ voucher_type = "Sales"
+ item_side = "CR"
+ party_side = "DR"
+
+ if party is None:
+ raise ValueError(
+ f"{'Supplier' if direction == 'PURCHASE' else 'Customer'} ledger for "
+ f"'{party_name}' could not be resolved confidently."
+ )
+ if main is None:
+ raise ValueError(
+ f"No {'Purchase' if direction == 'PURCHASE' else 'Sales'} ledger is classified "
+ "in the client's Chart of Accounts."
+ )
+
+ taxable = round(float(invoice.get("taxable_value") or 0), 2)
+ cgst = round(float(invoice.get("cgst") or 0), 2)
+ sgst = round(float(invoice.get("sgst") or 0), 2)
+ igst = round(float(invoice.get("igst") or 0), 2)
+ cess = round(float(invoice.get("cess") or 0), 2)
+ gross = round(taxable + cgst + sgst + igst + cess, 2)
+
+ draft = AccountingVoucherDraft(
+ tenant_id=int(tenant_id),
+ client_id=int(client_id),
+ tally_guid=tally_guid,
+ company_name=company_name,
+ voucher_type=voucher_type,
+ business_nature=direction,
+ voucher_date=invoice.get("invoice_date") or datetime.now().date().isoformat(),
+ reference=invoice.get("invoice_no") or invoice.get("eway_bill_no") or "",
+ narration=(
+ f"E-Way Bill {invoice.get('eway_bill_no') or '-'}"
+ f" · {party_name}"
+ ),
+ source_type="ewaybill",
+ source_record_type=invoice.get("source_table") or "ewaybill",
+ source_record_id=",".join([x for x in invoice.get("source_ids", []) if x])[:120],
+ source_evidence_json=json.dumps(invoice, ensure_ascii=False, default=str),
+ total_debit=gross,
+ total_credit=gross,
+ status="draft",
+ created_by_user_id=int(user_id),
+ updated_at_utc=_utcnow(),
+ )
+
+ line_no = 1
+ # Accounting lines remain required for semantic/audit classification.
+ if direction == "PURCHASE":
+ line_no = _append_line(draft, line_no, main, "DR", taxable, "Stock item taxable value")
+ line_no = _append_line(draft, line_no, input_cgst, "DR", cgst, "Input CGST")
+ line_no = _append_line(draft, line_no, input_sgst, "DR", sgst, "Input SGST")
+ line_no = _append_line(draft, line_no, input_igst, "DR", igst, "Input IGST")
+ line_no = _append_line(draft, line_no, cess_ledger, "DR", cess, "Input Cess")
+ _append_line(draft, line_no, party, "CR", gross, party_name)
+ else:
+ line_no = _append_line(draft, line_no, party, "DR", gross, party_name)
+ line_no = _append_line(draft, line_no, main, "CR", taxable, "Stock item taxable value")
+ line_no = _append_line(draft, line_no, input_cgst, "CR", cgst, "Output CGST")
+ line_no = _append_line(draft, line_no, input_sgst, "CR", sgst, "Output SGST")
+ line_no = _append_line(draft, line_no, input_igst, "CR", igst, "Output IGST")
+ _append_line(draft, line_no, cess_ledger, "CR", cess, "Output Cess")
+
+ for index, item in enumerate(invoice.get("items") or [], 1):
+ item_amount = round(float(item.get("taxable_value") or 0), 2)
+ qty = float(item.get("quantity") or 0)
+ rate = float(item.get("rate") or 0)
+ if not rate and qty:
+ rate = item_amount / qty
+
+ draft.items.append(
+ AccountingVoucherItemLine(
+ line_no=index,
+ stock_item_name=_s(item.get("item_name")),
+ hsn_sac=_s(item.get("hsn")),
+ quantity=qty,
+ unit=_s(item.get("unit")),
+ rate=round(rate, 4),
+ amount=item_amount,
+ dr_cr=item_side,
+ accounting_ledger_name=main.name,
+ godown_name="",
+ source_item_json=json.dumps(item, ensure_ascii=False, default=str),
+ )
+ )
+
+ roles = {line.ledger_role_code for line in draft.lines}
+ draft.audit_area = _audit_area(voucher_type, roles)
+ draft.audit_flags_json = json.dumps(
+ ["stock-item-wise", "eway-bill-source", "external-evidence"],
+ separators=(",", ":"),
+ )
+
+ db.add(draft)
+ db.commit()
+ db.refresh(draft)
+ return draft
diff --git a/app/modules/accounting/automatic_voucher_ui.py b/app/modules/accounting/automatic_voucher_ui.py
new file mode 100644
index 0000000..6c13b65
--- /dev/null
+++ b/app/modules/accounting/automatic_voucher_ui.py
@@ -0,0 +1,351 @@
+from __future__ import annotations
+
+from urllib.parse import urlencode
+
+from fastapi import APIRouter, Form, Request
+from fastapi.responses import RedirectResponse
+from sqlalchemy import select
+
+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.automatic_voucher_service import (
+ create_bank_draft,
+ create_eway_invoice_draft,
+)
+from app.modules.accounting.chart_models import AccountingChartLedger
+from app.modules.accounting.chart_service import effective_role
+from app.modules.accounting.source_adapter_service import (
+ bank_rows,
+ discover_sources,
+ eway_rows,
+ group_eway_invoices,
+)
+from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients
+from app.modules.core.rbac.deps import get_user_permissions, get_user_roles
+
+router = APIRouter(
+ prefix="/tools/accounting/voucher-engine/automatic",
+ tags=["accounting-automatic-voucher-source-ui"],
+)
+
+
+def _go(client_id=0, message="", error="", **extra):
+ params = {}
+ if client_id:
+ params["client_id"] = client_id
+ if message:
+ params["message"] = message[:350]
+ if error:
+ params["error"] = error[:350]
+ for key, value in extra.items():
+ if value not in ("", None):
+ params[key] = value
+ return RedirectResponse(
+ "/tools/accounting/voucher-engine/automatic"
+ + ("?" + urlencode(params) if params else ""),
+ status_code=303,
+ )
+
+
+def _company_options(db, tenant_id, client_id):
+ rows = list(
+ db.execute(
+ select(AccountingChartLedger.tally_guid, AccountingChartLedger.company_name)
+ .where(
+ AccountingChartLedger.tenant_id == int(tenant_id),
+ AccountingChartLedger.client_id == int(client_id),
+ )
+ .distinct()
+ .order_by(AccountingChartLedger.company_name)
+ ).all()
+ )
+ return [{"guid": guid, "name": name or guid} for guid, name in rows if guid]
+
+
+def _company_name(options, tally_guid):
+ return next((row["name"] for row in options if row["guid"] == tally_guid), "")
+
+
+def _source_table(valid, requested):
+ names = {row.table_name for row in valid}
+ if requested in names:
+ return requested
+ return valid[0].table_name if valid else ""
+
+
+@router.get("")
+def page(
+ request: Request,
+ client_id: int | None = None,
+ tally_guid: str = "",
+ bank_source: str = "",
+ eway_source: str = "",
+ direction: str = "",
+ from_date: str = "",
+ to_date: str = "",
+ message: str = "",
+ error: str = "",
+):
+ db = CommonSessionLocal()
+ try:
+ user, denied = _require_partner(request, db, "accounting.learning.view")
+ if denied:
+ return denied
+
+ clients, scope = _visible_clients(db, request, user)
+ selected = next(
+ (c for c in clients if client_id and int(c.id) == int(client_id)),
+ None,
+ )
+
+ company_options = []
+ sources = {"bank": [], "eway": []}
+ selected_bank_source = ""
+ selected_eway_source = ""
+ banks = []
+ eway_invoices = []
+ bank_ledgers = []
+
+ if selected:
+ company_options = _company_options(db, scope.tenant_id, selected.id)
+ if not tally_guid and len(company_options) == 1:
+ tally_guid = company_options[0]["guid"]
+ if tally_guid not in {row["guid"] for row in company_options}:
+ tally_guid = ""
+
+ if tally_guid:
+ bank_ledgers = list(
+ db.execute(
+ select(AccountingChartLedger)
+ .where(
+ AccountingChartLedger.tenant_id == int(scope.tenant_id),
+ AccountingChartLedger.client_id == int(selected.id),
+ AccountingChartLedger.tally_guid == tally_guid,
+ )
+ .order_by(AccountingChartLedger.name)
+ ).scalars().all()
+ )
+ bank_ledgers = [
+ row for row in bank_ledgers
+ if effective_role(row) in {"BANK", "CASH"}
+ ]
+
+ sources = discover_sources(db)
+ selected_bank_source = _source_table(sources["bank"], bank_source)
+ selected_eway_source = _source_table(sources["eway"], eway_source)
+
+ if selected_bank_source:
+ banks = bank_rows(
+ db,
+ table_name=selected_bank_source,
+ client_id=selected.id,
+ from_date=from_date,
+ to_date=to_date,
+ limit=300,
+ )
+
+ if selected_eway_source:
+ items = eway_rows(
+ db,
+ table_name=selected_eway_source,
+ client_id=selected.id,
+ direction=direction,
+ from_date=from_date,
+ to_date=to_date,
+ limit=5000,
+ )
+ eway_invoices = group_eway_invoices(items)
+
+ return templates.TemplateResponse(
+ "modules/accounting/templates/accounting/automatic_voucher_sources.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": "Automatic Accounting Preparation",
+ "clients": clients,
+ "selected_client": selected,
+ "selected_tally_guid": tally_guid,
+ "company_options": company_options,
+ "sources": sources,
+ "selected_bank_source": selected_bank_source,
+ "selected_eway_source": selected_eway_source,
+ "bank_rows": banks,
+ "eway_invoices": eway_invoices,
+ "bank_ledgers": bank_ledgers,
+ "direction": direction,
+ "from_date": from_date,
+ "to_date": to_date,
+ "message": message,
+ "error": error,
+ },
+ )
+ finally:
+ db.close()
+
+
+@router.post("/bank/draft")
+def bank_draft(
+ request: Request,
+ client_id: int = Form(...),
+ tally_guid: str = Form(...),
+ source_table: str = Form(...),
+ source_id: 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:
+ raise ValueError("Client is not visible.")
+
+ sources = discover_sources(db)["bank"]
+ if source_table not in {row.table_name for row in sources}:
+ raise ValueError("Selected bank source is not an approved discovered source.")
+
+ source = next(
+ (
+ row
+ for row in bank_rows(
+ db,
+ table_name=source_table,
+ client_id=client.id,
+ limit=2000,
+ )
+ if str(row.get("source_id") or "") == str(source_id)
+ ),
+ None,
+ )
+ if source is None:
+ raise ValueError("Bank transaction could not be reloaded from the source.")
+
+ options = _company_options(db, scope.tenant_id, client.id)
+ company_name = _company_name(options, tally_guid)
+ if not company_name:
+ raise ValueError("Selected Tally company is not available in Chart of Accounts.")
+
+ draft = create_bank_draft(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=client.id,
+ tally_guid=tally_guid,
+ company_name=company_name,
+ source_row=source,
+ bank_ledger_name=bank_ledger_name,
+ user_id=user.id,
+ )
+ return _go(
+ client.id,
+ tally_guid=tally_guid,
+ bank_source=source_table,
+ message=f"{draft.voucher_type} draft #{draft.id} created automatically from bank transaction.",
+ )
+ except Exception as exc:
+ db.rollback()
+ return _go(
+ client_id,
+ tally_guid=tally_guid,
+ bank_source=source_table,
+ error=str(exc),
+ )
+ finally:
+ db.close()
+
+
+@router.post("/eway/draft")
+def eway_draft(
+ request: Request,
+ client_id: int = Form(...),
+ tally_guid: str = Form(...),
+ source_table: str = Form(...),
+ direction: str = Form(...),
+ eway_bill_no: str = Form(""),
+ invoice_no: str = Form(""),
+ invoice_date: str = Form(""),
+ party_gstin: str = Form(""),
+ party_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:
+ raise ValueError("Client is not visible.")
+
+ sources = discover_sources(db)["eway"]
+ if source_table not in {row.table_name for row in sources}:
+ raise ValueError("Selected E-Way Bill source is not an approved discovered source.")
+
+ invoices = group_eway_invoices(
+ eway_rows(
+ db,
+ table_name=source_table,
+ client_id=client.id,
+ direction=direction,
+ limit=10000,
+ )
+ )
+ invoice = next(
+ (
+ row
+ for row in invoices
+ if (row.get("eway_bill_no") or "") == eway_bill_no
+ and (row.get("invoice_no") or "") == invoice_no
+ and (row.get("invoice_date") or "") == invoice_date
+ and (row.get("party_gstin") or "") == party_gstin
+ and (row.get("party_name") or "") == party_name
+ ),
+ None,
+ )
+ if invoice is None:
+ raise ValueError("E-Way Bill invoice/items could not be reloaded from the source.")
+
+ options = _company_options(db, scope.tenant_id, client.id)
+ company_name = _company_name(options, tally_guid)
+ if not company_name:
+ raise ValueError("Selected Tally company is not available in Chart of Accounts.")
+
+ draft = create_eway_invoice_draft(
+ db,
+ tenant_id=scope.tenant_id,
+ client_id=client.id,
+ tally_guid=tally_guid,
+ company_name=company_name,
+ invoice=invoice,
+ user_id=user.id,
+ )
+ return _go(
+ client.id,
+ tally_guid=tally_guid,
+ eway_source=source_table,
+ direction=direction,
+ message=(
+ f"{draft.voucher_type} draft #{draft.id} created automatically "
+ f"with {len(draft.items)} stock-item line(s)."
+ ),
+ )
+ except Exception as exc:
+ db.rollback()
+ return _go(
+ client_id,
+ tally_guid=tally_guid,
+ eway_source=source_table,
+ direction=direction,
+ error=str(exc),
+ )
+ finally:
+ db.close()
diff --git a/app/modules/accounting/source_adapter_service.py b/app/modules/accounting/source_adapter_service.py
new file mode 100644
index 0000000..503bca1
--- /dev/null
+++ b/app/modules/accounting/source_adapter_service.py
@@ -0,0 +1,370 @@
+from __future__ import annotations
+
+from dataclasses import dataclass
+from datetime import date, datetime
+from decimal import Decimal
+from typing import Any
+
+from sqlalchemy import MetaData, Table, and_, inspect, select
+
+
+@dataclass(frozen=True)
+class SourceDescriptor:
+ kind: str
+ table_name: str
+ score: int
+ columns: tuple[str, ...]
+
+
+BANK_TABLE_HINTS = (
+ "bank_statement_transaction",
+ "bank_transactions",
+ "bank_analyzer_transaction",
+ "bank_statement_rows",
+ "bank_entries",
+)
+
+EWAY_TABLE_HINTS = (
+ "ewaybill",
+ "eway_bill",
+ "e_way_bill",
+ "accounting_purchase_source",
+ "purchase_source",
+ "sales_source",
+)
+
+BANK_COLUMNS = {
+ "id": ("id", "transaction_id", "row_id"),
+ "client_id": ("client_id",),
+ "date": ("transaction_date", "txn_date", "date", "value_date"),
+ "narration": ("narration", "description", "particulars", "remarks"),
+ "debit": ("debit", "withdrawal", "debit_amount"),
+ "credit": ("credit", "deposit", "credit_amount"),
+ "amount": ("amount",),
+ "dr_cr": ("dr_cr", "type", "nature"),
+ "party": ("final_party", "manual_party", "auto_party", "party", "counterparty"),
+ "category": ("category", "final_category", "nature"),
+ "account_no": ("account_number", "bank_account_number", "account_no"),
+ "bank": ("bank_name", "bank"),
+ "reference": ("reference", "ref_no", "utr", "transaction_reference"),
+ "contra": ("contra_match_id", "contra_pair_id", "is_contra"),
+}
+
+EWAY_COLUMNS = {
+ "id": ("id", "item_id", "row_id"),
+ "client_id": ("client_id",),
+ "direction": ("direction", "supply_type", "transaction_type", "inward_outward"),
+ "ewb_no": ("eway_bill_no", "ewb_no", "ewaybill_no"),
+ "invoice_no": ("invoice_no", "doc_no", "document_number", "bill_no"),
+ "invoice_date": ("invoice_date", "doc_date", "document_date", "date"),
+ "party_name": ("supplier_name", "customer_name", "party_name", "trade_name", "legal_name"),
+ "party_gstin": ("supplier_gstin", "customer_gstin", "party_gstin", "gstin"),
+ "item_name": ("product_name", "item_name", "description", "product_description"),
+ "hsn": ("hsn", "hsn_code", "hsn_sac"),
+ "quantity": ("quantity", "qty"),
+ "unit": ("unit", "uqc"),
+ "rate": ("rate", "unit_rate"),
+ "taxable_value": ("taxable_value", "taxable_amount", "assessable_value", "amount"),
+ "cgst": ("cgst", "cgst_amount"),
+ "sgst": ("sgst", "sgst_amount"),
+ "igst": ("igst", "igst_amount"),
+ "cess": ("cess", "cess_amount"),
+ "tax_rate": ("tax_rate", "gst_rate", "rate_percent"),
+ "vehicle_no": ("vehicle_no", "vehicle_number"),
+}
+
+
+def _norm(value: Any) -> str:
+ return str(value or "").strip()
+
+
+def _float(value: Any) -> float:
+ if value in (None, ""):
+ return 0.0
+ if isinstance(value, Decimal):
+ return float(value)
+ try:
+ return float(str(value).replace(",", ""))
+ except Exception:
+ return 0.0
+
+
+def _filter_date(column, value: str):
+ text = _norm(value)
+ if not text:
+ return None
+ type_name = column.type.__class__.__name__.lower()
+ if "date" in type_name or "time" in type_name:
+ try:
+ return date.fromisoformat(text)
+ except Exception:
+ return text
+ return text
+
+
+def _date_text(value: Any) -> str:
+ if value in (None, ""):
+ return ""
+ if isinstance(value, datetime):
+ return value.date().isoformat()
+ if isinstance(value, date):
+ return value.isoformat()
+ text = _norm(value)
+ for fmt in ("%Y-%m-%d", "%d/%m/%Y", "%d-%m-%Y", "%Y%m%d"):
+ try:
+ return datetime.strptime(text, fmt).date().isoformat()
+ except Exception:
+ pass
+ return text[:10]
+
+
+def _resolve(columns: set[str], choices: tuple[str, ...]) -> str:
+ lower = {c.lower(): c for c in columns}
+ for candidate in choices:
+ if candidate.lower() in lower:
+ return lower[candidate.lower()]
+ return ""
+
+
+def _table_score(name: str, cols: set[str], hints: tuple[str, ...], mapping: dict) -> int:
+ score = 0
+ lname = name.lower()
+ if any(h in lname for h in hints):
+ score += 12
+ for choices in mapping.values():
+ if _resolve(cols, choices):
+ score += 1
+ return score
+
+
+def discover_sources(db) -> dict[str, list[SourceDescriptor]]:
+ inspector = inspect(db.get_bind())
+ result = {"bank": [], "eway": []}
+
+ for table_name in inspector.get_table_names():
+ try:
+ cols = {row["name"] for row in inspector.get_columns(table_name)}
+ except Exception:
+ continue
+
+ bank_score = _table_score(table_name, cols, BANK_TABLE_HINTS, BANK_COLUMNS)
+ if bank_score >= 18 and (
+ _resolve(cols, BANK_COLUMNS["narration"])
+ and (
+ _resolve(cols, BANK_COLUMNS["amount"])
+ or _resolve(cols, BANK_COLUMNS["debit"])
+ or _resolve(cols, BANK_COLUMNS["credit"])
+ )
+ ):
+ result["bank"].append(
+ SourceDescriptor("bank", table_name, bank_score, tuple(sorted(cols)))
+ )
+
+ eway_score = _table_score(table_name, cols, EWAY_TABLE_HINTS, EWAY_COLUMNS)
+ if eway_score >= 17 and (
+ _resolve(cols, EWAY_COLUMNS["ewb_no"])
+ and _resolve(cols, EWAY_COLUMNS["item_name"])
+ and _resolve(cols, EWAY_COLUMNS["taxable_value"])
+ ):
+ result["eway"].append(
+ SourceDescriptor("eway", table_name, eway_score, tuple(sorted(cols)))
+ )
+
+ for key in result:
+ result[key] = sorted(result[key], key=lambda row: (-row.score, row.table_name))
+ return result
+
+
+def _table(db, table_name: str):
+ metadata = MetaData()
+ return Table(table_name, metadata, autoload_with=db.get_bind())
+
+
+def bank_rows(
+ db,
+ *,
+ table_name: str,
+ client_id: int,
+ from_date: str = "",
+ to_date: str = "",
+ limit: int = 500,
+):
+ table = _table(db, table_name)
+ cols = set(table.c.keys())
+
+ cmap = {key: _resolve(cols, choices) for key, choices in BANK_COLUMNS.items()}
+ conditions = []
+ if cmap["client_id"]:
+ conditions.append(table.c[cmap["client_id"]] == int(client_id))
+ if from_date and cmap["date"]:
+ conditions.append(table.c[cmap["date"]] >= _filter_date(table.c[cmap["date"]], from_date))
+ if to_date and cmap["date"]:
+ conditions.append(table.c[cmap["date"]] <= _filter_date(table.c[cmap["date"]], to_date))
+
+ stmt = select(table)
+ if conditions:
+ stmt = stmt.where(and_(*conditions))
+ if cmap["date"]:
+ stmt = stmt.order_by(table.c[cmap["date"]].desc())
+ stmt = stmt.limit(max(1, min(2000, int(limit))))
+
+ rows = []
+ for raw in db.execute(stmt).mappings():
+ debit = _float(raw.get(cmap["debit"])) if cmap["debit"] else 0.0
+ credit = _float(raw.get(cmap["credit"])) if cmap["credit"] else 0.0
+ amount = _float(raw.get(cmap["amount"])) if cmap["amount"] else 0.0
+ dr_cr = _norm(raw.get(cmap["dr_cr"])).upper() if cmap["dr_cr"] else ""
+
+ if debit > 0:
+ direction = "DEBIT"
+ value = debit
+ elif credit > 0:
+ direction = "CREDIT"
+ value = credit
+ elif dr_cr.startswith("D"):
+ direction = "DEBIT"
+ value = abs(amount)
+ elif dr_cr.startswith("C"):
+ direction = "CREDIT"
+ value = abs(amount)
+ elif amount < 0:
+ direction = "DEBIT"
+ value = abs(amount)
+ else:
+ direction = "CREDIT"
+ value = abs(amount)
+
+ rows.append(
+ {
+ "source_table": table_name,
+ "source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
+ "date": _date_text(raw.get(cmap["date"])) if cmap["date"] else "",
+ "narration": _norm(raw.get(cmap["narration"])) if cmap["narration"] else "",
+ "party": _norm(raw.get(cmap["party"])) if cmap["party"] else "",
+ "category": _norm(raw.get(cmap["category"])) if cmap["category"] else "",
+ "bank_name": _norm(raw.get(cmap["bank"])) if cmap["bank"] else "",
+ "account_number": _norm(raw.get(cmap["account_no"])) if cmap["account_no"] else "",
+ "reference": _norm(raw.get(cmap["reference"])) if cmap["reference"] else "",
+ "direction": direction,
+ "amount": round(value, 2),
+ "contra_hint": _norm(raw.get(cmap["contra"])) if cmap["contra"] else "",
+ }
+ )
+ return rows
+
+
+def eway_rows(
+ db,
+ *,
+ table_name: str,
+ client_id: int,
+ direction: str = "",
+ from_date: str = "",
+ to_date: str = "",
+ limit: int = 2000,
+):
+ table = _table(db, table_name)
+ cols = set(table.c.keys())
+ cmap = {key: _resolve(cols, choices) for key, choices in EWAY_COLUMNS.items()}
+
+ conditions = []
+ if cmap["client_id"]:
+ conditions.append(table.c[cmap["client_id"]] == int(client_id))
+ if from_date and cmap["invoice_date"]:
+ conditions.append(table.c[cmap["invoice_date"]] >= _filter_date(table.c[cmap["invoice_date"]], from_date))
+ if to_date and cmap["invoice_date"]:
+ conditions.append(table.c[cmap["invoice_date"]] <= _filter_date(table.c[cmap["invoice_date"]], to_date))
+
+ stmt = select(table)
+ if conditions:
+ stmt = stmt.where(and_(*conditions))
+ if cmap["invoice_date"]:
+ stmt = stmt.order_by(table.c[cmap["invoice_date"]].desc())
+ stmt = stmt.limit(max(1, min(10000, int(limit))))
+
+ normalized_direction = _norm(direction).upper()
+ rows = []
+ for raw in db.execute(stmt).mappings():
+ raw_direction = _norm(raw.get(cmap["direction"])).upper() if cmap["direction"] else ""
+ detected = ""
+ if raw_direction in {"I", "IN", "INWARD", "PURCHASE"} or "INWARD" in raw_direction or "PURCHASE" in raw_direction:
+ detected = "PURCHASE"
+ elif raw_direction in {"O", "OUT", "OUTWARD", "SALES"} or "OUTWARD" in raw_direction or "SALES" in raw_direction:
+ detected = "SALES"
+
+ if normalized_direction and detected and detected != normalized_direction:
+ continue
+
+ taxable = round(abs(_float(raw.get(cmap["taxable_value"]))), 2)
+ qty = _float(raw.get(cmap["quantity"])) if cmap["quantity"] else 0.0
+ rate = _float(raw.get(cmap["rate"])) if cmap["rate"] else 0.0
+ if not rate and qty:
+ rate = taxable / qty
+
+ rows.append(
+ {
+ "source_table": table_name,
+ "source_id": _norm(raw.get(cmap["id"])) if cmap["id"] else "",
+ "direction": detected or normalized_direction or "UNKNOWN",
+ "eway_bill_no": _norm(raw.get(cmap["ewb_no"])) if cmap["ewb_no"] else "",
+ "invoice_no": _norm(raw.get(cmap["invoice_no"])) if cmap["invoice_no"] else "",
+ "invoice_date": _date_text(raw.get(cmap["invoice_date"])) if cmap["invoice_date"] else "",
+ "party_name": _norm(raw.get(cmap["party_name"])) if cmap["party_name"] else "",
+ "party_gstin": _norm(raw.get(cmap["party_gstin"])) if cmap["party_gstin"] else "",
+ "item_name": _norm(raw.get(cmap["item_name"])) if cmap["item_name"] else "",
+ "hsn": _norm(raw.get(cmap["hsn"])) if cmap["hsn"] else "",
+ "quantity": qty,
+ "unit": _norm(raw.get(cmap["unit"])) if cmap["unit"] else "",
+ "rate": round(rate, 4),
+ "taxable_value": taxable,
+ "cgst": round(abs(_float(raw.get(cmap["cgst"]))), 2) if cmap["cgst"] else 0.0,
+ "sgst": round(abs(_float(raw.get(cmap["sgst"]))), 2) if cmap["sgst"] else 0.0,
+ "igst": round(abs(_float(raw.get(cmap["igst"]))), 2) if cmap["igst"] else 0.0,
+ "cess": round(abs(_float(raw.get(cmap["cess"]))), 2) if cmap["cess"] else 0.0,
+ "tax_rate": _float(raw.get(cmap["tax_rate"])) if cmap["tax_rate"] else 0.0,
+ "vehicle_no": _norm(raw.get(cmap["vehicle_no"])) if cmap["vehicle_no"] else "",
+ }
+ )
+ return rows
+
+
+def group_eway_invoices(rows: list[dict]) -> list[dict]:
+ grouped: dict[tuple, dict] = {}
+ for row in rows:
+ key = (
+ row.get("direction") or "",
+ row.get("eway_bill_no") or "",
+ row.get("invoice_no") or "",
+ row.get("invoice_date") or "",
+ row.get("party_gstin") or "",
+ row.get("party_name") or "",
+ )
+ group = grouped.setdefault(
+ key,
+ {
+ "direction": row.get("direction") or "",
+ "eway_bill_no": row.get("eway_bill_no") or "",
+ "invoice_no": row.get("invoice_no") or "",
+ "invoice_date": row.get("invoice_date") or "",
+ "party_name": row.get("party_name") or "",
+ "party_gstin": row.get("party_gstin") or "",
+ "source_table": row.get("source_table") or "",
+ "source_ids": [],
+ "items": [],
+ "taxable_value": 0.0,
+ "cgst": 0.0,
+ "sgst": 0.0,
+ "igst": 0.0,
+ "cess": 0.0,
+ },
+ )
+ group["source_ids"].append(row.get("source_id") or "")
+ group["items"].append(row)
+ for field in ("taxable_value", "cgst", "sgst", "igst", "cess"):
+ group[field] = round(group[field] + float(row.get(field) or 0), 2)
+
+ return sorted(
+ grouped.values(),
+ key=lambda x: (x["invoice_date"], x["invoice_no"], x["eway_bill_no"]),
+ reverse=True,
+ )
diff --git a/app/modules/accounting/templates/accounting/automatic_voucher_sources.html b/app/modules/accounting/templates/accounting/automatic_voucher_sources.html
new file mode 100644
index 0000000..40a96d3
--- /dev/null
+++ b/app/modules/accounting/templates/accounting/automatic_voucher_sources.html
@@ -0,0 +1,244 @@
+{% extends "ui/templates/base/layout.html" %}
+{% block content %}
+
+
+
+
Accounting Engine · Phase 18
+
Automatic Accounting Preparation
+
+ Create native Tally voucher drafts automatically from existing Bank Analyzer and E-Way Bill source data. Purchase and Sales drafts retain stock-item level quantity, unit, rate, HSN and taxable value for native Tally inventory vouchers.
+
+
+
+
+
+ {% if message %}
{{ message }}
{% endif %}
+ {% if error %}
{{ error }}
{% endif %}
+
+
+
+ {% if selected_client and selected_tally_guid %}
+
+
+
Bank Statements
+
Receipt / Payment / Contra
+
Bank Analyzer rows are converted into native Receipt, Payment or Contra voucher drafts. The bank ledger is chosen from the client's classified Chart of Accounts.
+
+
+
E-Way Bill · Inward
+
Stock-item Purchase
+
Each invoice retains every item, HSN, quantity, unit, rate, taxable value and tax amounts and creates a native Purchase voucher draft.
+
+
+
E-Way Bill · Outward
+
Stock-item Sales
+
Outward invoices create native Sales voucher drafts with item-wise inventory allocations and output-tax ledger lines.
+
+
+
+
+
+
+
Bank Analyzer Transactions
+
One click creates the proposed Receipt / Payment / Contra draft. Posting remains subject to the common review and Tally preflight controls.
+
+
+
+
+ {% if not sources.bank %}
+
+ No existing Bank Analyzer transaction table containing client/date/narration/amount fields was discovered in the current ERP schema. Existing Bank Analyzer data has not been changed.
+
+ {% elif not bank_rows %}
+ No Bank Analyzer transactions were found for the selected client/period.
+ {% else %}
+
+
+
+
+ | Date |
+ Narration / Party |
+ Direction |
+ Amount |
+ Bank/Cash Ledger |
+ Action |
+
+
+
+ {% for row in bank_rows %}
+
+ | {{ row.date or '-' }} |
+
+ {{ row.party or row.narration or '-' }}
+ {% if row.party and row.narration %}{{ row.narration }} {% endif %}
+ {% if row.category %}Category: {{ row.category }} {% endif %}
+ {% if row.reference %}Ref: {{ row.reference }} {% endif %}
+ |
+ {{ row.direction }} |
+ ₹{{ '%.2f'|format(row.amount) }} |
+
+
+ |
+ {% if row.contra_hint %}Contra hint detected{% else %}Auto ledger matching from party/category{% endif %} |
+
+ {% endfor %}
+
+
+
+ {% endif %}
+
+
+
+
+
+
E-Way Bill Purchase / Sales Invoices
+
Invoices are grouped by E-Way Bill + invoice + party. Every underlying item remains separate in the voucher draft.
+
+
+
+
+ {% if not sources.eway %}
+
+ No existing E-Way Bill item table containing E-Way Bill number, item description and taxable value was discovered in the current ERP schema. Existing E-Way Bill features have not been modified.
+
+ {% elif not eway_invoices %}
+ No E-Way Bill item invoices were found for this client/period/filter.
+ {% else %}
+
+ {% for inv in eway_invoices %}
+
+
+
+
+ {{ inv.direction }}
+ {{ inv.items|length }} stock item(s)
+
+
{{ inv.party_name or 'Party not available' }}
+
GSTIN {{ inv.party_gstin or '-' }} · Invoice {{ inv.invoice_no or '-' }} · {{ inv.invoice_date or '-' }} · EWB {{ inv.eway_bill_no or '-' }}
+
+
+
Taxable ₹{{ '%.2f'|format(inv.taxable_value) }}
+
CGST {{ '%.2f'|format(inv.cgst) }} · SGST {{ '%.2f'|format(inv.sgst) }} · IGST {{ '%.2f'|format(inv.igst) }} · Cess {{ '%.2f'|format(inv.cess) }}
+
+
+
+
+
+ | Stock Item / Description | HSN | Qty | Unit | Rate | Taxable |
+
+ {% for item in inv.items %}
+
+ | {{ item.item_name or '-' }} |
+ {{ item.hsn or '-' }} |
+ {{ item.quantity }} |
+ {{ item.unit or '-' }} |
+ {{ '%.4f'|format(item.rate) }} |
+ ₹{{ '%.2f'|format(item.taxable_value) }} |
+
+ {% endfor %}
+
+
+
+
+
+
+ {% endfor %}
+
+ {% endif %}
+
+
+
+ Manual entry remains only as a fallback
+ Manual / adjustment voucher creation has not been removed. It remains useful for provisions, accruals, depreciation, reclassification, audit adjustments and source transactions that cannot yet be resolved automatically.
+ Open Manual / Review Voucher Engine
+
+ {% endif %}
+
+{% endblock %}
diff --git a/app/modules/accounting/templates/accounting/voucher_engine.html b/app/modules/accounting/templates/accounting/voucher_engine.html
index 512e5e9..cade64a 100644
--- a/app/modules/accounting/templates/accounting/voucher_engine.html
+++ b/app/modules/accounting/templates/accounting/voucher_engine.html
@@ -10,6 +10,7 @@
@@ -57,8 +58,8 @@
-
Create Voucher Draft
-
Every ledger must already exist in the Phase 16 Chart of Accounts. Debit and Credit must balance before the draft is accepted.
+
Manual / Adjustment Voucher Draft
+
Automatic Bank/E-Way Bill preparation is available above. Use this manual form for adjustments, provisions, accruals, depreciation, reclassification, audit adjustments or unresolved source transactions. Every ledger must exist in the Chart of Accounts and Debit must equal Credit.