from __future__ import annotations import calendar import re from datetime import date from pathlib import Path 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.agent_bridge import request_agent_command from app.modules.accounting.ui import _find_visible_client, _require_partner, _visible_clients, _node_online from app.modules.core.rbac.deps import get_user_permissions, get_user_roles from app.modules.credential_vault.crypto import decrypt_value from app.modules.credential_vault.models import CredentialVaultEntry from app.modules.credential_vault.service import can_view_entry, log_access from app.modules.documents.services import build_document_scope, client_folder_parts, get_active_storage_node_for_branch, sanitize_segment from app.modules.registrations.models import ClientRegistration, RegistrationType router = APIRouter(prefix="/tools/accounting/gst-reconciliation", tags=["accounting-gst-reconciliation-ui"]) def _fy_bounds(fy: str) -> tuple[date, date]: m = re.fullmatch(r"(\d{4})-(\d{2})", str(fy or "").strip()) if not m: raise ValueError("Invalid financial year.") y = int(m.group(1)) return date(y, 4, 1), date(y + 1, 3, 31) def _fy_for_period(period: str) -> str: digits = re.sub(r"\D", "", period or "") if len(digits) != 6: raise ValueError("Return period must be MMYYYY.") month, year = int(digits[:2]), int(digits[2:]) if month < 1 or month > 12: raise ValueError("Invalid GST return month.") sy = year if month >= 4 else year - 1 return f"{sy}-{str(sy+1)[-2:]}" def _period_bounds(period: str) -> tuple[str, str]: digits = re.sub(r"\D", "", period or "") month, year = int(digits[:2]), int(digits[2:]) last = calendar.monthrange(year, month)[1] return date(year, month, 1).isoformat(), date(year, month, last).isoformat() def _gst_regs(db, tenant_id: int, client_id: int): return db.execute( select(ClientRegistration, RegistrationType) .join(RegistrationType, RegistrationType.id == ClientRegistration.registration_type_id) .where(ClientRegistration.tenant_id == tenant_id, ClientRegistration.client_id == client_id) .order_by(ClientRegistration.id.asc()) ).all() def _is_gstin(reg, typ) -> bool: code = str(getattr(typ, "code", "") or "").upper().strip() num = re.sub(r"\s+", "", str(getattr(reg, "registration_number", "") or "").upper()) return code == "GSTIN" and len(num) == 15 def _norm_gstin(value: str | None) -> str: return re.sub(r"[^0-9A-Z]", "", str(value or "").upper()) def _credential_is_gst_portal(entry: CredentialVaultEntry) -> bool: category = str(entry.category or "").strip().lower() title = str(entry.title or "").strip().lower() portal_url = str(entry.portal_url or "").strip().lower() reference = str(entry.reference_number or "").strip().lower() if category == "gst_portal": return True # Older/client-level vault entries may have been saved under a generic category. # Accept them only when the entry itself clearly identifies a GST portal login. haystack = " ".join((title, portal_url, reference)) gst_hint = ( "gst portal" in haystack or "gst login" in haystack or "services.gst.gov.in" in portal_url or "www.gst.gov.in" in portal_url ) if not gst_hint: return False # Do not silently substitute E-Way Bill, E-Invoice or API/provider secrets for # a GST portal username/password unless the title explicitly says GST Portal/Login. if category in {"eway_bill", "einvoice", "api_key"}: return "gst portal" in title or "gst login" in title return True def _credential_matches_gstin(entry: CredentialVaultEntry, registration_id: int, gstin: str) -> bool: if int(entry.registration_id or 0) == int(registration_id): return True if _norm_gstin(entry.reference_number) == _norm_gstin(gstin): return True # Client-level GST Portal credentials created before registration-level vault # linking remain valid candidates for the selected client's GST registration. return entry.registration_id is None def _vault_entries(db, user, request, tenant_id: int, client_id: int, registration_id: int | None = None, gstin: str = ""): q = select(CredentialVaultEntry).where( CredentialVaultEntry.tenant_id == tenant_id, CredentialVaultEntry.client_id == client_id, CredentialVaultEntry.status != "archived", ) rows = db.execute(q.order_by(CredentialVaultEntry.title.asc())).scalars().all() branch_id = request.session.get("active_branch_id") or getattr(user, "branch_id", None) visible = [r for r in rows if can_view_entry(db, user, r, int(branch_id) if branch_id else None)] if not registration_id: return [r for r in visible if _credential_is_gst_portal(r)] eligible = [ r for r in visible if _credential_is_gst_portal(r) and _credential_matches_gstin(r, int(registration_id), gstin) ] def rank(entry: CredentialVaultEntry): exact_registration = int(entry.registration_id or 0) == int(registration_id) exact_gstin = _norm_gstin(entry.reference_number) == _norm_gstin(gstin) client_level = entry.registration_id is None return ( 0 if exact_registration else 1 if exact_gstin else 2 if client_level else 3, str(entry.title or "").lower(), int(entry.id or 0), ) return sorted(eligible, key=rank) def _storage_payload(client, fy: str, gstin: str) -> tuple[str, str]: fy_folder = sanitize_segment(f"FY{fy}", "FY") letter, client_folder = client_folder_parts(client, int(client.id)) root = Path(fy_folder) / "Clients" / letter / client_folder accounting = root / "Accounting" gst = root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN") return accounting.as_posix(), gst.as_posix() def _redirect(client_id: int, **params): data = {"client_id": client_id, **{k: v for k, v in params.items() if v not in (None, "")}} return RedirectResponse("/tools/accounting/gst-reconciliation?" + urlencode(data), status_code=303) @router.get("") def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", message: str = "", error: str = ""): db = CommonSessionLocal() try: user, response = _require_partner(request, db, "accounting.learning.view") if response: return response 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) registrations=[]; selected_reg=None; credentials=[]; node=None; status={}; analysis={} if selected: registrations=[(r,t) for r,t in _gst_regs(db,scope.tenant_id,selected.id) if _is_gstin(r,t)] selected_reg=next((r for r,t in registrations if registration_id and int(r.id)==int(registration_id)),None) if not selected_reg and registrations: selected_reg=registrations[0][0] selected_gstin = _norm_gstin(getattr(selected_reg, "registration_number", "")) if selected_reg else "" credentials=_vault_entries( db,user,request,scope.tenant_id,selected.id, int(selected_reg.id) if selected_reg else None, selected_gstin, ) node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id) if selected_reg and period and node and _node_online(node): gstin=re.sub(r"\s+","",str(selected_reg.registration_number or "").upper()) try: status=(request_agent_command(node.node_code,"gst_return_download_status",{"client_id":selected.id,"gstin":gstin,"period":re.sub(r"\D","",period)},timeout_seconds=8).get("result") or {}).get("job") or {} except Exception: status={} try: job_result=status.get("result") or {} # analysis is loaded only after an explicit Analyze action; status result is download manifest. analysis={} except Exception: pass return templates.TemplateResponse("modules/accounting/templates/accounting/gst_reconciliation.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), "clients":clients,"selected_client":selected,"registrations":registrations,"selected_registration":selected_reg,"credentials":credentials,"node":node,"node_online":_node_online(node) if node else False,"period":period,"status":status,"analysis":analysis,"message":message,"error":error,"title":"GST Return Reconciliation", }) finally: db.close() @router.post("/download/start") def start_download(request: Request, client_id: int=Form(...), registration_id: int=Form(...), credential_id: int=Form(...), period: str=Form(...), include_2a: str=Form(""), csrf_token: str=Form(...)): validate_csrf(request,csrf_token) db=CommonSessionLocal() try: user,response=_require_partner(request,db,"accounting.learning.manage") if response: return response client,_,scope=_find_visible_client(db,request,user,client_id) if not client: return _redirect(client_id,error="Client is not available in your scope.") pair=next(((r,t) for r,t in _gst_regs(db,scope.tenant_id,client.id) if int(r.id)==registration_id and _is_gstin(r,t)),None) if not pair: return _redirect(client_id,error="Select a valid GSTIN registration.") reg,_=pair; gstin=re.sub(r"\s+","",str(reg.registration_number or "").upper()) cred=db.get(CredentialVaultEntry,credential_id) credential_ok = bool( cred and int(cred.tenant_id or 0) == int(scope.tenant_id) and int(cred.client_id or 0) == int(client.id) and str(cred.status or "").lower() != "archived" and can_view_entry(db,user,cred,scope.branch_id) and _credential_is_gst_portal(cred) and _credential_matches_gstin(cred,int(reg.id),gstin) ) if not credential_ok: return _redirect(client_id,registration_id=registration_id,period=period,error="Selected GST Portal credential is not available for this client/GSTIN.") username=decrypt_value(cred.tenant_id,cred.username_encrypted) or ""; password=decrypt_value(cred.tenant_id,cred.secret_encrypted) or "" if not username or not password: return _redirect(client_id,registration_id=registration_id,period=period,error="GST username/password is missing in Credential Vault.") fy=_fy_for_period(period); accounting_dir,gst_dir=_storage_payload(client,fy,gstin) node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id) if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,error="Local Storage Agent is offline.") result=request_agent_command(node.node_code,"gst_return_download_start",{"client_id":client.id,"client_name":client.client_name,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"username":username,"password":password,"include_2a":bool(include_2a),"login_timeout_seconds":900},timeout_seconds=15) log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST return download {period}",fields="username,secret",success=bool(result.get("ok"))) db.commit() if not result.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=result.get("error") or "GST download could not be started.") return _redirect(client_id,registration_id=registration_id,period=period,message="GST browser started on the Local Storage workstation. Complete captcha/OTP there; downloaded returns will be stored in the client GST directory.") except Exception as exc: db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc)) finally: db.close() @router.post("/analyze") def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(...), csrf_token: str=Form(...)): validate_csrf(request,csrf_token) db=CommonSessionLocal() try: user,response=_require_partner(request,db,"accounting.learning.manage") if response: return response client,_,scope=_find_visible_client(db,request,user,client_id) if not client: return _redirect(client_id,error="Client is not available in your scope.") pair=next(((r,t) for r,t in _gst_regs(db,scope.tenant_id,client.id) if int(r.id)==registration_id and _is_gstin(r,t)),None) if not pair: return _redirect(client_id,error="GSTIN registration was not found.") reg,_=pair; gstin=re.sub(r"\s+","",str(reg.registration_number or "").upper()); fy=_fy_for_period(period) accounting_dir,gst_dir=_storage_payload(client,fy,gstin); date_from,date_to=_period_bounds(period) node=get_active_storage_node_for_branch(db,scope.tenant_id,scope.branch_id) if not node or not _node_online(node): return _redirect(client_id,registration_id=registration_id,period=period,error="Local Storage Agent is offline.") res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":fy,"period":re.sub(r"\D","",period),"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=25) if not res.get("ok"): return _redirect(client_id,registration_id=registration_id,period=period,error=res.get("error") or "GST reconciliation failed.") # Save compact analysis in session for immediate display; no GST raw data or credentials are stored on VPS. request.session["gst_reconciliation_result"]=(res.get("result") or {}).get("analysis") or {} return _redirect(client_id,registration_id=registration_id,period=period,message="GST Purchase, Sales and ITC reconciliation completed from local stored return data and Accounting Mirror.") except Exception as exc: return _redirect(client_id,registration_id=registration_id,period=period,error=str(exc)) finally: db.close()