using System.Text.Json; using ComplianceBLL.Adapter; using ComplianceBLL.CanonicalBuilder; using ComplianceBLL.Common; using ComplianceDAL.CustomCode.AdapterConfig; using ComplianceDAL.CustomCode.GSTInward; using ComplianceDAL.CustomCode.GSTRecon; using ComplianceDAL.DTO.GSTInward; using ComplianceDAL.DTO.GSTReturn; using GB5Shared.DTO.Framework.Login; using GB5Shared.Resource.GB5Solution; using GB5Shared.Validation; using Microsoft.Extensions.Logging; namespace ComplianceBLL.GSTInward; public class GSTInwardBLL : IGSTInwardBLL { private readonly IGSTInwardDAL _inwardDal; private readonly IGSTReconDAL _reconDal; private readonly IAdapterConfigDAL _adapterConfigDal; private readonly IComplianceAdapterFactory _adapterFactory; private readonly ICanonicalBuilderBLL _canonicalBuilder; private readonly IValidation _validation; private readonly ILogger _logger; public GSTInwardBLL( IGSTInwardDAL inwardDal, IGSTReconDAL reconDal, IAdapterConfigDAL adapterConfigDal, IComplianceAdapterFactory adapterFactory, ICanonicalBuilderBLL canonicalBuilder, IValidation validation, ILogger logger) { _inwardDal = inwardDal; _reconDal = reconDal; _adapterConfigDal = adapterConfigDal; _adapterFactory = adapterFactory; _canonicalBuilder = canonicalBuilder; _validation = validation; _logger = logger; } public async Task GetGSTInwardAsync(int inwardId, LoginDTO login, CancellationToken ct) { try { var dto = await _inwardDal.GetInwardAsync(inwardId, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(dto); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: GetGSTInwardAsync failed for InwardId {InwardId}", inwardId); throw; } } public async Task GetGSTInwardListAsync(GSTInwardFilterDTO filter, LoginDTO login, CancellationToken ct) { try { var list = await _inwardDal.GetInwardListAsync(filter, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: GetGSTInwardListAsync failed for OurGSTIN {OurGSTIN}, ReturnPeriod {ReturnPeriod}", filter.OurGSTIN, filter.ReturnPeriod); throw; } } public async Task SyncFromPortalAsync(SyncFromPortalRequestDTO request, GSTHeaderDTO header, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.OurGSTIN, nameof(request.OurGSTIN)); await _validation.NotEmpty(request.ReturnPeriod, nameof(request.ReturnPeriod)); if (string.IsNullOrEmpty(header?.AuthToken) || string.IsNullOrEmpty(header.Sek)) return JsonSerializer.Serialize(new { success = false, message = "Please request OTP and authenticate before syncing inward data." }); header.ReturnPeriod = request.ReturnPeriod; var adapterConfig = await _adapterConfigDal.GetAdapterConfigByGSTIN(request.OurGSTIN, "GST_INDIA", login, ct).ConfigureAwait(false); if (adapterConfig is null) return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.AdapterConfigNotFound }); // The FE's GSTHeader doesn't carry UserName — pull it from the resolved adapter config // (MGST username), same as GSTValidateBLL does when it first builds the header. header.UserName = adapterConfig.GSTNUserId; if (request.ForceResync) await _inwardDal.DeleteByPeriodAsync(request.OurGSTIN, request.ReturnPeriod, request.Source, login, ct).ConfigureAwait(false); // action: "B2B" for 2A, "B2BCS" for 2B — GSTCafe uses action param to distinguish var action = request.Source == "2A" ? "B2B" : "B2BCS"; var adapter = _adapterFactory.GetAdapter(adapterConfig); var result = await adapter.FetchInwardDataAsync(action, header, adapterConfig, ct).ConfigureAwait(false); if (!result.IsSuccess) { _logger.LogWarning("SyncFromPortal failed for GSTIN {GSTIN} Period {Period}: {Error}", request.OurGSTIN, request.ReturnPeriod, result.ErrorMessage); return JsonSerializer.Serialize(new { success = false, message = ComplianceResource.PortalError, errorMessage = result.ErrorMessage }); } var records = ParsePortalInwardResponse(result.RawResponse, request.OurGSTIN, request.ReturnPeriod, request.Source); var imported = 0; foreach (var record in records) { ct.ThrowIfCancellationRequested(); record.ClientId = login.ClientId; record.DatabaseName = login.DatabaseName; record.CreatedById = login.UserId; await _inwardDal.UpsertInwardAsync(record, login, ct).ConfigureAwait(false); imported++; } _logger.LogInformation("SyncFromPortal: GSTIN={GSTIN} Period={Period} Source={Source} Imported={Count}", request.OurGSTIN, request.ReturnPeriod, request.Source, imported); return JsonSerializer.Serialize(new { success = true, imported }); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: SyncFromPortalAsync failed for OurGSTIN {OurGSTIN}, ReturnPeriod {ReturnPeriod}", request.OurGSTIN, request.ReturnPeriod); throw; } } public async Task ImportGSTInwardAsync(ImportGSTInwardRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotNull(request, nameof(request)); if (request.Records is not { Count: > 0 }) return JsonSerializer.Serialize(new { success = false, message = "No records provided." }); var imported = 0; foreach (var rec in request.Records) { ct.ThrowIfCancellationRequested(); var dto = new GSTInwardDTO { SupplierGSTIN = rec.SupplierGSTIN, SupplierName = rec.SupplierName, OurGSTIN = rec.OurGSTIN, ReturnPeriod = rec.ReturnPeriod, InvoiceNumber = rec.InvoiceNumber, InvoiceDate = rec.InvoiceDate, InvoiceValue = rec.InvoiceValue, TaxableValue = rec.TaxableValue, IGSTAmount = rec.IGSTAmount, CGSTAmount = rec.CGSTAmount, SGSTAmount = rec.SGSTAmount, CessAmount = rec.CessAmount, ITCEligible = rec.ITCEligible, Source = rec.Source, ClientId = login.ClientId, DatabaseName = login.DatabaseName, CreatedById = login.UserId }; await _inwardDal.UpsertInwardAsync(dto, login, ct).ConfigureAwait(false); imported++; } _logger.LogInformation("ImportGSTInward: Imported {Count} records", imported); return JsonSerializer.Serialize(new { success = true, imported }); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: ImportGSTInwardAsync failed"); throw; } } // Reads purchase documents straight from the ERP for OU/GSTIN/Company/Period — no FE round-trip, // no pre-fetch-and-resend of already-built rows (unlike ImportGSTInwardAsync above, which exists // for the CSV/manual-import path and stays as-is). Mirrors GSTOutwardBLL.SyncGSTOutwardAsync's // shape; does NOT route through ImportGSTInwardAsync — that method has no SourceDocumentId field, // so reusing it here would silently break RunReconciliationAsync (every ERP-generated row would // read back with no MatchedDocumentId and misclassify as "MissingInERP"). public async Task GenerateGSTInwardFromERPAsync(GenerateGSTInwardBatchRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.OurGSTIN, nameof(request.OurGSTIN)); 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) = ReturnPeriodHelper.ParseReturnPeriod(request.ReturnPeriod); if (request.ForceResync) await _inwardDal.DeleteByPeriodAsync(request.OurGSTIN, request.ReturnPeriod, "ERP", login, ct).ConfigureAwait(false); int processed = 0; int skipped = 0; await foreach (var canonical in _canonicalBuilder.BuildPurchaseBatchAsync(fromDate, toDate, request.OUId, request.CompanyId, login, ct) .ConfigureAwait(false)) { ct.ThrowIfCancellationRequested(); if (!canonical.Receiver.GSTIN.Equals(request.OurGSTIN, StringComparison.OrdinalIgnoreCase)) { skipped++; continue; } var dto = new GSTInwardDTO { SupplierGSTIN = canonical.Supplier.GSTIN, SupplierName = canonical.Supplier.LegalName, OurGSTIN = request.OurGSTIN, ReturnPeriod = request.ReturnPeriod, SourceDocumentId = canonical.DocumentId, InvoiceNumber = canonical.DocumentNumber, // supplier's own invoice number (PARTYREFERENCENUMBER) InvoiceDate = canonical.DocumentDate, InvoiceValue = canonical.TotalInvoiceValue, TaxableValue = canonical.TotalTaxableValue, IGSTAmount = canonical.TotalIGSTAmount, CGSTAmount = canonical.TotalCGSTAmount, SGSTAmount = canonical.TotalSGSTAmount, CessAmount = canonical.TotalCessAmount, ITCEligible = true, Source = "ERP", ClientId = login.ClientId, DatabaseName = login.DatabaseName, CreatedById = login.UserId }; await _inwardDal.UpsertInwardAsync(dto, login, ct).ConfigureAwait(false); processed++; } _logger.LogInformation("GenerateGSTInwardFromERP: OurGSTIN={GSTIN} Period={Period} OUId={OUId} CompanyId={CompanyId} Processed={Processed} Skipped={Skipped}", request.OurGSTIN, request.ReturnPeriod, request.OUId, request.CompanyId, processed, skipped); return JsonSerializer.Serialize(new { success = true, processed, skipped }); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: GenerateGSTInwardFromERPAsync failed for OurGSTIN {OurGSTIN}, ReturnPeriod {ReturnPeriod}", request.OurGSTIN, request.ReturnPeriod); throw; } } public async Task RunReconciliationAsync(RunReconciliationRequestDTO request, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(request.OurGSTIN, nameof(request.OurGSTIN)); await _validation.NotEmpty(request.ReturnPeriod, nameof(request.ReturnPeriod)); // Clear previous non-resolved recon records for this period/type before re-run await _reconDal.DeleteByPeriodAsync(request.OurGSTIN, request.ReturnPeriod, request.ReconType, login, ct).ConfigureAwait(false); var filter = new GSTInwardFilterDTO { OurGSTIN = request.OurGSTIN, ReturnPeriod = request.ReturnPeriod, Source = request.ReconType == "InwardVs2B" ? "2B" : "2A", Page = 1, PageSize = 5000 }; var inwardRecords = await _inwardDal.GetInwardListAsync(filter, login, ct).ConfigureAwait(false); int matched = 0; int missingInERP = 0; foreach (var inward in inwardRecords) { ct.ThrowIfCancellationRequested(); var status = inward.MatchedDocumentId.HasValue ? "Matched" : "MissingInERP"; if (status == "Matched") matched++; else missingInERP++; var recon = new GSTReconDTO { ReconType = request.ReconType, Period = request.ReturnPeriod, OurGSTIN = request.OurGSTIN, InwardId = inward.InwardId, Status = status, DiffTaxableValue = 0, DiffIGST = 0, DiffCGST = 0, DiffSGST = 0, DiffCess = 0, ClientId = login.ClientId, DatabaseName = login.DatabaseName, CreatedById = login.UserId }; await _reconDal.UpsertReconInwardAsync(recon, login, ct).ConfigureAwait(false); } _logger.LogInformation("RunReconciliation: GSTIN={GSTIN} Period={Period} Matched={M} MissingInERP={ME}", request.OurGSTIN, request.ReturnPeriod, matched, missingInERP); return JsonSerializer.Serialize(new { success = true, matched, missingInERP, total = matched + missingInERP }); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: RunReconciliationAsync failed for OurGSTIN {OurGSTIN}, ReturnPeriod {ReturnPeriod}", request.OurGSTIN, request.ReturnPeriod); throw; } } public async Task GetGSTInwardReconciliationAsync(GSTReconFilterDTO filter, LoginDTO login, CancellationToken ct) { try { var list = await _reconDal.GetReconListAsync(filter, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(list); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: GetGSTInwardReconciliationAsync failed for OurGSTIN {OurGSTIN}, Period {Period}", filter.OurGSTIN, filter.Period); throw; } } public async Task GetReconSummaryAsync(string ourGSTIN, string period, string reconType, LoginDTO login, CancellationToken ct) { try { var summary = await _reconDal.GetReconSummaryAsync(ourGSTIN, period, reconType, login, ct).ConfigureAwait(false); return JsonSerializer.Serialize(summary); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: GetReconSummaryAsync failed for OurGSTIN {OurGSTIN}, Period {Period}, ReconType {ReconType}", ourGSTIN, period, reconType); throw; } } public async Task SaveReconResolutionAsync(ReconResolutionDTO resolution, LoginDTO login, CancellationToken ct) { try { await _validation.NotEmpty(resolution.Status, nameof(resolution.Status)); // If user is matching to an ERP document, update the inward record's MatchedDocumentId if (resolution.MatchedDocumentId.HasValue) { var recon = await _reconDal.GetReconAsync(resolution.ReconId, login, ct).ConfigureAwait(false); if (recon?.InwardId.HasValue == true) { await _inwardDal.UpdateMatchAsync( recon.InwardId.Value, resolution.MatchedDocumentId, resolution.MatchedEntityType, "Accepted", login.UserId, login, ct).ConfigureAwait(false); } } var rows = await _reconDal.ResolveReconAsync( resolution.ReconId, resolution.Status, resolution.Notes, login.UserId, login, ct).ConfigureAwait(false); if (rows == 0) return JsonSerializer.Serialize(new { success = false, message = "Reconciliation record not found or already resolved." }); _logger.LogInformation("ReconResolution: ReconId={ReconId} Status={Status}", resolution.ReconId, resolution.Status); return JsonSerializer.Serialize(new { success = true, message = ComplianceResource.SavedSuccessfully }); } catch (Exception ex) { _logger.LogError(ex, "GST Inward: SaveReconResolutionAsync failed for ReconId {ReconId}", resolution.ReconId); throw; } } // ── Private helpers ─────────────────────────────────────────────────────── private static List ParsePortalInwardResponse(string rawResponse, string ourGSTIN, string returnPeriod, string source) { var records = new List(); try { using var doc = JsonDocument.Parse(rawResponse); var root = doc.RootElement; // Portal response has data.b2b (array of suppliers, each with inv array) if (!root.TryGetProperty("data", out var data)) return records; if (!data.TryGetProperty("b2b", out var b2bArray)) return records; foreach (var supplier in b2bArray.EnumerateArray()) { var supplierGSTIN = supplier.TryGetProperty("ctin", out var c) ? c.GetString() ?? string.Empty : string.Empty; var supplierName = supplier.TryGetProperty("trdnm", out var t) ? t.GetString() ?? string.Empty : string.Empty; if (!supplier.TryGetProperty("inv", out var invArray)) continue; foreach (var inv in invArray.EnumerateArray()) { var invoiceNumber = inv.TryGetProperty("inum", out var n) ? n.GetString() ?? string.Empty : string.Empty; var invDateStr = inv.TryGetProperty("idt", out var d) ? d.GetString() : null; var invoiceValue = inv.TryGetProperty("val", out var v) ? v.GetDecimal() : 0m; var itcEligible = !inv.TryGetProperty("itcElg", out var itc) || itc.GetString() != "ineligible"; decimal taxableValue = 0, igst = 0, cgst = 0, sgst = 0, cess = 0; if (inv.TryGetProperty("itms", out var items)) { foreach (var item in items.EnumerateArray()) { if (!item.TryGetProperty("itm_det", out var det)) continue; taxableValue += det.TryGetProperty("txval", out var tv) ? tv.GetDecimal() : 0; igst += det.TryGetProperty("iamt", out var ia) ? ia.GetDecimal() : 0; cgst += det.TryGetProperty("camt", out var ca) ? ca.GetDecimal() : 0; sgst += det.TryGetProperty("samt", out var sa) ? sa.GetDecimal() : 0; cess += det.TryGetProperty("csamt", out var cs) ? cs.GetDecimal() : 0; } } records.Add(new GSTInwardDTO { SupplierGSTIN = supplierGSTIN, SupplierName = supplierName, OurGSTIN = ourGSTIN, ReturnPeriod = returnPeriod, InvoiceNumber = invoiceNumber, InvoiceDate = DateTime.TryParseExact(invDateStr, "dd-MM-yyyy", null, System.Globalization.DateTimeStyles.None, out var dt) ? dt : null, InvoiceValue = invoiceValue, TaxableValue = taxableValue, IGSTAmount = igst, CGSTAmount = cgst, SGSTAmount = sgst, CessAmount = cess, ITCEligible = itcEligible, Source = source }); } } } catch (Exception) { // Return partial records on parse errors; caller logs the raw response via audit } return records; } }