using System.Text.Json; using ComplianceBLL.Adapter; using ComplianceBLL.CanonicalBuilder; using ComplianceBLL.Common; using ComplianceDAL.CustomCode.AdapterConfig; using ComplianceDAL.CustomCode.ComplianceArtifact; using ComplianceDAL.CustomCode.GSTOutward; using ComplianceDAL.CustomCode.GSTReturn; using ComplianceDAL.DTO.ComplianceArtifact; using ComplianceDAL.DTO.GSTOutward; using ComplianceDAL.DTO.GSTReturn; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.GB5Solution; using GB5Shared.Validation; using Microsoft.Extensions.Logging; namespace ComplianceBLL.GSTOutward; public class GSTOutwardBLL : IGSTOutwardBLL { private readonly IGSTOutwardDAL _dal; private readonly IGSTReturnDAL _returnDal; 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 GSTOutwardBLL( IGSTOutwardDAL dal, IGSTReturnDAL returnDal, IComplianceArtifactDAL artifactDal, IAdapterConfigDAL adapterConfigDal, ICanonicalBuilderBLL canonicalBuilder, IComplianceAdapterFactory adapterFactory, IValidation validation, ILogger logger) { _dal = dal; _returnDal = returnDal; _artifactDal = artifactDal; _adapterConfigDal = adapterConfigDal; _canonicalBuilder = canonicalBuilder; _adapterFactory = adapterFactory; _validation = validation; _logger = logger; } public async Task GetGSTOutwardAsync(int outwardId, LoginDTO login, CancellationToken ct) { try { var dto = await _dal.GetGSTOutwardAsync(outwardId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(dto); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: GetGSTOutwardAsync failed for OutwardId {OutwardId}", outwardId); throw; } } public async Task GetGSTOutwardListAsync(GSTOutwardFilterDTO filter, LoginDTO login, CancellationToken ct) { try { var list = await _dal.GetGSTOutwardListAsync(filter, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: GetGSTOutwardListAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", filter.GSTIN, filter.ReturnPeriod); throw; } } public async Task SyncGSTOutwardAsync(SyncGSTOutwardRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.GSTIN, nameof(request.GSTIN)); await _validation.NotEmpty(request.ReturnPeriod, nameof(request.ReturnPeriod)); if (!ReturnPeriodHelper.IsValidShape(request.ReturnPeriod)) return JsonSerializer.Serialize(new { success = false, message = $"ReturnPeriod '{request.ReturnPeriod}' must be in MMYYYY format." }); var (fromDate, toDate) = ParseReturnPeriod(request.ReturnPeriod); if (request.ForceResync) await _dal.DeleteOutwardByPeriodAsync(request.GSTIN, request.ReturnPeriod, login, ct).ConfigureAwait(false); int processed = 0; int skipped = 0; int failed = 0; await foreach (var canonical in _canonicalBuilder.BuildBatchAsync(fromDate, toDate, request.OUId, request.CompanyId, login, ct) .ConfigureAwait(false)) { ct.ThrowIfCancellationRequested(); try { if (!canonical.Supplier.GSTIN.Equals(request.GSTIN, StringComparison.OrdinalIgnoreCase)) { skipped++; continue; } var invoiceType = DetermineInvoiceType(canonical); var rate = GetPredominantRate(canonical); var dto = new GSTOutwardDTO { GSTIN = canonical.Supplier.GSTIN, ReturnPeriod = request.ReturnPeriod, SourceDocumentId = canonical.DocumentId, InvoiceNumber = canonical.DocumentNumber, InvoiceDate = canonical.DocumentDate, InvoiceValue = canonical.TotalInvoiceValue, ReceiverGSTIN = canonical.Receiver.GSTIN, ReceiverName = canonical.Receiver.LegalName, POS = canonical.POS, SupplyType = canonical.SupplyType, InvoiceType = invoiceType, HSNCode = rate.HSNCode, UOM = rate.UOM, Quantity = rate.Quantity, TaxableValue = canonical.TotalTaxableValue, IGSTRate = rate.IGSTRate, IGSTAmount = canonical.TotalIGSTAmount, CGSTRate = rate.CGSTRate, CGSTAmount = canonical.TotalCGSTAmount, SGSTRate = rate.SGSTRate, SGSTAmount = canonical.TotalSGSTAmount, CessRate = rate.CessRate, CessAmount = canonical.TotalCessAmount, ClientId = login.ClientId, DatabaseName = login.DatabaseName, CreatedById = login.UserId }; await _dal.UpsertGSTOutwardAsync(dto, login, ct).ConfigureAwait(false); processed++; } catch (Exception itemEx) when (itemEx is not OperationCanceledException) { // A single bad document must not abort the rest of the batch — log and move on. failed++; _logger.LogError(itemEx, "SyncGSTOutward: failed to process DocumentId {DocumentId} for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", canonical.DocumentId, request.GSTIN, request.ReturnPeriod); } } _logger.LogInformation("SyncGSTOutward: GSTIN={GSTIN} Period={Period} Processed={Processed} Skipped={Skipped} Failed={Failed}", request.GSTIN, request.ReturnPeriod, processed, skipped, failed); return JsonSerializer.Serialize(new { success = true, processed, skipped, failed }); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: SyncGSTOutwardAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", request.GSTIN, request.ReturnPeriod); throw; } } public async Task GetGSTR1PreviewAsync(string gstin, string returnPeriod, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(gstin, nameof(gstin)); await _validation.NotEmpty(returnPeriod, nameof(returnPeriod)); var (fromDate, toDate) = ParseReturnPeriod(returnPeriod); // Fetch sections in parallel where possible var b2bTask = _dal.GetB2BInvoicesAsync(gstin, returnPeriod, login, ct); var b2clTask = _dal.GetB2CLInvoicesAsync(gstin, returnPeriod, login, ct); var b2csTask = _dal.GetB2CSAggregateAsync(gstin, returnPeriod, login, ct); var expTask = _dal.GetEXPInvoicesAsync(gstin, returnPeriod, login, ct); var cdnrTask = _dal.GetCDNRNotesAsync(gstin, returnPeriod, login, ct); var nilTask = _dal.GetNilSummaryAsync(gstin, returnPeriod, login, ct); var summaryTask = _dal.GetOutwardSummaryAsync(gstin, returnPeriod, login, ct); await Task.WhenAll(b2bTask, b2clTask, b2csTask, expTask, cdnrTask, nilTask, summaryTask).ConfigureAwait(false); // Build B2B section — group invoices by ReceiverGSTIN var b2b = (await b2bTask).GroupBy(r => r.ReceiverGSTIN) .Select(g => new GSTR1B2BDTO { ReceiverGSTIN = g.Key, ReceiverName = g.First().ReceiverName, Invoices = g.Select(MapToInvoiceDTO).ToList() }).ToList(); // Build B2CL section — group by POS var b2cl = (await b2clTask).GroupBy(r => r.POS) .Select(g => new GSTR1B2CLDTO { POS = g.Key, Invoices = g.Select(MapToInvoiceDTO).ToList() }).ToList(); // B2CS already aggregated from DB var b2cs = (await b2csTask).ToList(); // Build EXP section — group by SupplyType (WPAY / WOPAY) var exp = (await expTask).GroupBy(r => r.SupplyType) .Select(g => new GSTR1EXPDTO { ExportType = g.Key == "WOPAY" ? "WOPAY" : "WPAY", Invoices = g.Select(MapToInvoiceDTO).ToList() }).ToList(); // Build CDNR section — group by ReceiverGSTIN var cdnr = (await cdnrTask).GroupBy(r => r.ReceiverGSTIN) .Select(g => new GSTR1CDNRDTO { ReceiverGSTIN = g.Key, Notes = g.Select(r => new GSTR1NoteDTO { NoteType = r.InvoiceType == "CDNR" ? "C" : "D", NoteNumber = r.InvoiceNumber, NoteDate = r.InvoiceDate, NoteValue = r.InvoiceValue, TaxableValue = r.TaxableValue, IGSTAmount = r.IGSTAmount, CGSTAmount = r.CGSTAmount, SGSTAmount = r.SGSTAmount, CessAmount = r.CessAmount }).ToList() }).ToList(); // Build HSN section from canonical invoice lines var hsnSection = await BuildHSNSectionAsync(gstin, fromDate, toDate, login, ct).ConfigureAwait(false); var preview = new GSTR1PreviewDTO { GSTIN = gstin, ReturnPeriod = returnPeriod, B2B = b2b, B2CL = b2cl, B2CS = b2cs, EXP = exp, CDNR = cdnr, HSN = hsnSection, NIL = await nilTask, Summary = await summaryTask }; return JsonSerializer.Serialize(preview); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: GetGSTR1PreviewAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", gstin, returnPeriod); throw; } } public async Task SubmitGSTOutwardAsync(SubmitGSTOutwardRequestDTO request, GSTHeaderDTO header, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.GSTIN, nameof(request.GSTIN)); await _validation.NotEmpty(request.ReturnPeriod, nameof(request.ReturnPeriod)); // GB4's CallGSTService requires a valid auth-token/sek on every Save/Submit/File call, // obtained beforehand via RequestOTPAsync/GetAuthTokenAsync — never send the GSP blank ones. if (string.IsNullOrEmpty(header?.AuthToken) || string.IsNullOrEmpty(header.Sek)) return JsonSerializer.Serialize(new { success = false, message = "Please request OTP and authenticate before submitting this return." }); header.ReturnPeriod = request.ReturnPeriod; var adapterConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(request.GSTIN, "GST_INDIA", login, ct).ConfigureAwait(false); if (adapterConfig is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.AdapterConfigNotFound }); // Build GSTR-1 preview payload var previewJson = await GetGSTR1PreviewAsync(request.GSTIN, request.ReturnPeriod, login, ct).ConfigureAwait(false); var adapter = _adapterFactory.GetAdapter(adapterConfig); // Save return to portal (creates draft on portal side) var saveResult = await adapter.SaveReturnAsync( "GSTR1", previewJson, header, adapterConfig, ct).ConfigureAwait(false); await _artifactDal.SaveAudit(new ComplianceAuditDTO { Action = "API_CALL", RequestPayload = previewJson, ResponsePayload = saveResult.RawResponse, PreviousState = "NotStarted", NewState = saveResult.IsSuccess ? "Submitted" : "NotStarted", ProviderCode = "GSTCafe", HttpStatusCode = saveResult.HttpStatusCode, ReferenceNumber = saveResult.ReferenceId, ErrorMessage = saveResult.ErrorMessage, AttemptNumber = 1, UserId = login.UserId }, login, ct).ConfigureAwait(false); if (!saveResult.IsSuccess) { _logger.LogWarning("SubmitGSTOutward failed for GSTIN {GSTIN} Period {Period}: {Error}", request.GSTIN, request.ReturnPeriod, saveResult.ErrorMessage); return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorMessage = saveResult.ErrorMessage }); } // Submit return (locks the return for filing) var submitResult = await adapter.SubmitReturnAsync( "GSTR1", header, adapterConfig, ct).ConfigureAwait(false); if (!submitResult.IsSuccess) { _logger.LogWarning("SubmitReturn failed for GSTIN {GSTIN} Period {Period}: {Error}", request.GSTIN, request.ReturnPeriod, submitResult.ErrorMessage); return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorMessage = submitResult.ErrorMessage }); } // Update filing status to 1 (Saved/Submitted) await _dal.UpdateFilingStatusAsync(request.GSTIN, request.ReturnPeriod, 1, submitResult.ReferenceId, login.UserId, login, ct).ConfigureAwait(false); // Update compliance period var (year, month) = ParseReturnPeriodYearMonth(request.ReturnPeriod); await _returnDal.UpdatePeriodStatusAsync( "GST_INDIA", request.GSTIN, "GSTR1", year, month, "Submitted", submitResult.ReferenceId, string.Empty, login.UserId, login, ct).ConfigureAwait(false); _logger.LogInformation("GSTR-1 submitted: GSTIN={GSTIN} Period={Period} RefId={RefId}", request.GSTIN, request.ReturnPeriod, submitResult.ReferenceId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.ReturnSubmitted, referenceId = submitResult.ReferenceId }); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: SubmitGSTOutwardAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}", request.GSTIN, request.ReturnPeriod); throw; } } public async Task FileGSTReturnAsync(FileGSTReturnRequestDTO request, GSTHeaderDTO header, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.GSTIN, nameof(request.GSTIN)); await _validation.NotEmpty(request.ReturnPeriod, nameof(request.ReturnPeriod)); await _validation.NotEmpty(request.EVCOtp, nameof(request.EVCOtp)); if (string.IsNullOrEmpty(header?.AuthToken) || string.IsNullOrEmpty(header.Sek)) return JsonSerializer.Serialize(new { success = false, message = "Please request OTP and authenticate before filing this return." }); header.ReturnPeriod = request.ReturnPeriod; var (year, month) = ParseReturnPeriodYearMonth(request.ReturnPeriod); var period = await _returnDal.GetCompliancePeriodAsync( "GST_INDIA", request.GSTIN, request.ReturnType, year, month, login, ct).ConfigureAwait(false); if (period is null || period.Status != "Submitted") return JsonSerializer.Serialize(new { success = false, message = "Return must be submitted before filing." }); var adapterConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(request.GSTIN, "GST_INDIA", login, ct).ConfigureAwait(false); if (adapterConfig is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.AdapterConfigNotFound }); var adapter = _adapterFactory.GetAdapter(adapterConfig); header.EvcOTP = request.EVCOtp; var fileResult = await adapter.FileReturnAsync( request.ReturnType, header, adapterConfig, ct).ConfigureAwait(false); await _artifactDal.SaveAudit(new ComplianceAuditDTO { Action = "API_CALL", RequestPayload = JsonSerializer.Serialize(new { request.GSTIN, request.ReturnPeriod, request.ReturnType }), ResponsePayload = fileResult.RawResponse, PreviousState = "Submitted", NewState = fileResult.IsSuccess ? "Filed" : "Submitted", ProviderCode = "GSTCafe", HttpStatusCode = fileResult.HttpStatusCode, ReferenceNumber = fileResult.ReferenceId, ErrorMessage = fileResult.ErrorMessage, AttemptNumber = 1, UserId = login.UserId }, login, ct).ConfigureAwait(false); if (!fileResult.IsSuccess) { _logger.LogWarning("FileGSTReturn failed for GSTIN {GSTIN} Period {Period}: {Error}", request.GSTIN, request.ReturnPeriod, fileResult.ErrorMessage); return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorMessage = fileResult.ErrorMessage }); } // Update filing status to 2 (Filed) and period to Filed await _dal.UpdateFilingStatusAsync(request.GSTIN, request.ReturnPeriod, 2, fileResult.ReferenceId, login.UserId, login, ct).ConfigureAwait(false); await _returnDal.UpdatePeriodStatusAsync( "GST_INDIA", request.GSTIN, request.ReturnType, year, month, "Filed", string.Empty, fileResult.ReferenceId, login.UserId, login, ct).ConfigureAwait(false); _logger.LogInformation("GST Return filed: GSTIN={GSTIN} Period={Period} ReturnType={ReturnType} AckNumber={AckNumber}", request.GSTIN, request.ReturnPeriod, request.ReturnType, fileResult.ReferenceId); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.ReturnFiled, ackNumber = fileResult.ReferenceId }); } catch (Exception ex) { _logger.LogError(ex, "GST Outward: FileGSTReturnAsync failed for GSTIN {GSTIN}, ReturnPeriod {ReturnPeriod}, ReturnType {ReturnType}", request.GSTIN, request.ReturnPeriod, request.ReturnType); throw; } } // ── Private helpers ─────────────────────────────────────────────────────── private static string DetermineInvoiceType(ComplianceDAL.DTO.CanonicalInvoice.CanonicalInvoiceDTO canonical) { if (canonical.DocumentType is "CreditNote" or "DebitNote") return string.IsNullOrWhiteSpace(canonical.Receiver.GSTIN) ? "CDNUR" : "CDNR"; if (canonical.IsExport) return "EXP"; if (canonical.SupplyType is "NIL" or "EXEMPT" or "NONGST") return "NIL"; if (string.IsNullOrWhiteSpace(canonical.Receiver.GSTIN) || canonical.Receiver.GSTIN == "URP") return canonical.TotalInvoiceValue > 250000m ? "B2CL" : "B2CS"; return "B2B"; } private static (decimal IGSTRate, decimal CGSTRate, decimal SGSTRate, decimal CessRate, string HSNCode, string UOM, decimal Quantity) GetPredominantRate( ComplianceDAL.DTO.CanonicalInvoice.CanonicalInvoiceDTO canonical) { var first = canonical.Lines.FirstOrDefault(); return first is null ? (0, 0, 0, 0, string.Empty, string.Empty, 0) : (first.IGSTRate, first.CGSTRate, first.SGSTRate, first.CessRate, first.HSNCode, first.UOM, first.Quantity); } private static GSTR1InvoiceDTO MapToInvoiceDTO(GSTOutwardDTO r) => new() { InvoiceNumber = r.InvoiceNumber, InvoiceDate = r.InvoiceDate, InvoiceValue = r.InvoiceValue, POS = r.POS, TaxableValue = r.TaxableValue, IGSTRate = r.IGSTRate, IGSTAmount = r.IGSTAmount, CGSTRate = r.CGSTRate, CGSTAmount = r.CGSTAmount, SGSTRate = r.SGSTRate, SGSTAmount = r.SGSTAmount, CessRate = r.CessRate, CessAmount = r.CessAmount }; private async Task> BuildHSNSectionAsync( string gstin, DateTime fromDate, DateTime toDate, LoginDTO login, CancellationToken ct) { var hsnMap = new Dictionary(StringComparer.OrdinalIgnoreCase); // Note: always OU/Company-unfiltered (0, 0) regardless of what SyncGSTOutwardAsync was called // with — if a sync was OU-scoped, this section can include invoices from OUs excluded from the // persisted TGST_OUTWARD rows, and the two won't tie out. Pre-existing limitation, not fixed // here; the real fix is to read this section from TGST_OUTWARD instead of re-querying live. await foreach (var canonical in _canonicalBuilder.BuildBatchAsync(fromDate, toDate, 0, 0, login, ct) .ConfigureAwait(false)) { ct.ThrowIfCancellationRequested(); if (!canonical.Supplier.GSTIN.Equals(gstin, StringComparison.OrdinalIgnoreCase)) continue; try { foreach (var line in canonical.Lines) { if (string.IsNullOrWhiteSpace(line.HSNCode)) continue; var key = $"{line.HSNCode}|{line.UOM}"; if (!hsnMap.TryGetValue(key, out var row)) { row = new GSTR1HSNDTO { HSNCode = line.HSNCode, UOM = line.UOM }; hsnMap[key] = row; } row.Quantity += line.Quantity; row.TotalValue += line.Value; row.TaxableValue += line.TaxableValue; row.IGSTAmount += line.IGSTAmount; row.CGSTAmount += line.CGSTAmount; row.SGSTAmount += line.SGSTAmount; row.CessAmount += line.CessAmount; } } catch (Exception itemEx) when (itemEx is not OperationCanceledException) { // A single bad document must not abort HSN aggregation for the rest of the batch. _logger.LogError(itemEx, "GST Outward: BuildHSNSectionAsync failed to process DocumentId {DocumentId} for GSTIN {GSTIN}", canonical.DocumentId, gstin); } } return [.. hsnMap.Values.OrderBy(h => h.HSNCode)]; } private static (DateTime fromDate, DateTime toDate) ParseReturnPeriod(string returnPeriod) => ReturnPeriodHelper.ParseReturnPeriod(returnPeriod); private static (int year, int month) ParseReturnPeriodYearMonth(string returnPeriod) => ReturnPeriodHelper.ParseReturnPeriodYearMonth(returnPeriod); }