Tests and package name updated

This commit is contained in:
Savya Bikram Shah
2026-05-07 17:42:48 +05:45
commit 270d6a69ae
92 changed files with 2169 additions and 0 deletions

View File

@@ -0,0 +1,205 @@
using System;
using System.Globalization;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
using UnityEngine.Networking;
namespace Darkmatter.Fonepay
{
/// <summary>
/// HTTP layer. Auto-injects merchantCode/username/password from FonepayConfigSO and
/// computes the HMAC SHA-512 dataValidation before each request. Callers never set
/// credentials on request structs.
/// </summary>
internal sealed class FonepayApiClient
{
private const string QrPath = "merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrDownload";
private const string StatusPath = "merchant/merchantDetailsForThirdParty/thirdPartyDynamicQrGetStatus";
private const string TaxRefundPath = "merchant/merchantDetailsForThirdParty/taxRefund";
private readonly FonepayConfigSO _config;
private readonly HmacSha512Signer _signer;
internal FonepayApiClient(FonepayConfigSO config, HmacSha512Signer signer)
{
_config = config ?? throw new ArgumentNullException(nameof(config));
_signer = signer ?? throw new ArgumentNullException(nameof(signer));
}
// ── public API ────────────────────────────────────────────────────
internal async Task<QrResult> PostQRAsync(QrRequest request, CancellationToken ct)
{
var amountStr = request.amount.ToString("0.00", CultureInfo.InvariantCulture);
var prn = GUID.Generate().ToString();
var payload = new QrRequestPayload
{
amount = request.amount,
prn = prn,
remarks1 = request.remarks1,
remarks2 = request.remarks2,
merchantCode = _config.MerchantCode,
username = _config.Username,
password = _config.GetPassword(),
dataValidation = _signer.SignQrRequest(
amountStr, prn, _config.MerchantCode,
request.remarks1, request.remarks2),
};
var response = await SendPostAsync<QrResponse>(QrPath, payload, ct);
return MapToQrResult(response);
}
internal async Task<QrResult> GetStatusAsync(string prn, CancellationToken ct)
{
var sig = _signer.SignStatusCheck(prn, _config.MerchantCode);
var url = $"{_config.ResolveBaseUrl()}{StatusPath}" +
$"?prn={UnityWebRequest.EscapeURL(prn)}" +
$"&merchantCode={UnityWebRequest.EscapeURL(_config.MerchantCode)}" +
$"&dataValidation={UnityWebRequest.EscapeURL(sig)}" +
$"&username={UnityWebRequest.EscapeURL(_config.Username)}" +
$"&password={UnityWebRequest.EscapeURL(_config.GetPassword())}";
var response = await SendAsync<QrResponse>(url, UnityWebRequest.kHttpVerbGET, null, ct);
return MapToQrResult(response);
}
internal Task<TaxRefundResponse> PostTaxRefundAsync(TaxRefundRequest r, CancellationToken ct)
{
var invoiceDate = r.invoiceDate.ToString("yyyy-MM-dd", CultureInfo.InvariantCulture);
var amountStr = r.transactionAmount.ToString("0.00", CultureInfo.InvariantCulture);
var payload = new TaxRefundRequestPayload
{
fonepayTraceId = r.fonepayTraceId,
merchantPRN = r.merchantPRN,
invoiceNumber = r.invoiceNumber,
invoiceDate = invoiceDate,
transactionAmount = r.transactionAmount,
merchantCode = _config.MerchantCode,
username = _config.Username,
password = _config.GetPassword(),
dataValidation = _signer.SignTaxRefund(
r.fonepayTraceId, r.merchantPRN, r.invoiceNumber,
invoiceDate, amountStr, _config.MerchantCode),
};
return SendPostAsync<TaxRefundResponse>(TaxRefundPath, payload, ct);
}
// ── transport ─────────────────────────────────────────────────────
private Task<T> SendPostAsync<T>(string relativePath, object body, CancellationToken ct)
{
var url = _config.ResolveBaseUrl() + relativePath;
var json = JsonUtility.ToJson(body);
return SendAsync<T>(url, UnityWebRequest.kHttpVerbPOST, json, ct);
}
private static Task<T> SendAsync<T>(string url, string method, string jsonBody, CancellationToken ct)
{
var tcs = new TaskCompletionSource<T>(TaskCreationOptions.RunContinuationsAsynchronously);
var req = new UnityWebRequest(url, method)
{
downloadHandler = new DownloadHandlerBuffer(),
};
if (!string.IsNullOrEmpty(jsonBody))
{
req.uploadHandler = new UploadHandlerRaw(System.Text.Encoding.UTF8.GetBytes(jsonBody));
req.SetRequestHeader("Content-Type", "application/json");
}
req.SetRequestHeader("Accept", "application/json");
CancellationTokenRegistration reg = default;
if (ct.CanBeCanceled)
reg = ct.Register(() =>
{
try
{
req.Abort();
}
catch
{
/* ignored */
}
});
var op = req.SendWebRequest();
op.completed += _ =>
{
try
{
if (ct.IsCancellationRequested)
{
tcs.TrySetCanceled(ct);
return;
}
#if UNITY_2020_2_OR_NEWER
var failed = req.result != UnityWebRequest.Result.Success;
#else
var failed = req.isHttpError || req.isNetworkError;
#endif
if (failed)
{
tcs.TrySetException(new FonepayError(
(int)req.responseCode,
$"HTTP {(int)req.responseCode} {req.error}: {req.downloadHandler?.text}",
url));
return;
}
var text = req.downloadHandler.text ?? string.Empty;
T result;
try
{
result = JsonUtility.FromJson<T>(text);
}
catch (Exception e)
{
tcs.TrySetException(new FonepayError(0,
$"JSON parse failed: {e.Message}. Body: {text}", url));
return;
}
tcs.TrySetResult(result);
}
finally
{
reg.Dispose();
req.Dispose();
}
};
return tcs.Task;
}
private QrResult MapToQrResult(QrResponse response)
{
return new QrResult
{
message = response.message,
qrCode = TryGenerateQr(response.qrMessage),
status = response.status,
statusCode = response.statusCode,
success = response.success,
thirdpartyQrWebSocketUrl = response.thirdpartyQrWebSocketUrl,
qrMessage = response.qrMessage,
};
}
private static Texture2D TryGenerateQr(string qrMessage)
{
if (string.IsNullOrEmpty(qrMessage))
return null;
try
{
return FonepayQRGenerator.GenerateTexture(qrMessage);
}
catch (Exception ex)
{
Debug.LogWarning($"Fonepay QR render failed ({ex.Message}). " +
"Use QrResult.qrMessage with an external renderer.");
return null;
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3dfb04f3804724b2d92e02816ddc53f2

View File

@@ -0,0 +1,217 @@
using System;
using System.IO;
using System.Net.WebSockets;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using UnityEngine;
namespace Darkmatter.Fonepay
{
internal sealed class FonepayWebsocketClient : IDisposable
{
internal event Action<bool> OnQrVerified;
internal event Action<WebsocketMessage<QRPaymentStatus>> OnPaymentReceived;
internal event Action<string> OnRawMessage;
internal event Action<Exception> OnClosed;
private ClientWebSocket _client;
private CancellationTokenSource _cts;
private Task _receiveTask;
private bool _disposed;
internal async Task ConnectAsync(string url, CancellationToken cancellationToken)
{
ThrowIfDisposed();
if (_client != null)
throw new InvalidOperationException("WebSocket is already connected or connecting.");
_cts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
var client = new ClientWebSocket();
try
{
await client.ConnectAsync(new Uri(url), _cts.Token);
_client = client;
_receiveTask = ReceiveLoop(client, _cts.Token);
}
catch
{
client.Dispose();
_cts.Dispose();
_cts = null;
throw;
}
}
private async Task ReceiveLoop(
ClientWebSocket client,
CancellationToken cancellationToken)
{
var buffer = new byte[4096];
Exception error = null;
try
{
while (
client.State == WebSocketState.Open &&
!cancellationToken.IsCancellationRequested)
{
string message = await ReceiveFullMessageAsync(client, buffer, cancellationToken);
if (message == null)
break;
HandleMessage(message);
}
}
catch (OperationCanceledException)
{
// Expected during disconnect.
}
catch (WebSocketException ex)
{
error = ex;
}
catch (ObjectDisposedException)
{
// Socket disposed during shutdown.
}
catch (Exception ex)
{
error = ex;
}
finally
{
OnClosed?.Invoke(error);
}
}
private void HandleMessage(string message)
{
OnRawMessage?.Invoke(message);
var envelope = JsonUtility.FromJson<WebsocketMessage<QRPaymentStatus>>(message);
if (string.IsNullOrEmpty(envelope.transactionStatus))
return;
if (envelope.transactionStatus.Contains("qrVerified"))
{
var v = JsonUtility.FromJson<QRVerificationStatus>(envelope.transactionStatus);
OnQrVerified?.Invoke(v.qrVerified);
}
else if (envelope.transactionStatus.Contains("paymentSuccess"))
{
OnPaymentReceived?.Invoke(envelope);
}
}
private static async Task<string> ReceiveFullMessageAsync(
ClientWebSocket client,
byte[] buffer,
CancellationToken cancellationToken)
{
using var ms = new MemoryStream();
while (true)
{
WebSocketReceiveResult result = await client.ReceiveAsync(
new ArraySegment<byte>(buffer),
cancellationToken
);
if (result.MessageType == WebSocketMessageType.Close)
{
if (client.State == WebSocketState.CloseReceived)
{
await client.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Closing",
CancellationToken.None
);
}
return null;
}
ms.Write(buffer, 0, result.Count);
if (result.EndOfMessage)
break;
}
return Encoding.UTF8.GetString(ms.ToArray());
}
internal async Task DisconnectAsync()
{
if (_client == null)
return;
ClientWebSocket client = _client;
_client = null;
try
{
_cts?.Cancel();
if (client.State == WebSocketState.Open ||
client.State == WebSocketState.CloseReceived)
{
await client.CloseAsync(
WebSocketCloseStatus.NormalClosure,
"Client closing",
CancellationToken.None
);
}
}
catch
{
client.Abort();
}
finally
{
client.Dispose();
_cts?.Dispose();
_cts = null;
_receiveTask = null;
}
}
public void Dispose()
{
if (_disposed)
return;
_disposed = true;
try
{
_cts?.Cancel();
_client?.Abort();
}
catch
{
// Ignore cleanup errors.
}
finally
{
_client?.Dispose();
_client = null;
_cts?.Dispose();
_cts = null;
}
}
private void ThrowIfDisposed()
{
if (_disposed)
throw new ObjectDisposedException(nameof(FonepayWebsocketClient));
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 58200a692447f4f10a39df731fa8a521