using System.Globalization; using System.Runtime.InteropServices; using System.Security.Cryptography; using System.Text; using ChangeT50.Adapter; namespace ChangeT50.Agent; public interface IChangeT50Provider : IDisposable { Task GetHealthAsync(int pendingUploadCount, CancellationToken cancellationToken); Task ConsumeAsync(AgentTask task, CancellationToken cancellationToken); Task UploadAsync(AgentTask task, ConsumeExecution consume, int retryCount, CancellationToken cancellationToken); } public sealed class SimulatedChangeT50Provider : IChangeT50Provider { public Task GetHealthAsync(int pendingUploadCount, CancellationToken cancellationToken) => Task.FromResult(new HealthSnapshot { Status = "online", AgentVersion = "1.0.0-simulated", DllVersion = "simulated-t5.0", ReaderStatus = "ready", PsamStatus = "ready", PendingUploadCount = pendingUploadCount }); public Task ConsumeAsync(AgentTask task, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var seed = SHA256.HashData(Encoding.UTF8.GetBytes(task.ClientOrderNo)); var cardToken = Convert.ToHexString(seed).ToLowerInvariant(); return Task.FromResult(new ConsumeExecution { ClientOrderNo = task.ClientOrderNo, AmountCents = task.AmountCents, Result = "success", ResultCode = "0", CardNoHash = cardToken, CardNoMasked = $"SIM-{cardToken[..4]}****{cardToken[^4..]}", OpCountAfter = BitConverter.ToUInt32(seed, 0), PsamTradeNo = $"SIM-PSAM-{Convert.ToHexString(seed.AsSpan(4, 6))}", Tac = Convert.ToHexString(seed.AsSpan(10, 4)), OccurredAt = DateTimeOffset.Now }); } public Task UploadAsync(AgentTask task, ConsumeExecution consume, int retryCount, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); var tradeNo = "SIM-CENTER-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(task.ClientOrderNo)).AsSpan(0, 8)); return Task.FromResult(new UploadExecution { Result = "success", ResultCode = "0", ProviderTradeNo = tradeNo, RetryCount = retryCount, BalanceBeforeCents = null, BalanceAfterCents = null }); } public void Dispose() { } } public sealed class NativeChangeT50Provider : IChangeT50Provider { private readonly ChangeNativeLibrary _library; private readonly AgentOptions _options; public NativeChangeT50Provider(AgentOptions options) { if (string.IsNullOrWhiteSpace(options.DllPath)) { throw new ArgumentException("Native mode requires --dll or CHANGE_T50_DLL_PATH."); } _options = options; _library = new ChangeNativeLibrary(options.DllPath); } public Task GetHealthAsync(int pendingUploadCount, CancellationToken cancellationToken) => Task.FromResult(new HealthSnapshot { Status = "online", AgentVersion = "1.0.0-native", DllVersion = "T5.0", ReaderStatus = "loaded", PsamStatus = "unknown", PendingUploadCount = pendingUploadCount }); public Task ConsumeAsync(AgentTask task, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (task.AmountCents <= 0 || task.AmountCents > int.MaxValue) { throw new InvalidOperationException("Change.dll amount must fit a positive 32-bit integer number of cents."); } if (!int.TryParse(task.WalletCode, NumberStyles.Integer, CultureInfo.InvariantCulture, out var walletNumber)) { throw new InvalidOperationException("Change.dll wallet_code must be an integer."); } var openResult = _library.OpenReader(); if (openResult != 0) { return Task.FromResult(Failure(task, "OPEN_READER", $"OpenCom returned {openResult}.")); } try { var queryResult = _library.QueryCard(out _); if (queryResult != 0) { return Task.FromResult(Failure(task, "QUERY_CARD", $"CapNBQueryCard returned {queryResult}.")); } var readResult = _library.ReadCard(out var before); if (readResult != 0) { return Task.FromResult(Failure(task, "READ_CARD", $"CapGetNBCardInfo returned {readResult}.")); } var tradeTime = DateTime.Now; var debitResult = _library.DebitCard( before.CustomerId, checked((int)task.AmountCents), walletNumber, tradeTime, out var psamId, out var psamTradeNumber, out var tac); if (debitResult != 0) { return Task.FromResult(Failure(task, "DEBIT_CARD", $"CapSetNBCardInfo returned {debitResult}.")); } var after = before; _library.QueryCard(out _); _library.ReadCard(out after); var cardIdentity = $"{before.CardNumber}:{before.CardSerialNumber}:{before.CustomerId}"; var cardHash = Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(cardIdentity))).ToLowerInvariant(); return Task.FromResult(new ConsumeExecution { ClientOrderNo = task.ClientOrderNo, AmountCents = task.AmountCents, Result = "success", ResultCode = debitResult.ToString(CultureInfo.InvariantCulture), CardNoHash = cardHash, CardNoMasked = MaskCardNumber(before.CardNumber), OpCountAfter = walletNumber == 1 ? after.MainWalletOperationCount : after.SubsidyWalletOperationCount, PsamTradeNo = $"{psamId}-{psamTradeNumber}", Tac = tac.ToString("X8", CultureInfo.InvariantCulture), OccurredAt = new DateTimeOffset(tradeTime), NativeUpload = new NativeUploadPayload { TerminalId = _options.TerminalId, WalletNumber = walletNumber, TradeTime = tradeTime, PsamId = psamId, PsamTradeNumber = psamTradeNumber, Tac = tac, Before = Snapshot(before), After = Snapshot(after) } }); } finally { _library.CloseReader(); } } public unsafe Task UploadAsync(AgentTask task, ConsumeExecution consume, int retryCount, CancellationToken cancellationToken) { cancellationToken.ThrowIfCancellationRequested(); if (consume.NativeUpload is not { } context) { throw new InvalidOperationException("The local journal does not contain the original native upload record. Never debit the card again; route the task to manual review."); } if (string.IsNullOrWhiteSpace(_options.WebServiceUrl)) { throw new InvalidOperationException("Native upload requires CHANGE_T50_WEBSERVICE_URL."); } if (_options.UploadMessageBufferBytes <= 0) { throw new InvalidOperationException("Native upload requires a vendor-confirmed positive CHANGE_T50_UPLOAD_MESSAGE_BYTES value."); } var connectResult = _library.ConnectWebService(_options.WebServiceUrl); if (connectResult != 0) { return Task.FromResult(new UploadExecution { Result = "failed", ResultCode = $"CONNECT_{connectResult}", Message = "Change WebService connection failed.", RetryCount = retryCount }); } var buffer = Marshal.AllocHGlobal(_options.UploadMessageBufferBytes); try { new Span((void*)buffer, _options.UploadMessageBufferBytes).Clear(); var upload = BuildUpload(context, task); var result = _library.UploadTrade(ref upload, buffer); var message = Marshal.PtrToStringAnsi(buffer) ?? string.Empty; return Task.FromResult(new UploadExecution { Result = result == 0 ? "success" : "failed", ResultCode = result.ToString(CultureInfo.InvariantCulture), Message = message.Length > 500 ? message[..500] : message, ProviderTradeNo = result == 0 ? context.PsamTradeNumber.ToString(CultureInfo.InvariantCulture) : string.Empty, RetryCount = retryCount, BalanceBeforeCents = WalletBalance(context.Before, context.WalletNumber), BalanceAfterCents = WalletBalance(context.After, context.WalletNumber) }); } finally { Marshal.FreeHGlobal(buffer); _library.DisconnectWebService(); } } public void Dispose() => _library.Dispose(); private static ConsumeExecution Failure(AgentTask task, string code, string message) => new() { ClientOrderNo = task.ClientOrderNo, AmountCents = task.AmountCents, Result = "failed", ResultCode = code, Message = message, OccurredAt = DateTimeOffset.Now }; private static unsafe UploadInfoNative BuildUpload(NativeUploadPayload context, AgentTask task) { var before = context.Before; var after = context.After; var upload = new UploadInfoNative { TerminalId = context.TerminalId, CustomerId = before.CustomerId, CardNumber = before.CardNumber, CardSerialNumber = before.CardSerialNumber, CardClass = before.CardClass, TotalAmountCents = after.TotalAmountCents, ManagementFeeCents = 0, WalletNumber = context.WalletNumber, OperationAmountCents = checked((int)task.AmountCents), RemainingBalanceCents = WalletBalance(after, context.WalletNumber), OperationCount = context.WalletNumber == 1 ? after.MainWalletOperationCount : after.SubsidyWalletOperationCount, PsamId = context.PsamId, PsamTradeNumber = context.PsamTradeNumber, Tac = context.Tac, Result = 0 }; var cardAsn = Convert.FromBase64String(before.CardAsnBase64); for (var index = 0; index < 24; index++) { upload.CardAsn[index] = index < cardAsn.Length ? cardAsn[index] : (byte)0; } var tradeTime = Encoding.ASCII.GetBytes(context.TradeTime.ToString("yyyy-MM-dd HH:mm:ss", CultureInfo.InvariantCulture)); for (var index = 0; index < Math.Min(tradeTime.Length, 23); index++) { upload.TradeTime[index] = tradeTime[index]; } return upload; } private static int WalletBalance(CustomerInfoNative customer, int walletNumber) => walletNumber == 1 ? customer.MainWalletBalanceCents : customer.SubsidyWalletBalanceCents; private static int WalletBalance(NativeCardSnapshot customer, int walletNumber) => walletNumber == 1 ? customer.MainWalletBalanceCents : customer.SubsidyWalletBalanceCents; private static unsafe NativeCardSnapshot Snapshot(CustomerInfoNative customer) { var cardAsn = new byte[24]; for (var index = 0; index < cardAsn.Length; index++) { cardAsn[index] = customer.CardAsn[index]; } return new NativeCardSnapshot { CardClass = customer.CardClass, CustomerId = customer.CustomerId, CardNumber = customer.CardNumber, CardSerialNumber = customer.CardSerialNumber, TotalAmountCents = customer.TotalAmountCents, MainWalletBalanceCents = customer.MainWalletBalanceCents, MainWalletOperationCount = customer.MainWalletOperationCount, SubsidyWalletBalanceCents = customer.SubsidyWalletBalanceCents, SubsidyWalletOperationCount = customer.SubsidyWalletOperationCount, CardAsnBase64 = Convert.ToBase64String(cardAsn) }; } private static string MaskCardNumber(int cardNumber) { var value = Math.Abs((long)cardNumber).ToString(CultureInfo.InvariantCulture); return value.Length <= 4 ? "****" : value[..2] + new string('*', Math.Max(2, value.Length - 4)) + value[^2..]; } }