Tests and package name updated
This commit is contained in:
19
CHANGELOG.md
Normal file
19
CHANGELOG.md
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
# Changelog
|
||||||
|
All notable changes to this package will be documented in this file.
|
||||||
|
|
||||||
|
The format is based on [Keep a Changelog](http://keepachangelog.com/en/1.0.0/)
|
||||||
|
and this project adheres to [Semantic Versioning](http://semver.org/spec/v2.0.0.html).
|
||||||
|
|
||||||
|
## [0.1.0] - 2026-05-07
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `FonepayClient` façade: `PurchaseAsync`, `GetStatusAsync`, `AwaitPaymentAsync`, `PostTaxRefundAsync`.
|
||||||
|
- HMAC-SHA512 signing of all signed payloads.
|
||||||
|
- Editor tooling for credential management (Tools > Fonepay > Settings).
|
||||||
|
- Example sample under Package Manager > Samples.
|
||||||
|
- Edit-mode tests covering `WebsocketMessage<T>.Status` parsing and `PaymentOutcome` rules.
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
- `WebsocketMessage<T>.Status` returned default values due to invalid `??=` cache on generic struct. Now reparses on each access.
|
||||||
|
- `AwaitPaymentAsync` hung indefinitely when the server closed the websocket before a payment frame; now throws `InvalidOperationException`.
|
||||||
|
- `QRPaymentStatus.transactionDate` changed from `DateTime` to `string` (JsonUtility cannot deserialize `DateTime`).
|
||||||
7
CHANGELOG.md.meta
Normal file
7
CHANGELOG.md.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 447b7ed65abae475788ba71349c47ca1
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Documentation.meta
Normal file
8
Documentation.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d97f7922b43d34693a60a2786cda5aa3
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
36
Documentation/Fonepay Unity.md
Normal file
36
Documentation/Fonepay Unity.md
Normal file
@@ -0,0 +1,36 @@
|
|||||||
|
# Fonepay Unity
|
||||||
|
|
||||||
|
See [README](../README.md) for setup, API reference, and examples.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
| Layer | Type | Purpose |
|
||||||
|
|---|---|---|
|
||||||
|
| Public | `FonepayClient` | Façade. Auto-loads config + secrets. |
|
||||||
|
| API | `FonepayApiClient` | Signed REST calls (QR, status, tax refund). |
|
||||||
|
| Websocket | `FonepayWebsocketClient` | Receive-loop for QR verification + payment frames. |
|
||||||
|
| Crypto | `HmacSha512Signer` | HMAC-SHA512 dataValidation. |
|
||||||
|
| Models | `QrRequest`/`QrResult`/`QRPaymentStatus`/... | Serializable DTOs. |
|
||||||
|
|
||||||
|
## Flow
|
||||||
|
|
||||||
|
```
|
||||||
|
PurchaseAsync(req) ── POST QR ──▶ QrResult { qrCode, thirdpartyQrWebSocketUrl }
|
||||||
|
AwaitPaymentAsync(url) ── WS ──▶ QRPaymentStatus { Outcome }
|
||||||
|
├─ qrVerified frame → onQrVerified callback
|
||||||
|
└─ paymentSuccess frame → resolve
|
||||||
|
```
|
||||||
|
|
||||||
|
## Outcome rules
|
||||||
|
|
||||||
|
| `success` | `paymentSuccess` | `Outcome` |
|
||||||
|
|---|---|---|
|
||||||
|
| true | true | Complete |
|
||||||
|
| true | false | CancelledByUser |
|
||||||
|
| false | * | Failed |
|
||||||
|
|
||||||
|
## Termination
|
||||||
|
|
||||||
|
- Payment frame received → resolve normally.
|
||||||
|
- `CancellationToken` cancelled → `OperationCanceledException`, socket disconnects.
|
||||||
|
- Server closes socket before payment → `InvalidOperationException`.
|
||||||
7
Documentation/Fonepay Unity.md.meta
Normal file
7
Documentation/Fonepay Unity.md.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: db08287d38c1f43dea2c5c29e7185436
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Editor.meta
Normal file
8
Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 7b0399dbb25024efbbe82e3f53f61b7f
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
18
Editor/DarkmatterFonepayUnity.Editor.asmdef
Normal file
18
Editor/DarkmatterFonepayUnity.Editor.asmdef
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"name": "Darkmatter.FonepayUnity.Editor",
|
||||||
|
"rootNamespace": "Darkmatter.Fonepay.Editor",
|
||||||
|
"references": [
|
||||||
|
"Darkmatter.FonepayUnity"
|
||||||
|
],
|
||||||
|
"includePlatforms": [
|
||||||
|
"Editor"
|
||||||
|
],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": false,
|
||||||
|
"precompiledReferences": [],
|
||||||
|
"autoReferenced": true,
|
||||||
|
"defineConstraints": [],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
7
Editor/DarkmatterFonepayUnity.Editor.asmdef.meta
Normal file
7
Editor/DarkmatterFonepayUnity.Editor.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e655521fa332549829d66024acdbcc5c
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
43
Editor/FonepayBuildSecretsInjector.cs
Normal file
43
Editor/FonepayBuildSecretsInjector.cs
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
using System.IO;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEditor.Build;
|
||||||
|
using UnityEditor.Build.Reporting;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Bakes EditorPrefs secrets into a temporary Resources asset before player build,
|
||||||
|
/// then deletes the asset after the build (success or fail) so secrets never linger on disk.
|
||||||
|
/// </summary>
|
||||||
|
internal sealed class FonepayBuildSecretsInjector : IPreprocessBuildWithReport, IPostprocessBuildWithReport
|
||||||
|
{
|
||||||
|
private const string ResourcesDir = "Assets/Resources";
|
||||||
|
private const string AssetPath = "Assets/Resources/FonepayBakedSecrets.asset";
|
||||||
|
|
||||||
|
public int callbackOrder => 0;
|
||||||
|
|
||||||
|
public void OnPreprocessBuild(BuildReport report)
|
||||||
|
{
|
||||||
|
if (!FonepaySecretsStore.HasAll())
|
||||||
|
throw new BuildFailedException(
|
||||||
|
"Fonepay secrets missing. Open Tools > Fonepay > Settings before building.");
|
||||||
|
|
||||||
|
if (!Directory.Exists(ResourcesDir))
|
||||||
|
Directory.CreateDirectory(ResourcesDir);
|
||||||
|
|
||||||
|
var baked = ScriptableObject.CreateInstance<FonepayBakedSecrets>();
|
||||||
|
baked.password = FonepaySecretsStore.GetPassword();
|
||||||
|
baked.secretKey = FonepaySecretsStore.GetSecretKey();
|
||||||
|
|
||||||
|
AssetDatabase.CreateAsset(baked, AssetPath);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
}
|
||||||
|
|
||||||
|
public void OnPostprocessBuild(BuildReport report)
|
||||||
|
{
|
||||||
|
if (AssetDatabase.LoadAssetAtPath<FonepayBakedSecrets>(AssetPath) != null)
|
||||||
|
AssetDatabase.DeleteAsset(AssetPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Editor/FonepayBuildSecretsInjector.cs.meta
Normal file
2
Editor/FonepayBuildSecretsInjector.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: abf4fd45c03134382a1e0052168c60c7
|
||||||
42
Editor/FonepaySecretsStore.cs
Normal file
42
Editor/FonepaySecretsStore.cs
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay.Editor
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Per-project secret storage backed by EditorPrefs.
|
||||||
|
/// Keys are namespaced with project path hash so different machines/projects never collide.
|
||||||
|
/// EditorPrefs lives in OS user store (Keychain on macOS, registry on Windows) — not in repo.
|
||||||
|
/// </summary>
|
||||||
|
public static class FonepaySecretsStore
|
||||||
|
{
|
||||||
|
private const string PrefixBase = "Darkmatter.Fonepay.";
|
||||||
|
|
||||||
|
private static string ProjectKey =>
|
||||||
|
PrefixBase + Application.dataPath.GetHashCode().ToString("X") + ".";
|
||||||
|
|
||||||
|
public static string PasswordKey => ProjectKey + "Password";
|
||||||
|
public static string SecretKeyKey => ProjectKey + "SecretKey";
|
||||||
|
|
||||||
|
public static string GetPassword() => EditorPrefs.GetString(PasswordKey, string.Empty);
|
||||||
|
public static string GetSecretKey() => EditorPrefs.GetString(SecretKeyKey, string.Empty);
|
||||||
|
|
||||||
|
public static void SetPassword(string v) => EditorPrefs.SetString(PasswordKey, v ?? string.Empty);
|
||||||
|
public static void SetSecretKey(string v) => EditorPrefs.SetString(SecretKeyKey, v ?? string.Empty);
|
||||||
|
|
||||||
|
public static void Clear()
|
||||||
|
{
|
||||||
|
EditorPrefs.DeleteKey(PasswordKey);
|
||||||
|
EditorPrefs.DeleteKey(SecretKeyKey);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool HasAll() =>
|
||||||
|
!string.IsNullOrEmpty(GetPassword()) && !string.IsNullOrEmpty(GetSecretKey());
|
||||||
|
|
||||||
|
[InitializeOnLoadMethod]
|
||||||
|
private static void RegisterRuntimeProvider()
|
||||||
|
{
|
||||||
|
FonepayConfig.EditorSecretsProvider = () => (GetPassword(), GetSecretKey());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Editor/FonepaySecretsStore.cs.meta
Normal file
2
Editor/FonepaySecretsStore.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 9b9868ef88aab4e2594c17b50c42df62
|
||||||
152
Editor/FonepaySettingsWindow.cs
Normal file
152
Editor/FonepaySettingsWindow.cs
Normal file
@@ -0,0 +1,152 @@
|
|||||||
|
using System.IO;
|
||||||
|
using UnityEditor;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay.Editor
|
||||||
|
{
|
||||||
|
public sealed class FonepaySettingsWindow : EditorWindow
|
||||||
|
{
|
||||||
|
private const string ResourcesDir = "Assets/Resources";
|
||||||
|
private const string ConfigAssetPath = "Assets/Resources/FonepayConfig.asset";
|
||||||
|
|
||||||
|
private FonepayConfigSO _config;
|
||||||
|
private SerializedObject _serialized;
|
||||||
|
private SerializedProperty _envProp, _merchantProp, _userProp;
|
||||||
|
|
||||||
|
private string _password;
|
||||||
|
private string _secretKey;
|
||||||
|
private bool _showSecrets;
|
||||||
|
private Vector2 _scroll;
|
||||||
|
|
||||||
|
[MenuItem("Tools/Darkmatter/Fonepay/Settings")]
|
||||||
|
public static void Open()
|
||||||
|
{
|
||||||
|
var w = GetWindow<FonepaySettingsWindow>("Fonepay");
|
||||||
|
w.minSize = new Vector2(420, 360);
|
||||||
|
w.Show();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnEnable()
|
||||||
|
{
|
||||||
|
LoadOrPrepare();
|
||||||
|
_password = FonepaySecretsStore.GetPassword();
|
||||||
|
_secretKey = FonepaySecretsStore.GetSecretKey();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void LoadOrPrepare()
|
||||||
|
{
|
||||||
|
_config = AssetDatabase.LoadAssetAtPath<FonepayConfigSO>(ConfigAssetPath);
|
||||||
|
if (_config != null)
|
||||||
|
{
|
||||||
|
_serialized = new SerializedObject(_config);
|
||||||
|
_envProp = _serialized.FindProperty("environment");
|
||||||
|
_merchantProp = _serialized.FindProperty("merchantCode");
|
||||||
|
_userProp = _serialized.FindProperty("username");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnGUI()
|
||||||
|
{
|
||||||
|
_scroll = EditorGUILayout.BeginScrollView(_scroll);
|
||||||
|
|
||||||
|
EditorGUILayout.LabelField("Fonepay Setup", EditorStyles.boldLabel);
|
||||||
|
EditorGUILayout.HelpBox(
|
||||||
|
"Non-secret fields saved to Resources/FonepayConfig.asset (commit safe).\n" +
|
||||||
|
"Password & Secret Key saved to EditorPrefs (per-machine, never in repo).\n" +
|
||||||
|
"On player build, secrets baked into a temp Resources asset, removed after build.",
|
||||||
|
MessageType.Info);
|
||||||
|
|
||||||
|
EditorGUILayout.Space();
|
||||||
|
|
||||||
|
if (_config == null)
|
||||||
|
{
|
||||||
|
EditorGUILayout.HelpBox("No FonepayConfig asset. Click below to create.", MessageType.Warning);
|
||||||
|
if (GUILayout.Button("Create Config Asset", GUILayout.Height(28)))
|
||||||
|
CreateConfigAsset();
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_serialized.Update();
|
||||||
|
EditorGUILayout.PropertyField(_envProp);
|
||||||
|
EditorGUILayout.PropertyField(_merchantProp);
|
||||||
|
EditorGUILayout.PropertyField(_userProp);
|
||||||
|
_serialized.ApplyModifiedProperties();
|
||||||
|
|
||||||
|
EditorGUILayout.Space(12);
|
||||||
|
EditorGUILayout.LabelField("Secrets (EditorPrefs)", EditorStyles.boldLabel);
|
||||||
|
|
||||||
|
_showSecrets = EditorGUILayout.ToggleLeft("Show secrets", _showSecrets);
|
||||||
|
_password = DrawSecret("Password", _password);
|
||||||
|
_secretKey = DrawSecret("Secret Key", _secretKey);
|
||||||
|
|
||||||
|
EditorGUILayout.Space(12);
|
||||||
|
DrawStatus();
|
||||||
|
|
||||||
|
EditorGUILayout.Space(8);
|
||||||
|
using (new EditorGUILayout.HorizontalScope())
|
||||||
|
{
|
||||||
|
if (GUILayout.Button("Save", GUILayout.Height(28)))
|
||||||
|
Save();
|
||||||
|
if (GUILayout.Button("Clear Secrets", GUILayout.Height(28)))
|
||||||
|
ClearSecrets();
|
||||||
|
}
|
||||||
|
|
||||||
|
EditorGUILayout.EndScrollView();
|
||||||
|
}
|
||||||
|
|
||||||
|
private string DrawSecret(string label, string value)
|
||||||
|
{
|
||||||
|
return _showSecrets
|
||||||
|
? EditorGUILayout.TextField(label, value ?? string.Empty)
|
||||||
|
: EditorGUILayout.PasswordField(label, value ?? string.Empty);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void DrawStatus()
|
||||||
|
{
|
||||||
|
var ok = !string.IsNullOrEmpty(_merchantProp.stringValue)
|
||||||
|
&& !string.IsNullOrEmpty(_userProp.stringValue)
|
||||||
|
&& !string.IsNullOrEmpty(_password)
|
||||||
|
&& !string.IsNullOrEmpty(_secretKey);
|
||||||
|
|
||||||
|
EditorGUILayout.HelpBox(
|
||||||
|
ok ? "All required fields set." : "Missing fields — fill all values then Save.",
|
||||||
|
ok ? MessageType.Info : MessageType.Warning);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
_serialized.ApplyModifiedPropertiesWithoutUndo();
|
||||||
|
EditorUtility.SetDirty(_config);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
|
||||||
|
FonepaySecretsStore.SetPassword(_password);
|
||||||
|
FonepaySecretsStore.SetSecretKey(_secretKey);
|
||||||
|
|
||||||
|
FonepayConfig.Invalidate();
|
||||||
|
ShowNotification(new GUIContent("Saved"));
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ClearSecrets()
|
||||||
|
{
|
||||||
|
if (!EditorUtility.DisplayDialog("Clear Fonepay secrets",
|
||||||
|
"Remove password and secret key from EditorPrefs on this machine?",
|
||||||
|
"Clear", "Cancel"))
|
||||||
|
return;
|
||||||
|
FonepaySecretsStore.Clear();
|
||||||
|
_password = _secretKey = string.Empty;
|
||||||
|
FonepayConfig.Invalidate();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void CreateConfigAsset()
|
||||||
|
{
|
||||||
|
if (!Directory.Exists(ResourcesDir))
|
||||||
|
Directory.CreateDirectory(ResourcesDir);
|
||||||
|
var so = ScriptableObject.CreateInstance<FonepayConfigSO>();
|
||||||
|
AssetDatabase.CreateAsset(so, ConfigAssetPath);
|
||||||
|
AssetDatabase.SaveAssets();
|
||||||
|
AssetDatabase.Refresh();
|
||||||
|
LoadOrPrepare();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Editor/FonepaySettingsWindow.cs.meta
Normal file
2
Editor/FonepaySettingsWindow.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e0327216de93347279c639b786a694b2
|
||||||
31
Editor/FonepayStartupCheck.cs
Normal file
31
Editor/FonepayStartupCheck.cs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
using UnityEditor;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay.Editor
|
||||||
|
{
|
||||||
|
[InitializeOnLoad]
|
||||||
|
internal static class FonepayStartupCheck
|
||||||
|
{
|
||||||
|
private const string SessionFlag = "Darkmatter.Fonepay.StartupChecked";
|
||||||
|
private const string ConfigAssetPath = "Assets/Resources/FonepayConfig.asset";
|
||||||
|
|
||||||
|
static FonepayStartupCheck()
|
||||||
|
{
|
||||||
|
EditorApplication.delayCall += RunOnce;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void RunOnce()
|
||||||
|
{
|
||||||
|
if (SessionState.GetBool(SessionFlag, false)) return;
|
||||||
|
SessionState.SetBool(SessionFlag, true);
|
||||||
|
|
||||||
|
var so = AssetDatabase.LoadAssetAtPath<FonepayConfigSO>(ConfigAssetPath);
|
||||||
|
var hasConfig = so != null
|
||||||
|
&& !string.IsNullOrEmpty(so.MerchantCode)
|
||||||
|
&& !string.IsNullOrEmpty(so.Username);
|
||||||
|
var hasSecrets = FonepaySecretsStore.HasAll();
|
||||||
|
|
||||||
|
if (!hasConfig || !hasSecrets)
|
||||||
|
FonepaySettingsWindow.Open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Editor/FonepayStartupCheck.cs.meta
Normal file
2
Editor/FonepayStartupCheck.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f3b5a436eccc14d9d889ed58ec3b0682
|
||||||
96
README.md
Normal file
96
README.md
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
# Fonepay Unity
|
||||||
|
|
||||||
|
Fonepay payment integration for Unity. Request QR codes, await payment confirmation over websocket, process tax refunds — all from a single async-friendly client.
|
||||||
|
|
||||||
|
## Requirements
|
||||||
|
|
||||||
|
- Unity 6000.4+
|
||||||
|
- [UniTask](https://github.com/Cysharp/UniTask) (for sample only — runtime uses `System.Threading.Tasks`)
|
||||||
|
|
||||||
|
## Setup
|
||||||
|
|
||||||
|
1. Install the package via Package Manager (Git URL or local).
|
||||||
|
2. Open **Tools > Fonepay > Settings** to create the `FonepayConfig` asset and enter your merchant credentials. Credentials are kept out of source control and baked at build time.
|
||||||
|
3. (Optional) **Window > Package Manager > Fonepay Unity > Samples > Import** to drop the example MonoBehaviour into your project.
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
### 1. Request a QR
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
using Darkmatter.Fonepay;
|
||||||
|
|
||||||
|
var fonepay = new FonepayClient();
|
||||||
|
|
||||||
|
QrResult qr = await fonepay.PurchaseAsync(new QrRequest
|
||||||
|
{
|
||||||
|
amount = 100f,
|
||||||
|
remarks1 = "order #1234"
|
||||||
|
}, ct);
|
||||||
|
|
||||||
|
// qr.qrCode is a Texture2D ready to render
|
||||||
|
// qr.thirdpartyQrWebSocketUrl — pass to AwaitPaymentAsync
|
||||||
|
```
|
||||||
|
|
||||||
|
### 2. Await payment
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
QRPaymentStatus result = await fonepay.AwaitPaymentAsync(
|
||||||
|
qr.thirdpartyQrWebSocketUrl,
|
||||||
|
onQrVerified: verified => Debug.Log($"QR scanned: {verified}"),
|
||||||
|
ct: ct);
|
||||||
|
|
||||||
|
switch (result.Outcome)
|
||||||
|
{
|
||||||
|
case PaymentOutcome.Complete: // success
|
||||||
|
case PaymentOutcome.CancelledByUser: // user dismissed in app
|
||||||
|
case PaymentOutcome.Failed: // server rejected
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
`AwaitPaymentAsync` opens the websocket, fires `onQrVerified` when the QR is scanned, then resolves on the terminal payment frame and disconnects.
|
||||||
|
|
||||||
|
### 3. Cancellation & timeout
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||||
|
cts.CancelAfter(TimeSpan.FromMinutes(15)); // QR validity
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var payment = await fonepay.AwaitPaymentAsync(qr.thirdpartyQrWebSocketUrl, ct: cts.Token);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException) { /* user cancelled or timed out */ }
|
||||||
|
catch (InvalidOperationException) { /* server closed websocket early */ }
|
||||||
|
catch (FonepayError e) { /* API error — e.ErrorCode, e.Docs */ }
|
||||||
|
```
|
||||||
|
|
||||||
|
Calling `cts.Cancel()` from a button handler aborts the await and disconnects the socket.
|
||||||
|
|
||||||
|
### 4. Tax refund
|
||||||
|
|
||||||
|
```csharp
|
||||||
|
TaxRefundResponse refund = await fonepay.PostTaxRefundAsync(new TaxRefundRequest
|
||||||
|
{
|
||||||
|
fonepayTraceId = "...",
|
||||||
|
merchantPRN = "...",
|
||||||
|
invoiceNumber = "INV-001",
|
||||||
|
invoiceDate = DateTime.UtcNow,
|
||||||
|
transactionAmount = 100f,
|
||||||
|
}, ct);
|
||||||
|
```
|
||||||
|
|
||||||
|
## Errors
|
||||||
|
|
||||||
|
`FonepayError` (extends `Exception`) carries `ErrorCode` and `Docs`. Throw paths:
|
||||||
|
- Missing/invalid `FonepayConfig` asset or credentials
|
||||||
|
- Non-2xx HTTP responses from Fonepay
|
||||||
|
- HMAC signing failures
|
||||||
|
|
||||||
|
## Samples
|
||||||
|
|
||||||
|
Import **Example Payment Flow** from the Package Manager. Wires a button → QR image → success/fail panels with cancel support.
|
||||||
|
|
||||||
|
## Tests
|
||||||
|
|
||||||
|
Editor tests live under `Tests/Editor` (NUnit). Run via **Window > General > Test Runner > EditMode**.
|
||||||
7
README.md.meta
Normal file
7
README.md.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 648fc16b27f0044b187c596f573898ca
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Runtime.meta
Normal file
8
Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 8fedf1a029592467d8f26d6c8b7f60f8
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Runtime/API.meta
Normal file
8
Runtime/API.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: cfc7d75f72cd74bfeb9c54fc8c8675bc
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
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
|
||||||
8
Runtime/Async.meta
Normal file
8
Runtime/Async.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: bae1e38d737bf4e26ac9a9e3c76f6b25
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
49
Runtime/Async/FonepayAsync.cs
Normal file
49
Runtime/Async/FonepayAsync.cs
Normal 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
|
||||||
|
}
|
||||||
2
Runtime/Async/FonepayAsync.cs.meta
Normal file
2
Runtime/Async/FonepayAsync.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e222c5804d46c4c138b5a413587344f4
|
||||||
27
Runtime/Async/FonepayAsyncBridge.cs
Normal file
27
Runtime/Async/FonepayAsyncBridge.cs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Async/FonepayAsyncBridge.cs.meta
Normal file
2
Runtime/Async/FonepayAsyncBridge.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 778cc7d92ed1645c195685b07ca02ee1
|
||||||
8
Runtime/Core.meta
Normal file
8
Runtime/Core.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: a5ca890ee6ddf43ebb0a47f228ffe838
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
15
Runtime/Core/FonepayBakedSecrets.cs
Normal file
15
Runtime/Core/FonepayBakedSecrets.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Core/FonepayBakedSecrets.cs.meta
Normal file
2
Runtime/Core/FonepayBakedSecrets.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f6baad01d5e6740bfb8abc8faadfe594
|
||||||
94
Runtime/Core/FonepayClient.cs
Normal file
94
Runtime/Core/FonepayClient.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Core/FonepayClient.cs.meta
Normal file
2
Runtime/Core/FonepayClient.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: e1ab3a2c9f1aa4d5aa87b6305323a1fc
|
||||||
61
Runtime/Core/FonepayConfig.cs
Normal file
61
Runtime/Core/FonepayConfig.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Core/FonepayConfig.cs.meta
Normal file
2
Runtime/Core/FonepayConfig.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3617f631547ae4b10a0f8f634751c61f
|
||||||
59
Runtime/Core/FonepayConfigSO.cs
Normal file
59
Runtime/Core/FonepayConfigSO.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Core/FonepayConfigSO.cs.meta
Normal file
2
Runtime/Core/FonepayConfigSO.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ba80c86a04c584a649c6d31e8f10adad
|
||||||
8
Runtime/Core/FonepayEnvironment.cs
Normal file
8
Runtime/Core/FonepayEnvironment.cs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
namespace Darkmatter.Fonepay
|
||||||
|
{
|
||||||
|
public enum FonepayEnvironment
|
||||||
|
{
|
||||||
|
Dev,
|
||||||
|
Live
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Core/FonepayEnvironment.cs.meta
Normal file
2
Runtime/Core/FonepayEnvironment.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c92e6dd7cf3a84c1e85fa2f19741794e
|
||||||
8
Runtime/Crypto.meta
Normal file
8
Runtime/Crypto.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 74b5b5675851d481a89f7c6136b159d7
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
47
Runtime/Crypto/HmacSha512Signer.cs
Normal file
47
Runtime/Crypto/HmacSha512Signer.cs
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Crypto/HmacSha512Signer.cs.meta
Normal file
2
Runtime/Crypto/HmacSha512Signer.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 93fb47df723f74372b0fa6e228e4dd73
|
||||||
22
Runtime/Darkmatter.FonepayUnity.Runtime.asmdef
Normal file
22
Runtime/Darkmatter.FonepayUnity.Runtime.asmdef
Normal 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
|
||||||
|
}
|
||||||
7
Runtime/Darkmatter.FonepayUnity.Runtime.asmdef.meta
Normal file
7
Runtime/Darkmatter.FonepayUnity.Runtime.asmdef.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: de841957485ec4208a629f66aa4b24c9
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Runtime/Models.meta
Normal file
8
Runtime/Models.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: c216a6be596b84649a99f22d45ca0ce5
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
22
Runtime/Models/FonepayError.cs
Normal file
22
Runtime/Models/FonepayError.cs
Normal 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})";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Models/FonepayError.cs.meta
Normal file
2
Runtime/Models/FonepayError.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 46adc238f3552442bad6f68d9b2ee68a
|
||||||
29
Runtime/Models/QrRequest.cs
Normal file
29
Runtime/Models/QrRequest.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Models/QrRequest.cs.meta
Normal file
2
Runtime/Models/QrRequest.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 255c2e13d0ed54987904cd65a5349d29
|
||||||
29
Runtime/Models/QrResponse.cs
Normal file
29
Runtime/Models/QrResponse.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/QrResponse.cs.meta
Normal file
3
Runtime/Models/QrResponse.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 405247a27c684f08ab11d6ea33d250c0
|
||||||
|
timeCreated: 1778138521
|
||||||
32
Runtime/Models/TaxRefundRequest.cs
Normal file
32
Runtime/Models/TaxRefundRequest.cs
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/TaxRefundRequest.cs.meta
Normal file
3
Runtime/Models/TaxRefundRequest.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 328f1e46b5a941bbbf5c0e8294fc9c34
|
||||||
|
timeCreated: 1778140313
|
||||||
9
Runtime/Models/TaxRefundResponse.cs
Normal file
9
Runtime/Models/TaxRefundResponse.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Darkmatter.Fonepay
|
||||||
|
{
|
||||||
|
public struct TaxRefundResponse
|
||||||
|
{
|
||||||
|
public string fonepayTraceId;
|
||||||
|
public string message;
|
||||||
|
public bool success;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/TaxRefundResponse.cs.meta
Normal file
3
Runtime/Models/TaxRefundResponse.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: ccdcd51d0dc843eabdb25aa3312bd91a
|
||||||
|
timeCreated: 1778140771
|
||||||
3
Runtime/Models/Websocket.meta
Normal file
3
Runtime/Models/Websocket.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 0b231c6bbbdf4b519ac94e2d745b84c4
|
||||||
|
timeCreated: 1778143935
|
||||||
9
Runtime/Models/Websocket/PaymentOutcome.cs
Normal file
9
Runtime/Models/Websocket/PaymentOutcome.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Darkmatter.Fonepay
|
||||||
|
{
|
||||||
|
public enum PaymentOutcome
|
||||||
|
{
|
||||||
|
Failed,
|
||||||
|
Complete,
|
||||||
|
CancelledByUser
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/Models/Websocket/PaymentOutcome.cs.meta
Normal file
2
Runtime/Models/Websocket/PaymentOutcome.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b18f877972094e3993a95d2ae363f12a
|
||||||
27
Runtime/Models/Websocket/QRPaymentStatus.cs
Normal file
27
Runtime/Models/Websocket/QRPaymentStatus.cs
Normal 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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/Websocket/QRPaymentStatus.cs.meta
Normal file
3
Runtime/Models/Websocket/QRPaymentStatus.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5ee6badc7f924737865c86113de9d2ac
|
||||||
|
timeCreated: 1778144200
|
||||||
12
Runtime/Models/Websocket/QRVerificationStatus.cs
Normal file
12
Runtime/Models/Websocket/QRVerificationStatus.cs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public struct QRVerificationStatus
|
||||||
|
{
|
||||||
|
public bool success;
|
||||||
|
public string message;
|
||||||
|
public bool qrVerified;
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/Websocket/QRVerificationStatus.cs.meta
Normal file
3
Runtime/Models/Websocket/QRVerificationStatus.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f26424156fc543c487793821806dd8c7
|
||||||
|
timeCreated: 1778144700
|
||||||
9
Runtime/Models/Websocket/TransactionStatus.cs
Normal file
9
Runtime/Models/Websocket/TransactionStatus.cs
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
using System;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay
|
||||||
|
{
|
||||||
|
[Serializable]
|
||||||
|
public struct TransactionStatus
|
||||||
|
{
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/Websocket/TransactionStatus.cs.meta
Normal file
3
Runtime/Models/Websocket/TransactionStatus.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: d83df4253afa4d4b968ad3b8c88da203
|
||||||
|
timeCreated: 1778143652
|
||||||
14
Runtime/Models/Websocket/WebsocketMessage.cs
Normal file
14
Runtime/Models/Websocket/WebsocketMessage.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
3
Runtime/Models/Websocket/WebsocketMessage.cs.meta
Normal file
3
Runtime/Models/Websocket/WebsocketMessage.cs.meta
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: f74983b79502486a891686f80f33737c
|
||||||
|
timeCreated: 1778143616
|
||||||
8
Runtime/Plugins.meta
Normal file
8
Runtime/Plugins.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: debdfefd8b01242e8b95f0e29bb09c0d
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
21
Runtime/Plugins/QRCoder-LICENSE.txt
Normal file
21
Runtime/Plugins/QRCoder-LICENSE.txt
Normal 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.
|
||||||
7
Runtime/Plugins/QRCoder-LICENSE.txt.meta
Normal file
7
Runtime/Plugins/QRCoder-LICENSE.txt.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3902a28335b7043bf831aab193145f4c
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
BIN
Runtime/Plugins/QRCoder.dll
Normal file
BIN
Runtime/Plugins/QRCoder.dll
Normal file
Binary file not shown.
33
Runtime/Plugins/QRCoder.dll.meta
Normal file
33
Runtime/Plugins/QRCoder.dll.meta
Normal 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:
|
||||||
8
Runtime/QR.meta
Normal file
8
Runtime/QR.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 47a1e64b2c84a4238b5cc62aa4c8fb80
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
89
Runtime/QR/FonepayQRGenerator.cs
Normal file
89
Runtime/QR/FonepayQRGenerator.cs
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Runtime/QR/FonepayQRGenerator.cs.meta
Normal file
2
Runtime/QR/FonepayQRGenerator.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 926e00b59f70b418bbf937c11a1c8494
|
||||||
4
Samples~/Example/.sample.json
Normal file
4
Samples~/Example/.sample.json
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"displayName":"Example Sample",
|
||||||
|
"description": "Replace this string with your own description of the sample. Delete the Samples folder if not needed."
|
||||||
|
}
|
||||||
88
Samples~/Example/SamplePayment.cs
Normal file
88
Samples~/Example/SamplePayment.cs
Normal file
@@ -0,0 +1,88 @@
|
|||||||
|
using System;
|
||||||
|
using System.Threading;
|
||||||
|
using Cysharp.Threading.Tasks;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.UI;
|
||||||
|
|
||||||
|
namespace Darkmatter.Fonepay.Samples
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Minimal end-to-end sample: request QR, render to Image, await payment,
|
||||||
|
/// support cancellation via a cancel button.
|
||||||
|
/// </summary>
|
||||||
|
public class SamplePayment : MonoBehaviour
|
||||||
|
{
|
||||||
|
[SerializeField] private Image qrImage;
|
||||||
|
[SerializeField] private GameObject successObject;
|
||||||
|
[SerializeField] private GameObject failedObject;
|
||||||
|
[SerializeField] private Button payButton;
|
||||||
|
[SerializeField] private Button cancelButton;
|
||||||
|
[SerializeField] private float amount = 1f;
|
||||||
|
|
||||||
|
private CancellationTokenSource _payCts;
|
||||||
|
|
||||||
|
private void Start()
|
||||||
|
{
|
||||||
|
payButton.onClick.AddListener(() => InitiatePayment().Forget());
|
||||||
|
if (cancelButton != null)
|
||||||
|
cancelButton.onClick.AddListener(() => _payCts?.Cancel());
|
||||||
|
}
|
||||||
|
|
||||||
|
private async UniTask InitiatePayment()
|
||||||
|
{
|
||||||
|
var fonepay = new FonepayClient();
|
||||||
|
_payCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var qr = await fonepay.PurchaseAsync(
|
||||||
|
new QrRequest { amount = amount, remarks1 = "sample" },
|
||||||
|
_payCts.Token);
|
||||||
|
|
||||||
|
if (qr.qrCode != null)
|
||||||
|
{
|
||||||
|
qrImage.sprite = Sprite.Create(
|
||||||
|
qr.qrCode,
|
||||||
|
new Rect(0, 0, qr.qrCode.width, qr.qrCode.height),
|
||||||
|
new Vector2(0.5f, 0.5f));
|
||||||
|
qrImage.gameObject.SetActive(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
var payment = await fonepay.AwaitPaymentAsync(
|
||||||
|
qr.thirdpartyQrWebSocketUrl,
|
||||||
|
onQrVerified: v => Debug.Log($"Fonepay QR verified: {v}"),
|
||||||
|
ct: _payCts.Token);
|
||||||
|
|
||||||
|
Debug.Log($"Payment frame: {JsonUtility.ToJson(payment)}");
|
||||||
|
ShowResult(payment.Outcome == PaymentOutcome.Complete);
|
||||||
|
}
|
||||||
|
catch (OperationCanceledException)
|
||||||
|
{
|
||||||
|
Debug.Log("Payment cancelled.");
|
||||||
|
ShowResult(false);
|
||||||
|
}
|
||||||
|
catch (FonepayError e)
|
||||||
|
{
|
||||||
|
Debug.LogError($"Fonepay API error {e.ErrorCode}: {e.Message}");
|
||||||
|
ShowResult(false);
|
||||||
|
}
|
||||||
|
catch (InvalidOperationException e)
|
||||||
|
{
|
||||||
|
Debug.LogWarning($"Websocket closed early: {e.Message}");
|
||||||
|
ShowResult(false);
|
||||||
|
}
|
||||||
|
finally
|
||||||
|
{
|
||||||
|
_payCts.Dispose();
|
||||||
|
_payCts = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void ShowResult(bool ok)
|
||||||
|
{
|
||||||
|
qrImage.gameObject.SetActive(false);
|
||||||
|
if (successObject != null) successObject.SetActive(ok);
|
||||||
|
if (failedObject != null) failedObject.SetActive(!ok);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
8
Tests.meta
Normal file
8
Tests.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3287b987b9ae04cc8af7bb76a1fafd21
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
8
Tests/Editor.meta
Normal file
8
Tests/Editor.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 98341f79b8169426c8edbc4469715037
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
25
Tests/Editor/Darkmatter.FonepayUnity.Editor.Tests.asmdef
Normal file
25
Tests/Editor/Darkmatter.FonepayUnity.Editor.Tests.asmdef
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
{
|
||||||
|
"name": "Darkmatter.FonepayUnity.Editor.Tests",
|
||||||
|
"rootNamespace": "",
|
||||||
|
"references": [
|
||||||
|
"Darkmatter.FonepayUnity",
|
||||||
|
"Darkmatter.FonepayUnity.Editor",
|
||||||
|
"UnityEditor.TestRunner",
|
||||||
|
"UnityEngine.TestRunner"
|
||||||
|
],
|
||||||
|
"includePlatforms": [
|
||||||
|
"Editor"
|
||||||
|
],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": true,
|
||||||
|
"precompiledReferences": [
|
||||||
|
"nunit.framework.dll"
|
||||||
|
],
|
||||||
|
"autoReferenced": false,
|
||||||
|
"defineConstraints": [
|
||||||
|
"UNITY_INCLUDE_TESTS"
|
||||||
|
],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 2a4ef70a61be54ea6b8ada95c5e2743c
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
78
Tests/Editor/WebsocketMessageTests.cs
Normal file
78
Tests/Editor/WebsocketMessageTests.cs
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
using Darkmatter.Fonepay;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
|
||||||
|
namespace Darkmatter.FonepayUnity.Editor.Tests
|
||||||
|
{
|
||||||
|
public class WebsocketMessageTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Status_ParsesNestedJsonString()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"{\"merchantId\":\"M1\",\"deviceId\":\"D1\"," +
|
||||||
|
"\"transactionStatus\":\"{\\\"paymentSuccess\\\":true,\\\"success\\\":true,\\\"amount\\\":42.5}\"}";
|
||||||
|
|
||||||
|
var envelope = JsonUtility.FromJson<WebsocketMessage<QRPaymentStatus>>(raw);
|
||||||
|
var status = envelope.Status;
|
||||||
|
|
||||||
|
Assert.IsTrue(status.success);
|
||||||
|
Assert.IsTrue(status.paymentSuccess);
|
||||||
|
Assert.AreEqual(42.5f, status.amount);
|
||||||
|
Assert.AreEqual(PaymentOutcome.Complete, status.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Status_QrVerifiedFrame()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"{\"transactionStatus\":\"{\\\"success\\\":true,\\\"qrVerified\\\":true,\\\"message\\\":\\\"ok\\\"}\"}";
|
||||||
|
|
||||||
|
var envelope = JsonUtility.FromJson<WebsocketMessage<QRVerificationStatus>>(raw);
|
||||||
|
var v = envelope.Status;
|
||||||
|
|
||||||
|
Assert.IsTrue(v.qrVerified);
|
||||||
|
Assert.AreEqual("ok", v.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PaymentOutcomeTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void SuccessTrue_PaymentTrue_Complete()
|
||||||
|
{
|
||||||
|
var s = new QRPaymentStatus { success = true, paymentSuccess = true };
|
||||||
|
Assert.AreEqual(PaymentOutcome.Complete, s.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SuccessTrue_PaymentFalse_CancelledByUser()
|
||||||
|
{
|
||||||
|
var s = new QRPaymentStatus { success = true, paymentSuccess = false };
|
||||||
|
Assert.AreEqual(PaymentOutcome.CancelledByUser, s.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void SuccessFalse_Failed()
|
||||||
|
{
|
||||||
|
var s = new QRPaymentStatus { success = false, paymentSuccess = true };
|
||||||
|
Assert.AreEqual(PaymentOutcome.Failed, s.Outcome);
|
||||||
|
|
||||||
|
s = new QRPaymentStatus { success = false, paymentSuccess = false };
|
||||||
|
Assert.AreEqual(PaymentOutcome.Failed, s.Outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FonepayErrorTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Carries_CodeAndDocs()
|
||||||
|
{
|
||||||
|
var err = new FonepayError(42, "boom", "docs/url");
|
||||||
|
Assert.AreEqual(42, err.ErrorCode);
|
||||||
|
Assert.AreEqual("docs/url", err.Docs);
|
||||||
|
Assert.AreEqual("boom", err.Message);
|
||||||
|
StringAssert.Contains("ErrorCode: 42", err.ToString());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Tests/Editor/WebsocketMessageTests.cs.meta
Normal file
2
Tests/Editor/WebsocketMessageTests.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: b507c7c2bcf2f403fb4157318b8d7e97
|
||||||
8
Tests/Runtime.meta
Normal file
8
Tests/Runtime.meta
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6639ec7313a77493fb517eb322745684
|
||||||
|
folderAsset: yes
|
||||||
|
DefaultImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
21
Tests/Runtime/Darkmatter.FonepayUnity.Runtime.Tests.asmdef
Normal file
21
Tests/Runtime/Darkmatter.FonepayUnity.Runtime.Tests.asmdef
Normal file
@@ -0,0 +1,21 @@
|
|||||||
|
{
|
||||||
|
"name": "Darkmatter.FonepayUnity.Runtime.Tests",
|
||||||
|
"rootNamespace": "",
|
||||||
|
"references": [
|
||||||
|
"Darkmatter.FonepayUnity",
|
||||||
|
"UnityEngine.TestRunner"
|
||||||
|
],
|
||||||
|
"includePlatforms": [],
|
||||||
|
"excludePlatforms": [],
|
||||||
|
"allowUnsafeCode": false,
|
||||||
|
"overrideReferences": true,
|
||||||
|
"precompiledReferences": [
|
||||||
|
"nunit.framework.dll"
|
||||||
|
],
|
||||||
|
"autoReferenced": false,
|
||||||
|
"defineConstraints": [
|
||||||
|
"UNITY_INCLUDE_TESTS"
|
||||||
|
],
|
||||||
|
"versionDefines": [],
|
||||||
|
"noEngineReferences": false
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 5b7c0851a1002412dadc7d1c37a4cef5
|
||||||
|
AssemblyDefinitionImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
100
Tests/Runtime/FonepayRuntimeTests.cs
Normal file
100
Tests/Runtime/FonepayRuntimeTests.cs
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
using System;
|
||||||
|
using System.Collections;
|
||||||
|
using System.Threading;
|
||||||
|
using System.Threading.Tasks;
|
||||||
|
using Darkmatter.Fonepay;
|
||||||
|
using NUnit.Framework;
|
||||||
|
using UnityEngine;
|
||||||
|
using UnityEngine.TestTools;
|
||||||
|
|
||||||
|
namespace Darkmatter.FonepayUnity.Tests
|
||||||
|
{
|
||||||
|
public class WebsocketMessagePlayModeTests
|
||||||
|
{
|
||||||
|
[Test]
|
||||||
|
public void Status_ParsesNestedJsonInPlayer()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"{\"transactionStatus\":\"{\\\"paymentSuccess\\\":true,\\\"success\\\":true,\\\"amount\\\":7}\"}";
|
||||||
|
|
||||||
|
var envelope = JsonUtility.FromJson<WebsocketMessage<QRPaymentStatus>>(raw);
|
||||||
|
var status = envelope.Status;
|
||||||
|
|
||||||
|
Assert.IsTrue(status.paymentSuccess);
|
||||||
|
Assert.AreEqual(7f, status.amount);
|
||||||
|
Assert.AreEqual(PaymentOutcome.Complete, status.Outcome);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Status_ReparsesEachAccess()
|
||||||
|
{
|
||||||
|
const string raw =
|
||||||
|
"{\"transactionStatus\":\"{\\\"success\\\":true,\\\"paymentSuccess\\\":true}\"}";
|
||||||
|
|
||||||
|
var envelope = JsonUtility.FromJson<WebsocketMessage<QRPaymentStatus>>(raw);
|
||||||
|
var a = envelope.Status;
|
||||||
|
var b = envelope.Status;
|
||||||
|
|
||||||
|
Assert.AreEqual(a.Outcome, b.Outcome);
|
||||||
|
Assert.AreEqual(PaymentOutcome.Complete, a.Outcome);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public class FonepayClientPlayModeTests
|
||||||
|
{
|
||||||
|
[SetUp]
|
||||||
|
public void Reset() => FonepayConfig.Invalidate();
|
||||||
|
|
||||||
|
[Test]
|
||||||
|
public void Ctor_WithoutConfigAsset_Throws()
|
||||||
|
{
|
||||||
|
Assert.Throws<FonepayError>(() => new FonepayClient());
|
||||||
|
}
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator AwaitPaymentAsync_EmptyUrl_Throws() => RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var client = TryBuildClient();
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
Assert.Pass("Skipped: no FonepayConfig in test context.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<ArgumentException>(async () =>
|
||||||
|
await client.AwaitPaymentAsync(string.Empty));
|
||||||
|
await Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
[UnityTest]
|
||||||
|
public IEnumerator AwaitPaymentAsync_PreCancelled_Throws() => RunAsync(async () =>
|
||||||
|
{
|
||||||
|
var client = TryBuildClient();
|
||||||
|
if (client == null)
|
||||||
|
{
|
||||||
|
Assert.Pass("Skipped: no FonepayConfig in test context.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
using var cts = new CancellationTokenSource();
|
||||||
|
cts.Cancel();
|
||||||
|
|
||||||
|
Assert.ThrowsAsync<OperationCanceledException>(async () =>
|
||||||
|
await client.AwaitPaymentAsync("ws://127.0.0.1:1/", ct: cts.Token));
|
||||||
|
await Task.CompletedTask;
|
||||||
|
});
|
||||||
|
|
||||||
|
private static FonepayClient TryBuildClient()
|
||||||
|
{
|
||||||
|
try { return new FonepayClient(); }
|
||||||
|
catch (FonepayError) { return null; }
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IEnumerator RunAsync(Func<Task> body)
|
||||||
|
{
|
||||||
|
var t = body();
|
||||||
|
while (!t.IsCompleted) yield return null;
|
||||||
|
if (t.IsFaulted) throw t.Exception;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
2
Tests/Runtime/FonepayRuntimeTests.cs.meta
Normal file
2
Tests/Runtime/FonepayRuntimeTests.cs.meta
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 3eeb964c506324b58bd6f95c088096fa
|
||||||
16
Third Party Notices.md
Normal file
16
Third Party Notices.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
This package contains third-party software components governed by the license(s) indicated below:
|
||||||
|
---------
|
||||||
|
|
||||||
|
Component Name: [provide component name]
|
||||||
|
|
||||||
|
License Type: [Provide license type, i.e. "MIT", "Apache 2.0"]
|
||||||
|
|
||||||
|
[Provide License Details]
|
||||||
|
|
||||||
|
---------
|
||||||
|
Component Name: [provide component name]
|
||||||
|
|
||||||
|
License Type: [Provide license type, i.e. "MIT", "Apache 2.0"]
|
||||||
|
|
||||||
|
[Provide License Details]
|
||||||
|
|
||||||
7
Third Party Notices.md.meta
Normal file
7
Third Party Notices.md.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 6ddd2e7cbebff474c8e1bfdf019e71aa
|
||||||
|
TextScriptImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
23
package.json
Normal file
23
package.json
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
{
|
||||||
|
"name": "com.darkmattergameproduction.fonepay-unity",
|
||||||
|
"displayName": "Fonepay Unity",
|
||||||
|
"version": "0.1.0",
|
||||||
|
"unity": "6000.4",
|
||||||
|
"unityRelease": "5f1",
|
||||||
|
"description": "Fonepay payment integration for Unity. Generate Fonepay QR codes, await payment confirmation via websocket, and process tax refunds. Credentials managed via Tools > Fonepay > Settings.",
|
||||||
|
"dependencies": {
|
||||||
|
"com.unity.test-framework": "1.6.0"
|
||||||
|
},
|
||||||
|
"samples": [
|
||||||
|
{
|
||||||
|
"displayName": "Example Payment Flow",
|
||||||
|
"description": "Minimal MonoBehaviour: request QR, render, await payment with cancel support.",
|
||||||
|
"path": "Samples~/Example"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"author": {
|
||||||
|
"name": "Savya Bikram Shah",
|
||||||
|
"url": "https://savya.com.np",
|
||||||
|
"email": "mail@savya.com.np"
|
||||||
|
}
|
||||||
|
}
|
||||||
7
package.json.meta
Normal file
7
package.json.meta
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fileFormatVersion: 2
|
||||||
|
guid: 450856927d9ed4360b654814b041ebf0
|
||||||
|
PackageManifestImporter:
|
||||||
|
externalObjects: {}
|
||||||
|
userData:
|
||||||
|
assetBundleName:
|
||||||
|
assetBundleVariant:
|
||||||
Reference in New Issue
Block a user