Compare commits

...

7 Commits

Author SHA1 Message Date
Savya Bikram Shah
b609c43199 sample fixes
Some checks failed
Publish UPM / split (push) Has been cancelled
2026-05-07 18:44:59 +05:45
Savya Bikram Shah
ac61fdba83 fix: add UniTask ref to runtime test asmdef
Some checks failed
Publish UPM / split (push) Has been cancelled
2026-05-07 18:13:09 +05:45
Savya Bikram Shah
98526d82d9 feat: route FonepayClient through FonepayAsyncBridge; bump 0.2.0
Some checks failed
Publish UPM / split (push) Has been cancelled
2026-05-07 18:10:17 +05:45
Savya Bikram Shah
f7f287bca7 gitignore updated 2026-05-07 18:02:04 +05:45
Savya Bikram Shah
5515a88cf5 readne created 2026-05-07 17:55:36 +05:45
Savya Bikram Shah
028a1a7f46 Readme added 2026-05-07 17:49:53 +05:45
Savya Bikram Shah
dface29ff2 Delete Packages/com.voidbotz.fonepayunity/Tests/Runtime directory 2026-05-07 17:43:55 +05:45
30 changed files with 2311 additions and 140 deletions

BIN
.DS_Store vendored

Binary file not shown.

22
.github/workflows/upm-split.yml vendored Normal file
View File

@@ -0,0 +1,22 @@
name: Publish UPM
on:
push:
branches: [main]
paths: ['Packages/com.darkmattergameproduction.fonepay-unity/**']
workflow_dispatch:
jobs:
split:
runs-on: ubuntu-latest
permissions:
contents: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Split subtree to upm branch
run: |
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git subtree split --prefix=Packages/com.darkmattergameproduction.fonepay-unity -b upm
git push origin upm --force

3
.gitignore vendored
View File

@@ -104,4 +104,5 @@ InitTestScene*.unity*
# Auto-generated cache in Assets folder
/[Aa]ssets/[Ss]ceneDependencyCache*
Assets/Resources/FonepayBakedSecrets.asset*
Assets/Resources/FonepayBakedSecrets.asset*
Assets/Resources/FonepayConfig.asset*

8
Assets/Samples.meta Normal file
View File

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

View File

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

View File

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

Binary file not shown.

View File

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

Binary file not shown.

View 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."
}

View File

@@ -0,0 +1,97 @@
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()
{
if (_payCts != null) return;
payButton.interactable = false;
var fonepay = new FonepayClient();
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
_payCts = cts;
try
{
var qr = await fonepay.PurchaseAsync(
new QrRequest { amount = amount, remarks1 = "sample" },
cts.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: cts.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
{
cts.Dispose();
if (_payCts == cts) _payCts = null;
if (this != null && payButton != null) payButton.interactable = true;
}
}
private void ShowResult(bool ok)
{
if (qrImage != null)
{
qrImage.sprite = null;
qrImage.gameObject.SetActive(false);
}
if (successObject != null) successObject.SetActive(ok);
if (failedObject != null) failedObject.SetActive(!ok);
}
}
}

View File

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

View File

