using System.Globalization; using System.Text.Json; using ComplianceBLL.Adapter; using ComplianceBLL.CanonicalBuilder; using ComplianceDAL.CustomCode.AdapterConfig; using ComplianceDAL.CustomCode.ComplianceArtifact; using ComplianceDAL.CustomCode.GSTEInvoice; using ComplianceDAL.DTO.AdapterConfig; using ComplianceDAL.DTO.CanonicalInvoice; using ComplianceDAL.DTO.ComplianceArtifact; using ComplianceDAL.DTO.GSTEInvoice; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.GB5Solution; using GB5Shared.Validation; using Microsoft.Extensions.Logging; namespace ComplianceBLL.GSTEInvoice; public class GSTEInvoiceBLL : IGSTEInvoiceBLL { private readonly IGSTEInvoiceDAL _dal; private readonly IComplianceArtifactDAL _artifactDal; private readonly IAdapterConfigDAL _adapterConfigDal; private readonly ICanonicalBuilderBLL _canonicalBuilder; private readonly IComplianceAdapterFactory _adapterFactory; private readonly IValidation _validation; private readonly ILogger _logger; public GSTEInvoiceBLL( IGSTEInvoiceDAL dal, IComplianceArtifactDAL artifactDal, IAdapterConfigDAL adapterConfigDal, ICanonicalBuilderBLL canonicalBuilder, IComplianceAdapterFactory adapterFactory, IValidation validation, ILogger logger) { _dal = dal; _artifactDal = artifactDal; _adapterConfigDal = adapterConfigDal; _canonicalBuilder = canonicalBuilder; _adapterFactory = adapterFactory; _validation = validation; _logger = logger; } public async Task GetEInvoiceAsync(int eInvoiceId, LoginDTO login, CancellationToken ct) { try { var dto = await _dal.GetEInvoiceAsync(eInvoiceId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(dto); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: GetEInvoiceAsync failed for EInvoiceId {EInvoiceId}", eInvoiceId); throw; } } public async Task GetEInvoiceByDocumentAsync(int documentId, string documentType, LoginDTO login, CancellationToken ct) { try { var dto = await _dal.GetEInvoiceByDocumentAsync(documentId, documentType, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(dto); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: GetEInvoiceByDocumentAsync failed for DocumentId {DocumentId}", documentId); throw; } } public async Task GenerateEInvoiceAsync(GenerateEInvoiceRequestDTO request, LoginDTO login, CancellationToken ct) { // Hoisted so the catch block below can still report which artifact/attempt was in flight // when an unexpected exception hit — every step from here on gets its own try/catch context // rather than relying solely on the endpoint's generic top-level handler. int? artifactId = null; int attemptNumber = 0; try { await _validation.NotNull(request, nameof(request)); if (request.DocumentId == -1) throw new ArgumentException("DocumentId can not be -1."); bool isOffline = string.Equals(request.SubmissionMode, "Offline", StringComparison.OrdinalIgnoreCase); // Build canonical invoice from ERP data CanonicalInvoiceDTO? canonical; try { canonical = await _canonicalBuilder.BuildAsync(request.DocumentId, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: canonical build failed for DocumentId {DocumentId}", request.DocumentId); throw; } if (canonical is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.CanonicalBuildFailed }); // SubmissionMode "Offline" = Generate only: return the IRP JSON built straight from the // canonical query response as-is. No "already generated" check, no validation, no // adapter/artifact lookup, nothing persisted — only the Upload path (SubmissionMode "API") // talks to the government portal and needs the full pipeline below. if (isOffline) { _logger.LogInformation("E-Invoice generated (offline) for DocumentId {DocumentId}", request.DocumentId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.EInvoiceOfflineGenerated, eInvoicePayload = BuildEInvoicePayload(canonical) }); } // Check: not already generated var existing = await _dal.GetEInvoiceByDocumentAsync(request.DocumentId, request.DocumentType, login, ct).ConfigureAwait(false); if (existing is { Status: "Generated" }) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.EInvoiceAlreadyGenerated, eInvoiceId = existing.EInvoiceId }); // Pre-validation before portal call List validationErrors; try { validationErrors = _canonicalBuilder.ValidateCanonicalInvoice(canonical); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: canonical validation failed for DocumentId {DocumentId}", request.DocumentId); throw; } if (validationErrors.Count > 0) return JsonSerializer.Serialize(new { success = false, validationErrors }); // Load adapter config for this GSTIN AdapterConfigDTO? adapterConfig; try { adapterConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(canonical.Supplier.GSTIN, "GST_INDIA", login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: adapter config lookup failed for GSTIN {GSTIN}", canonical.Supplier.GSTIN); throw; } if (adapterConfig is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.AdapterConfigNotFound }); // Get or create artifact try { var artifact = await _artifactDal.GetArtifactBySource(request.DocumentType, request.DocumentId, "EInvoice", login, ct).ConfigureAwait(false); if (artifact is null) { artifactId = await _artifactDal.SaveArtifact(new ComplianceArtifactDTO { ArtifactType = "EInvoice", Framework = "GST_INDIA", SourceEntityType = request.DocumentType, SourceEntityId = request.DocumentId, GSTIN = canonical.Supplier.GSTIN, Status = "DRAFT", AttemptCount = 0, CreatedById = login.UserId }, login, ct).ConfigureAwait(false); } else { artifactId = artifact.ArtifactId; } attemptNumber = (artifact?.AttemptCount ?? 0) + 1; } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: artifact get-or-create failed for DocumentId {DocumentId}", request.DocumentId); throw; } // Build E-Invoice JSON payload from canonical model var eInvoicePayload = BuildEInvoicePayload(canonical); var payloadJson = JsonSerializer.Serialize(eInvoicePayload); try { // Store canonical payload (immutable) await _artifactDal.SavePayload(new CompliancePayloadDTO { ArtifactId = artifactId.Value, PayloadType = "Canonical", PayloadFormat = "JSON", Payload = JsonSerializer.Serialize(canonical), AttemptNumber = attemptNumber }, login, ct).ConfigureAwait(false); // Store transformed payload (immutable) await _artifactDal.SavePayload(new CompliancePayloadDTO { ArtifactId = artifactId.Value, PayloadType = "Transformed", PayloadFormat = "JSON", Payload = payloadJson, AttemptNumber = attemptNumber }, login, ct).ConfigureAwait(false); // Update artifact status to VALIDATED await _artifactDal.UpdateArtifactStatus(artifactId.Value, "VALIDATED", attemptNumber, login.UserId, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: payload persistence failed for ArtifactId {ArtifactId}", artifactId); throw; } // Same override GB4's GenerateIRN does right before calling the portal: adapterConfig was // resolved via a fixed reference OU (see GET_ADAPTER_CONFIG_BY_GSTIN), which can carry a // different GSTIN than the actual invoice's seller — if the payload's SellerDtls.Gstin // disagrees with the resolved config, re-fetch config for that specific seller GSTIN. try { using var payloadDoc = JsonDocument.Parse(payloadJson); var sellerGstin = payloadDoc.RootElement.GetProperty("SellerDtls").GetProperty("Gstin").GetString(); if (!string.IsNullOrEmpty(sellerGstin) && sellerGstin != adapterConfig.GSTIN) { var dynamicConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(sellerGstin, "GST_INDIA", login, ct).ConfigureAwait(false); if (dynamicConfig is not null) adapterConfig = dynamicConfig; } } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: dynamic GSTIN override check failed for ArtifactId {ArtifactId}", artifactId); throw; } // Call portal via adapter — no OTP/auth-token step for GSTCafe e-invoice, ASP credentials // on adapterConfig go straight onto the call (see GSTCafeAdapter.CallEInvoiceServiceAsync) EInvoicePortalResponseDTO response; try { var adapter = _adapterFactory.GetAdapter(adapterConfig); response = await adapter.GenerateEInvoiceAsync(payloadJson, canonical.Supplier.GSTIN, string.Empty, string.Empty, adapterConfig, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: portal adapter call failed for ArtifactId {ArtifactId}", artifactId); throw; } try { // Log the API call await _artifactDal.SaveAudit(new ComplianceAuditDTO { ArtifactId = artifactId.Value, Action = "API_CALL", RequestPayload = payloadJson, ResponsePayload = response.RawResponse, PreviousState = "VALIDATED", NewState = response.IsSuccess ? "CLEARED" : "REJECTED", ProviderCode = "GSTCafe", HttpStatusCode = response.HttpStatusCode, ReferenceNumber = response.AcknowledgementNumber, ErrorMessage = response.ErrorMessage, AttemptNumber = attemptNumber, UserId = login.UserId }, login, ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: audit logging failed for ArtifactId {ArtifactId}", artifactId); throw; } if (!response.IsSuccess) { await _artifactDal.UpdateArtifactStatus(artifactId.Value, "REJECTED", attemptNumber, login.UserId, login, ct).ConfigureAwait(false); _logger.LogWarning("E-Invoice generation failed for DocumentId {DocumentId}: {Error}", request.DocumentId, response.ErrorMessage); return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorCode = response.ErrorCode, errorMessage = response.ErrorMessage }); } int eInvoiceId; try { // Store response payload await _artifactDal.SavePayload(new CompliancePayloadDTO { ArtifactId = artifactId.Value, PayloadType = "Response", PayloadFormat = "JSON", Payload = response.RawResponse, AttemptNumber = attemptNumber }, login, ct).ConfigureAwait(false); // Persist IRN record eInvoiceId = await _dal.SaveEInvoiceAsync(new GSTEInvoiceDTO { ArtifactId = artifactId.Value, DocumentId = request.DocumentId, DocumentType = request.DocumentType, DocumentNumber = canonical.DocumentNumber, DocumentDate = canonical.DocumentDate, GSTIN = canonical.Supplier.GSTIN, IRN = response.IRN, SignedQRCode = response.SignedQRCode, SignedEInvoice = response.SignedEInvoice, AcknowledgementNumber = response.AcknowledgementNumber, AcknowledgementDate = response.AcknowledgementDate, Status = "Generated", SubmissionMode = request.SubmissionMode, ClientId = login.ClientId, DatabaseName = login.DatabaseName, CreatedById = login.UserId }, login, ct).ConfigureAwait(false); // Matches GB4's EInvoiceDAL.UploadEInvoiceFunction (DocumentId branch): TMMHEAD itself // carries IRN/SIGNEDQRCODE/ACKNOWLEDGEMENTNUMBER/ACKNOWLEDGEMENTDATE, read directly by // other MM reports/documents, so it needs updating alongside TGST_EINVOICE above. await _dal.UpdateTMMHeadByDocumentIdAsync(request.DocumentId, response.IRN, response.SignedQRCode, response.AcknowledgementNumber, response.AcknowledgementDate, login, ct).ConfigureAwait(false); await _artifactDal.UpdateArtifactStatus(artifactId.Value, "CLEARED", attemptNumber, login.UserId, login, ct).ConfigureAwait(false); } catch (Exception ex) { // The portal already cleared this IRN at this point — a failure here means the // clearance succeeded but our own persistence didn't, which needs distinct handling // (retry-save, not retry-submit) from every earlier failure branch above. _logger.LogError(ex, "E-Invoice: IRN persisted-clearance bookkeeping failed for ArtifactId {ArtifactId}, IRN {IRN} — portal already cleared this invoice.", artifactId, response.IRN); throw; } _logger.LogInformation("E-Invoice generated: IRN={IRN} DocumentId={DocumentId} EInvoiceId={EInvoiceId}", response.IRN, request.DocumentId, eInvoiceId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.EInvoiceGenerated, eInvoiceId, irn = response.IRN, acknowledgementNumber = response.AcknowledgementNumber, acknowledgementDate = response.AcknowledgementDate }); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice generation failed unexpectedly for DocumentId {DocumentId}, ArtifactId {ArtifactId}", request.DocumentId, artifactId); if (artifactId is not null) { // Best-effort — never let a failure in the failure-handling path itself mask the // real exception being rethrown below. try { await _artifactDal.SaveAudit(new ComplianceAuditDTO { ArtifactId = artifactId.Value, Action = "EXCEPTION", ErrorMessage = ex.Message, AttemptNumber = attemptNumber, UserId = login.UserId }, login, ct).ConfigureAwait(false); await _artifactDal.UpdateArtifactStatus(artifactId.Value, "REJECTED", attemptNumber, login.UserId, login, ct).ConfigureAwait(false); } catch (Exception auditEx) { _logger.LogError(auditEx, "E-Invoice: failed to record failure audit for ArtifactId {ArtifactId}", artifactId); } } throw; } } public async Task GenerateBulkEInvoiceAsync(GenerateBulkEInvoiceRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotNull(request, nameof(request)); if (request.DocumentIds is not { Count: > 0 }) return JsonSerializer.Serialize(new { success = false, message = "No document IDs provided." }); var results = new List(); foreach (var docId in request.DocumentIds) { ct.ThrowIfCancellationRequested(); var singleRequest = new GenerateEInvoiceRequestDTO { DocumentId = docId, DocumentType = request.DocumentType, SubmissionMode = request.SubmissionMode }; var resultJson = await GenerateEInvoiceAsync(singleRequest, login, ct).ConfigureAwait(false); results.Add(JsonSerializer.Deserialize(resultJson)!); } return JsonSerializer.Serialize(new { success = true, count = results.Count, results }); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: GenerateBulkEInvoiceAsync failed for DocumentType {DocumentType}", request.DocumentType); throw; } } // Ported from GB4's EInvoiceDAL.CancelIRN(int Type, CancelIRNDTO, LoginDTO): // Type 1 (Online) calls the GSP cancel API and, only on success, clears TMMHEAD's IRN column. // Type 0 (Offline) never calls the portal — it just clears TMMHEAD's IRN directly, since an // Offline-generated e-invoice was never reported to the government in the first place. // GB5 branches on request.SubmissionMode (posted by the FE, same as GenerateEInvoiceAsync) // instead of a separate Type parameter — the FE decides Offline vs API, same pattern as Generate. public async Task CancelEInvoiceAsync(CancelEInvoiceRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotNull(request, nameof(request)); await _validation.NotEmpty(request.IRNNumber, nameof(request.IRNNumber)); await _validation.NotEmpty(request.CancelReason, nameof(request.CancelReason)); var existing = await _dal.GetEInvoiceAsync(request.EInvoiceId, login, ct).ConfigureAwait(false); if (existing is null || existing.Status != "Generated") return JsonSerializer.Serialize(new { success = false, message = "E-Invoice not found or already cancelled." }); if (request.SubmissionMode == "Offline") { await _dal.ClearTMMHeadIRNAsync(request.IRNNumber, login, ct).ConfigureAwait(false); await _dal.CancelEInvoiceAsync(request.EInvoiceId, request.CancelReason, login, ct).ConfigureAwait(false); await _artifactDal.UpdateArtifactStatus(existing.ArtifactId, "ARCHIVED", 0, login.UserId, login, ct).ConfigureAwait(false); _logger.LogInformation("E-Invoice cancelled (Offline, no portal call): IRN={IRN} EInvoiceId={EInvoiceId}", request.IRNNumber, request.EInvoiceId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.EInvoiceCancelled }); } var adapterConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(existing.GSTIN, "GST_INDIA", login, ct).ConfigureAwait(false); if (adapterConfig is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.AdapterConfigNotFound }); // Matches GB4's EInvoiceDAL.CancelIRN UserGstin check, applied the same way // CancelEWayBillAsync applies it for e-way: resolve the GSTIN currently registered // against the e-invoice's OU (or store) fresh (by IRN), and if it disagrees with the // resolved adapterConfig's own GSTIN, re-fetch config for that GSTIN specifically. // // TODO: GB4 picks OU-vs-Store lookup by two hardcoded ClientIds (WB/Sunbeam) — ported // literally for now per explicit instruction; this discriminator itself is expected to // change soon, at which point this ClientId check should be replaced. var isStoreBasedClient = login.ClientId == -1399999740 || login.ClientId == -1399999737; var userGstin = isStoreBasedClient ? await _dal.GetEInvoiceGstinBasedOnStoreAsync(request.IRNNumber, login, ct).ConfigureAwait(false) : await _dal.GetEInvoiceGstinBasedOnOuAsync(request.IRNNumber, login, ct).ConfigureAwait(false); if (string.IsNullOrEmpty(userGstin)) return JsonSerializer.Serialize(new { success = false, message = isStoreBasedClient ? "Gstin is not found based on Store.." : "Gstin is not found based on Ou.." }); if (userGstin != adapterConfig.GSTIN) { var dynamicConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(userGstin, "GST_INDIA", login, ct).ConfigureAwait(false); if (dynamicConfig is not null) adapterConfig = dynamicConfig; } var adapter = _adapterFactory.GetAdapter(adapterConfig); var response = await adapter.CancelEInvoiceAsync(request.IRNNumber, request.CancelReasonType, request.CancelReason, existing.GSTIN, string.Empty, string.Empty, adapterConfig, ct).ConfigureAwait(false); await _artifactDal.SaveAudit(new ComplianceAuditDTO { ArtifactId = existing.ArtifactId, Action = "API_CALL", RequestPayload = JsonSerializer.Serialize(request), ResponsePayload = response.RawResponse, PreviousState = "CLEARED", NewState = response.IsSuccess ? "ARCHIVED" : "CLEARED", ProviderCode = "GSTCafe", HttpStatusCode = response.HttpStatusCode, ErrorMessage = response.ErrorMessage, AttemptNumber = 1, UserId = login.UserId }, login, ct).ConfigureAwait(false); if (!response.IsSuccess) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorMessage = response.ErrorMessage }); await _dal.ClearTMMHeadIRNAsync(request.IRNNumber, login, ct).ConfigureAwait(false); await _dal.CancelEInvoiceAsync(request.EInvoiceId, request.CancelReason, login, ct).ConfigureAwait(false); await _artifactDal.UpdateArtifactStatus(existing.ArtifactId, "ARCHIVED", 0, login.UserId, login, ct).ConfigureAwait(false); _logger.LogInformation("E-Invoice cancelled: IRN={IRN} EInvoiceId={EInvoiceId}", request.IRNNumber, request.EInvoiceId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.EInvoiceCancelled }); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: CancelEInvoiceAsync failed for EInvoiceId {EInvoiceId}, IRN {IRN}", request.EInvoiceId, request.IRNNumber); throw; } } public async Task GetEInvoiceCoverageAsync(string gstin, string returnPeriod, LoginDTO login, CancellationToken ct) { try { var list = await _dal.GetCoverageListAsync(gstin, returnPeriod, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list); } catch (Exception ex) { _logger.LogError(ex, "E-Invoice: GetEInvoiceCoverageAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", gstin, returnPeriod); throw; } } // ── Payload builder ─────────────────────────────────────────────────────── private static object BuildEInvoicePayload(CanonicalInvoiceDTO canonical) { // Builds the IRP-compliant e-invoice JSON structure from the canonical model. // Field names follow IRP schema (Version 1.1). return new { Version = "1.1", TranDtls = new { TaxSch = "GST", SupTyp = canonical.SupplyType, RegRev = canonical.IsReverseCharge ? "Y" : "N", IgstOnIntra = "N" }, DocDtls = new { // CanonicalBuilderDAL already resolves this to the IRP code (INV/CRN/DBN) via // MBIZTRANSACTIONCLASS, so it's passed through as-is here. Typ = canonical.DocumentType, No = canonical.DocumentNumber, // Unescaped '/' in a .NET date format string is the CURRENT CULTURE's date separator, // not a literal slash — without InvariantCulture this renders as "dd-MM-yyyy" on a // server whose thread culture uses '-'. IRP requires the literal "dd/MM/yyyy" shape. Dt = canonical.DocumentDate.ToString("dd/MM/yyyy", CultureInfo.InvariantCulture) }, SellerDtls = new { Gstin = canonical.Supplier.GSTIN, LglNm = canonical.Supplier.LegalName, TrdNm = canonical.Supplier.TradeName, Addr1 = canonical.Supplier.Address1, Addr2 = canonical.Supplier.Address2, Loc = canonical.Supplier.City, Pin = int.TryParse(canonical.Supplier.Pincode, out var sPin) ? sPin : 0, Stcd = canonical.Supplier.StateCode, Ph = canonical.Supplier.Phone, Em = canonical.Supplier.Email }, BuyerDtls = new { Gstin = canonical.Receiver.GSTIN, LglNm = canonical.Receiver.LegalName, TrdNm = canonical.Receiver.TradeName, Addr1 = canonical.Receiver.Address1, Addr2 = canonical.Receiver.Address2, Loc = canonical.Receiver.City, Pin = int.TryParse(canonical.Receiver.Pincode, out var rPin) ? rPin : 0, Stcd = canonical.Receiver.StateCode, Pos = canonical.POS }, ValDtls = new { AssVal = canonical.TotalTaxableValue, IgstVal = canonical.TotalIGSTAmount, CgstVal = canonical.TotalCGSTAmount, SgstVal = canonical.TotalSGSTAmount, CesVal = canonical.TotalCessAmount, Discount = canonical.DiscountAmount, OthChrg = canonical.OtherChargesAmount, RndOffAmt = canonical.RoundOffAmount, TotInvVal = canonical.TotalInvoiceValue }, ItemList = canonical.Lines.Select((l, idx) => new { SlNo = (idx + 1).ToString(), PrdDesc = l.ItemName, IsServc = l.ItemNature == "Service" ? "Y" : "N", HsnCd = l.HSNCode, Qty = l.Quantity, Unit = l.UOM, UnitPrice = l.Rate, TotAmt = l.Value, Discount = l.Discount, AssAmt = l.TaxableValue, GstRt = l.IGSTRate > 0 ? l.IGSTRate : l.CGSTRate + l.SGSTRate, IgstAmt = l.IGSTAmount, CgstAmt = l.CGSTAmount, SgstAmt = l.SGSTAmount, CesRt = l.CessRate, CesAmt = l.CessAmount, TotItemVal = l.TaxableValue + l.IGSTAmount + l.CGSTAmount + l.SGSTAmount + l.CessAmount }).ToList() }; } }