ChipDNAClientCLI/TransactionStreaming.cs

270 lines
10 KiB
C#

using System;
using System.Collections.Generic;
using System.Collections.Concurrent;
using System.IO;
using System.Threading;
using System.Threading.Tasks;
using Newtonsoft.Json;
namespace Creditcall.ChipDna.Client
{
// This is the original start/wait mechanism shared by the XML and NDJSON adapters.
internal static class TransactionExecution
{
internal static Dictionary<string, string> Run(Action start,
Func<Task<Dictionary<string, string>>> completion, Action suppress, TimeSpan timeout)
{
try
{
start();
var task = completion();
// Task.Wait intentionally retains its existing exception behavior.
if (!task.Wait(timeout))
{
suppress();
return new Dictionary<string, string>
{
{ ParameterKeys.TransactionResult, "TIMEOUT" },
{ ParameterKeys.Errors, "Transaction timed out waiting for device response" }
};
}
try { return task.Result; }
catch (AggregateException agg)
{
var first = agg.Flatten().InnerExceptions[0];
return new Dictionary<string, string>
{
{ ParameterKeys.TransactionResult, "ERROR" },
{ ParameterKeys.Errors, $"Transaction task faulted: {first.Message}" }
};
}
}
catch
{
suppress();
throw;
}
}
}
internal sealed class TransactionProgressTap
{
private readonly object gate = new object();
private readonly HashSet<string> references = new HashSet<string>(StringComparer.Ordinal);
private Action<string, string> listener;
private string reference;
private bool suppressed;
internal Dictionary<string, string> Observe(Action<string, string> callback, Func<Dictionary<string, string>> execute)
{
lock (gate) { listener += callback; }
try { return execute(); }
finally { lock (gate) { listener -= callback; } }
}
internal void Begin(string value)
{
lock (gate)
{
if (suppressed) return;
// The existing reference generation can repeat within one second.
// A repeated reference cannot prove callback ownership.
if (reference != null || string.IsNullOrEmpty(value) || !references.Add(value))
suppressed = true;
reference = value;
}
}
internal void End() { lock (gate) { reference = null; } }
internal void Suppress() { lock (gate) { suppressed = true; reference = null; } }
internal void SuppressIfActive()
{
lock (gate)
{
if (reference == null) return;
suppressed = true;
reference = null;
}
}
internal void StartFailed(string errors)
{
lock (gate)
{
if (suppressed) return;
// SDK 3.17 StartCommand returns this exact error before SendRequest.
// No other error category proves that dispatch never happened.
if (errors == ClientErrorCodes.ClientNotConnectedToServer.ToString())
{
if (reference != null) references.Remove(reference);
reference = null;
return;
}
suppressed = true;
reference = null;
}
}
internal void Report(string source, EventParameters parameters)
{
lock (gate)
{
if (suppressed || reference == null || parameters == null) return;
string eventReference;
parameters.GetValue(ParameterKeys.Reference, out eventReference);
bool hasReference = !string.IsNullOrEmpty(eventReference);
if (hasReference && eventReference != reference) return;
string value;
switch (source)
{
case "UPDATE":
if (!parameters.GetValue(ParameterKeys.Update, out value)) return;
break;
case "CARD_STATUS":
if (!parameters.GetValue(ParameterKeys.Notification, out value)) return;
break;
case "SIGNATURE": value = "Requested"; break;
case "PAUSE": value = "Paused"; break;
default: return;
}
if (!TransactionStreamFields.IsProgress(source, value)) return;
// Normal SDK 3.17 progress has no reference. The serial active window
// provides best-effort UI ownership, not a financial decision: a queued
// callback, including removal, may briefly show stale progress in a later window.
// The listener only attempts a bounded, nonblocking enqueue.
listener?.Invoke(source, value);
}
}
}
internal sealed class TransactionStreamWriter
{
private readonly object gate = new object();
private readonly ConcurrentQueue<string> progress = new ConcurrentQueue<string>();
private readonly AutoResetEvent available = new AutoResetEvent(false);
private readonly Stream output;
private string terminal;
private bool completed;
private bool disconnected;
private bool delivering;
internal TransactionStreamWriter(Stream output) { this.output = output; }
internal void Progress(string source, string value)
{
if (!TransactionStreamFields.IsProgress(source, value)) return;
lock (gate)
{
if (completed || disconnected || progress.Count >= 128) return;
progress.Enqueue(JsonConvert.SerializeObject(new { type = "status", source, value }));
available.Set();
}
}
internal void Complete(object frame)
{
lock (gate)
{
if (completed || disconnected) return;
completed = true;
terminal = JsonConvert.SerializeObject(frame);
available.Set();
}
}
internal void Deliver()
{
lock (gate) { if (delivering) return; delivering = true; }
try
{
// One writer owns all progress and terminal writes.
using (var writer = new StreamWriter(output, new System.Text.UTF8Encoding(false), 1024, true))
{
while (true)
{
string line;
bool isTerminal = false;
lock (gate)
{
if (!progress.TryDequeue(out line) && completed)
{
line = terminal;
isTerminal = true;
}
}
if (line == null) { available.WaitOne(); continue; }
writer.WriteLine(line);
writer.Flush();
if (isTerminal) return;
}
}
}
catch (Exception ex) when (ex is IOException || ex is System.Net.HttpListenerException || ex is ObjectDisposedException)
{
lock (gate) { disconnected = true; }
}
finally
{
lock (gate)
{
disconnected = true;
available.Dispose();
}
}
}
}
internal static class TransactionStreamFields
{
internal static bool IsProgress(string source, string value)
{
switch (source)
{
case "UPDATE":
switch (value)
{
case "CardRequested": case "ProvideCard": case "InsertOrSwipeCard":
case "InsertOrContactless": case "InsertCard": case "SwipeCard":
case "CardRemovalRequested": case "CardRemovalEnforced":
case "PinEntryStarted": case "PinEntryPrompt": case "PinEntryInProgress":
case "PinEntryFailed": case "PinEntrySuccessful": case "TransactionStarted":
case "OnlineAuthRequested": case "OnlineAuthorizeRequest": case "OnlineAuthCompleted":
return true;
}
return false;
case "CARD_STATUS": return value == "Inserted" || value == "Tapped" || value == "Swiped";
case "SIGNATURE": return value == "Requested";
case "PAUSE": return value == "Paused";
default: return false;
}
}
internal static Dictionary<string, string> Required(Dictionary<string, string> fields)
{
var result = new Dictionary<string, string>();
// Receipts remain local to hardlink; no raw parameter bag reaches the kiosk.
foreach (var key in new[] { "TRANSACTION_RESULT", "ERRORS", "ERROR_DESCRIPTION",
"REFERENCE", "PAN_MASKED", "CARD_SCHEME_ID", "EXPIRY_DATE", "CARD_HASH",
"CARD_REFERENCE", "RECEIPT_DATA_CARDHOLDER" })
{
string value;
if (fields.TryGetValue(key, out value))
result[key] = key == "PAN_MASKED" ? MaskedCardNumber(value) : value;
}
return result;
}
internal static string MaskedCardNumber(string value)
{
int digits = 0, masks = 0;
foreach (char ch in value ?? "")
{
if (ch >= '0' && ch <= '9') digits++;
else if (ch == '*' || ch == 'x' || ch == 'X') masks++;
else if (ch != ' ' && ch != '-') return "";
}
return masks >= 4 && digits <= 10 ? value : "";
}
}
}