Compare commits

...

11 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
Savya Bikram Shah
6a8a6e46f0 Tests and package name updated 2026-05-07 17:42:48 +05:45
Savya Bikram Shah
9f620084b2 Added docs and test 2026-05-07 17:33:26 +05:45
Savya Bikram Shah
3c17829453 Added Socket closed handling 2026-05-07 17:16:21 +05:45
Savya Bikram Shah
846a4fda9c QR and payment fixes 2026-05-07 16:43:28 +05:45
140 changed files with 2958 additions and 1063 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

1
.gitignore vendored
View File

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

View File

@@ -1,56 +0,0 @@
using System;
using Cysharp.Threading.Tasks;
using UnityEngine;
using UnityEngine.UI;
namespace Darkmatter.Fonepay.Samples
{
public class SamplePayment : MonoBehaviour
{
[SerializeField] private Image qrImage;
[SerializeField] private GameObject successObject;
[SerializeField] private GameObject failedObject;
[SerializeField] private Button payButton;
private void Start()
{
payButton.onClick.AddListener(OnPayButtonClicked);
}
private void OnPayButtonClicked()
{
InitiatePayment().Forget();
}
private async UniTask InitiatePayment()
{
var fonepay = new FonepayClient();
var request = new QrRequest
{
amount = 1,
remarks1 = "mausham ko paisa"
};
var qr = await fonepay.PurchaseAsync(request, destroyCancellationToken);
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: destroyCancellationToken);
var ok = payment.Outcome == PaymentOutcome.Complete;
qrImage.gameObject.SetActive(false);
successObject.SetActive(ok);
failedObject.SetActive(!ok);
}
}
}

View File

@@ -1,2 +0,0 @@
fileFormatVersion: 2
guid: 57da22305386447fcabd663378359ede

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 111b87cef6134401aa05813da47709aa
guid: 5dff20c6950e54d71bfe971186191b01
folderAsset: yes
DefaultImporter:
externalObjects: {}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: 7f9dbf7477f88405ca98d50075ced835
guid: 2b7e8dee7532643a181e55ce459fef09
folderAsset: yes
DefaultImporter:
externalObjects: {}

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,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}
@@ -524,13 +525,15 @@ MonoBehaviour:
m_GameObject: {fileID: 508689484}
m_Enabled: 1
m_EditorHideFlags: 0
m_Script: {fileID: 11500000, guid: 57da22305386447fcabd663378359ede, type: 3}
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
@@ -789,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
@@ -821,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
@@ -1063,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

@@ -0,0 +1,24 @@
# 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.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
- `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`).

