Tests and package name updated
This commit is contained in:
205
Runtime/API/FonepayApiClient.cs
Normal file
205
Runtime/API/FonepayApiClient.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/API/FonepayApiClient.cs.meta
Normal file
2
Runtime/API/FonepayApiClient.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 3dfb04f3804724b2d92e02816ddc53f2
|
||||
217
Runtime/API/FonepayWebsocketClient.cs
Normal file
217
Runtime/API/FonepayWebsocketClient.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
}
|
||||
2
Runtime/API/FonepayWebsocketClient.cs.meta
Normal file
2
Runtime/API/FonepayWebsocketClient.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 58200a692447f4f10a39df731fa8a521
|
||||
Reference in New Issue
Block a user