@@ -151,9 +151,9 @@ RectTransform:
m_Children: []
m_Father: {fileID: 508689485}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -140.79999}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 0, y: -69}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &454749
@@ -388,7 +388,8 @@ RectTransform:
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 7.9915, y: 7.9915, z: 7.9915}
m_ConstrainProportionsScale: 0
m_Children: []
m_Children:
- {fileID: 1350158650}
m_Father: {fileID: 508689485}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
@@ -445,6 +446,7 @@ GameObject:
- component: {fileID: 508689485}
- component: {fileID: 508689487}
- component: {fileID: 508689486}
- component: {fileID: 508689488}
m_Layer: 5
m_Name: Image
m_TagString: Untagged
@@ -514,6 +516,24 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 508689484}
m_CullTransparentMesh: 1
--- !u!114 &508689488
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 508689484}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: d53a9483205b945d69e846f2285560bd, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Darkmatter.Fonepay.Samples.SamplePayment
qrImage: {fileID: 308706331}
successObject: {fileID: 454747}
failedObject: {fileID: 1400840764}
payButton: {fileID: 1002816889}
cancelButton: {fileID: 1350158652}
amount: 1
--- !u!1 &519420028
GameObject:
m_ObjectHideFlags: 0
@@ -772,6 +792,140 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1002816887}
m_CullTransparentMesh: 1
--- !u!1 &1350158649
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1350158650}
- component: {fileID: 1350158654}
- component: {fileID: 1350158653}
- component: {fileID: 1350158652}
- component: {fileID: 1350158651}
m_Layer: 5
m_Name: Cancel
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1350158650
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1350158649}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 0.18375774, y: 0.18375774, z: 0.18375774}
m_ConstrainProportionsScale: 0
m_Children:
- {fileID: 1884838835}
m_Father: {fileID: 308706330}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0}
m_AnchorMax: {x: 0.5, y: 0}
m_AnchoredPosition: {x: 0, y: -12.8}
m_SizeDelta: {x: 160, y: 30}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1350158651
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1350158649}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 7d55618be80a043b0b467209e1a80cbb, type: 3}
m_Name:
m_EditorClassIdentifier: Assembly-CSharp::Darkmatter.Fonepay.Samples.SamplePayment
--- !u!114 &1350158652
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1350158649}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 4e29b1a8efbd4b44bb3f3716e73f07ff, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Button
m_Navigation:
m_Mode: 3
m_WrapAround: 0
m_SelectOnUp: {fileID: 0}
m_SelectOnDown: {fileID: 0}
m_SelectOnLeft: {fileID: 0}
m_SelectOnRight: {fileID: 0}
m_Transition: 1
m_Colors:
m_NormalColor: {r: 1, g: 1, b: 1, a: 1}
m_HighlightedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_PressedColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 1}
m_SelectedColor: {r: 0.9607843, g: 0.9607843, b: 0.9607843, a: 1}
m_DisabledColor: {r: 0.78431374, g: 0.78431374, b: 0.78431374, a: 0.5019608}
m_ColorMultiplier: 1
m_FadeDuration: 0.1
m_SpriteState:
m_HighlightedSprite: {fileID: 0}
m_PressedSprite: {fileID: 0}
m_SelectedSprite: {fileID: 0}
m_DisabledSprite: {fileID: 0}
m_AnimationTriggers:
m_NormalTrigger: Normal
m_HighlightedTrigger: Highlighted
m_PressedTrigger: Pressed
m_SelectedTrigger: Selected
m_DisabledTrigger: Disabled
m_Interactable: 1
m_TargetGraphic: {fileID: 1350158653}
m_OnClick:
m_PersistentCalls:
m_Calls: []
--- !u!114 &1350158653
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1350158649}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: fe87c0e1cc204ed48ad3b37840f39efc, type: 3}
m_Name:
m_EditorClassIdentifier: UnityEngine.UI::UnityEngine.UI.Image
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_Sprite: {fileID: 10905, guid: 0000000000000000f000000000000000, type: 0}
m_Type: 1
m_PreserveAspect: 0
m_FillCenter: 1
m_FillMethod: 4
m_FillAmount: 1
m_FillClockwise: 1
m_FillOrigin: 0
m_UseSpriteMesh: 0
m_PixelsPerUnitMultiplier: 1
--- !u!222 &1350158654
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1350158649}
m_CullTransparentMesh: 1
--- !u!1 &1400840764
GameObject:
m_ObjectHideFlags: 0
@@ -804,9 +958,9 @@ RectTransform:
m_Children: []
m_Father: {fileID: 508689485}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0.5, y: 0.5}
m_AnchorMax: {x: 0.5, y: 0.5}
m_AnchoredPosition: {x: 0, y: -140.8}
m_AnchorMin: {x: 0.5, y: 1}
m_AnchorMax: {x: 0.5, y: 1}
m_AnchoredPosition: {x: 0, y: -69}
m_SizeDelta: {x: 200, y: 50}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1400840766
@@ -1046,6 +1200,143 @@ CanvasRenderer:
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1625398629}
m_CullTransparentMesh: 1
--- !u!1 &1884838834
GameObject:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
serializedVersion: 6
m_Component:
- component: {fileID: 1884838835}
- component: {fileID: 1884838837}
- component: {fileID: 1884838836}
m_Layer: 5
m_Name: Text (TMP)
m_TagString: Untagged
m_Icon: {fileID: 0}
m_NavMeshLayer: 0
m_StaticEditorFlags: 0
m_IsActive: 1
--- !u!224 &1884838835
RectTransform:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1884838834}
m_LocalRotation: {x: -0, y: -0, z: -0, w: 1}
m_LocalPosition: {x: 0, y: 0, z: 0}
m_LocalScale: {x: 1, y: 1, z: 1}
m_ConstrainProportionsScale: 0
m_Children: []
m_Father: {fileID: 1350158650}
m_LocalEulerAnglesHint: {x: 0, y: 0, z: 0}
m_AnchorMin: {x: 0, y: 0}
m_AnchorMax: {x: 1, y: 1}
m_AnchoredPosition: {x: 0, y: 0}
m_SizeDelta: {x: 0, y: 0}
m_Pivot: {x: 0.5, y: 0.5}
--- !u!114 &1884838836
MonoBehaviour:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1884838834}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: f4688fdb7df04437aeb418b961361dc5, type: 3}
m_Name:
m_EditorClassIdentifier: Unity.TextMeshPro::TMPro.TextMeshProUGUI
m_Material: {fileID: 0}
m_Color: {r: 1, g: 1, b: 1, a: 1}
m_RaycastTarget: 1
m_RaycastPadding: {x: 0, y: 0, z: 0, w: 0}
m_Maskable: 1
m_OnCullStateChanged:
m_PersistentCalls:
m_Calls: []
m_text: Cancel
m_isRightToLeft: 0
m_fontAsset: {fileID: 11400000, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_sharedMaterial: {fileID: 2180264, guid: 8f586378b4e144a9851e7b34d9b748ee, type: 2}
m_fontSharedMaterials: []
m_fontMaterial: {fileID: 0}
m_fontMaterials: []
m_fontColor32:
serializedVersion: 2
rgba: 4281479730
m_fontColor: {r: 0.19607843, g: 0.19607843, b: 0.19607843, a: 1}
m_enableVertexGradient: 0
m_colorMode: 3
m_fontColorGradient:
topLeft: {r: 1, g: 1, b: 1, a: 1}
topRight: {r: 1, g: 1, b: 1, a: 1}
bottomLeft: {r: 1, g: 1, b: 1, a: 1}
bottomRight: {r: 1, g: 1, b: 1, a: 1}
m_fontColorGradientPreset: {fileID: 0}
m_spriteAsset: {fileID: 0}
m_tintAllSprites: 0
m_StyleSheet: {fileID: 0}
m_TextStyleHashCode: -1183493901
m_overrideHtmlColors: 0
m_faceColor:
serializedVersion: 2
rgba: 4294967295
m_fontSize: 24
m_fontSizeBase: 24
m_fontWeight: 400
m_enableAutoSizing: 0
m_fontSizeMin: 18
m_fontSizeMax: 72
m_fontStyle: 0
m_HorizontalAlignment: 2
m_VerticalAlignment: 512
m_textAlignment: 65535
m_characterSpacing: 0
m_characterHorizontalScale: 1
m_wordSpacing: 0
m_lineSpacing: 0
m_lineSpacingMax: 0
m_paragraphSpacing: 0
m_charWidthMaxAdj: 0
m_TextWrappingMode: 1
m_wordWrappingRatios: 0.4
m_overflowMode: 0
m_linkedTextComponent: {fileID: 0}
parentLinkedComponent: {fileID: 0}
m_enableKerning: 0
m_ActiveFontFeatures: 6e72656b
m_enableExtraPadding: 0
checkPaddingRequired: 0
m_isRichText: 1
m_EmojiFallbackSupport: 1
m_parseCtrlCharacters: 1
m_isOrthographic: 1
m_isCullingEnabled: 0
m_horizontalMapping: 0
m_verticalMapping: 0
m_uvLineOffset: 0
m_geometrySortingOrder: 0
m_IsTextObjectScaleStatic: 0
m_VertexBufferAutoSizeReduction: 0
m_useMaxVisibleDescender: 1
m_pageToDisplay: 1
m_margin: {x: 0, y: 0, z: 0, w: 0}
m_isUsingLegacyAnimationComponent: 0
m_isVolumetricText: 0
m_hasFontAssetChanged: 0
m_baseMaterial: {fileID: 0}
m_maskOffset: {x: 0, y: 0, z: 0, w: 0}
--- !u!222 &1884838837
CanvasRenderer:
m_ObjectHideFlags: 0
m_CorrespondingSourceObject: {fileID: 0}
m_PrefabInstance: {fileID: 0}
m_PrefabAsset: {fileID: 0}
m_GameObject: {fileID: 1884838834}
m_CullTransparentMesh: 1
--- !u!1 &1949324813
GameObject:
m_ObjectHideFlags: 0

BIN
Packages/.DS_Store vendored Normal file

Binary file not shown.

Binary file not shown.

View File

@@ -4,6 +4,11 @@ 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.2.0] - 2026-05-07
### Changed
- All `FonepayClient` public methods now return `FonepayAsync<T>` / `FonepayAsync` instead of `Task<T>` / `Task`. Callers `await` the same way; UniTask users get native `UniTask` under `#if UNITASK_SUPPORT`.
## [0.1.0] - 2026-05-07
### Added

