using ComplianceDAL.DTO.AdapterConfig; using ComplianceDAL.DTO.GSTEInvoice; using ComplianceDAL.DTO.GSTReturn; using Microsoft.AspNetCore.DataProtection; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using System.IO; using System.Net.Http.Headers; using System.Security.Cryptography; using System.Text; using System.Text.Json; namespace ComplianceBLL.Adapter; // GSTCafe (Pinnacle) GSP adapter. // Ported from legacy GSTCafeServiceBLL.cs — WebClient replaced with IHttpClientFactory, // all synchronous I/O replaced with async/await. public class GSTCafeAdapter : IComplianceAdapter { private readonly IHttpClientFactory _httpFactory; private readonly ILogger _logger; private readonly IConfiguration _config; // GSTN's RSA public key (SPKI, base64, no PEM header/footer) — per the GSTN Authentication API // spec ("Overview.pdf"): the app-key is a temporary key the Tax Payer system itself generates // fresh on every OTP-request call, exchanged with GSTN by RSA-encrypting it with this public key // ("no intermediary — including the GSP — is able to decrypt it"). Not the same thing as GB4's // old GSTCafeServiceBLL, which sent a single fixed app_key literal unencrypted — that was either // a GSTCafe-proprietary shortcut or predates this spec; this follows the documented GSTN flow. private const string GstPublicKeyBase64 = "MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAjsp2Omto9s9U926yp/YG" + "ao3MDaFv9rr/tB9n8qTxiyHqV1i5RfydpoEIMe0jgmgtUEIY4Vyl4TXhCwSr5/bL" + "ey/uK5j5kN6ng28fECed1SI46i4DFLC+DPg2nad4NF0poRs+JAt8owEdfuWwZKeT" + "LuAOlxO4L3fbhAKqjEHsfIxwotcUfLoAKqv8g+y9cA4Rd1VlfAZRRauFEgzUOozu" + "658Q9qHU3QVeaUJ/JFPVmJCzwZlMA3GbNvYwZl9MQMseHvwambRMi5Q5rIT/m1Mi" + "EyUzBZeqHsWGDbM2o5ZWZwYIzj7ztLYvv7s9OywPFn3RknR8l9+eng1WIOfE3u2e" + "AwIDAQAB"; public GSTCafeAdapter(IHttpClientFactory httpFactory, ILogger logger, IConfiguration config) { _httpFactory = httpFactory; _logger = logger; _config = config; } // ── Auth ───────────────────────────────────────────────────────────────── // Step 1 of the spec's 4-element handshake: generates a fresh, per-session app-key (a random // AES-256 key — the spec's "temporary key... generated by the Tax payer system during the // initial OTP request call"), sends it RSA-encrypted with GSTN's public key, and surfaces the // raw app-key back to the caller via AdapterAuthResult.AppKey. The caller MUST echo it back as // header.AppKey on the following GetAuthTokenAsync call — it's needed there to encrypt the OTP // and to decrypt the returned session-encryption-key (sek). public async Task RequestOTPAsync( GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { var appKeyBytes = RandomNumberGenerator.GetBytes(32); var encryptedAppKey = EncryptWithGstPublicKey(appKeyBytes); // Built as a raw template + Replace (matches GB4's PostBody.Replace(":@:username", ...)) // rather than JsonSerializer.Serialize — serializing a base64 value can re-encode // characters (e.g. "+") and corrupt it. var body = $@"{{ ""action"": ""OTPREQUEST"", ""username"": "":@:username"", ""app_key"": ""{encryptedAppKey}"" }}"; body = body.Replace(":@:username", header.UserName); var url = $"{config.GSTUrl}/v1.0/authenticate"; var raw = await CallAsync(HttpMethod.Post, url, body, header, config, isAuth: true, ct); var result = ParseAuthResult(raw); result.AppKey = Convert.ToBase64String(appKeyBytes); return result; } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: RequestOTPAsync failed for GSTIN {GSTIN}", header.GSTIN); throw; } } // Step 2: encrypts the OTP with the same app-key established in RequestOTPAsync (spec: // "otp=encrypt(otp, app-key)"), sends the same RSA-encrypted app_key again, and — on success — // decrypts the returned sek using the app-key (spec: "SEK=encrypt(EK, app-key)") so // AdapterAuthResult.Sek carries the actual usable session-encryption-key, not the still-wrapped // value straight off the wire. public async Task GetAuthTokenAsync( string otp, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { if (string.IsNullOrEmpty(header.AppKey)) return new AdapterAuthResult { IsSuccess = false, ErrorMessage = "Missing app-key — call RequestOTPAsync first and echo AdapterAuthResult.AppKey back as header.AppKey." }; var appKeyBytes = Convert.FromBase64String(header.AppKey); var encryptedAppKey = EncryptWithGstPublicKey(appKeyBytes); var encryptedOtp = Convert.ToBase64String(AesCryptoHelper.AES256Encrypt(Encoding.UTF8.GetBytes(otp), appKeyBytes)); var body = $@"{{ ""action"": ""AUTHTOKEN"", ""username"": "":@:username"", ""app_key"": ""{encryptedAppKey}"", ""otp"": ""{encryptedOtp}"" }}"; body = body.Replace(":@:username", header.UserName); var url = $"{config.GSTUrl}/v1.0/authenticate"; var raw = await CallAsync(HttpMethod.Post, url, body, header, config, isAuth: true, ct); var result = ParseAuthResult(raw); if (result.IsSuccess && !string.IsNullOrEmpty(result.Sek)) { var decryptedSek = AesCryptoHelper.AES256Decrypt(Convert.FromBase64String(result.Sek), appKeyBytes); result.Sek = Convert.ToBase64String(decryptedSek); } result.AppKey = header.AppKey; return result; } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GetAuthTokenAsync failed for GSTIN {GSTIN}", header.GSTIN); throw; } } // ── E-Invoice ───────────────────────────────────────────────────────────── public async Task GenerateEInvoiceAsync( string payloadJson, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { // No OTP/auth-token step for e-invoice — GB4's GSTCafeCallFrameDAL.GenerateIRN sends the // ASP credentials on every call. authToken/sek are unused here (kept for interface parity). var url = $"{config.EInvoiceUrl}/Invoice"; var raw = await CallEInvoiceServiceAsync(url, payloadJson, gstin, config, ct); return ParseEInvoiceResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GenerateEInvoiceAsync failed for GSTIN {GSTIN}", gstin); throw; } } public async Task CancelEInvoiceAsync( string irn, int cancelReasonType, string reason, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { var body = JsonSerializer.Serialize(new { Irn = irn, CnlRsn = cancelReasonType.ToString(), // 1=Duplicate, 2=Data Error, 3=Order Cancelled, 4=Others CnlRem = reason }); var url = $"{config.EInvoiceUrl}/cancel"; var raw = await CallEInvoiceServiceAsync(url, body, gstin, config, ct); return ParseEInvoiceResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: CancelEInvoiceAsync failed for GSTIN {GSTIN}, IRN {IRN}", gstin, irn); throw; } } // ── E-Way Bill ──────────────────────────────────────────────────────────── public async Task GenerateEWayBillAsync( string payloadJson, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { // No OTP/auth-token step for e-way — GB4's GSTCafeCallFrameDAL.GenerateEWay sends the ASP // credentials on every call. authToken/sek are unused here (kept for interface parity). var url = $"{config.EWayUrl}/GenerateEwayBill"; var raw = await CallEWayServiceAsync(url, payloadJson, gstin, config, ct); return ParseEWayCallResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GenerateEWayBillAsync failed for GSTIN {GSTIN}", gstin); throw; } } public async Task CancelEWayBillAsync( string ewbNumber, int cancelReasonType, string reason, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { var body = JsonSerializer.Serialize(new { ewbNo = ewbNumber, cancelRsnCode = cancelReasonType, cancelRmrk = reason }); var url = $"{config.EWayUrl}/CancelEwayBill"; var raw = await CallEWayServiceAsync(url, body, gstin, config, ct); return ParseEWayCallResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: CancelEWayBillAsync failed for GSTIN {GSTIN}, EwbNumber {EwbNumber}", gstin, ewbNumber); throw; } } public async Task UpdateVehicleAsync( string payloadJson, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { var url = $"{config.BaseURL}/v1.03/ewayapi/vehewb"; var header = new GSTHeaderDTO { GSTIN = gstin, AuthToken = authToken, Sek = sek }; var raw = await CallAsync(HttpMethod.Post, url, payloadJson, header, config, isAuth: false, ct); return ParseCallResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: UpdateVehicleAsync failed for GSTIN {GSTIN}", gstin); throw; } } // Ported from GB4's GSTCafeCallFrameDAL.GenerateEWayFromIRN — a fixed absolute URL, NOT built from // config.EWayUrl/Sandbox-Production like the regular e-way endpoints (GB4 keeps the old, // commented-out "GSPCallDTO.EWayUrl + /einvewb/ewaybill" line as evidence this was deliberate). // Reuses CallEWayServiceAsync — same ASP-credential headers and the same request/response log // file mechanism already built for GenerateEWayBillAsync/CancelEWayBillAsync; no separate log // mechanism is built for this endpoint. public async Task GenerateEWayBillFromIRNAsync( string payloadJson, string gstin, string authToken, string sek, AdapterConfigDTO config, CancellationToken ct) { try { const string url = "https://api.mygstcafe.com/eicore/v1.03/einvewb/ewaybill"; var raw = await CallEWayServiceAsync(url, payloadJson, gstin, config, ct); return ParseEWayFromIRNCallResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GenerateEWayBillFromIRNAsync failed for GSTIN {GSTIN}", gstin); throw; } } // ── Returns ─────────────────────────────────────────────────────────────── public async Task SaveReturnAsync( string returnType, string payloadJson, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { var version = returnType == "GSTR3B" ? "v0.3" : "v2.1"; var type = returnType == "GSTR3B" ? "gstr3b" : "gstr1"; var url = $"{config.GSTUrl}/{version}/returns/{type}/save"; var raw = await CallAsync(HttpMethod.Put, url, payloadJson, header, config, isAuth: false, ct); return ParseGSTReturnResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: SaveReturnAsync failed for GSTIN {GSTIN}, ReturnType {ReturnType}, ReturnPeriod {ReturnPeriod}", header.GSTIN, returnType, header.ReturnPeriod); throw; } } public async Task SubmitReturnAsync( string returnType, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { var version = returnType == "GSTR3B" ? "v0.3" : "v2.1"; var type = returnType == "GSTR3B" ? "gstr3b" : "gstr1"; var url = $"{config.GSTUrl}/{version}/returns/{type}/submit"; var body = JsonSerializer.Serialize(new { gstin = header.GSTIN, ret_period = header.ReturnPeriod }); var raw = await CallAsync(HttpMethod.Post, url, body, header, config, isAuth: false, ct); return ParseGSTReturnResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: SubmitReturnAsync failed for GSTIN {GSTIN}, ReturnType {ReturnType}, ReturnPeriod {ReturnPeriod}", header.GSTIN, returnType, header.ReturnPeriod); throw; } } public async Task FileReturnAsync( string returnType, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { var version = returnType == "GSTR3B" ? "v0.3" : "v2.1"; var type = returnType == "GSTR3B" ? "gstr3b" : "gstr1"; var url = $"{config.GSTUrl}/{version}/returns/{type}/file?signId={config.ClientIdAPI}&evcOtp={header.EvcOTP}"; // File body is the portal summary — caller must provide it via the save flow var raw = await CallAsync(HttpMethod.Post, url, string.Empty, header, config, isAuth: false, ct); return ParseGSTReturnResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: FileReturnAsync failed for GSTIN {GSTIN}, ReturnType {ReturnType}, ReturnPeriod {ReturnPeriod}", header.GSTIN, returnType, header.ReturnPeriod); throw; } } public async Task GetReturnStatusAsync( string referenceId, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { var url = $"{config.GSTUrl}/v1.1/returns/status?action=RETSTATUS&gstin={header.GSTIN}&ret_period={header.ReturnPeriod}&ref_id={referenceId}"; var raw = await CallAsync(HttpMethod.Get, url, string.Empty, header, config, isAuth: false, ct); return ParseGSTReturnResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GetReturnStatusAsync failed for GSTIN {GSTIN}, ReferenceId {ReferenceId}", header.GSTIN, referenceId); throw; } } public async Task FetchInwardDataAsync( string action, GSTHeaderDTO header, AdapterConfigDTO config, CancellationToken ct) { try { // action: B2B, CDN, ISD — for 2A/2B var url = $"{config.GSTUrl}/v2.1/returns/gstr2a?action={action}&ret_period={header.ReturnPeriod}&gstin={header.GSTIN}"; var raw = await CallAsync(HttpMethod.Get, url, string.Empty, header, config, isAuth: false, ct); return ParseGSTReturnResult(raw); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: FetchInwardDataAsync failed for GSTIN {GSTIN}, Action {Action}, ReturnPeriod {ReturnPeriod}", header.GSTIN, action, header.ReturnPeriod); throw; } } // ── Core HTTP call ──────────────────────────────────────────────────────── // Matches GB4's GSTCafeServiceBLL.CallGSTService header set exactly: ASP creds (APIId/APISecret/ // CustomerId), GSP creds (clientid/client-secret — GSPClientId/GSPClientSecret, not the legacy // ClientIdAPI/ClientSecret fields those headers used to read from), Username/gstin/state-cd/ // ret_period all from the GSTHeaderDTO (header.ReturnPeriod = GB4's LoginDTO.FP), sent // unconditionally (GB4 always adds ret_period, even when blank). auth-token/sek are only added // when !isAuth — same as GB4's "Type != 0 && Type != 1" check, since the OTP-request/auth-token- // exchange calls are what produce those values in the first place. private async Task<(string body, int statusCode)> CallAsync( HttpMethod method, string url, string requestBody, GSTHeaderDTO header, AdapterConfigDTO config, bool isAuth, CancellationToken ct) { try { using var client = _httpFactory.CreateClient("GSTCafe"); using var request = new HttpRequestMessage(method, url); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.TryAddWithoutValidation("APIId", config.ASPId); request.Headers.TryAddWithoutValidation("APISecret", config.ASPSecret); request.Headers.TryAddWithoutValidation("CustomerId", config.ASPClientId); request.Headers.TryAddWithoutValidation("clientid", config.GSPClientId); request.Headers.TryAddWithoutValidation("client-secret", config.GSPClientSecret); request.Headers.TryAddWithoutValidation("Username", header.UserName); request.Headers.TryAddWithoutValidation("gstin", header.GSTIN); request.Headers.TryAddWithoutValidation("ret_period", header.ReturnPeriod); request.Headers.TryAddWithoutValidation("state-cd", header.StateCode); if (!isAuth) { request.Headers.TryAddWithoutValidation("auth-token", header.AuthToken); request.Headers.TryAddWithoutValidation("sek", header.Sek); } if (!string.IsNullOrEmpty(requestBody) && (method == HttpMethod.Post || method == HttpMethod.Put)) request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); var logFile = await WriteGSTCallLogFileAsync(method, url, header, config, requestBody, isAuth, ct).ConfigureAwait(false); var response = await client.SendAsync(request, ct).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // Same log file as the request — matches GB4's CallGSTService appending "OUTPUT>>" + the // response body to the same StreamWriter it opened for the request. await AppendGSTCallOutputToLogFileAsync(logFile, body, ct).ConfigureAwait(false); _logger.LogDebug("GSTCafe response {StatusCode} length={Length}", (int)response.StatusCode, body.Length); return (body, (int)response.StatusCode); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: HTTP call failed {Method} {Url} GSTIN={GSTIN}", method, url, header.GSTIN); throw; } } // Matches GB4's CallGSTService per-call log file, same style as WriteEInvoiceCallLogFileAsync/ // WriteEWayCallLogFileAsync: LogPath/GSTReturn/.txt. Covers every GSTR call // through CallAsync (OTP/auth-token exchange, Save/Submit/File, GetReturnStatus, FetchInwardData). // Field order matches CallAsync's header-add order exactly (APIId/APISecret/CustomerId/clientid/ // client-secret/Username/gstin/ret_period/state-cd/[auth-token/sek]). APISecret/client-secret/ // auth-token/sek are redacted rather than written in plaintext — same security-driven deviation // already flagged for e-invoice/e-way (GB4 logs all of these in the clear) — auth-token/sek lines // are only present when !isAuth, same condition CallAsync uses to add those headers at all. private async Task WriteGSTCallLogFileAsync( HttpMethod method, string url, GSTHeaderDTO header, AdapterConfigDTO config, string requestBody, bool isAuth, CancellationToken ct) { try { var logPath = _config["LogPath"] ?? Path.Combine(Path.GetTempPath(), "GB5"); var dir = Path.Combine(logPath, "GSTReturn"); Directory.CreateDirectory(dir); var file = Path.Combine(dir, DateTime.Now.ToString("dd-MMM-yyyyHHmmssss") + ".txt"); var lines = new List { $"URI >> {url}", string.Empty, $"Method >> {method}", string.Empty, $"APIId >> {config.ASPId}", string.Empty, $"APISecret >> {config.ASPSecret}", string.Empty, $"CustomerId >> {config.ASPClientId}", string.Empty, $"clientid >> {config.GSPClientId}", string.Empty, $"client-secret >> {config.GSPClientSecret}", string.Empty, $"Username >> {header.UserName}", string.Empty, $"gstin >> {header.GSTIN}", string.Empty, $"ret_period >> {header.ReturnPeriod}", string.Empty, $"state-cd >> {header.StateCode}", string.Empty }; if (!isAuth) { lines.Add("auth-token >> [REDACTED]"); lines.Add(string.Empty); lines.Add("sek >> [REDACTED]"); lines.Add(string.Empty); } lines.Add($"JsonData >> {requestBody}"); lines.Add(string.Empty); await File.WriteAllLinesAsync(file, lines, ct).ConfigureAwait(false); return file; } catch (Exception ex) { // Best-effort — a log file write failure (e.g. path unwritable) must never block the actual // GSTR call, only be visible via the regular ILogger error stream. _logger.LogError(ex, "GSTCafe: GSTR call log file write failed GSTIN={GSTIN}", header.GSTIN); return string.Empty; } } // Matches GB4's "tw.WriteLine(\"OUTPUT>>\" + readstream);" — appends the raw response body to the // same log file the request was written to. private async Task AppendGSTCallOutputToLogFileAsync(string logFile, string responseBody, CancellationToken ct) { if (string.IsNullOrEmpty(logFile)) return; try { await File.AppendAllLinesAsync(logFile, [$"Output >> {responseBody}", string.Empty], ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: GSTR call log file output append failed {LogFile}", logFile); } } // ── E-Invoice HTTP call ────────────────────────────────────────────────── // Ported from GB4's GSTGovtSwitchFrameDAL/GSTCafeCallFrameDAL.CallEINVOICEService: a single POST // with the ASP credentials on every call (GSTIN/Username/Password/CustomerId/APIId/APISecret/ // Source headers) — GSTCafe's e-invoice endpoint has no separate OTP/auth-token step, unlike the // e-way/returns endpoints CallAsync above serves. private async Task<(string body, int statusCode)> CallEInvoiceServiceAsync( string url, string requestBody, string gstin, AdapterConfigDTO config, CancellationToken ct) { try { using var client = _httpFactory.CreateClient("GSTCafe"); using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.TryAddWithoutValidation("GSTIN", gstin); request.Headers.TryAddWithoutValidation("Username", config.EInvoiceUserId); request.Headers.TryAddWithoutValidation("Password", config.EInvoicePassword); request.Headers.TryAddWithoutValidation("CustomerId", config.ASPClientId); request.Headers.TryAddWithoutValidation("APIId", config.ASPId); request.Headers.TryAddWithoutValidation("APISecret", config.ASPSecret); request.Headers.TryAddWithoutValidation("Source", config.ASPSource); request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); var logFile = await WriteEInvoiceCallLogFileAsync(url, gstin, config, requestBody, ct).ConfigureAwait(false); var response = await client.SendAsync(request, ct).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // Same log file as the request — GB4's CallEINVOICEService appends "Output " + the response // body to the same tw (StreamWriter) it opened for the request. await AppendEInvoiceCallOutputToLogFileAsync(logFile, body, ct).ConfigureAwait(false); _logger.LogDebug("GSTCafe e-invoice response {StatusCode} length={Length}", (int)response.StatusCode, body.Length); return (body, (int)response.StatusCode); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: e-invoice HTTP call failed {Url} GSTIN={GSTIN}", url, gstin); throw; } } // Matches GB4's CallEINVOICEService per-call log file exactly: LogPath/EinvoiceFiles/.txt, // same fields written (URI/GSTIN/Username/CustomerId/APIId/Source/JsonData). Password/APISecret are // redacted rather than written in plaintext — same security-driven deviation already flagged for e-way. // Returns the file path (empty on failure) so the response "Output" line can be appended to the // SAME file afterward, matching GB4's single tw (StreamWriter) spanning the whole call. private async Task WriteEInvoiceCallLogFileAsync(string url, string gstin, AdapterConfigDTO config, string requestBody, CancellationToken ct) { try { var logPath = _config["LogPath"] ?? Path.Combine(Path.GetTempPath(), "GB5"); var dir = Path.Combine(logPath, "EinvoiceFiles"); Directory.CreateDirectory(dir); var file = Path.Combine(dir, DateTime.Now.ToString("dd-MMM-yyyyHHmmssss") + ".txt"); var lines = new[] { $"URI >> {url}", string.Empty, $"GSTIN >> {gstin}", string.Empty, $"Username >> {config.EInvoiceUserId}", string.Empty, $"CustomerId >> {config.ASPClientId}", string.Empty, $"APIId >> {config.ASPId}", string.Empty, $"Source >> {config.ASPSource}", string.Empty, $"JsonData >> {requestBody}", string.Empty }; await File.WriteAllLinesAsync(file, lines, ct).ConfigureAwait(false); return file; } catch (Exception ex) { // Best-effort — a log file write failure (e.g. path unwritable) must never block the actual // e-invoice call, only be visible via the regular ILogger error stream. _logger.LogError(ex, "GSTCafe: e-invoice call log file write failed GSTIN={GSTIN}", gstin); return string.Empty; } } // Matches GB4's "tw.WriteLine(\"Output \" + readstream);" — appends the raw response body to the // same log file the request was written to. private async Task AppendEInvoiceCallOutputToLogFileAsync(string logFile, string responseBody, CancellationToken ct) { if (string.IsNullOrEmpty(logFile)) return; try { await File.AppendAllLinesAsync(logFile, [$"Output >> {responseBody}", string.Empty], ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: e-invoice call log file output append failed {LogFile}", logFile); } } // ── E-Way Bill HTTP call ───────────────────────────────────────────────── // Ported from GB4's GSTCafeCallFrameDAL.CallEWayService: same ASP-credential header set as // e-invoice, plus an "Environment-type" header GB4 sends only for e-way. GB4's sandbox branch // (EWayEnvironment != 1) substituted a hardcoded test GSTIN/username/password — that is a leaked // test credential specific to one GB4 client's GSP sandbox account, not a generic default, so it is // NOT ported here; config's own EWayUserId/EWayPassword are used in both environments. private async Task<(string body, int statusCode)> CallEWayServiceAsync( string url, string requestBody, string gstin, AdapterConfigDTO config, CancellationToken ct) { try { using var client = _httpFactory.CreateClient("GSTCafe"); using var request = new HttpRequestMessage(HttpMethod.Post, url); request.Headers.Accept.Add(new MediaTypeWithQualityHeaderValue("application/json")); request.Headers.TryAddWithoutValidation("GSTIN", gstin); request.Headers.TryAddWithoutValidation("Username", config.EWayUserId); request.Headers.TryAddWithoutValidation("Password", config.EWayPassword); request.Headers.TryAddWithoutValidation("CustomerId", config.ASPClientId); request.Headers.TryAddWithoutValidation("APIId", config.ASPId); request.Headers.TryAddWithoutValidation("APISecret", config.ASPSecret); request.Headers.TryAddWithoutValidation("Source", config.ASPSource); request.Headers.TryAddWithoutValidation("Environment-type", config.EWayEnvironment == 1 ? "Production" : "Sandbox"); request.Content = new StringContent(requestBody, Encoding.UTF8, "application/json"); var logFile = await WriteEWayCallLogFileAsync(url, gstin, config, requestBody, ct).ConfigureAwait(false); var response = await client.SendAsync(request, ct).ConfigureAwait(false); var body = await response.Content.ReadAsStringAsync(ct).ConfigureAwait(false); // Same log file as the request — GB4's CallEWayService appends "Output " + the response // body to the same tw (StreamWriter) it opened for the request. await AppendEWayCallOutputToLogFileAsync(logFile, body, ct).ConfigureAwait(false); _logger.LogDebug("GSTCafe e-way response {StatusCode} length={Length}", (int)response.StatusCode, body.Length); return (body, (int)response.StatusCode); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: e-way HTTP call failed {Url} GSTIN={GSTIN}", url, gstin); throw; } } // Matches GB4's CallEWayService per-call log file exactly: LogPath/EWAYBill/.txt, // same fields written (URI/GSTIN/Username/CustomerId/APIId/Source/JsonData). "LogPath" comes from // IConfiguration (GB5's equivalent of GB4's Common.ReadValueFromCommonConfig("LogPath")), falling // back to the temp folder if unset. Password/APISecret are redacted rather than written in plaintext — // same security-driven deviation already flagged for the ILogger-based e-way logging. // Returns the file path (empty on failure) so the response "Output" line can be appended to the // SAME file afterward, matching GB4's single tw (StreamWriter) spanning the whole call. private async Task WriteEWayCallLogFileAsync(string url, string gstin, AdapterConfigDTO config, string requestBody, CancellationToken ct) { try { var logPath = _config["LogPath"] ?? Path.Combine(Path.GetTempPath(), "GB5"); var dir = Path.Combine(logPath, "EWAYBill"); Directory.CreateDirectory(dir); var file = Path.Combine(dir, DateTime.Now.ToString("dd-MMM-yyyyHHmmssss") + ".txt"); var lines = new[] { $"URI >> {url}", string.Empty, $"GSTIN >> {gstin}", string.Empty, $"Username >> {config.EWayUserId}", string.Empty, $"CustomerId >> {config.ASPClientId}", string.Empty, $"APIId >> {config.ASPId}", string.Empty, $"Source >> {config.ASPSource}", string.Empty, $"JsonData >> {requestBody}", string.Empty }; await File.WriteAllLinesAsync(file, lines, ct).ConfigureAwait(false); return file; } catch (Exception ex) { // Best-effort — a log file write failure (e.g. path unwritable) must never block the actual // e-way call, only be visible via the regular ILogger error stream. _logger.LogError(ex, "GSTCafe: e-way call log file write failed GSTIN={GSTIN}", gstin); return string.Empty; } } // Matches GB4's "tw.WriteLine(\"Output \" + readstream);" — appends the raw response body to the // same log file the request was written to. private async Task AppendEWayCallOutputToLogFileAsync(string logFile, string responseBody, CancellationToken ct) { if (string.IsNullOrEmpty(logFile)) return; try { await File.AppendAllLinesAsync(logFile, [$"Output >> {responseBody}", string.Empty], ct).ConfigureAwait(false); } catch (Exception ex) { _logger.LogError(ex, "GSTCafe: e-way call log file output append failed {LogFile}", logFile); } } // ── Crypto ─────────────────────────────────────────────────────────────── // RSA/PKCS1 — the padding scheme GSTN's Authentication API spec uses to wrap the app-key with // the public key it hands out ("app_key=encrypt(, GST public key)"). private static string EncryptWithGstPublicKey(byte[] data) { using var rsa = RSA.Create(); rsa.ImportSubjectPublicKeyInfo(Convert.FromBase64String(GstPublicKeyBase64), out _); return Convert.ToBase64String(rsa.Encrypt(data, RSAEncryptionPadding.Pkcs1)); } // ── Response parsers ────────────────────────────────────────────────────── // Matches GB4's GSTResponseDTO shape exactly: status_cd/auth_token/sek at the top level, but errors // nest under an "error" object (message/error_cd/code/desc) — not flat "message"/"errorCode" fields. private static AdapterAuthResult ParseAuthResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var statusCd = root.TryGetProperty("status_cd", out var sc) ? sc.GetString() : "0"; if (statusCd == "1") { return new AdapterAuthResult { IsSuccess = true, AuthToken = root.TryGetProperty("auth_token", out var at) ? at.GetString() ?? string.Empty : string.Empty, Sek = root.TryGetProperty("sek", out var sk) ? sk.GetString() ?? string.Empty : string.Empty, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } var (errCode, errMsg) = ExtractGSTError(root, raw.body); return new AdapterAuthResult { IsSuccess = false, ErrorCode = errCode, ErrorMessage = errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new AdapterAuthResult { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } // Shared by ParseAuthResult/ParseGSTReturnResult — GB4's GSTErrorDTO (message/error_cd/code/desc). private static (string errorCode, string errorMessage) ExtractGSTError(JsonElement root, string fallbackBody) { if (!root.TryGetProperty("error", out var err) || err.ValueKind != JsonValueKind.Object) return (string.Empty, fallbackBody); var message = err.TryGetProperty("message", out var m) ? m.GetString() ?? string.Empty : string.Empty; if (string.IsNullOrEmpty(message)) message = err.TryGetProperty("desc", out var d) ? d.GetString() ?? string.Empty : string.Empty; var code = err.TryGetProperty("error_cd", out var ec) ? ec.GetString() ?? string.Empty : string.Empty; if (string.IsNullOrEmpty(code)) code = err.TryGetProperty("code", out var c) ? c.GetString() ?? string.Empty : string.Empty; return (code, message); } // Matches GB4's GSTResponseDTO shape used by every SaveGSTR1/SubmitGSTR1/GetGSTR1Summary/ // FileGSTR1/GetReturnStatus/GetB2BInvoice call (and the GSTR3B equivalents): status_cd/ // referenceid at the top level, errors nested under "error" (see ExtractGSTError above) — not // ParseCallResult's flat "message"/"errorCode" shape (that one's for the OTP/returns-analytics // endpoints ParseCallResult already served correctly before the Returns family existed). private static AdapterCallResult ParseGSTReturnResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var statusCd = root.TryGetProperty("status_cd", out var sc) ? sc.GetString() ?? string.Empty : string.Empty; var refId = root.TryGetProperty("referenceid", out var ri) ? ri.GetString() ?? string.Empty : root.TryGetProperty("reference_id", out var ri2) ? ri2.GetString() ?? string.Empty : string.Empty; var (errCode, errMsg) = ExtractGSTError(root, string.Empty); return new AdapterCallResult { IsSuccess = statusCd == "1", ReferenceId = refId, StatusCode = statusCd, Body = raw.body, ErrorCode = errCode, ErrorMessage = errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new AdapterCallResult { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } private static EInvoicePortalResponseDTO ParseEInvoiceResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var statusCd = root.TryGetProperty("status_cd", out var sc) ? sc.GetString() : "0"; if (statusCd == "1" && root.TryGetProperty("data", out var data)) { // GSTCafe wraps the IRP response in data.EwbDtls or directly var irn = data.TryGetProperty("Irn", out var i) ? i.GetString() ?? string.Empty : string.Empty; var qr = data.TryGetProperty("SignedQRCode", out var q) ? q.GetString() ?? string.Empty : string.Empty; var sign = data.TryGetProperty("SignedInvoice", out var si) ? si.GetString() ?? string.Empty : string.Empty; var ackNo = data.TryGetProperty("AckNo", out var an) ? an.GetString() ?? string.Empty : string.Empty; DateTime? ackDt = null; if (data.TryGetProperty("AckDt", out var ad) && DateTime.TryParse(ad.GetString(), out var parsedDt)) ackDt = parsedDt; return new EInvoicePortalResponseDTO { IsSuccess = true, IRN = irn, SignedQRCode = qr, SignedEInvoice = sign, AcknowledgementNumber = ackNo, AcknowledgementDate = ackDt, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } var errMsg = root.TryGetProperty("message", out var m) ? m.GetString() ?? raw.body : raw.body; var errCode = root.TryGetProperty("errorCode", out var ec) ? ec.GetString() ?? string.Empty : string.Empty; return new EInvoicePortalResponseDTO { IsSuccess = false, ErrorCode = errCode, ErrorMessage = errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new EInvoicePortalResponseDTO { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } private static AdapterCallResult ParseCallResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var statusCd = root.TryGetProperty("status_cd", out var sc) ? sc.GetString() ?? "0" : "0"; var refId = root.TryGetProperty("referenceid", out var ri) ? ri.GetString() ?? string.Empty : string.Empty; var errMsg = root.TryGetProperty("message", out var m) ? m.GetString() ?? string.Empty : string.Empty; var errCode = root.TryGetProperty("errorCode", out var ec) ? ec.GetString() ?? string.Empty : string.Empty; return new AdapterCallResult { IsSuccess = statusCd == "1", ReferenceId = refId, StatusCode = statusCd, Body = raw.body, ErrorCode = errCode, ErrorMessage = errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new AdapterCallResult { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } // GSTCafe's e-way response shape is different from ParseCallResult's (returns-endpoint) shape: // {"status":0|1,"error":[{"errorCode":"303","errorMessage":"..."}]} rather than a flat // status_cd/message/errorCode — ParseCallResult always came back with empty ErrorCode/ErrorMessage // for e-way failures because it was reading fields this response never has. Matches GB4's // EwayOutputResponseDTO shape (see ComplianceDAL.DTO.GSTEWayBill.EwayOutputResponseDTO). private static AdapterCallResult ParseEWayCallResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var status = root.TryGetProperty("status", out var s) && s.TryGetInt32(out var statusVal) ? statusVal : 0; string errCode = string.Empty, errMsg = string.Empty; if (root.TryGetProperty("error", out var errors) && errors.ValueKind == JsonValueKind.Array) { var codes = new List(); var messages = new List(); foreach (var err in errors.EnumerateArray()) { if (err.TryGetProperty("errorCode", out var ec)) codes.Add(ec.GetString() ?? string.Empty); if (err.TryGetProperty("errorMessage", out var em)) messages.Add(em.GetString() ?? string.Empty); } errCode = string.Join(", ", codes); errMsg = string.Join("; ", messages); } var ewbNumber = root.TryGetProperty("data", out var data) && data.TryGetProperty("ewayBillNo", out var ewb) ? ewb.GetString() ?? string.Empty : string.Empty; return new AdapterCallResult { IsSuccess = status == 1, ReferenceId = ewbNumber, StatusCode = status.ToString(), Body = raw.body, ErrorCode = errCode, ErrorMessage = errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new AdapterCallResult { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } // Matches GB4's EwayFromIRNOutPutDTO shape (status_cd/response_data/infodtls) — GenerateEWayFromIRN // has a different response contract from the regular GenerateEwayBill/CancelEwayBill endpoints // (status/data/error), so it needs its own parser rather than reusing ParseEWayCallResult. The // authoritative field extraction (EwbNo/EwbDt/EwbValidTill/distance) for the TMMHEAD write happens // in GSTEWayBillBLL — this parser only determines success/failure and a message for the audit trail. private static AdapterCallResult ParseEWayFromIRNCallResult((string body, int statusCode) raw) { try { using var doc = JsonDocument.Parse(raw.body); var root = doc.RootElement; var statusCd = root.TryGetProperty("status_cd", out var sc) && sc.TryGetInt32(out var scVal) ? scVal : 0; var errMsg = string.Empty; if (root.TryGetProperty("infodtls", out var info) && info.ValueKind == JsonValueKind.Array) { var descriptions = new List(); foreach (var item in info.EnumerateArray()) if (item.TryGetProperty("Desc", out var d)) descriptions.Add(d.GetString() ?? string.Empty); errMsg = string.Join("; ", descriptions); } return new AdapterCallResult { IsSuccess = statusCd == 1, StatusCode = statusCd.ToString(), Body = raw.body, ErrorMessage = statusCd == 1 ? string.Empty : errMsg, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } catch { return new AdapterCallResult { IsSuccess = false, ErrorMessage = raw.body, HttpStatusCode = raw.statusCode, RawResponse = raw.body }; } } }