View 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`.

View 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**.

View File

@@ -177,14 +177,29 @@ namespace Darkmatter.Fonepay
return new QrResult
{
message = response.message,
qrCode = string.IsNullOrEmpty(response.qrMessage)
? null
: FonepayQRGenerator.GenerateTexture(response.qrMessage),
qrCode = TryGenerateQr(response.qrMessage),
status = response.status,
statusCode = response.statusCode,
success = response.success,
thirdpartyQrWebSocketUrl = response.thirdpartyQrWebSocketUrl,
qrMessage = response.qrMessage,
};
}
private static Texture2D TryGenerateQr(string qrMessage)
{
if (string.IsNullOrEmpty(qrMessage))
return null;
try
{
return FonepayQRGenerator.GenerateTexture(qrMessage);
}
catch (Exception ex)
{
Debug.LogWarning($"Fonepay QR render failed ({ex.Message}). " +
"Use QrResult.qrMessage with an external renderer.");
return null;
}
}
}
}

View File

@@ -13,6 +13,7 @@ namespace Darkmatter.Fonepay
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;
@@ -51,6 +52,7 @@ namespace Darkmatter.Fonepay
CancellationToken cancellationToken)
{
var buffer = new byte[4096];
Exception error = null;
try
{
@@ -70,9 +72,9 @@ namespace Darkmatter.Fonepay
{
// Expected during disconnect.
}
catch (WebSocketException)
catch (WebSocketException ex)
{
// Network disconnect or broken socket.
error = ex;
}
catch (ObjectDisposedException)
{
@@ -80,7 +82,11 @@ namespace Darkmatter.Fonepay
}
catch (Exception ex)
{
Console.WriteLine($"WebSocket receive error: {ex.Message}");
error = ex;
}
finally
{
OnClosed?.Invoke(error);
}
}

View File

@@ -0,0 +1,74 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace Darkmatter.Fonepay
{
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);
}
public FonepayAsync<QrResult> PurchaseAsync(QrRequest req, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.PostQRAsync(req, ct), ct);
public FonepayAsync<QrResult> GetStatusAsync(string prn, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.GetStatusAsync(prn, ct), ct);
public FonepayAsync<TaxRefundResponse> PostTaxRefundAsync(TaxRefundRequest req, CancellationToken ct = default)
=> FonepayAsyncBridge.Wrap(_api.PostTaxRefundAsync(req, ct), ct);
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));
using var ws = new FonepayWebsocketClient();
var tcs = new TaskCompletionSource<QRPaymentStatus>(
TaskCreationOptions.RunContinuationsAsynchronously);
if (onQrVerified != null)
ws.OnQrVerified += onQrVerified;
ws.OnPaymentReceived += msg => tcs.TrySetResult(msg.Status);
ws.OnClosed += err =>
{
if (err != null)
tcs.TrySetException(err);
else
tcs.TrySetException(new InvalidOperationException(
"Fonepay websocket closed before payment frame received."));
};
using var ctReg = ct.Register(() => tcs.TrySetCanceled(ct));
try
{
await ws.ConnectAsync(websocketUrl, ct);
return await tcs.Task;
}
finally
{
await ws.DisconnectAsync();
}
}
}
}

View File

@@ -12,6 +12,7 @@ namespace Darkmatter.Fonepay
public int statusCode;
public bool success;
public string thirdpartyQrWebSocketUrl;
public string qrMessage;
}

View File

@@ -7,7 +7,7 @@ namespace Darkmatter.Fonepay
{
public string remarks1;
public string remarks2;
public DateTime transactionDate;
public string transactionDate;
public string productNumber;
public float amount;
public string message;

View File

@@ -9,7 +9,6 @@ namespace Darkmatter.Fonepay
public string merchantId;
public string deviceId;
public string transactionStatus;
private T _status;
public T Status => _status ??= JsonUtility.FromJson<T>(transactionStatus);
public T Status => JsonUtility.FromJson<T>(transactionStatus);
}
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,11 +1,13 @@
using System.Collections;
using QRCoder;
using UnityEngine;
namespace Darkmatter.Fonepay
{
/// <summary>
/// Pure C# QR Code generator. Byte mode, ECC L/M/Q/H, versions 110.
/// QR code generator backed by QRCoder (MIT). See Plugins/QRCoder-LICENSE.txt.
/// </summary>
public static partial class FonepayQRGenerator
public static class FonepayQRGenerator
{
public enum EccLevel { L, M, Q, H }
@@ -25,16 +27,21 @@ namespace Darkmatter.Fonepay
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++)
tex.SetPixel(col * pixelSize + px,
(size - 1 - row) * pixelSize + py, c);
pixels[rowStart + px] = c;
}
}
tex.SetPixels(pixels);
tex.Apply();
return tex;
}
@@ -57,10 +64,26 @@ namespace Darkmatter.Fonepay
{
if (string.IsNullOrEmpty(text))
throw new System.ArgumentException("QR text must be non-empty", nameof(text));
byte[] data = System.Text.Encoding.UTF8.GetBytes(text);
var (version, ecBlocks) = ChooseVersion(data.Length, ecc);
byte[] codewords = BuildCodewords(data, version, ecc, ecBlocks);
return BuildMatrix(version, codewords);
}
using var data = new QRCodeGenerator().CreateQrCode(text, MapEcc(ecc), forceUtf8: true);
int size = data.ModuleMatrix.Count;
var matrix = new bool[size, size];
for (int row = 0; row < size; row++)
{
BitArray bits = data.ModuleMatrix[row];
for (int col = 0; col < size; col++)
matrix[row, col] = bits[col];
}
return matrix;
}
static QRCodeGenerator.ECCLevel MapEcc(EccLevel e) => e switch
{
EccLevel.L => QRCodeGenerator.ECCLevel.L,
EccLevel.M => QRCodeGenerator.ECCLevel.M,
EccLevel.Q => QRCodeGenerator.ECCLevel.Q,
EccLevel.H => QRCodeGenerator.ECCLevel.H,
_ => QRCodeGenerator.ECCLevel.M
};
}
}

View File

@@ -1,5 +1,5 @@
fileFormatVersion: 2
guid: a65b4dbd6e610485fae19b0acddf3890
guid: 65d000fc5ca9742ec882f227d68c7bd4
folderAsset: yes
DefaultImporter:
externalObjects: {}

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

Some files were not shown because too many files have changed in this diff Show More