View File

@@ -4,10 +4,6 @@ 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;
@@ -22,41 +18,25 @@ namespace Darkmatter.Fonepay
_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);
public FonepayAsync<QrResult> PurchaseAsync(QrRequest req, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.PostQRAsync(req, ct), 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);
public FonepayAsync<QrResult> GetStatusAsync(string prn, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.GetStatusAsync(prn, ct), 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);
public FonepayAsync<TaxRefundResponse> PostTaxRefundAsync(TaxRefundRequest req, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.PostTaxRefundAsync(req, ct), 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(
public FonepayAsync<QRPaymentStatus> AwaitPaymentAsync(
string websocketUrl,
Action<bool> onQrVerified = null,
CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(AwaitPaymentInternalAsync(websocketUrl, onQrVerified, ct), ct);
private async Task<QRPaymentStatus> AwaitPaymentInternalAsync(
string websocketUrl,
Action<bool> onQrVerified,
CancellationToken ct)
{
if (string.IsNullOrEmpty(websocketUrl))
throw new ArgumentException("Websocket URL required", nameof(websocketUrl));

View File

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

View File

@@ -30,14 +30,18 @@ namespace Darkmatter.Fonepay.Samples
private async UniTask InitiatePayment()
{
if (_payCts != null) return;
payButton.interactable = false;
var fonepay = new FonepayClient();
_payCts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
_payCts = cts;
try
{
var qr = await fonepay.PurchaseAsync(
new QrRequest { amount = amount, remarks1 = "sample" },
_payCts.Token);
cts.Token);
if (qr.qrCode != null)
{
@@ -51,7 +55,7 @@ namespace Darkmatter.Fonepay.Samples
var payment = await fonepay.AwaitPaymentAsync(
qr.thirdpartyQrWebSocketUrl,
onQrVerified: v => Debug.Log($"Fonepay QR verified: {v}"),
ct: _payCts.Token);
ct: cts.Token);
Debug.Log($"Payment frame: {JsonUtility.ToJson(payment)}");
ShowResult(payment.Outcome == PaymentOutcome.Complete);
@@ -73,14 +77,19 @@ namespace Darkmatter.Fonepay.Samples
}
finally
{
_payCts.Dispose();
_payCts = null;
cts.Dispose();
if (_payCts == cts) _payCts = null;
if (this != null && payButton != null) payButton.interactable = true;
}
}
private void ShowResult(bool ok)
{
qrImage.gameObject.SetActive(false);
if (qrImage != null)
{
qrImage.sprite = null;
qrImage.gameObject.SetActive(false);
}
if (successObject != null) successObject.SetActive(ok);
if (failedObject != null) failedObject.SetActive(!ok);
}

View File

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

View File

@@ -0,0 +1,7 @@
fileFormatVersion: 2
guid: 8c9cfa26abfee488c85f1582747f6a02
DefaultImporter:
externalObjects: {}
userData:
assetBundleName:
assetBundleVariant:

View File

@@ -3,6 +3,7 @@
"rootNamespace": "",
"references": [
"Darkmatter.FonepayUnity",
"UniTask",
"UnityEngine.TestRunner"
],
"includePlatforms": [],

View File

@@ -1,7 +1,7 @@
{
"name": "com.darkmattergameproduction.fonepay-unity",
"displayName": "Fonepay Unity",
"version": "0.1.0",
"version": "0.2.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.",

View File

@@ -1,91 +0,0 @@
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()
{
// No FonepayConfig resource in test context → Load() throws FonepayError.
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.");
Assert.ThrowsAsync<ArgumentException>(async () =>
await client.AwaitPaymentAsync(string.Empty));
});
[UnityTest]
public IEnumerator AwaitPaymentAsync_PreCancelled_Throws() => RunAsync(async () =>
{
var client = TryBuildClient();
if (client == null) Assert.Pass("Skipped: no FonepayConfig in test context.");
using var cts = new CancellationTokenSource();
cts.Cancel();
Assert.ThrowsAsync<OperationCanceledException>(async () =>
await client.AwaitPaymentAsync("ws://127.0.0.1:1/", ct: cts.Token));
});
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;
}
}
}

