Added docs and test

This commit is contained in:
Savya Bikram Shah
2026-05-07 17:33:26 +05:45
parent 3c17829453
commit 9f620084b2
112 changed files with 589 additions and 574 deletions

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: cfc7d75f72cd74bfeb9c54fc8c8675bc
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

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

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: bae1e38d737bf4e26ac9a9e3c76f6b25
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,49 @@
using System.Threading.Tasks;
namespace Darkmatter.Fonepay
{
#if UNITASK_SUPPORT
using Cysharp.Threading.Tasks;
public readonly struct FonepayAsync<T>
{
private readonly UniTask<T> _task;
internal FonepayAsync(UniTask<T> task) => _task = task;
// UniTask<T>.Awaiter is a public named type — no problem
public UniTask<T>.Awaiter GetAwaiter() => _task.GetAwaiter();
}
public readonly struct FonepayAsync
{
private readonly UniTask _task;
internal FonepayAsync(UniTask task) => _task = task;
public UniTask.Awaiter GetAwaiter() => _task.GetAwaiter();
}
#else
// Unity 2023.1+ path — Awaitable<T>.GetAwaiter() return type is internal,
// so we store Task<T> and convert lazily via an async wrapper.
// The wrapper's return type is inferred — we never name the awaiter.
public readonly struct FonepayAsync<T>
{
private readonly System.Threading.Tasks.Task<T> _task;
internal FonepayAsync(System.Threading.Tasks.Task<T> task) => _task = task;
// Return type inferred from the async method — compiler handles it
public System.Runtime.CompilerServices.TaskAwaiter<T> GetAwaiter()
=> _task.GetAwaiter();
}
public readonly struct FonepayAsync
{
private readonly System.Threading.Tasks.Task _task;
internal FonepayAsync(System.Threading.Tasks.Task task) => _task = task;
public System.Runtime.CompilerServices.TaskAwaiter GetAwaiter()
=> _task.GetAwaiter();
}
#endif
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e222c5804d46c4c138b5a413587344f4

View File

@@ -0,0 +1,27 @@
using System.Threading;
using System.Threading.Tasks;
#if UNITASK_SUPPORT
using Cysharp.Threading.Tasks;
#endif
namespace Darkmatter.Fonepay
{
internal static class FonepayAsyncBridge
{
#if UNITASK_SUPPORT
internal static FonepayAsync<T> Wrap<T>(Task<T> task, CancellationToken ct = default)
=> new FonepayAsync<T>(task.AsUniTask().AttachExternalCancellation(ct));
internal static FonepayAsync Wrap(Task task, CancellationToken ct = default)
=> new FonepayAsync(task.AsUniTask().AttachExternalCancellation(ct));
#else
// Task is already awaitable — wrap directly, no Awaitable needed
internal static FonepayAsync<T> Wrap<T>(Task<T> task, CancellationToken ct = default)
=> new FonepayAsync<T>(task);
internal static FonepayAsync Wrap(Task task, CancellationToken ct = default)
=> new FonepayAsync(task);
#endif
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 778cc7d92ed1645c195685b07ca02ee1

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: a5ca890ee6ddf43ebb0a47f228ffe838
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,15 @@
using UnityEngine;
namespace Darkmatter.Fonepay
{
/// <summary>
/// Transient ScriptableObject containing baked secrets for player builds.
/// Created by FonepayBuildSecretsInjector before build, deleted after build completes.
/// Never commit this asset — added to .gitignore by the settings window.
/// </summary>
public class FonepayBakedSecrets : ScriptableObject
{
public string password;
public string secretKey;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: f6baad01d5e6740bfb8abc8faadfe594

View File

@@ -0,0 +1,94 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Darkmatter.Fonepay
{
/// <summary>
/// Public facade. Auto-loads config + secrets via <see cref="FonepayConfig.Load"/>.
/// Caller never touches credentials.
/// </summary>
public sealed class FonepayClient
{
private readonly FonepayApiClient _api;
public FonepayClient() : this(FonepayConfig.Load())
{
}
public FonepayClient(FonepayConfigSO config)
{
var signer = new HmacSha512Signer(config.GetSecretKey());
_api = new FonepayApiClient(config, signer);
}
/// <summary>
/// Request a new QR code. The returned URL is valid for 15 minutes. Call GetStatusAsync() to check if the QR code has been paid.
/// </summary>
/// <param name="req"></param>
/// <param name="ct"></param>
/// <returns></returns>
public Task<QrResult> PurchaseAsync(QrRequest req, CancellationToken ct = default)
=> _api.PostQRAsync(req, ct);
/// <summary>
/// Check if a QR code has been paid. Call after PostQRAsync() to check if the QR code has been paid. Returns "PAID" if successful, "UNPAID" if not yet paid, or an error message if the PRN is invalid or expired.
/// </summary>
/// <param name="prn"></param>
/// <param name="ct"></param>
/// <returns></returns>
public Task<QrResult> GetStatusAsync(string prn, CancellationToken ct = default)
=> _api.GetStatusAsync(prn, ct);
/// <summary>
/// Request a tax refund. Returns "REFUND_SUCCESS" if successful, or an error message if the PRN is invalid, expired, or not eligible for refund.
/// </summary>
/// <param name="req"></param>
/// <param name="ct"></param>
/// <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);
ws.OnClosed += err =>
{
if (err != null)
tcs.TrySetException(err);
else
tcs.TrySetException(new InvalidOperationException(
"Fonepay websocket closed before payment frame received."));
};
using var ctReg = ct.Register(() => tcs.TrySetCanceled(ct));
try
{
await ws.ConnectAsync(websocketUrl, ct);
return await tcs.Task;
}
finally
{
await ws.DisconnectAsync();
}
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: e1ab3a2c9f1aa4d5aa87b6305323a1fc

View File

@@ -0,0 +1,61 @@
using System;
using UnityEngine;
namespace Darkmatter.Fonepay
{
/// <summary>
/// Static accessor. Returns ready-to-use <see cref="FonepayConfigSO"/> with credentials injected.
/// Editor: secrets supplied via <see cref="EditorSecretsProvider"/> hook (set by FonepaySecretsStore on editor load).
/// Builds: secrets read from <see cref="FonepayBakedSecrets"/> resource (written by build preprocessor, removed after build).
/// </summary>
public static class FonepayConfig
{
private const string ResourcePath = "FonepayConfig";
private const string BakedSecretsResource = "FonepayBakedSecrets";
public static Func<(string password, string secretKey)> EditorSecretsProvider;
private static FonepayConfigSO _cached;
public static FonepayConfigSO Load()
{
if (_cached != null) return _cached;
var so = Resources.Load<FonepayConfigSO>(ResourcePath);
if (so == null)
throw new FonepayError(0,
"FonepayConfig asset missing. Open Tools > Fonepay > Settings to create it.",
"FonepayConfig.Load");
string password = null;
string secretKey = null;
if (Application.isEditor && EditorSecretsProvider != null)
{
var creds = EditorSecretsProvider();
password = creds.password;
secretKey = creds.secretKey;
}
else
{
var baked = Resources.Load<FonepayBakedSecrets>(BakedSecretsResource);
if (baked != null)
{
password = baked.password;
secretKey = baked.secretKey;
}
}
if (string.IsNullOrEmpty(password) || string.IsNullOrEmpty(secretKey))
throw new FonepayError(0,
"Fonepay credentials missing. Open Tools > Fonepay > Settings.",
"FonepayConfig.Load");
so.SetCredentials(password, secretKey);
_cached = so;
return so;
}
public static void Invalidate() => _cached = null;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 3617f631547ae4b10a0f8f634751c61f

View File

@@ -0,0 +1,59 @@
using System;
using UnityEngine;
namespace Darkmatter.Fonepay
{
// <summary>
// ScriptableObject. Holds non-secret config fields serialised to disk. Secret key and password are injected at runtime only — never serialised.
// </summary>
public class FonepayConfigSO : ScriptableObject
{
[SerializeField] private FonepayEnvironment environment;
[SerializeField] private string merchantCode;
[SerializeField] private string username;
private string _password;
private string _secretKey;
private bool _credentialsSet;
// ── public read access ────────────────────────────────────────────
public FonepayEnvironment Environment => environment;
public string MerchantCode => merchantCode;
public string Username => username;
// ── runtime credential injection (called by FonepayPlayModeInjector) ──
public void SetCredentials(string password, string secretKey)
{
_password = password;
_secretKey = secretKey;
_credentialsSet = true;
}
private const string FonepayLiveEndpoint = "https://merchantapi.fonepay.com/api/";
private const string FonepayDevEndpoint = "https://dev-merchantapi.fonepay.com/api/";
// ── used internally by FonepayApiClient and HmacSha512Signer ─────
internal string GetPassword() => GuardCredentials(_password);
internal string GetSecretKey() => GuardCredentials(_secretKey);
// ── URL resolution ────────────────────────────────────────────────
public string ResolveBaseUrl() => environment == FonepayEnvironment.Live
? FonepayLiveEndpoint
: FonepayDevEndpoint;
// ── guards ────────────────────────────────────────────────────────
private string GuardCredentials(string value)
{
if (!_credentialsSet)
throw new FonepayError(0,
"Credentials not set. Call SetCredentials() before using FonepayClient.",
"FonepayConfig.SetCredentials");
return value;
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: ba80c86a04c584a649c6d31e8f10adad

View File

@@ -0,0 +1,8 @@
namespace Darkmatter.Fonepay
{
public enum FonepayEnvironment
{
Dev,
Live
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: c92e6dd7cf3a84c1e85fa2f19741794e

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 74b5b5675851d481a89f7c6136b159d7
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,47 @@
using System.Security.Cryptography;
using System.Text;
internal sealed class HmacSha512Signer
{
private readonly byte[] _keyBytes;
internal HmacSha512Signer(string secretKey)
{
_keyBytes = Encoding.UTF8.GetBytes(secretKey);
}
internal string SignQrRequest(
string amount, string prn, string merchantCode,
string remarks1, string remarks2)
=> Compute($"{amount},{prn},{merchantCode},{remarks1},{remarks2}");
internal string SignQrRequestWithTax(
string amount, string prn, string merchantCode,
string remarks1, string remarks2,
string taxAmount, string taxRefund)
=> Compute($"{amount},{prn},{merchantCode},{remarks1},{remarks2},{taxAmount},{taxRefund}");
internal string SignStatusCheck(string prn, string merchantCode)
=> Compute($"{prn},{merchantCode}");
internal string SignTaxRefund(
string fonepayTraceId, string merchantPrn,
string invoiceNumber, string invoiceDate,
string transactionAmount, string merchantCode)
=> Compute($"{fonepayTraceId},{merchantPrn},{invoiceNumber},{invoiceDate},{transactionAmount},{merchantCode}");
private string Compute(string message)
{
using var hmac = new HMACSHA512(_keyBytes);
byte[] hash = hmac.ComputeHash(Encoding.UTF8.GetBytes(message));
return BytesToHexLower(hash);
}
private static string BytesToHexLower(byte[] bytes)
{
var sb = new StringBuilder(bytes.Length * 2);
foreach (byte b in bytes)
sb.Append(b.ToString("x2"));
return sb.ToString();
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 93fb47df723f74372b0fa6e228e4dd73

View File

@@ -0,0 +1,22 @@
{
"name": "Darkmatter.FonepayUnity",
"rootNamespace": "Darkmatter.Fonepay",
"references": [
"UniTask"
],
"includePlatforms": [],
"excludePlatforms": [],
"allowUnsafeCode": false,
"overrideReferences": false,
"precompiledReferences": [],
"autoReferenced": true,
"defineConstraints": [],
"versionDefines": [
{
"name": "com.cysharp.unitask",
"expression": "",
"define": "UNITASK_SUPPORT"
}
],
"noEngineReferences": false
}

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: de841957485ec4208a629f66aa4b24c9
AssemblyDefinitionImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: c216a6be596b84649a99f22d45ca0ce5
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,22 @@
using System;
namespace Darkmatter.Fonepay
{
public sealed class FonepayError : Exception
{
public int ErrorCode { get; }
public string Docs { get; }
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})";
}
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 46adc238f3552442bad6f68d9b2ee68a

View File

@@ -0,0 +1,29 @@
using System;
namespace Darkmatter.Fonepay
{
/// <summary>
/// User-facing QR request. Credentials (merchantCode/username/password) and the
/// HMAC dataValidation are injected by FonepayApiClient at send time.
/// </summary>
[Serializable]
public struct QrRequest
{
public float amount;
public string remarks1;
public string remarks2;
}
[Serializable]
internal struct QrRequestPayload
{
public float amount;
public string prn;
public string remarks1;
public string remarks2;
public string merchantCode;
public string dataValidation;
public string username;
public string password;
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 255c2e13d0ed54987904cd65a5349d29

View File

@@ -0,0 +1,29 @@
using System;
using UnityEngine;
namespace Darkmatter.Fonepay
{
[Serializable]
public struct QrResult
{
public string message;
public Texture2D qrCode;
public string status;
public int statusCode;
public bool success;
public string thirdpartyQrWebSocketUrl;
public string qrMessage;
}
[Serializable]
internal struct QrResponse
{
public string message;
public string qrMessage;
public string status;
public int statusCode;
public bool success;
public string thirdpartyQrWebSocketUrl;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 405247a27c684f08ab11d6ea33d250c0
timeCreated: 1778138521

View File

@@ -0,0 +1,32 @@
using System;
namespace Darkmatter.Fonepay
{
/// <summary>
/// User-facing tax refund request. Credentials and HMAC dataValidation are
/// injected by FonepayApiClient at send time.
/// </summary>
[Serializable]
public struct TaxRefundRequest
{
public string fonepayTraceId;
public string merchantPRN;
public string invoiceNumber;
public DateTime invoiceDate;
public float transactionAmount;
}
[Serializable]
internal struct TaxRefundRequestPayload
{
public string fonepayTraceId;
public string merchantPRN;
public string invoiceNumber;
public string invoiceDate;
public float transactionAmount;
public string merchantCode;
public string dataValidation;
public string username;
public string password;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 328f1e46b5a941bbbf5c0e8294fc9c34
timeCreated: 1778140313

View File

@@ -0,0 +1,9 @@
namespace Darkmatter.Fonepay
{
public struct TaxRefundResponse
{
public string fonepayTraceId;
public string message;
public bool success;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: ccdcd51d0dc843eabdb25aa3312bd91a
timeCreated: 1778140771

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 0b231c6bbbdf4b519ac94e2d745b84c4
timeCreated: 1778143935

View File

@@ -0,0 +1,9 @@
namespace Darkmatter.Fonepay
{
public enum PaymentOutcome
{
Failed,
Complete,
CancelledByUser
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: b18f877972094e3993a95d2ae363f12a

View File

@@ -0,0 +1,27 @@
using System;
namespace Darkmatter.Fonepay
{
[Serializable]
public struct QRPaymentStatus
{
public string remarks1;
public string remarks2;
public string 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,
};
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: 5ee6badc7f924737865c86113de9d2ac
timeCreated: 1778144200

View File

@@ -0,0 +1,12 @@
using System;
namespace Darkmatter.Fonepay
{
[Serializable]
public struct QRVerificationStatus
{
public bool success;
public string message;
public bool qrVerified;
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f26424156fc543c487793821806dd8c7
timeCreated: 1778144700

View File

@@ -0,0 +1,9 @@
using System;
namespace Darkmatter.Fonepay
{
[Serializable]
public struct TransactionStatus
{
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: d83df4253afa4d4b968ad3b8c88da203
timeCreated: 1778143652

View File

@@ -0,0 +1,14 @@
using System;
using UnityEngine;
namespace Darkmatter.Fonepay
{
[Serializable]
public struct WebsocketMessage<T>
{
public string merchantId;
public string deviceId;
public string transactionStatus;
public T Status => JsonUtility.FromJson<T>(transactionStatus);
}
}

View File

@@ -0,0 +1,3 @@
fileFormatVersion: 2
guid: f74983b79502486a891686f80f33737c
timeCreated: 1778143616

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: debdfefd8b01242e8b95f0e29bb09c0d
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright (c) 2013-2025 Raffael Herrmann
Copyright (c) 2024-2025 Shane Krueger
Permission is hereby granted, free of charge, to any person obtaining a copy of
this software and associated documentation files (the "Software"), to deal in
the Software without restriction, including without limitation the rights to
use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of
the Software, and to permit persons to whom the Software is furnished to do so,
subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS
FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR
COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER
IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN
CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 3902a28335b7043bf831aab193145f4c
TextScriptImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,33 @@
fileFormatVersion: 2
guid: ecb337d209ad74a598de572b4e034307
PluginImporter:
externalObjects: {}
serializedVersion: 2
iconMap: {}
executionOrder: {}
defineConstraints: []
isPreloaded: 0
isOverridable: 0
isExplicitlyReferenced: 0
validateReferences: 1
platformData:
- first:
Any:
second:
enabled: 1
settings: {}
- first:
Editor: Editor
second:
enabled: 0
settings:
DefaultValueInitialized: true
- first:
Windows Store Apps: WindowsStoreApps
second:
enabled: 0
settings:
CPU: AnyCPU
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,8 @@
fileFormatVersion: 2
guid: 47a1e64b2c84a4238b5cc62aa4c8fb80
folderAsset: yes
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -0,0 +1,89 @@
using System.Collections;
using QRCoder;
using UnityEngine;
namespace Darkmatter.Fonepay
{
/// <summary>
/// QR code generator backed by QRCoder (MIT). See Plugins/QRCoder-LICENSE.txt.
/// </summary>
public static class FonepayQRGenerator
{
public enum EccLevel { L, M, Q, H }
public static Texture2D GenerateTexture(string text, int pixelSize = 10,
EccLevel ecc = EccLevel.M, Color? darkColor = null, Color? lightColor = null)
{
bool[,] matrix = GenerateMatrix(text, ecc);
int size = matrix.GetLength(0);
int texSize = size * pixelSize;
var tex = new Texture2D(texSize, texSize, TextureFormat.RGBA32, false)
{
filterMode = FilterMode.Point,
wrapMode = TextureWrapMode.Clamp
};
Color dark = darkColor ?? Color.black;
Color light = lightColor ?? Color.white;
var pixels = new Color[texSize * texSize];
for (int row = 0; row < size; row++)
for (int col = 0; col < size; col++)
{
Color c = matrix[row, col] ? dark : light;
int yBase = (size - 1 - row) * pixelSize;
int xBase = col * pixelSize;
for (int py = 0; py < pixelSize; py++)
{
int rowStart = (yBase + py) * texSize + xBase;
for (int px = 0; px < pixelSize; px++)
pixels[rowStart + px] = c;
}
}
tex.SetPixels(pixels);
tex.Apply();
return tex;
}
public static Sprite GenerateSprite(string text, int pixelSize = 10,
EccLevel ecc = EccLevel.M, Color? darkColor = null, Color? lightColor = null,
float pixelsPerUnit = 100f)
{
var tex = GenerateTexture(text, pixelSize, ecc, darkColor, lightColor);
var sprite = Sprite.Create(tex,
new Rect(0, 0, tex.width, tex.height),
new Vector2(0.5f, 0.5f),
pixelsPerUnit,
0, SpriteMeshType.FullRect);
sprite.name = "FonepayQR";
return sprite;
}
public static bool[,] GenerateMatrix(string text, EccLevel ecc = EccLevel.M)
{
if (string.IsNullOrEmpty(text))
throw new System.ArgumentException("QR text must be non-empty", nameof(text));
using var data = new QRCodeGenerator().CreateQrCode(text, MapEcc(ecc), forceUtf8: true);
int size = data.ModuleMatrix.Count;
var matrix = new bool[size, size];
for (int row = 0; row < size; row++)
{
BitArray bits = data.ModuleMatrix[row];
for (int col = 0; col < size; col++)
matrix[row, col] = bits[col];
}
return matrix;
}
static QRCodeGenerator.ECCLevel MapEcc(EccLevel e) => e switch
{
EccLevel.L => QRCodeGenerator.ECCLevel.L,
EccLevel.M => QRCodeGenerator.ECCLevel.M,
EccLevel.Q => QRCodeGenerator.ECCLevel.Q,
EccLevel.H => QRCodeGenerator.ECCLevel.H,
_ => QRCodeGenerator.ECCLevel.M
};
}
}

View File

@@ -0,0 +1,2 @@
fileFormatVersion: 2
guid: 926e00b59f70b418bbf937c11a1c8494