diff --git a/app/modules/accounting/gst_reconciliation_ui.py b/app/modules/accounting/gst_reconciliation_ui.py index 87d1cca..e01444b 100644 --- a/app/modules/accounting/gst_reconciliation_ui.py +++ b/app/modules/accounting/gst_reconciliation_ui.py @@ -66,18 +66,77 @@ def _is_gstin(reg, typ) -> bool: return code == "GSTIN" and len(num) == 15 -def _vault_entries(db, user, request, tenant_id: int, client_id: int, registration_id: int | None = None): +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", ) - if registration_id: - q = q.where(CredentialVaultEntry.registration_id == registration_id) rows = db.execute(q.order_by(CredentialVaultEntry.title.asc())).scalars().all() - visible = [r for r in rows if can_view_entry(db, user, r, getattr(user, "branch_id", None))] - gst = [r for r in visible if "gst" in (str(r.title or "") + " " + str(r.category or "") + " " + str(r.portal_url or "")).lower()] - return gst or visible + 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]: @@ -109,7 +168,11 @@ def page(request: Request, client_id: int | None = None, registration_id: int | 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] - credentials=_vault_entries(db,user,request,scope.tenant_id,selected.id,int(selected_reg.id) if selected_reg else None) + 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()) @@ -144,8 +207,17 @@ def start_download(request: Request, client_id: int=Form(...), registration_id: 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) - if not cred or int(cred.client_id or 0)!=int(client.id) or (cred.registration_id and int(cred.registration_id)!=int(reg.id)) or not can_view_entry(db,user,cred,scope.branch_id): - return _redirect(client_id,registration_id=registration_id,period=period,error="Selected GST credential is not available for this client/registration.") + 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) diff --git a/app/modules/accounting/templates/accounting/gst_reconciliation.html b/app/modules/accounting/templates/accounting/gst_reconciliation.html index faa9f6d..4fb3e4d 100644 --- a/app/modules/accounting/templates/accounting/gst_reconciliation.html +++ b/app/modules/accounting/templates/accounting/gst_reconciliation.html @@ -16,9 +16,24 @@

1. Download from GST Portal

The Local Storage Agent opens GST portal on the workstation. Username/password are taken from Credential Vault; captcha/OTP remains interactive. Raw return data is saved under the client's FY/GST/GSTIN/period directory.

- + + {% if not credentials %} +
+ No usable GST Portal credential is linked to this client/GSTIN. + Add GST Portal credential. +
+ {% elif credentials|length == 1 %} +
The only eligible GST Portal credential has been selected automatically.
+ {% endif %} - +
Storage Agent: {{ 'Online' if node_online else 'Offline' }}