View File

@@ -6,7 +6,7 @@ EditorBuildSettings:
serializedVersion: 2
m_Scenes:
- enabled: 1
path: Assets/Scenes/SampleScene.unity
path: Packages/com.darkmattergameproduction.fonepay-unity/Samples/Example/SampleScene.unity
guid: 8c9cfa26abfee488c85f1582747f6a02
m_configObjects:
com.unity.input.settings.actions: {fileID: -944628639613478452, guid: 2bcd2660ca9b64942af0de543d8d7100, type: 3}

239
README.md Normal file
View File

@@ -0,0 +1,239 @@
# Fonepay Unity
Fonepay payment SDK for Unity. Generate Fonepay QR codes, await payment confirmation over websocket, and process tax refunds — all from a single async-friendly client.
This repo is a **Unity 6000.4.5f1** project that hosts the package source under `Packages/com.darkmattergameproduction.fonepay-unity/` and a sample scene under `Assets/`.
---
## Features
- `FonepayClient` façade — `PurchaseAsync`, `GetStatusAsync`, `AwaitPaymentAsync`, `PostTaxRefundAsync`.
- HMAC-SHA512 dataValidation on all signed payloads.
- Websocket payment listener with cancellation, timeout, and clean disconnect.
- QR rendered straight into a `Texture2D` ready to assign to `UnityEngine.UI.Image`.
- Editor-side credential management (Tools > Fonepay > Settings) — secrets baked at build time, never committed.
- NUnit edit-mode + play-mode tests.
- Importable sample under Package Manager > Samples.
---
## Requirements
| | |
|---|---|
| Unity | 6000.4.5f1+ |
| Test Framework | `com.unity.test-framework` 1.6.0+ |
| UniTask | for the sample only — runtime uses `System.Threading.Tasks` |
---
## Install
### Via Unity Package Manager (recommended)
**Window > Package Manager > + > Install package from git URL**
```
https://github.com/Savya-lol/Fonepay-Unity.git#0.1.0
```
Or edit `Packages/manifest.json` directly:
```json
"com.darkmattergameproduction.fonepay-unity": "https://github.com/Savya-lol/Fonepay-Unity.git#0.1.0"
```
Pin to any released tag (e.g. `#0.1.0`) or track the floating `upm` branch (`#upm`). The `upm` branch is the package subtree, auto-published from `main` via GitHub Actions.
### As a local clone of this repo
1. Clone:
```bash
git clone https://github.com/Savya-lol/Fonepay-Unity.git "Fonepay Unity"
```
2. Open in Unity Hub (6000.4.5f1).
### Setup credentials
1. **Tools > Fonepay > Settings** — create the `FonepayConfig` asset under `Assets/Resources/FonepayConfig.asset`.
2. Enter merchant code, username, password, and HMAC secret. The window stores secrets outside source control; a build preprocessor injects them as `FonepayBakedSecrets` and removes the resource after build.
---
## Usage
### Request a QR
```csharp
using Darkmatter.Fonepay;
var fonepay = new FonepayClient();
QrResult qr = await fonepay.PurchaseAsync(new QrRequest
{
amount = 100f,
remarks1 = "order #1234",
}, ct);
qrImage.sprite = Sprite.Create(
qr.qrCode,
new Rect(0, 0, qr.qrCode.width, qr.qrCode.height),
new Vector2(0.5f, 0.5f));
```
### Await payment
```csharp
QRPaymentStatus result = await fonepay.AwaitPaymentAsync(
qr.thirdpartyQrWebSocketUrl,
onQrVerified: scanned => Debug.Log($"QR scanned: {scanned}"),
ct: ct);
switch (result.Outcome)
{
case PaymentOutcome.Complete: break; // success
case PaymentOutcome.CancelledByUser: break; // dismissed in app
case PaymentOutcome.Failed: break; // server rejected
}
```
### Cancel / timeout
```csharp
var cts = CancellationTokenSource.CreateLinkedTokenSource(destroyCancellationToken);
cts.CancelAfter(TimeSpan.FromMinutes(15)); // QR validity window
try
{
var payment = await fonepay.AwaitPaymentAsync(qr.thirdpartyQrWebSocketUrl, ct: cts.Token);
}
catch (OperationCanceledException) { /* user cancel or timeout */ }
catch (InvalidOperationException) { /* server closed websocket early */ }
catch (FonepayError e) { /* API error: e.ErrorCode, e.Docs */ }
```
`cts.Cancel()` from a button handler aborts the await and disconnects the socket.
### Status poll
```csharp
QrResult status = await fonepay.GetStatusAsync(prn, ct);
// status.message: "PAID" | "UNPAID" | error
```
### Tax refund
```csharp
TaxRefundResponse refund = await fonepay.PostTaxRefundAsync(new TaxRefundRequest
{
fonepayTraceId = "...",
merchantPRN = "...",
invoiceNumber = "INV-001",
invoiceDate = DateTime.UtcNow,
transactionAmount = 100f,
}, ct);
```
---
## API surface
| Type | Purpose |
|---|---|
| `FonepayClient` | Public façade, auto-loads config + secrets. |
| `QrRequest` / `QrResult` | QR request/response DTOs. |
| `QRPaymentStatus` | Terminal payment frame, exposes `Outcome`. |
| `QRVerificationStatus` | Intermediate "QR scanned" frame. |
| `PaymentOutcome` | `Complete` / `CancelledByUser` / `Failed`. |
| `TaxRefundRequest` / `TaxRefundResponse` | Refund DTOs. |
| `FonepayError` | API-level exception with `ErrorCode` + `Docs`. |
### `Outcome` rules
| `success` | `paymentSuccess` | `Outcome` |
|---|---|---|
| true | true | Complete |
| true | false | CancelledByUser |
| false | * | Failed |
### Termination of `AwaitPaymentAsync`
| Condition | Result |
|---|---|
| Payment frame received | resolves with `QRPaymentStatus` |
| `CancellationToken` cancelled | throws `OperationCanceledException` |
| Server closes socket before payment | throws `InvalidOperationException` |
| Network/HTTP error during connect | throws `WebSocketException` |
---
## Repo layout
```
Assets/ Sample scene + test MonoBehaviour
Packages/
com.darkmattergameproduction.fonepay-unity/
Runtime/
Core/ FonepayClient, FonepayConfig, FonepayConfigSO
API/ FonepayApiClient (REST), FonepayWebsocketClient
Crypto/ HmacSha512Signer
Models/ DTOs (QrRequest, QrResult, …)
QR/ FonepayQRGenerator
Editor/ Settings window, build secrets injector
Samples~/ Importable example
Tests/
Editor/ Edit-mode unit tests (NUnit)
Runtime/ Play-mode tests
Documentation/ Architecture notes
README.md Package-level docs
```
---
## Tests
**Window > General > Test Runner**
- **EditMode** — `WebsocketMessage<T>.Status` parsing, `PaymentOutcome` truth table, `FonepayError`.
- **PlayMode** — runtime parse sanity, missing-config throw, empty-URL throw, pre-cancelled token throw.
CLI (Unity 6 batchmode):
```bash
"<Unity>" -batchmode -nographics -projectPath . -runTests \
-testPlatform EditMode -testResults Logs/edit-tests.xml -quit
```
---
## Security
- Credentials never live in this repo. `FonepayConfigSO` holds only public fields; password + HMAC secret are stored via `FonepaySecretsStore` (EditorPrefs) and baked into a temp `FonepayBakedSecrets` resource at build, then removed.
- All signed requests use HMAC-SHA512 — see `HmacSha512Signer`.
- `FonepayConfig.Invalidate()` clears the cached config (use after rotating credentials in Editor).
---
## Troubleshooting
| Symptom | Cause | Fix |
|---|---|---|
| `FonepayError: FonepayConfig asset missing` | No `Resources/FonepayConfig.asset` | Tools > Fonepay > Settings → Save |
| `FonepayError: Fonepay credentials missing` | Secrets not entered (Editor) or baked secrets stripped (build) | Re-open Settings window |
| `AwaitPaymentAsync` returns empty/default `QRPaymentStatus` | Old build pre-fix on `WebsocketMessage<T>.Status` | Update to current; status now reparses each access |
| `AwaitPaymentAsync` hangs forever | Server closed socket without payment frame | Update to current; now throws `InvalidOperationException` |
| `transactionDate` always default | `DateTime` not supported by `JsonUtility` | Fixed — field is `string` now |
---
## Contributing
1. Branch from `main`.
2. Run **Test Runner** (EditMode + PlayMode) before opening a PR.
3. Update `CHANGELOG.md` in the package.
4. Keep `FonepayConfig.asset` and any baked secrets out of commits.
---
## License
See `Packages/com.darkmattergameproduction.fonepay-unity/Third Party Notices.md` for third-party attributions (QRCoder, UniTask). Project license: TBD.