diff --git a/app/modules/accounting/cash_payment_ui.py b/app/modules/accounting/cash_payment_ui.py index a1fd43d..ce21749 100644 --- a/app/modules/accounting/cash_payment_ui.py +++ b/app/modules/accounting/cash_payment_ui.py @@ -3,7 +3,6 @@ from __future__ import annotations from datetime import date, datetime from decimal import Decimal, InvalidOperation from urllib.parse import quote -import hashlib import uuid from fastapi import APIRouter, Request @@ -126,6 +125,29 @@ def _allocation_plan(source_text: str, expected_total: float, cash_limit: float, } +def _parse_review_date(value: str, fallback: date) -> date: + text = str(value or "").strip() + if not text: + return fallback + try: + return date.fromisoformat(text) + except Exception: + return fallback + + +def _review_financial_year(date_from: str, date_to: str, fallback_today: date) -> tuple[str, date, date]: + """Resolve Cash Payment review FY from the requested review period. + + The review must follow the period selected by the user, not the ERP workspace FY. + """ + parsed_from = _parse_review_date(date_from, fallback_today) + parsed_to = _parse_review_date(date_to, parsed_from) + if parsed_to < parsed_from: + parsed_to = parsed_from + fy = _financial_year_for_date(parsed_from) + return fy, parsed_from, parsed_to + + def _render(request: Request, db, user, **context): base = { "request": request, @@ -173,10 +195,17 @@ def cash_payment_review( online = _node_online(node) today = date.today() - fy = _financial_year_for_date(today) - fy_start_year = int(fy.split("-", 1)[0]) - start_text = date_from or date(fy_start_year, 4, 1).isoformat() + + # Default period still follows the current date when the user first opens + # the page, but once a date range is supplied the mirror is resolved + # strictly from that review period rather than from the workspace FY. + default_fy = _financial_year_for_date(today) + default_start_year = int(default_fy.split("-", 1)[0]) + start_text = date_from or date(default_start_year, 4, 1).isoformat() end_text = date_to or today.isoformat() + review_fy, parsed_from, parsed_to = _review_financial_year(start_text, end_text, today) + start_text = parsed_from.isoformat() + end_text = parsed_to.isoformat() review = None mirror_status = None @@ -191,7 +220,7 @@ def cash_payment_review( else: storage_payload = _accounting_storage_payload( selected_client, - _financial_year_for_date(date.fromisoformat(start_text)), + review_fy, ) try: status_response = request_agent_command( @@ -264,6 +293,7 @@ def cash_payment_review( allocation_plan=None, allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""}, page_mode="compliance", + review_financial_year=review_fy, ) finally: db.close() @@ -636,8 +666,7 @@ def cash_payment_allocation( saved=False, command_error=error or "", allocation_plan=None, - allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": "", "cash_ledger": "Cash"}, - posting_result=None, + allocation_input={"total_amount": "", "party_name": "", "expense_ledger": "", "source_payments": ""}, page_mode="allocation", ) finally: @@ -764,111 +793,8 @@ async def cash_payment_allocation_plan(request: Request): "party_name": party_name, "expense_ledger": expense_ledger, "source_payments": source_payments, - "cash_ledger": str(form.get("cash_ledger") or "Cash"), }, - posting_result=None, page_mode="allocation", ) finally: db.close() - - -@router.post("/allocation/post") -async def cash_payment_allocation_post(request: Request): - form = await request.form() - validate_csrf(request, str(form.get("csrf_token") or "")) - db = CommonSessionLocal() - try: - user, response = _require_partner(request, db, "accounting.tally.writeback") - if response: - return response - clients, scope = _visible_clients(db, request, user) - client_id = int(form.get("client_id") or 0) - selected_client = next((row for row in clients if int(row.id) == client_id), None) - if not selected_client: - return _denied() - setting = _setting(db, _tenant_id(request, user)) - total_amount_text = str(form.get("total_amount") or "") - party_name = str(form.get("party_name") or "").strip() - expense_ledger = str(form.get("expense_ledger") or "").strip() - cash_ledger = str(form.get("cash_ledger") or "Cash").strip() - source_payments = str(form.get("source_payments") or "") - total_amount = _parse_money(total_amount_text, "Total cash payment") - plan = _allocation_plan(source_payments, total_amount, float(setting.cash_limit), int(setting.max_allocation_days)) - if plan.get("status") != "ready": - raise ValueError("The source-date allocation must pass validation before posting to Tally.") - if not expense_ledger: - raise ValueError("Expense / purchase ledger is required for Tally posting.") - if not cash_ledger: - raise ValueError("Cash ledger is required for Tally posting.") - if str(form.get("confirm_posting") or "") != "yes" or str(form.get("confirm_text") or "").strip().upper() != "POST": - raise ValueError("Confirm the reviewed allocation and type POST exactly before Tally write-back.") - - first_date = date.fromisoformat(str(plan["entries"][0]["date"])) - source_material = "|".join([str(client_id), expense_ledger, cash_ledger, party_name, source_payments, f"{total_amount:.2f}"]) - source_key = "CASHALLOC-" + hashlib.sha256(source_material.encode("utf-8")).hexdigest()[:20].upper() - node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) - if not node or not _node_online(node): - raise ValueError("ERP Local Agent is offline for the active branch.") - result = request_agent_command( - node.node_code, - "accounting_post_cash_allocation", - { - **_accounting_storage_payload(selected_client, _financial_year_for_date(first_date)), - "client_id": int(selected_client.id), - "source_key": source_key, - "party_name": party_name, - "debit_ledger": expense_ledger, - "cash_ledger": cash_ledger, - "entries": list(plan.get("entries") or []), - "posted_by_user_id": int(user.id), - "narration": f"Cash payment allocation · {party_name or expense_ledger}", - }, - timeout_seconds=180, - ) - if not result.get("ok"): - raise ValueError(str(result.get("error") or "Cash allocation batch posting failed.")) - body = result.get("result") or {} - return _render( - request, db, user, title="Cash Payment Compliance Review", - clients=clients, selected_client=selected_client, storage_node=node, - agent_online=_node_online(node), live_result=None, selected_tally_guid="", - date_from="", date_to="", setting=setting, review=None, cache_job=None, - active_job_id="", saved=False, command_error="", allocation_plan=plan, - allocation_input={ - "total_amount": total_amount_text, "party_name": party_name, - "expense_ledger": expense_ledger, "source_payments": source_payments, - "cash_ledger": cash_ledger, - }, - posting_result=body, page_mode="allocation", - ) - except Exception as exc: - clients, scope = _visible_clients(db, request, user) if 'user' in locals() else ([], None) - selected_client = next((row for row in clients if int(row.id) == int(form.get("client_id") or 0)), None) if clients else None - setting = _setting(db, _tenant_id(request, user)) if 'user' in locals() else None - plan = None - try: - total_amount_text = str(form.get("total_amount") or "") - plan = _allocation_plan(str(form.get("source_payments") or ""), _parse_money(total_amount_text, "Total cash payment"), float(setting.cash_limit), int(setting.max_allocation_days)) if setting else None - except Exception: - pass - if 'user' not in locals(): - raise - node = get_active_storage_node_for_branch(db, scope.tenant_id, scope.branch_id) if scope else None - return _render( - request, db, user, title="Cash Payment Compliance Review", clients=clients, - selected_client=selected_client, storage_node=node, agent_online=_node_online(node), - live_result=None, selected_tally_guid="", date_from="", date_to="", setting=setting, - review=None, cache_job=None, active_job_id="", saved=False, command_error=str(exc), - allocation_plan=plan, - allocation_input={ - "total_amount": str(form.get("total_amount") or ""), - "party_name": str(form.get("party_name") or ""), - "expense_ledger": str(form.get("expense_ledger") or ""), - "source_payments": str(form.get("source_payments") or ""), - "cash_ledger": str(form.get("cash_ledger") or "Cash"), - }, - posting_result=None, page_mode="allocation", - ) - finally: - db.close() diff --git a/app/modules/accounting/templates/accounting/cash_payment_review.html b/app/modules/accounting/templates/accounting/cash_payment_review.html index c2cf287..d58d65e 100644 --- a/app/modules/accounting/templates/accounting/cash_payment_review.html +++ b/app/modules/accounting/templates/accounting/cash_payment_review.html @@ -57,6 +57,7 @@
Mirror Period
{% if mirror.ready and mirror.voucher_period %}{{ mirror.voucher_period.from_date or '—' }} → {{ mirror.voucher_period.to_date or '—' }}{% else %}—{% endif %}
+
Resolved FY: {{ review_financial_year }}
Vouchers Available
@@ -70,7 +71,7 @@ {% if mirror.ready %}

Source: {{ mirror.path or mirror_status.mirror_db_path or '' }}

{% else %} -
Mirror this client first from Tally Accounting → Mirror Tally to SQLite. After that, Cash Payment Review works even if TallyPrime is closed.
+
No local Accounting Mirror was found for FY {{ review_financial_year }}. Mirror this client for that FY from Tally Accounting → Mirror Tally to SQLite. After that, Cash Payment Review works even if TallyPrime is closed.
{% endif %} {% endif %}
@@ -91,9 +92,10 @@ {% if review %}
-
- Download Excel - Download PDF +
+ Analysis completed entirely from the local SQLite Accounting Mirror. TallyPrime was not contacted. + {% if review.cash_ledgers %} Cash ledger(s): {{ review.cash_ledgers|join(', ') }}.{% endif %} + {% if review.mirror_period %} Mirror period: {{ review.mirror_period.from_date or '—' }} → {{ review.mirror_period.to_date or '—' }}.{% endif %}
{% for label, value in [('Vouchers reviewed', review.summary.vouchers_reviewed), ('Cash payments', review.summary.cash_payment_vouchers), ('Single-voucher exceptions', review.summary.single_voucher_exceptions), ('Same-day exceptions', review.summary.same_day_exceptions), ('Possible split patterns', review.summary.possible_split_patterns)] %} @@ -141,32 +143,6 @@
Expected total: ₹{{ '%.2f'|format(allocation_plan.expected_total or 0) }} · Source total: ₹{{ '%.2f'|format(allocation_plan.supplied_total or 0) }} · Date span: {{ allocation_plan.span_days or 0 }} day(s) / {{ allocation_plan.max_allocation_days or setting.max_allocation_days }} allowed.
{% if allocation_plan.entries %}{% for row in allocation_plan.entries %}{% endfor %}
Actual DateProposed Entry Amount
{{ row.date }}₹{{ '%.2f'|format(row.amount) }}
{% endif %}

{{ allocation_plan.note or 'No accounting dates are generated automatically.' }}

- {% if allocation_plan.status == 'ready' %} -
- - - - - - -
-

The reviewed expense / purchase ledger is debited.

-
-
-
- - - -
-

The entries are first stored in this client's local .act write queue, then transferred to Tally in .NET batches and read back for verification.

-
- {% endif %} -
- {% endif %} - {% if posting_result %} -
-
Cash payment split batch #{{ posting_result.batch_id }} posted and verified.
-
{{ posting_result.batch.batch.verified_count }} voucher(s) verified in TallyPrime.
{% endif %}