laraditz/tng-ewallet

Laravel SDK for Touch 'n Go's Mini Program OpenAPI (Cashier Payment, Agreement Payment, refunds, user info, and messaging).

Maintainers

Package info

github.com/laraditz/tng-ewallet

pkg:composer/laraditz/tng-ewallet

Transparency log

Statistics

Installs: 32

Dependents: 0

Suggesters: 0

Stars: 0

Open Issues: 0

1.1.0 2026-07-21 20:16 UTC

This package is auto-updated.

Last update: 2026-07-21 20:16:50 UTC


README

Latest Version on Packagist Total Downloads License

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 notifyPayment webhooks 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.pem is what TNG_PRIVATE_KEY_PATH points at (matches the default below) — keep it secret, never commit it.
  • Submit the contents of storage/tng/public.pem to 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 (matches TNG_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 paymentNotifyUrl unless you're prepared to handle the webhook yourself. You can pass your own paymentNotifyUrl and it will be used instead of the package default — but this package's signature-verifying middleware, controller, and PaymentNotified event 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. Leave paymentNotifyUrl unset 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 U result must never be independently refunded or re-charged offline — the payment may still complete on TNG's side after you've observed U.
  • 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() — a U refund 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.