moho / payment-gateways
Hosted payment page gateways for Macedonian payment providers (cPay/Casys, NLB/Bankart, Halkbank/NestPay), built for Cream ERP.
Requires
- php: ^8.2
Requires (Dev)
- phpunit/phpunit: ^11.0
Suggests
None
Provides
None
Conflicts
None
Replaces
None
README
Hosted payment page gateways for Macedonian payment providers, built for Cream ERP.
The package builds the signed request that sends a customer to the provider's payment page, and verifies the callback that brings them back. It is plain PHP (^8.2) with no framework dependency: the application keeps routes, sessions, payment records and merchant credentials.
| Provider | Type | Payment page | Status |
|---|---|---|---|
| cPay (Casys) | casys |
signed POST form |
Supported |
| NLB (Bankart) | nlb |
redirect from Bankart's API | Beta: tested against Bankart's API documentation, not yet with a live account |
| Halkbank (NestPay) | halkbank |
signed POST form |
Beta: not yet tested with a sandbox account |
Contents
Installation
composer require moho/payment-gateways
To work on the package next to your application, use a path repository instead (symlinked into vendor/):
composer config repositories.payment-gateways path ../payment-gateways
composer require "moho/payment-gateways:@dev"
Usage
A payment takes three steps: build the gateway, send the customer to the payment page, and verify the callback when they return.
1. Build the gateway
use Moho\PaymentGateways\Gateways; $gateway = Gateways::make(Gateways::CASYS, $config, testMode: false);
$config holds the provider's merchant details (see Providers); a missing required key throws InvalidArgumentException.
Providers called from the server (NLB) use a small built-in HTTP client, which needs allow_url_fopen. To use your own client (for logging, timeouts or fakes in tests), pass a closure that sends the request and returns the response body. With Laravel's HTTP client:
use Illuminate\Support\Facades\Http; $gateway = Gateways::make(Gateways::NLB, $config, false, fn (string $method, string $url, array $headers, string $body): string => Http::withHeaders($headers) ->timeout(30) ->when($body !== '', fn ($request) => $request->withBody($body, $headers['Content-Type'])) ->send($method, $url) ->body());
2. Send the customer to the payment page
use Moho\PaymentGateways\PaymentRequest; $form = $gateway->checkout(new PaymentRequest( paymentId: 'ORD-42-1', // unique per attempt, echoed back in the callback amount: 1990, // 1.990 MKD currency: 'MKD', successUrl: route('payment.success'), failUrl: route('payment.fail'), fee: 30, // optional, charged on top of the amount description: 'Order #42', // optional firstName: 'Ana', // optional payer details, lastName: 'Petrovska', // prefilled on the payment page email: 'ana@example.mk', address: 'Partizanska 1', city: 'Skopje', zip: '1000', country: 'North Macedonia', phone: '+38970123123', ));
Amounts are whole denars as integers. Round the order total before paying, so the customer is charged exactly what they saw; the provider charges amount + fee. Use a new paymentId for every attempt: providers refuse one they have already seen.
checkout() returns a RedirectForm (url, method, fields). Render it and submit it straight away, keeping the field order:
<form method="{{ $form->method }}" action="{{ $form->url }}" id="pay"> @foreach ($form->fields as $name => $value) <input type="hidden" name="{{ $name }}" value="{{ $value }}"> @endforeach </form> <script>document.getElementById('pay').submit();</script>
3. Verify the callback
Call handleCallback() on the success return only, with everything the provider sent (query string and body):
use Moho\PaymentGateways\InvalidCallbackException; try { $result = $gateway->handleCallback($request->all()); } catch (InvalidCallbackException) { return redirect()->route('payment.failed'); // not a completed payment } $result->provider; // 'casys', 'nlb' or 'halkbank' $result->paymentId; // the paymentId you sent $result->amount; // total charged in denars, fee included $result->currency; $result->reference; // the provider's transaction reference, when it sends one $result->raw; // the callback (cPay) or Bankart's status answer (NLB)
It throws InvalidCallbackException unless the callback proves a completed payment. The fail return needs no verification: nothing was paid.
Security
- Trust the result, not the URL. cPay also signs declined and canceled payments, so a signed callback on the success URL proves nothing by itself.
handleCallback()accepts a cPay callback only when cPay signed its payment reference (cPayPaymentRef) together with the payment id and amount; for NLB it asks Bankart's status API. - Match it to your payment. Before marking a payment paid, compare
paymentIdandamountwith your stored payment. - Make the handler idempotent. Providers may call back more than once, sometimes at the same moment: lock the payment row and process it once.
- Never take test payments on a live site. In test mode cPay signs with its public test key (
TEST_PASS), so anyone can sign a test callback, and test payments take no money.
Found a vulnerability? Please report it privately, as described in SECURITY.md.
Providers
cPay (Casys)
| Config key | Required | Notes |
|---|---|---|
merchant_id |
yes | sent as PayToMerchant |
merchant_name |
yes | sent as MerchantName |
merchant_password |
live only | checksum key; test mode always uses TEST_PASS |
merchant_details |
no | sent as Details1; falls back to the description, then the payment id |
api_version |
no | new (HMAC-SHA256, default) or old (MD5) |
AmountToPayis sent in denars ×100, as cPay requires;OriginalAmountandFeein denars.- Empty optional fields are left out of both the form and the checksum.
- Test mode posts to
https://vpos.cpay.com.mk/mk-MK/TestChecksum, which only checks the checksum.
NLB (Bankart)
| Config key | Required | Notes |
|---|---|---|
api_key |
yes | Bankart API key |
username |
yes | API user |
password |
yes | API password |
shared_secret |
yes | signs each API call (HMAC-SHA512) |
checkout()calls Bankart's debit API and returns its payment page as aGETform. It throwsRuntimeExceptionwhen Bankart refuses the payment or can't be reached; send the customer to your failed payment page then.merchantTransactionId={paymentId}is appended to the success URL, which is also Bankart'scallbackUrl: Bankart POSTs JSON there from its servers and expects a200response with the bodyOK. That URL must be reachable from the internet.- The fail URL is used for both canceled and failed payments.
- The payer's name, email, address, city, zip and phone are sent as Bankart's customer data; the country is not.
handleCallback()trusts nothing it receives except that id: it asks Bankart's status API and accepts only a successful debit.- Test mode uses
https://bankart.paymentsandbox.cloud.
Halkbank (Asseco NestPay)
| Config key | Required | Notes |
|---|---|---|
client_id |
yes | merchant id, sent as clientid |
store_key |
yes | signs the form and the callback |
gateway_url |
live only | the bank's est3Dgate URL; test mode uses https://torus-stage-halkbankmacedonia.asseco-see.com.tr/fim/est3Dgate |
- 3D Pay Hosting with hash version 3: base64 SHA-512 over every field's value in case-insensitive name order, then the store key.
- The amount is sent with two decimals (
1990.00), the currency as its ISO numeric code (MKD807, EUR978, USD840). - NestPay posts the result from the customer's browser to the success or fail URL.
handleCallback()checks the hash over every field it received and accepts onlyResponse=ApprovedwithProcReturnCode=00. Pass the posted fields unchanged: trimming values breaks the hash.
Adding a provider
- Create
src/<Provider>/<Provider>Gateway.phpimplementingPaymentGateway, validating its config in the constructor. - Add a type constant and a
matcharm inGateways::make(). - Add tests under
tests/<Provider>/, with checksum or signature vectors from the provider's documentation or a known working implementation. - Document its config keys and behaviour here.
Tests
composer install vendor/bin/phpunit
Contributing
Issues and pull requests are welcome. Please add tests for any change, and report security issues privately as described in SECURITY.md.