laraditz / tng-ewallet
Laravel SDK for Touch 'n Go's Mini Program OpenAPI (Cashier Payment, Agreement Payment, refunds, user info, and messaging).
Requires
- php: ^8.1
- illuminate/support: ^11.0|^12.0|^13.0
Requires (Dev)
- orchestra/testbench: ^9.0|^10.0|^11.0
- pestphp/pest: ^2.0|^3.0
README
A Laravel SDK for Touch 'n Go's Mini Program OpenAPI — RSA256 request signing, response/webhook signature verification, and a persistence layer covering every call, access token, payment, refund, and inbound notification.
Requires PHP 8.1+ and Laravel 11.0+.
Installation
composer require laraditz/tng-ewallet
Publish the config file and the migrations:
php artisan vendor:publish --tag=tng-ewallet-config php artisan vendor:publish --tag=tng-ewallet-migrations
Then run them:
php artisan migrate
Re-running vendor:publish --tag=tng-ewallet-migrations is safe — it checks your app's database/migrations directory and only publishes files that aren't already there (matched by filename, ignoring the timestamp prefix), so upgrading the package never creates duplicate migrations.
If you'll use the Agreement Payment flow (anything involving stored access tokens), also generate a dedicated encryption key now, before you go any further:
php artisan tng-ewallet:generate-key
This sets TNG_ENCRYPTION_KEY in your .env — independent of your app's APP_KEY, used to encrypt access/refresh tokens at rest. Set it once and never change it afterwards — there's no migration path if it's rotated or lost, and every stored token would stop working. A Cashier Payment-only integration never needs this.
RSA keys
TNG's signing scheme involves two independent RSA keypairs, not one:
- Yours — generated by you. You keep the private half and use it to sign every outbound request; TNG never sees it. You give TNG the public half out-of-band (during API onboarding/registration) so they can verify requests genuinely came from you.
- TNG's — generated and held by TNG. They give you their public key so you can verify their responses and inbound
notifyPaymentwebhooks weren't tampered with. You never see TNG's private key, and TNG never sees yours.
Generate your own keypair:
openssl genrsa -out storage/tng/private.pem 2048 openssl rsa -in storage/tng/private.pem -pubout -out storage/tng/public.pem
storage/tng/private.pemis whatTNG_PRIVATE_KEY_PATHpoints at (matches the default below) — keep it secret, never commit it.- Submit the contents of
storage/tng/public.pemto TNG during onboarding. This file's job is done once submitted — the SDK itself never reads it. - TNG will separately give you their public key. Save it as
storage/tng/tng_public.pem(matchesTNG_PUBLIC_KEY_PATH's default) — this is the file the SDK actually reads at runtime, to verify TNG's responses.
Add storage/tng/*.pem to your app's .gitignore so neither key is ever committed.
Configuration
Set these in your .env:
| Variable | Description | Default |
|---|---|---|
TNG_SANDBOX |
Use TNG's sandbox host instead of production | true |
TNG_CLIENT_ID |
Your TNG-assigned Client-Id | (required) |
TNG_PARTNER_ID |
Your TNG-assigned partner ID | (required) |
TNG_PRIVATE_KEY_PATH |
Path to your RSA private key (PEM), used to sign every outbound request — see RSA keys | storage_path('tng/private.pem') |
TNG_PUBLIC_KEY_PATH |
Path to TNG's RSA public key (PEM), used to verify responses and inbound notifications — see RSA keys | storage_path('tng/tng_public.pem') |
TNG_KEY_VERSION |
Key version sent in the Signature header |
1 |
TNG_VERIFY_RESPONSE_SIGNATURE |
Verify every response's signature before returning data | true |
TNG_TIMEOUT |
HTTP timeout in seconds | 30 |
TNG_NOTIFY_PATH |
Path the inbound notifyPayment webhook is registered at |
/tng-ewallet/notify |
TNG_RETURN_PATH |
Path the Cashier Payment return page is registered at — see Return page | /tng-ewallet/return |
TNG_DEFAULT_RETURN_URL |
Fallback "Back" destination on the return page when pay() wasn't given a customerReturnUrl — see Return page |
config('app.url') |
TNG_ENCRYPTION_KEY |
Dedicated key for encrypting sensitive stored data (independent of APP_KEY) — see Installation |
(required if using the Agreement Payment / access-token flow) |
client_id, partner_id, and private_key_path are required — a missing value throws ConfigurationException before any HTTP call is made. encryption_key is only required if you use the Agreement Payment flow (anything involving access tokens) — a Cashier Payment-only integration never needs to set it.
partner_id is automatically included in every call that needs it (prepare, pay, inquiryPayment, refund, inquiryRefund) — you never need to pass it yourself, though an explicit partnerId in your own call data always takes precedence if you do.
API Reference
Every wrapped call is a POST under config('tng-ewallet.api_path') (default /acl/api), reached through the Tng facade. Every response DTO shares resultStatus/resultCode/resultMessage plus isSuccessful()/isAccepted()/isFailed()/isUnknown()/raw()/toArray() — see Handling U and A results below.
| Resource | Method | TNG endpoint | Description | Docs |
|---|---|---|---|---|
| Authorization | prepare() |
/v1/authorizations/prepare |
Start the Agreement Payment binding flow | docs/authorization.md |
| Authorization | applyToken() |
/v1/authorizations/applyToken |
Exchange an authCode (or refresh token) for an access token | docs/authorization.md |
| Authorization | cancelToken() |
/v1/authorizations/cancelToken |
Revoke a binding | docs/authorization.md |
| Payment | pay() |
/v1/payments/pay |
Create a Cashier or Agreement payment | docs/payment.md |
| Payment | inquiry() |
/v1/payments/inquiryPayment |
Check the real status of a payment | docs/payment.md |
| Refund | create() |
/v1/payments/refund |
Refund all or part of a payment | docs/refund.md |
| Refund | inquiry() |
/v1/payments/inquiryRefund |
Check the real status of a refund | docs/refund.md |
| User | inquiryByAccessToken() |
/v1/customers/user/inquiryUserInfoByAccessToken |
Fetch a user's profile info | docs/user-and-messaging.md |
| Message | sendByAccessToken() |
/v2/customers/message/sendByAccessToken |
Send a message to a user | docs/user-and-messaging.md |
| Client (escape hatch) | client()->post($uri, $data) |
any | Raw signed POST for endpoints not wrapped above | — |
pay(), applyToken(), cancelToken(), inquiryByAccessToken(), and sendByAccessToken() each have a full working example further down, in the Cashier Payment, Agreement Payment, and User info and messaging sections. The remaining calls are shown below; full parameter lists, response DTO fields, and side-effect notes for every method are in docs/.
authorization()->prepare()
$prepare = Tng::authorization()->prepare([ 'referenceClientId' => 'your-mini-program-client-id', ]); // Redirect / hand $prepare->authURL to your Mini Program frontend.
payment()->inquiry()
$status = Tng::payment()->inquiry(['paymentRequestId' => $paymentRequestId]); if ($status->isSuccessful()) { // Safe to mark the order paid. } elseif ($status->isFailed()) { // $status->paymentFailReason explains why. }
refund()->create()
$refund = Tng::refund()->create([ 'refundRequestId' => (string) Str::uuid(), 'paymentRequestId' => $paymentRequestId, 'refundAmount' => ['currency' => 'MYR', 'value' => '10000'], 'refundReason' => 'Customer requested cancellation', ]);
refund()->inquiry()
$status = Tng::refund()->inquiry(['refundRequestId' => $refundRequestId]); $status->refundStatus; // PROCESSING | SUCCESS | FAIL
Cashier Payment (redirect checkout)
This is the documented golden path: create a payment, redirect the user to TNG's hosted cashier page, then receive the result asynchronously via the webhook.
use Laraditz\TngEwallet\Facades\Tng; $response = Tng::payment()->pay([ // partnerId, paymentNotifyUrl, paymentReturnUrl, and envInfo are filled in automatically — see below. 'paymentRequestId' => (string) Str::uuid(), // your own unique ID — the SDK never generates one for you 'paymentOrderTitle' => 'Order #1234', 'productCode' => '51051000101000100001', // Cashier Payment product code 'paymentAmount' => ['currency' => 'MYR', 'value' => '10000'], // smallest currency unit 'paymentFactor' => ['isCashierPayment' => true], 'customerReturnUrl' => route('checkout.thanks'), // optional — see "Return page" below ]); if ($response->isAccepted()) { // The normal Cashier Payment path — redirect the user to finish payment. return redirect()->away($response->actionForm->redirectionUrl); } if ($response->isFailed()) { // $response->resultCode / $response->resultMessage explain why. } if ($response->isUnknown()) { // See "Handling U (Unknown) results" below — do not treat as final. }
pay() defaults paymentNotifyUrl to this package's own auto-registered webhook route (config('tng-ewallet.notify_path'), default /tng-ewallet/notify) — that's what TNG calls when the payment reaches a final state, and it's what the middleware/controller/job pipeline below is built to receive. Listen for the result in your own listener:
use Laraditz\TngEwallet\Events\PaymentNotified; class HandlePaymentNotified implements ShouldQueue // recommended, though not required — see the afterResponse() note below { public function handle(PaymentNotified $event): void { $payload = $event->payload; // the raw, already-verified notifyPayment body // $payload['paymentResult']['resultStatus'] is 'S' or 'F'. // Persist your own order state here — this package never touches // your application's domain model, only its own audit tables. } }
Don't override
paymentNotifyUrlunless you're prepared to handle the webhook yourself. You can pass your ownpaymentNotifyUrland it will be used instead of the package default — but this package's signature-verifying middleware, controller, andPaymentNotifiedevent only run for requests that hit its own registered route. If you point TNG at a different URL, none of that pipeline applies to that call: you're responsible for verifying the inbound signature, returning the mandatory ack, and processing the notification entirely on your own. LeavepaymentNotifyUrlunset unless you have a specific reason to bypass this package's webhook handling.
pay() similarly defaults paymentReturnUrl to this package's own return route (config('tng-ewallet.return_path'), default /tng-ewallet/return) — that's where TNG redirects the customer's browser once the hosted cashier page finishes. This package owns that whole page: it looks up the payment, checks its live status via inquiry(), and shows a status page with a "Back" link. Pass customerReturnUrl (as in the example above) to control where that Back link points — otherwise it falls back to config('tng-ewallet.default_return_url'). See docs/payment.md#return-page for the full behavior, including the three states it can render.
Agreement Payment (stored-credential auto-debit)
Used once a user has bound their account to your app and authorized recurring/one-off charges without re-entering payment details each time. Three steps: bind, apply for a token, then pay with it.
use Laraditz\TngEwallet\Facades\Tng; // 1. Prepare — kicks off the binding flow, returns a URL for the user to authorize. $prepare = Tng::authorization()->prepare([ 'referenceClientId' => 'your-mini-program-client-id', ]); // Redirect / hand $prepare->authURL to your Mini Program frontend. // 2. Apply for an access token — after the user authorizes, you'll receive an authCode. $token = Tng::authorization()->applyToken([ 'grantType' => 'AUTHORIZATION_CODE', 'authCode' => $authCodeFromMiniProgram, ]); // $token->accessToken / $token->customerId are now persisted in tng_ewallet_access_tokens. // 3. Pay using the access token — no cashier redirect needed. $response = Tng::payment()->pay([ // partnerId, paymentNotifyUrl, and envInfo are filled in automatically — see above. 'paymentRequestId' => (string) Str::uuid(), 'paymentOrderTitle' => 'Order #1234', 'productCode' => '51051000101000100031', // Agreement Payment product code 'paymentAmount' => ['currency' => 'MYR', 'value' => '10000'], 'paymentFactor' => ['isAgreementPay' => true], 'paymentAuthCode' => $token->accessToken, ]);
Refreshing an expired access token uses the same applyToken() call with grantType: REFRESH_TOKEN — each call creates a new tng_ewallet_access_tokens row rather than overwriting the old one, so rotation history is preserved.
To revoke a binding:
Tng::authorization()->cancelToken(['accessToken' => $token->accessToken]);
User info and messaging
Given an access token from the Agreement Payment flow above:
$userInfo = Tng::user()->inquiryByAccessToken(['accessToken' => $token->accessToken]); $userInfo->userInfo; // raw array, e.g. ['userId' => '...'] Tng::message()->sendByAccessToken([ 'accessToken' => $token->accessToken, 'message' => 'Your order has shipped!', ]);
Handling U (Unknown) and A (Accepted) results
Every response DTO exposes isSuccessful() (S), isAccepted() (A), isFailed() (F), and isUnknown() (U). Two of these need explicit caller attention beyond a simple if/else:
isAccepted() on pay() is not a failure — it's TNG's normal Cashier Payment success path. PayResponse::isAccepted() plus $response->actionForm->redirectionUrl is the documented golden-path branch (see the Cashier Payment example above). Treating A as an error will break the most common call in this SDK.
isUnknown() must never be treated as final. Per TNG's own docs, a U result means an unknown exception occurred on the wallet's side — the SDK does not auto-retry or auto-inquire on your behalf. Specifically for pay():
- A
Uresult must never be independently refunded or re-charged offline — the payment may still complete on TNG's side after you've observedU. - Use
Tng::payment()->inquiry(['paymentRequestId' => $id])to check the real status once you're ready, rather than assuming failure or success. - The same caution applies to
Tng::refund()->create()— aUrefund result must not be treated as failed and retried, since the original refund may still be processing.
Operational notes
The webhook ack is guaranteed to be sent before PaymentNotified fires, via Laravel's dispatch(...)->afterResponse() — not a queue. This relies on fastcgi_finish_request(), which is standard under PHP-FPM (the overwhelming majority of production Laravel deployments). Under non-FPM SAPIs (php artisan serve, some Octane configurations), verify this ordering holds in your own environment before relying on it in production.
There is no retry if your own PaymentNotified listener throws, and none from TNG either — by the time the job runs, TNG has already received a successful ack and will not redeliver. Make listeners resilient (catch your own exceptions, log failures, reconcile via Tng::payment()->inquiry() if needed) rather than assuming the event always completes cleanly.
Listeners must be idempotent. TNG retries the notification until it receives an S ack; each delivery gets its own tng_ewallet_notifications row (the package does not de-duplicate), so the same PaymentNotified event can fire more than once for the same payment. Design your listener so processing the same payload twice is safe.
notify_path must match exactly what you configured as paymentNotifyUrl when calling pay() — including scheme, host, and any trailing slash. A mismatch (e.g. a reverse proxy that rewrites paths) causes legitimate notifications to be rejected with 401, not silently accepted; there's no way for this package to detect a misconfigured path itself.
Add your own rate limiting (via your reverse proxy, CDN, or a throttle: middleware sized to your expected TNG callback volume) if the notify endpoint needs protection beyond signature verification — the package intentionally ships with none, since a wrong hardcoded limit could reject legitimate TNG traffic.
Rotate your TNG public key file atomically (write to a temp file, then rename()) rather than editing it in place — the key is read fresh from disk on every verification, and a torn read during a non-atomic rewrite would cause verification failures, not a security gap (verification fails closed either way).
Security
- Access and refresh tokens are encrypted at rest using
TNG_ENCRYPTION_KEY, generated during Installation above. Exclude it from any blanket "rotate all secrets" tooling or policy — there's no migration path if it's rotated or lost. - Credentials are automatically stripped from logged request/response data before it's stored, so a database backup or read replica doesn't expose them.
- This package's tables are a full audit trail of your payment activity — every call, success or failure, is recorded. Apply the same database-level access controls you'd use for any PII/financial-data store.