Fixes and sample
This commit is contained in:
@@ -32,18 +32,18 @@ namespace Darkmatter.Fonepay
|
||||
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 = request.prn,
|
||||
prn = prn,
|
||||
remarks1 = request.remarks1,
|
||||
remarks2 = request.remarks2,
|
||||
pm = request.pm,
|
||||
merchantCode = _config.MerchantCode,
|
||||
username = _config.Username,
|
||||
password = _config.GetPassword(),
|
||||
dataValidation = _signer.SignQrRequest(
|
||||
amountStr, request.prn, _config.MerchantCode,
|
||||
amountStr, prn, _config.MerchantCode,
|
||||
request.remarks1, request.remarks2),
|
||||
};
|
||||
var response = await SendPostAsync<QrResponse>(QrPath, payload, ct);
|
||||
|
||||
@@ -1,19 +1,211 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Net.WebSockets;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
public class FonepayWebsocketClient : MonoBehaviour
|
||||
internal sealed class FonepayWebsocketClient : IDisposable
|
||||
{
|
||||
// Start is called once before the first execution of Update after the MonoBehaviour is created
|
||||
void Start()
|
||||
internal event Action<bool> OnQrVerified;
|
||||
internal event Action<WebsocketMessage<QRPaymentStatus>> OnPaymentReceived;
|
||||
internal event Action<string> OnRawMessage;
|
||||
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
// Update is called once per frame
|
||||
void Update()
|
||||
private async Task ReceiveLoop(
|
||||
ClientWebSocket client,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
var buffer = new byte[4096];
|
||||
|
||||
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)
|
||||
{
|
||||
// Network disconnect or broken socket.
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
// Socket disposed during shutdown.
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.WriteLine($"WebSocket receive error: {ex.Message}");
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -27,7 +28,7 @@ namespace Darkmatter.Fonepay
|
||||
/// <param name="req"></param>
|
||||
/// <param name="ct"></param>
|
||||
/// <returns></returns>
|
||||
public Task<QrResult> PostQRAsync(QrRequest req, CancellationToken ct = default)
|
||||
public Task<QrResult> PurchaseAsync(QrRequest req, CancellationToken ct = default)
|
||||
=> _api.PostQRAsync(req, ct);
|
||||
|
||||
/// <summary>
|
||||
@@ -47,5 +48,39 @@ namespace Darkmatter.Fonepay
|
||||
/// <returns></returns>
|
||||
public Task<TaxRefundResponse> PostTaxRefundAsync(TaxRefundRequest req, CancellationToken ct = default)
|
||||
=> _api.PostTaxRefundAsync(req, ct);
|
||||
|
||||
/// <summary>
|
||||
/// Connects to the QR websocket and awaits the terminal payment frame, then auto-disconnects.
|
||||
/// Pass <see cref="QrResult.thirdpartyQrWebSocketUrl"/> from <see cref="PurchaseAsync"/>.
|
||||
/// </summary>
|
||||
public async Task<QRPaymentStatus> AwaitPaymentAsync(
|
||||
string websocketUrl,
|
||||
Action<bool> onQrVerified = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
if (string.IsNullOrEmpty(websocketUrl))
|
||||
throw new ArgumentException("Websocket URL required", nameof(websocketUrl));
|
||||
|
||||
using var ws = new FonepayWebsocketClient();
|
||||
var tcs = new TaskCompletionSource<QRPaymentStatus>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
|
||||
if (onQrVerified != null)
|
||||
ws.OnQrVerified += onQrVerified;
|
||||
|
||||
ws.OnPaymentReceived += msg => tcs.TrySetResult(msg.Status);
|
||||
|
||||
using var ctReg = ct.Register(() => tcs.TrySetCanceled(ct));
|
||||
|
||||
try
|
||||
{
|
||||
await ws.ConnectAsync(websocketUrl, ct);
|
||||
return await tcs.Task;
|
||||
}
|
||||
finally
|
||||
{
|
||||
await ws.DisconnectAsync();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -5,11 +5,18 @@ namespace Darkmatter.Fonepay
|
||||
public sealed class FonepayError : Exception
|
||||
{
|
||||
public int ErrorCode { get; }
|
||||
public string Docs { get; }
|
||||
|
||||
public FonepayError(int errorCode, string message,
|
||||
string docs)
|
||||
public FonepayError(int errorCode, string message, string docs)
|
||||
: base(message)
|
||||
{
|
||||
ErrorCode = errorCode;
|
||||
Docs = docs ?? throw new ArgumentNullException(nameof(docs));
|
||||
}
|
||||
|
||||
public override string ToString()
|
||||
{
|
||||
return $"{base.ToString()} (ErrorCode: {ErrorCode}, Docs: {Docs})";
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -10,10 +10,8 @@ namespace Darkmatter.Fonepay
|
||||
public struct QrRequest
|
||||
{
|
||||
public float amount;
|
||||
public string prn;
|
||||
public string remarks1;
|
||||
public string remarks2;
|
||||
public string pm;
|
||||
}
|
||||
|
||||
[Serializable]
|
||||
@@ -23,7 +21,6 @@ namespace Darkmatter.Fonepay
|
||||
public string prn;
|
||||
public string remarks1;
|
||||
public string remarks2;
|
||||
public string pm;
|
||||
public string merchantCode;
|
||||
public string dataValidation;
|
||||
public string username;
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 0b231c6bbbdf4b519ac94e2d745b84c4
|
||||
timeCreated: 1778143935
|
||||
@@ -0,0 +1,9 @@
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
public enum PaymentOutcome
|
||||
{
|
||||
Failed,
|
||||
Complete,
|
||||
CancelledByUser
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
fileFormatVersion: 2
|
||||
guid: b18f877972094e3993a95d2ae363f12a
|
||||
@@ -0,0 +1,27 @@
|
||||
using System;
|
||||
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
[Serializable]
|
||||
public struct QRPaymentStatus
|
||||
{
|
||||
public string remarks1;
|
||||
public string remarks2;
|
||||
public DateTime transactionDate;
|
||||
public string productNumber;
|
||||
public float amount;
|
||||
public string message;
|
||||
public bool success;
|
||||
public string commissionType;
|
||||
public float commissionAmount;
|
||||
public float totalCalculatedAmount;
|
||||
public bool paymentSuccess;
|
||||
|
||||
public PaymentOutcome Outcome => (success, paymentSuccess) switch
|
||||
{
|
||||
(true, true) => PaymentOutcome.Complete,
|
||||
(true, false) => PaymentOutcome.CancelledByUser,
|
||||
(false, _) => PaymentOutcome.Failed,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: 5ee6badc7f924737865c86113de9d2ac
|
||||
timeCreated: 1778144200
|
||||
@@ -0,0 +1,12 @@
|
||||
using System;
|
||||
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
[Serializable]
|
||||
public struct QRVerificationStatus
|
||||
{
|
||||
public bool success;
|
||||
public string message;
|
||||
public bool qrVerified;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f26424156fc543c487793821806dd8c7
|
||||
timeCreated: 1778144700
|
||||
@@ -0,0 +1,9 @@
|
||||
using System;
|
||||
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
[Serializable]
|
||||
public struct TransactionStatus
|
||||
{
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: d83df4253afa4d4b968ad3b8c88da203
|
||||
timeCreated: 1778143652
|
||||
@@ -0,0 +1,15 @@
|
||||
using System;
|
||||
using UnityEngine;
|
||||
|
||||
namespace Darkmatter.Fonepay
|
||||
{
|
||||
[Serializable]
|
||||
public struct WebsocketMessage<T>
|
||||
{
|
||||
public string merchantId;
|
||||
public string deviceId;
|
||||
public string transactionStatus;
|
||||
private T _status;
|
||||
public T Status => _status ??= JsonUtility.FromJson<T>(transactionStatus);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
fileFormatVersion: 2
|
||||
guid: f74983b79502486a891686f80f33737c
|
||||
timeCreated: 1778143616
|
||||
Reference in New Issue
Block a user