Files
arrr-erp/app/modules/accounting/gst_reconciliation_ui.py
T

365 lines
20 KiB
Python

from __future__ import annotations
import calendar
import re
import tempfile
import uuid
from datetime import date, datetime, timedelta, timezone
from pathlib import Path
from urllib.parse import urlencode
import jwt
from fastapi import APIRouter, File, Form, Request, UploadFile
from fastapi.responses import FileResponse, JSONResponse, 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.settings import get_settings
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 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"])
_TOKEN_PURPOSE = "gst_operator_browser_v1"
_TOKEN_MINUTES = 30
_UPLOAD_ROOT = Path(tempfile.gettempdir()) / "audit_firm_gst_operator_uploads"
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 _periods_for_fy(fy: str) -> list[str]:
start, _ = _fy_bounds(fy)
periods=[]
y=start.year; m=4
for _ in range(12):
periods.append(f"{m:02d}{y:04d}")
m += 1
if m == 13:
m = 1; y += 1
return periods
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
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
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
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
return (root / "Accounting").as_posix(), (root / "GST" / sanitize_segment(f"GSTIN_{gstin}", "GSTIN")).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)
def _encode_operator_token(payload: dict) -> str:
now = datetime.now(timezone.utc)
body = {**payload, "purpose": _TOKEN_PURPOSE, "iat": now, "exp": now + timedelta(minutes=_TOKEN_MINUTES)}
return jwt.encode(body, get_settings().SECRET_KEY, algorithm="HS256")
def _decode_operator_token(token: str) -> dict:
data = jwt.decode(token, get_settings().SECRET_KEY, algorithms=["HS256"])
if data.get("purpose") != _TOKEN_PURPOSE:
raise ValueError("Invalid GST operator token.")
return data
def _selected_periods(download_mode: str, financial_year: str, period: str) -> list[str]:
if download_mode == "full_fy":
return _periods_for_fy(financial_year)
digits = re.sub(r"\D", "", period or "")
if len(digits) != 6:
raise ValueError("Enter a valid MMYYYY period for Single Month mode.")
return [digits]
@router.get("")
def page(request: Request, client_id: int | None = None, registration_id: int | None = None, period: str = "", financial_year: str = "", download_mode: str = "single", 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
if not financial_year:
financial_year = str(request.session.get("active_financial_year") or request.session.get("active_fy") or "2025-26")
if download_mode not in {"single", "full_fy"}:
download_mode = "single"
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)
operator_job = request.session.pop("gst_operator_job", None)
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,"financial_year":financial_year,"download_mode":download_mode,"operator_job":operator_job,"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(""), financial_year: str=Form(...), download_mode: str=Form("single"),
gstr1: str=Form(""), gstr2b: str=Form(""), gstr3b: str=Form(""), gstr2a: 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=_norm_gstin(reg.registration_number)
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,financial_year=financial_year,download_mode=download_mode,error="Selected GST Portal credential is not available for this client/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,financial_year=financial_year,download_mode=download_mode,error="Local Storage Agent is offline.")
periods=_selected_periods(download_mode,financial_year,period)
return_types=[name for name,flag in (("GSTR1",gstr1),("GSTR2B",gstr2b),("GSTR3B",gstr3b),("GSTR2A",gstr2a)) if flag]
if not return_types:
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error="Select at least one GST return to download.")
# Ensure FY is consistent in single-month mode.
if download_mode == "single":
financial_year=_fy_for_period(periods[0])
accounting_dir,gst_dir=_storage_payload(client,financial_year,gstin)
jti=uuid.uuid4().hex
token=_encode_operator_token({
"jti":jti,"user_id":int(user.id),"tenant_id":int(scope.tenant_id),"branch_id":int(scope.branch_id),
"node_code":str(node.node_code),"client_id":int(client.id),"client_name":str(client.client_name or ""),
"registration_id":int(reg.id),"credential_id":int(cred.id),"gstin":gstin,"financial_year":financial_year,
"periods":periods,"return_types":return_types,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,
})
log_access(db,request,user,cred,"use_for_gst_download",reason=f"GST returns {financial_year}: {','.join(return_types)}",fields="username,secret",success=True)
db.commit()
request.session["gst_operator_job"]={"token":token,"job_id":jti,"periods":periods,"return_types":return_types}
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,message="GST download prepared. The ERP will now ask the Local Agent on this computer to open the visible GST browser; CAPTCHA/OTP will appear here. Completed files will be transferred to the configured client local storage.")
except Exception as exc:
db.rollback(); return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=download_mode,error=str(exc))
finally:
db.close()
@router.post("/operator/redeem")
async def operator_redeem(request: Request):
body=await request.json(); token=str(body.get("token") or "")
try:
data=_decode_operator_token(token)
db=CommonSessionLocal()
try:
cred=db.get(CredentialVaultEntry,int(data["credential_id"]))
if not cred or int(cred.tenant_id or 0)!=int(data["tenant_id"]) or int(cred.client_id or 0)!=int(data["client_id"]) or str(cred.status or "").lower()=="archived":
raise ValueError("GST credential is no longer available.")
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:
raise ValueError("GST username/password is missing in Credential Vault.")
finally:
db.close()
return JSONResponse({"ok":True,"payload":{**{k:v for k,v in data.items() if k not in {"iat","exp","purpose"}},"username":username,"password":password,"upload_url":str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/")+"/tools/accounting/gst-reconciliation/operator/upload","login_timeout_seconds":900}})
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.post("/operator/upload")
async def operator_upload(token: str=Form(...), package: UploadFile=File(...)):
try:
data=_decode_operator_token(token); jti=str(data.get("jti") or "")
if not jti:
raise ValueError("GST operator job id is missing.")
_UPLOAD_ROOT.mkdir(parents=True,exist_ok=True)
package_path=_UPLOAD_ROOT/f"{jti}.zip"
total=0
with package_path.open("wb") as out:
while True:
chunk=await package.read(1024*1024)
if not chunk: break
total += len(chunk)
if total > 250*1024*1024:
raise ValueError("GST download package exceeds the 250 MB safety limit.")
out.write(chunk)
package_url=str(get_settings().ERP_PUBLIC_BASE_URL).rstrip("/")+f"/tools/accounting/gst-reconciliation/operator/package/{jti}?token={token}"
result=request_agent_command(str(data["node_code"]),"gst_return_package_store",{
"client_id":int(data["client_id"]),"gstin":str(data["gstin"]),"financial_year":str(data["financial_year"]),
"gst_relative_dir":str(data["gst_relative_dir"]),"package_url":package_url,"periods":data.get("periods") or [],"return_types":data.get("return_types") or [],
},timeout_seconds=120)
if not result.get("ok"):
raise RuntimeError(result.get("error") or "Local Storage Agent could not store the GST return package.")
try: package_path.unlink(missing_ok=True)
except Exception: pass
return JSONResponse({"ok":True,"stored":result.get("result") or {}})
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.get("/operator/package/{job_id}")
def operator_package(job_id: str, token: str):
try:
data=_decode_operator_token(token)
if str(data.get("jti") or "") != str(job_id):
raise ValueError("GST package token does not match the requested job.")
path=_UPLOAD_ROOT/f"{job_id}.zip"
if not path.is_file():
return JSONResponse({"ok":False,"error":"GST package is no longer available."},status_code=404)
return FileResponse(path,media_type="application/zip",filename=f"gst_returns_{job_id}.zip")
except Exception as exc:
return JSONResponse({"ok":False,"error":str(exc)},status_code=400)
@router.post("/analyze")
def analyze(request: Request, client_id: int=Form(...), registration_id: int=Form(...), period: str=Form(""), financial_year: str=Form(...), analyze_mode: str=Form("single"), 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=_norm_gstin(reg.registration_number)
periods=_periods_for_fy(financial_year) if analyze_mode=="full_fy" else [re.sub(r"\D","",period or "")]
if any(len(p)!=6 for p in periods): raise ValueError("Enter a valid MMYYYY period.")
accounting_dir,gst_dir=_storage_payload(client,financial_year,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,financial_year=financial_year,error="Local Storage Agent is offline.")
results=[]
for p in periods:
date_from,date_to=_period_bounds(p)
res=request_agent_command(node.node_code,"gst_reconciliation_analyze",{"client_id":client.id,"gstin":gstin,"financial_year":financial_year,"period":p,"gst_relative_dir":gst_dir,"accounting_relative_dir":accounting_dir,"date_from":date_from,"date_to":date_to},timeout_seconds=30)
if res.get("ok"):
results.append((res.get("result") or {}).get("analysis") or {})
if not results: raise RuntimeError("No stored GST periods could be reconciled.")
if analyze_mode=="full_fy":
def sum_counts(section):
out={}
for r in results:
for k,v in (((r.get(section) or {}).get("counts") or {}).items()): out[k]=out.get(k,0)+int(v or 0)
return out
taxes={k:{"gstr2b":0.0,"gstr3b":0.0,"difference":0.0} for k in ("igst","cgst","sgst","cess")}
for r in results:
for k,row in (r.get("itc_reconciliation") or {}).items():
if k in taxes:
for f in taxes[k]: taxes[k][f]=round(taxes[k][f]+float(row.get(f) or 0),2)
analysis={"period":financial_year,"sales_reconciliation":{"counts":sum_counts("sales_reconciliation")},"purchase_reconciliation":{"counts":sum_counts("purchase_reconciliation")},"itc_reconciliation":taxes}
else:
analysis=results[0]
request.session["gst_reconciliation_result"]=analysis
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,download_mode=("full_fy" if analyze_mode=="full_fy" else "single"),message="GST Purchase, Sales and ITC reconciliation completed from stored local return data and Accounting Mirror.")
except Exception as exc:
return _redirect(client_id,registration_id=registration_id,period=period,financial_year=financial_year,error=str(exc))
finally:
db.close()