diff --git a/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj index f1b38eb..0d66818 100644 --- a/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj +++ b/AMREZ.EOP.Abstractions/AMREZ.EOP.Abstractions.csproj @@ -16,4 +16,7 @@ + + + diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/IVerifyFromImageUseCase.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/IVerifyFromImageUseCase.cs index 1bc21ff..4528f51 100644 --- a/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/IVerifyFromImageUseCase.cs +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/IVerifyFromImageUseCase.cs @@ -1,6 +1,6 @@ using AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification; -namespace AMREZ.EOP.Abstractions.Applications.UseCases.Payments; +namespace AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification; public interface IVerifyFromImageUseCase { diff --git a/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/QrDecode/IQrDecoder.cs b/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/QrDecode/IQrDecoder.cs index 71342ef..4dcdfe9 100644 --- a/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/QrDecode/IQrDecoder.cs +++ b/AMREZ.EOP.Abstractions/Applications/UseCases/Payments/SlipVerification/QrDecode/IQrDecoder.cs @@ -2,5 +2,5 @@ namespace AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification public interface IQrDecoder { - + string? TryDecodeText(byte[] imageBytes); } \ No newline at end of file diff --git a/AMREZ.EOP.Abstractions/Infrastructures/Integrations/SCB/Clients/IScbSlipClient.cs b/AMREZ.EOP.Abstractions/Infrastructures/Integrations/SCB/Clients/IScbSlipClient.cs index 45a1693..e972152 100644 --- a/AMREZ.EOP.Abstractions/Infrastructures/Integrations/SCB/Clients/IScbSlipClient.cs +++ b/AMREZ.EOP.Abstractions/Infrastructures/Integrations/SCB/Clients/IScbSlipClient.cs @@ -1,6 +1,8 @@ +using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.SlipVerification; + namespace AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients; public interface IScbSlipClient { - + Task VerifyAsync(string transRef, string sendingBank, CancellationToken ct); } \ No newline at end of file diff --git a/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj index b1fd7ad..eef6ff9 100644 --- a/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj +++ b/AMREZ.EOP.Application/AMREZ.EOP.Application.csproj @@ -12,6 +12,12 @@ + + + + + + diff --git a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/BankDetect/ParserBankDetect.cs b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/BankDetect/ParserBankDetect.cs index c26ba18..fe8a542 100644 --- a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/BankDetect/ParserBankDetect.cs +++ b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/BankDetect/ParserBankDetect.cs @@ -1,12 +1,10 @@ -using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification.BankDetect; using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode; using SkiaSharp; namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.BankDetect; -public static class BankDetect +public static class ParserBankDetect { - // SCB=014, KBank=004 public static string? FromQrText(string? raw) { if (string.IsNullOrWhiteSpace(raw)) return null; diff --git a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/TransRefParser.cs b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/TransRefParser.cs index a1342f4..82e6394 100644 --- a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/TransRefParser.cs +++ b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/TransRefParser.cs @@ -1,6 +1,87 @@ +using System.Text.RegularExpressions; + namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode; -public class TransRefParser +public static partial class TransRefParser { - + [GeneratedRegex(@"(?i)(?:[?&](?:transref|transactionid|txnid|transid)=)(?[-A-Za-z0-9]{10,64})", + RegexOptions.Compiled | RegexOptions.IgnoreCase)] + private static partial Regex QueryId(); + + [GeneratedRegex(@"(?[0-9]{6,14}[A-Za-z0-9]{6,40})(?![A-Za-z0-9])", + RegexOptions.Compiled)] + private static partial Regex FallbackShape(); + + public static string? TryExtract(string? raw) + { + if (string.IsNullOrWhiteSpace(raw)) return null; + + // 1) ?transRef=... ใน URL + var q = QueryId().Match(raw); + if (q.Success) return q.Groups["id"].Value; + + // 2) เดิน TLV แบบ recursive + foreach (var val in EnumerateTlvValues(raw)) + { + // ต้องเป็น a-z0-9 ล้วน ยาวพอสมควร และมีตัวอักษรด้วย (กันเคสเป็นตัวเลขล้วน) + if (val.Length is >= 18 and <= 40 + && val.All(IsAlphaNum) + && val.Any(char.IsLetter)) + return val; + } + + // 3) เผื่อกรณีหลุดรูปแบบ TLV + var f = FallbackShape().Match(raw); + return f.Success ? f.Groups["id"].Value : null; + } + + private static IEnumerable EnumerateTlvValues(string s) + { + int i = 0; + while (i + 4 <= s.Length) + { + // รูปแบบ TLV: [ID(2)][LEN(2)][VALUE(LEN)] + if (!char.IsDigit(s[i]) || !char.IsDigit(s[i + 1]) || + !char.IsDigit(s[i + 2]) || !char.IsDigit(s[i + 3])) + yield break; + + var id = s.Substring(i, 2); + if (!int.TryParse(s.AsSpan(i + 2, 2), out var len)) yield break; + var end = i + 4 + len; + if (len < 0 || end > s.Length) yield break; + + var val = s.Substring(i + 4, len); + yield return val; + + // ถ้า value ดูเป็น TLV ซ้อนอยู่ ให้ไล่ต่อ + if (LooksLikeTlv(val)) + foreach (var inner in EnumerateTlvValues(val)) + yield return inner; + + // CRC tag "63" ไม่เกี่ยว แต่เราไม่ถึงเงื่อนไข length อยู่แล้ว + i = end; + } + } + + private static bool LooksLikeTlv(string s) + { + int i = 0; + while (i + 4 <= s.Length) + { + if (!char.IsDigit(s[i]) || !char.IsDigit(s[i + 1]) || + !char.IsDigit(s[i + 2]) || !char.IsDigit(s[i + 3])) + return false; + + if (!int.TryParse(s.AsSpan(i + 2, 2), out var len)) return false; + i += 4 + len; + if (i == s.Length) return true; + if (i > s.Length) return false; + } + return false; + } + + private static bool IsAlphaNum(char c) => + (c >= '0' && c <= '9') || + (c >= 'A' && c <= 'Z') || + (c >= 'a' && c <= 'z'); } \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/ZxingQrDecoder.cs b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/ZxingQrDecoder.cs index ce0bd3c..95236ca 100644 --- a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/ZxingQrDecoder.cs +++ b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/QrDecode/ZxingQrDecoder.cs @@ -1,6 +1,22 @@ +using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification.QrDecode; +using SkiaSharp; +using ZXing.SkiaSharp; + namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode; -public class ZxingQrDecoder +public sealed class ZxingQrDecoder : IQrDecoder { - + public string? TryDecodeText(byte[] imageBytes) + { + using var bmp = SKBitmap.Decode(imageBytes); + if (bmp is null) return null; + + var reader = new BarcodeReader + { + AutoRotate = true, + Options = { TryHarder = true, PossibleFormats = new[] { ZXing.BarcodeFormat.QR_CODE } } + }; + var res = reader.Decode(bmp); + return res?.Text; + } } \ No newline at end of file diff --git a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/VerifyFromImageUseCase.cs b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/VerifyFromImageUseCase.cs index 510508e..79b3001 100644 --- a/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/VerifyFromImageUseCase.cs +++ b/AMREZ.EOP.Application/UseCases/Payments/SlipVerification/VerifyFromImageUseCase.cs @@ -1,6 +1,93 @@ +using System.Globalization; +using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification; +using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification.QrDecode; +using AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients; +using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.BankDetect; +using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode; +using AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification; +using AMREZ.EOP.Domain.Shared.Payments; + namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification; -public class VerifyFromImageUseCase +public sealed class VerifyFromImageUseCase : IVerifyFromImageUseCase { - + private readonly IQrDecoder _qr; + private readonly IScbSlipClient _scb; + private readonly SCBOptions _opts; + + public VerifyFromImageUseCase(IQrDecoder qr, IScbSlipClient scb, SCBOptions opts) + { + _qr = qr; + _scb = scb; + _opts = opts; + } + + public async Task ExecuteAsync(VerifyFromImageRequest request, CancellationToken ct) + { + await using var ms = new MemoryStream(); + await request.Image.CopyToAsync(ms, ct); + + var qrText = _qr.TryDecodeText(ms.ToArray()); + var transId = TransRefParser.TryExtract(qrText); + var bank = ParserBankDetect.FromQrText(qrText) ?? _opts.DefaultSendingBank; + + if (string.IsNullOrWhiteSpace(transId)) return null; // ไม่มี id ก็หยุด + + // ← SCB API ต้องการ “id” (transRef/txnId) + sendingBank เท่านั้น + var env = await _scb.VerifyAsync(transId, bank, ct); + if (env?.Status?.Code != 1000 || env.Data is null) return null; + + // 3) map → controller response + var d = env.Data; + var amount = decimal.TryParse(d.PaidLocalAmount ?? d.Amount ?? "0", NumberStyles.Any, + CultureInfo.InvariantCulture, out var v) + ? v + : 0m; + var when = CombineBangkok(d.TransDate, d.TransTime); + + var msisdn = d.Sender?.Proxy?.Type == "MSISDN" ? d.Sender.Proxy.Value : null; + var biller = d.Receiver?.Proxy?.Type == "BILLERID" ? d.Receiver.Proxy.Value : null; + + return new VerifyFromImageResponse + { + TransRef = d.TransRef ?? transId, + SendingBank = bank, + AmountTHB = amount, + CurrencyNumeric = d.PaidLocalCurrency ?? "764", + SenderMsisdn = msisdn, + BillerId = biller, + TransactedAt = when + }; + } + + private static DateTimeOffset CombineBangkok(string? date, string? time) + { + if (string.IsNullOrWhiteSpace(date) || string.IsNullOrWhiteSpace(time)) + return DateTimeOffset.UtcNow; // fallback + + var dt = DateTime.ParseExact($"{date} {time}", "yyyyMMdd HH:mm:ss", CultureInfo.InvariantCulture); + var local = DateTime.SpecifyKind(dt, DateTimeKind.Unspecified); + + TimeZoneInfo? tz = null; + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById("Asia/Bangkok"); + } + catch + { + } + + if (tz is null) + try + { + tz = TimeZoneInfo.FindSystemTimeZoneById("SE Asia Standard Time"); + } + catch + { + } + + return tz is null + ? new DateTimeOffset(local, TimeSpan.FromHours(7)) + : new DateTimeOffset(local, tz.GetUtcOffset(local)); + } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/Common/SCBStatus.cs b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/Common/SCBStatus.cs index 4ecfd34..254f9c7 100644 --- a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/Common/SCBStatus.cs +++ b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/Common/SCBStatus.cs @@ -1,6 +1,7 @@ namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common; -public class SCBStatus +public sealed class SCBStatus { - + public int? Code { get; set; } + public string? Description { get; set; } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthRequest.cs b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthRequest.cs index bf6cc5a..fc1fa21 100644 --- a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthRequest.cs +++ b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthRequest.cs @@ -1,6 +1,13 @@ namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.OAuth; -public class OAuthRequest +public sealed class OAuthRequest { - + public OAuthRequest(string applicationKey, string applicationSecret) + { + ApplicationKey = applicationKey; + ApplicationSecret = applicationSecret; + } + + public string ApplicationKey { get; } + public string ApplicationSecret { get; } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthResponse.cs b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthResponse.cs index 9e6b450..b212272 100644 --- a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthResponse.cs +++ b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/OAuth/OAuthResponse.cs @@ -1,6 +1,15 @@ +using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common; + namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.OAuth; -public class OAuthResponse +public sealed class OAuthResponse { - + public SCBStatus? Status { get; set; } + public OAuthData? Data { get; set; } +} + +public sealed class OAuthData +{ + public string? AccessToken { get; set; } + public int? ExpiresIn { get; set; } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/SlipVerification/SlipVerificationEnvelope.cs b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/SlipVerification/SlipVerificationEnvelope.cs index 7d11026..9257b84 100644 --- a/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/SlipVerification/SlipVerificationEnvelope.cs +++ b/AMREZ.EOP.Contracts/DTOs/Integrations/SCB/SlipVerification/SlipVerificationEnvelope.cs @@ -1,6 +1,41 @@ +using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common; + namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.SlipVerification; -public class SlipVerificationEnvelope +public sealed class SlipVerificationEnvelope { - + public SCBStatus? Status { get; set; } + public SlipVerificationData? Data { get; set; } +} + +public sealed class SlipVerificationData +{ + public string? TransRef { get; set; } + public string? SendingBank { get; set; } + public string? ReceivingBank { get; set; } + public string? TransDate { get; set; } // yyyyMMdd + public string? TransTime { get; set; } // HH:mm:ss + public SCBParty? Sender { get; set; } + public SCBParty? Receiver { get; set; } + public string? Amount { get; set; } + public string? PaidLocalAmount { get; set; } + public string? PaidLocalCurrency { get; set; } // "764" + public string? CountryCode { get; set; } // "TH" + public string? Ref1 { get; set; } + public string? Ref2 { get; set; } + public string? Ref3 { get; set; } +} + +public sealed class SCBParty +{ + public string? DisplayName { get; set; } + public string? Name { get; set; } + public SCBId? Proxy { get; set; } + public SCBId? Account { get; set; } +} + +public sealed class SCBId +{ + public string? Type { get; set; } + public string? Value { get; set; } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageRequest.cs b/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageRequest.cs index c96d5f8..5c0528c 100644 --- a/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageRequest.cs +++ b/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageRequest.cs @@ -1,6 +1,13 @@ +using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.Mvc; + namespace AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification; -public class VerifyFromImageRequest +public sealed class VerifyFromImageRequest { - + [FromForm(Name = "Image")] + public IFormFile? Image { get; set; } = default!; + + [FromForm(Name = "OverrideSendingBank")] + public string? OverrideSendingBank { get; set; } } \ No newline at end of file diff --git a/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageResponse.cs b/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageResponse.cs index 139ebf9..8ce5d25 100644 --- a/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageResponse.cs +++ b/AMREZ.EOP.Contracts/DTOs/Payments/SlipVerification/VerifyFromImageResponse.cs @@ -1,6 +1,12 @@ namespace AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification; -public class VerifyFromImageResponse +public sealed class VerifyFromImageResponse { - + public required string TransRef { get; init; } + public required string SendingBank { get; init; } + public decimal AmountTHB { get; init; } + public string CurrencyNumeric { get; init; } = "764"; + public string? SenderMsisdn { get; init; } + public string? BillerId { get; init; } + public DateTimeOffset TransactedAt { get; init; } } \ No newline at end of file diff --git a/AMREZ.EOP.Domain/Shared/Payments/SCBOptions.cs b/AMREZ.EOP.Domain/Shared/Payments/SCBOptions.cs index 3c97a4e..3037e95 100644 --- a/AMREZ.EOP.Domain/Shared/Payments/SCBOptions.cs +++ b/AMREZ.EOP.Domain/Shared/Payments/SCBOptions.cs @@ -1,6 +1,11 @@ namespace AMREZ.EOP.Domain.Shared.Payments; -public class SCBOptions +public sealed class SCBOptions { - + public string BaseUrl { get; init; } = "https://api.partners.scb/partners"; + public string SCBResourceOwnerId { get; init; } = "l7eb41a014586448c6be6cfaecce1f5b5e"; + public string SCBApplicationKey { get; init; } = "l7eb41a014586448c6be6cfaecce1f5b5e"; + public string SCBApplicationSecret { get; init; } = "5f20b35809034d16b7930af95ab6a7de"; + public string DefaultLanguage { get; init; } = "EN"; + public string DefaultSendingBank { get; init; } = "014"; } \ No newline at end of file diff --git a/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj index 00b400c..38b0f06 100644 --- a/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj +++ b/AMREZ.EOP.Infrastructures/AMREZ.EOP.Infrastructures.csproj @@ -12,9 +12,11 @@ + + diff --git a/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs index 623779e..86d9068 100644 --- a/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs +++ b/AMREZ.EOP.Infrastructures/DependencyInjections/ServiceCollectionExtensions.cs @@ -1,16 +1,24 @@ using AMREZ.EOP.Abstractions.Applications.Tenancy; using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications; using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources; +using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification; +using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification.QrDecode; using AMREZ.EOP.Abstractions.Applications.UseCases.Tenancy; using AMREZ.EOP.Abstractions.Infrastructures.Common; +using AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients; using AMREZ.EOP.Abstractions.Infrastructures.Repositories; using AMREZ.EOP.Abstractions.Security; using AMREZ.EOP.Abstractions.Storage; using AMREZ.EOP.Application.UseCases.Authentications; using AMREZ.EOP.Application.UseCases.HumanResources; +using AMREZ.EOP.Application.UseCases.Payments.SlipVerification; +using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.BankDetect; +using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode; using AMREZ.EOP.Application.UseCases.Tenancy; +using AMREZ.EOP.Domain.Shared.Payments; using AMREZ.EOP.Domain.Shared.Tenancy; using AMREZ.EOP.Infrastructures.Data; +using AMREZ.EOP.Infrastructures.Integrations.SCB.Clients; using AMREZ.EOP.Infrastructures.Options; using AMREZ.EOP.Infrastructures.Repositories; using AMREZ.EOP.Infrastructures.Security; @@ -30,7 +38,17 @@ public static class ServiceCollectionExtensions { services.AddHttpContextAccessor(); services.AddScoped(); - + + services.AddSingleton(new SCBOptions()); + + services.AddHttpClient((sp, http) => + { + var o = sp.GetRequiredService(); + var baseUrl = o.BaseUrl.EndsWith('/') ? o.BaseUrl[..^1] : o.BaseUrl; + http.BaseAddress = new Uri(baseUrl + "/v1/"); + http.Timeout = TimeSpan.FromSeconds(20); + }); + // Options services.AddOptions() .BindConfiguration("Connections") @@ -116,6 +134,11 @@ public static class ServiceCollectionExtensions services.AddScoped(); services.AddScoped(); + + // UseCase & helpers (no OCR) + services.AddScoped(); + services.AddScoped(); + // Redis (optional) services.AddSingleton(sp => { diff --git a/AMREZ.EOP.Infrastructures/Integrations/SCB/Clients/ScbSlipClient.cs b/AMREZ.EOP.Infrastructures/Integrations/SCB/Clients/ScbSlipClient.cs index 0af769a..69dc94a 100644 --- a/AMREZ.EOP.Infrastructures/Integrations/SCB/Clients/ScbSlipClient.cs +++ b/AMREZ.EOP.Infrastructures/Integrations/SCB/Clients/ScbSlipClient.cs @@ -1,6 +1,83 @@ +using System.Net.Http.Headers; +using System.Text; +using System.Text.Json; +using AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients; +using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.OAuth; +using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.SlipVerification; +using AMREZ.EOP.Domain.Shared.Payments; + namespace AMREZ.EOP.Infrastructures.Integrations.SCB.Clients; -public class ScbSlipClient +public sealed class ScbSlipClient : IScbSlipClient { - + private readonly HttpClient _http; + private readonly SCBOptions _opts; + private readonly JsonSerializerOptions _json = new() + { + PropertyNamingPolicy = JsonNamingPolicy.CamelCase, + DefaultIgnoreCondition = System.Text.Json.Serialization.JsonIgnoreCondition.WhenWritingNull + }; + + private string? _token; + private DateTimeOffset _exp; + private readonly SemaphoreSlim _gate = new(1,1); + + public ScbSlipClient(HttpClient http, SCBOptions opts) + { + _http = http; + _opts = opts; + } + + public async Task VerifyAsync(string transRef, string sendingBank, CancellationToken ct) + { + var bearer = await GetTokenAsync(ct); + var path = $"payment/billpayment/transactions/{Uri.EscapeDataString(transRef)}?sendingBank={Uri.EscapeDataString(sendingBank)}"; + + using var req = new HttpRequestMessage(HttpMethod.Get, path); + AddHeaders(req.Headers, bearer); + + using var res = await _http.SendAsync(req, ct); + var body = await res.Content.ReadAsStringAsync(ct); + if (!res.IsSuccessStatusCode) return null; + + return JsonSerializer.Deserialize(body, _json); + } + + private async Task GetTokenAsync(CancellationToken ct) + { + if (!string.IsNullOrEmpty(_token) && _exp > DateTimeOffset.UtcNow.AddSeconds(30)) return _token!; + await _gate.WaitAsync(ct); + try + { + if (!string.IsNullOrEmpty(_token) && _exp > DateTimeOffset.UtcNow.AddSeconds(30)) return _token!; + + var json = JsonSerializer.Serialize(new OAuthRequest(_opts.SCBApplicationKey, _opts.SCBApplicationSecret), _json); + using var req = new HttpRequestMessage(HttpMethod.Post, "oauth/token") + { Content = new StringContent(json, Encoding.UTF8, "application/json") }; + AddHeaders(req.Headers, null); + + using var res = await _http.SendAsync(req, ct); + var body = await res.Content.ReadAsStringAsync(ct); + res.EnsureSuccessStatusCode(); + + var parsed = JsonSerializer.Deserialize(body, _json) + ?? throw new UnauthorizedAccessException("OAuth parse failed"); + if (parsed.Status?.Code != 1000) throw new UnauthorizedAccessException(parsed.Status?.Description ?? "OAuth not OK"); + + _token = parsed.Data?.AccessToken ?? throw new UnauthorizedAccessException("No token"); + var ttl = parsed.Data?.ExpiresIn ?? 300; + _exp = DateTimeOffset.UtcNow.AddSeconds(ttl); + return _token!; + } + finally { _gate.Release(); } + } + + private void AddHeaders(HttpRequestHeaders h, string? bearer) + { + h.TryAddWithoutValidation("accept-language", _opts.DefaultLanguage); + h.TryAddWithoutValidation("requestUId", Guid.NewGuid().ToString()); + h.TryAddWithoutValidation("resourceOwnerId", _opts.SCBResourceOwnerId); + if (!string.IsNullOrEmpty(bearer)) + h.Authorization = new AuthenticationHeaderValue("Bearer", bearer); + } } \ No newline at end of file diff --git a/qodana.yaml b/qodana.yaml new file mode 100644 index 0000000..541fb41 --- /dev/null +++ b/qodana.yaml @@ -0,0 +1,46 @@ +#-------------------------------------------------------------------------------# +# Qodana analysis is configured by qodana.yaml file # +# https://www.jetbrains.com/help/qodana/qodana-yaml.html # +#-------------------------------------------------------------------------------# + +################################################################################# +# WARNING: Do not store sensitive information in this file, # +# as its contents will be included in the Qodana report. # +################################################################################# +version: "1.0" + +#Specify IDE code to run analysis without container (Applied in CI/CD pipeline) +ide: QDNET + +#Specify inspection profile for code analysis +profile: + name: qodana.starter + +#Enable inspections +#include: +# - name: + +#Disable inspections +#exclude: +# - name: +# paths: +# - + +#Execute shell command before Qodana execution (Applied in CI/CD pipeline) +#bootstrap: sh ./prepare-qodana.sh + +#Install IDE plugins before Qodana execution (Applied in CI/CD pipeline) +#plugins: +# - id: #(plugin id can be found at https://plugins.jetbrains.com) + +# Quality gate. Will fail the CI/CD pipeline if any condition is not met +# severityThresholds - configures maximum thresholds for different problem severities +# testCoverageThresholds - configures minimum code coverage on a whole project and newly added code +# Code Coverage is available in Ultimate and Ultimate Plus plans +#failureConditions: +# severityThresholds: +# any: 15 +# critical: 5 +# testCoverageThresholds: +# fresh: 70 +# total: 50