Fix Redundance Reference
This commit is contained in:
@@ -16,4 +16,7 @@
|
|||||||
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
|
<ProjectReference Include="..\AMREZ.EOP.Domain\AMREZ.EOP.Domain.csproj" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
using AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification;
|
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
|
public interface IVerifyFromImageUseCase
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -2,5 +2,5 @@ namespace AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification
|
|||||||
|
|
||||||
public interface IQrDecoder
|
public interface IQrDecoder
|
||||||
{
|
{
|
||||||
|
string? TryDecodeText(byte[] imageBytes);
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
|
using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.SlipVerification;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients;
|
namespace AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients;
|
||||||
|
|
||||||
public interface IScbSlipClient
|
public interface IScbSlipClient
|
||||||
{
|
{
|
||||||
|
Task<SlipVerificationEnvelope?> VerifyAsync(string transRef, string sendingBank, CancellationToken ct);
|
||||||
}
|
}
|
||||||
@@ -12,6 +12,12 @@
|
|||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||||
|
<PackageReference Include="SkiaSharp" Version="3.119.1" />
|
||||||
|
<PackageReference Include="SkiaSharp.NativeAssets.Linux" Version="3.119.1" />
|
||||||
|
<PackageReference Include="ZXing.Net.Bindings.SkiaSharp" Version="0.16.21" />
|
||||||
|
</ItemGroup>
|
||||||
|
|
||||||
|
<ItemGroup>
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -1,12 +1,10 @@
|
|||||||
using AMREZ.EOP.Abstractions.Applications.UseCases.Payments.SlipVerification.BankDetect;
|
|
||||||
using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode;
|
using AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode;
|
||||||
using SkiaSharp;
|
using SkiaSharp;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.BankDetect;
|
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)
|
public static string? FromQrText(string? raw)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(raw)) return null;
|
if (string.IsNullOrWhiteSpace(raw)) return null;
|
||||||
|
|||||||
@@ -1,6 +1,87 @@
|
|||||||
|
using System.Text.RegularExpressions;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode;
|
namespace AMREZ.EOP.Application.UseCases.Payments.SlipVerification.QrDecode;
|
||||||
|
|
||||||
public class TransRefParser
|
public static partial class TransRefParser
|
||||||
{
|
{
|
||||||
|
[GeneratedRegex(@"(?i)(?:[?&](?:transref|transactionid|txnid|transid)=)(?<id>[-A-Za-z0-9]{10,64})",
|
||||||
|
RegexOptions.Compiled | RegexOptions.IgnoreCase)]
|
||||||
|
private static partial Regex QueryId();
|
||||||
|
|
||||||
|
[GeneratedRegex(@"(?<![A-Za-z0-9])(?<id>[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<string> 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');
|
||||||
}
|
}
|
||||||
@@ -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;
|
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;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -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;
|
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<VerifyFromImageResponse?> 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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.OAuth;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,15 @@
|
|||||||
|
using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.OAuth;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,41 @@
|
|||||||
|
using AMREZ.EOP.Contracts.DTOs.Integrations.SCB.Common;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Contracts.DTOs.Integrations.SCB.SlipVerification;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,13 @@
|
|||||||
|
using Microsoft.AspNetCore.Http;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
namespace AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
namespace AMREZ.EOP.Contracts.DTOs.Payments.SlipVerification;
|
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; }
|
||||||
}
|
}
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
namespace AMREZ.EOP.Domain.Shared.Payments;
|
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";
|
||||||
}
|
}
|
||||||
@@ -12,9 +12,11 @@
|
|||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Http" Version="2.3.0" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.9" />
|
||||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="9.0.9" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
|
<PackageReference Include="Microsoft.Extensions.Hosting" Version="9.0.9" />
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Http" Version="9.0.9" />
|
||||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||||
<PackageReference Include="StackExchange.Redis" Version="2.9.17" />
|
<PackageReference Include="StackExchange.Redis" Version="2.9.17" />
|
||||||
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
<PackageReference Include="System.IdentityModel.Tokens.Jwt" Version="8.14.0" />
|
||||||
|
|||||||
@@ -1,16 +1,24 @@
|
|||||||
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
using AMREZ.EOP.Abstractions.Applications.Tenancy;
|
||||||
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
|
using AMREZ.EOP.Abstractions.Applications.UseCases.Authentications;
|
||||||
using AMREZ.EOP.Abstractions.Applications.UseCases.HumanResources;
|
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.Applications.UseCases.Tenancy;
|
||||||
using AMREZ.EOP.Abstractions.Infrastructures.Common;
|
using AMREZ.EOP.Abstractions.Infrastructures.Common;
|
||||||
|
using AMREZ.EOP.Abstractions.Infrastructures.Integrations.SCB.Clients;
|
||||||
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
|
using AMREZ.EOP.Abstractions.Infrastructures.Repositories;
|
||||||
using AMREZ.EOP.Abstractions.Security;
|
using AMREZ.EOP.Abstractions.Security;
|
||||||
using AMREZ.EOP.Abstractions.Storage;
|
using AMREZ.EOP.Abstractions.Storage;
|
||||||
using AMREZ.EOP.Application.UseCases.Authentications;
|
using AMREZ.EOP.Application.UseCases.Authentications;
|
||||||
using AMREZ.EOP.Application.UseCases.HumanResources;
|
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.Application.UseCases.Tenancy;
|
||||||
|
using AMREZ.EOP.Domain.Shared.Payments;
|
||||||
using AMREZ.EOP.Domain.Shared.Tenancy;
|
using AMREZ.EOP.Domain.Shared.Tenancy;
|
||||||
using AMREZ.EOP.Infrastructures.Data;
|
using AMREZ.EOP.Infrastructures.Data;
|
||||||
|
using AMREZ.EOP.Infrastructures.Integrations.SCB.Clients;
|
||||||
using AMREZ.EOP.Infrastructures.Options;
|
using AMREZ.EOP.Infrastructures.Options;
|
||||||
using AMREZ.EOP.Infrastructures.Repositories;
|
using AMREZ.EOP.Infrastructures.Repositories;
|
||||||
using AMREZ.EOP.Infrastructures.Security;
|
using AMREZ.EOP.Infrastructures.Security;
|
||||||
@@ -30,7 +38,17 @@ public static class ServiceCollectionExtensions
|
|||||||
{
|
{
|
||||||
services.AddHttpContextAccessor();
|
services.AddHttpContextAccessor();
|
||||||
services.AddScoped<IJwtFactory, JwtFactory>();
|
services.AddScoped<IJwtFactory, JwtFactory>();
|
||||||
|
|
||||||
|
services.AddSingleton(new SCBOptions());
|
||||||
|
|
||||||
|
services.AddHttpClient<IScbSlipClient, ScbSlipClient>((sp, http) =>
|
||||||
|
{
|
||||||
|
var o = sp.GetRequiredService<SCBOptions>();
|
||||||
|
var baseUrl = o.BaseUrl.EndsWith('/') ? o.BaseUrl[..^1] : o.BaseUrl;
|
||||||
|
http.BaseAddress = new Uri(baseUrl + "/v1/");
|
||||||
|
http.Timeout = TimeSpan.FromSeconds(20);
|
||||||
|
});
|
||||||
|
|
||||||
// Options
|
// Options
|
||||||
services.AddOptions<AuthOptions>()
|
services.AddOptions<AuthOptions>()
|
||||||
.BindConfiguration("Connections")
|
.BindConfiguration("Connections")
|
||||||
@@ -116,6 +134,11 @@ public static class ServiceCollectionExtensions
|
|||||||
services.AddScoped<IListTenantsUseCase, ListTenantsUseCase>();
|
services.AddScoped<IListTenantsUseCase, ListTenantsUseCase>();
|
||||||
services.AddScoped<IListDomainsUseCase, ListDomainsUseCase>();
|
services.AddScoped<IListDomainsUseCase, ListDomainsUseCase>();
|
||||||
|
|
||||||
|
|
||||||
|
// UseCase & helpers (no OCR)
|
||||||
|
services.AddScoped<IQrDecoder, ZxingQrDecoder>();
|
||||||
|
services.AddScoped<IVerifyFromImageUseCase, VerifyFromImageUseCase>();
|
||||||
|
|
||||||
// Redis (optional)
|
// Redis (optional)
|
||||||
services.AddSingleton<IConnectionMultiplexer?>(sp =>
|
services.AddSingleton<IConnectionMultiplexer?>(sp =>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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;
|
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<SlipVerificationEnvelope?> 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<SlipVerificationEnvelope>(body, _json);
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<string> 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<OAuthResponse>(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);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
46
qodana.yaml
Normal file
46
qodana.yaml
Normal file
@@ -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: <SomeEnabledInspectionId>
|
||||||
|
|
||||||
|
#Disable inspections
|
||||||
|
#exclude:
|
||||||
|
# - name: <SomeDisabledInspectionId>
|
||||||
|
# paths:
|
||||||
|
# - <path/where/not/run/inspection>
|
||||||
|
|
||||||
|
#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> #(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
|
||||||
Reference in New Issue
Block a user