Skip to content

Add a payment gateway

The platform already has three gateways — Paymob (Egypt), Thawani (Oman), WSPG (Montenegro) — selected by the unit’s country. Adding a fourth (“Foo”) is a well-worn path: one concrete service, one switch branch, one public callback, and a frontend redirect. This recipe is the companion to the online payment pipeline flow — read that first for the mechanics. Verified against current backend + orascom-shopper-app.

1. Selection — pick the gateway by country

Section titled “1. Selection — pick the gateway by country”

PaymentSale::handle($sale, $amount) switches on Util::getUnitCountrySlug($sale->unit) (app/Actions/Sale/PaymentSale.php:16-49): EGYPT → PaymobService, OMAN → ThawaniService::createSession, MONTENEGRO → WSPGService::getSignatureKey, default → null. The caller (Shopper/UnitSaleController::paymentUnitSale, :112-143) returns 400 “failed to get payment key…” when the action returns null. Add a case CountryEnums::<NEWCOUNTRY>->value: returning FooService’s init result (add the country to CountryEnums first if it’s new).

2. The service class — FooService extends PaymentService

Section titled “2. The service class — FooService extends PaymentService”

Create app/Services/FooService.php. Here’s what the three existing services do, so you know the contract:

Service Init method Gateway call Amount units Currency
PaymobService getAuthenticationTokenregisterOrdergetPaymentKey 3 HTTP steps cents ×100 (:56,110) $sale->currency
ThawaniService createSession (:21-73) one POST → session_id baisa ×1000 (:35) OMR (:65)
WSPGService getSignatureKey (:13-48) no HTTP — local sha512 signature cents ×100 (:34) $sale->unit->currency

Every init method must do three things — copy this contract:

  1. Start the payment — auth/session/signature per the gateway’s API. Read creds from config('payment.foo.*'). Generate a payment_reference_id (Thawani/WSPG use (string) Str::uuid(); Paymob uses the gateway order id).
  2. Persist a PENDING transaction via StoreTransaction (app/Actions/Transaction/StoreTransaction.php) with status => PENDING, type => TransactionTypes::FOO, payment_reference_id, amount, currency, model_type => UnitSale::class, model_id => $sale->id.
  3. Link back to the sale$sale->update(['transaction_id' => $transaction->id]).

Extend the enum: add case FOO = 'foo'; to app/Enums/TransactionTypes.php:11-13 (it’s the value stored in transactions.type); add the currency to CurrencyEnums if new. PaymentSale’s return type is string|array|null, so return whatever the SPA needs — Paymob/Thawani return a token/session string; WSPG returns an array of signed form fields.

3. The callback — route, controller, and the verification the others skip

Section titled “3. The callback — route, controller, and the verification the others skip”

Route — add to the public callback group in routes/api.php:31-38 (which only strips FormatApiResponse, adds no auth): Route::get('foo/callback/{status}', [CallbackController::class, 'foo']); (or a POST server-webhook pair like Paymob’s). Controller — add a foo(Request, $status) method to CallbackController that news up FooService and delegates to handleTransactionCallbackStatus (:44-65), which switches the URL {status} segment to handleSuccessTransactionCallback / handleFailTransactionCallback.

On success, PaymentService::handleSuccessTransactionCallback (:36-73) looks up the transaction by payment_reference_id (throws if missing), marks it SUCCESS, runs reserveUnitCrm, writes the result to status_after_success_transaction (:70), and redirects the buyer to …/reserve-unit/{unit}?reserve-unit=true|false. That path is model-driven — no PaymentService change needed as long as your callback lands there with a valid payment_reference_id.

The minor-unit conversion is per gateway and lives in the service (see the table above): Paymob cents (×100), Thawani baisa (×1000), WSPG cents (×100, inside the signature). Set Foo’s multiplier to the gateway’s minor-unit factor and write the correct currency on the Transaction. When the destination has requires_payment_currency_choice, the sale’s currency is chosen at request time (UnitSaleController.php:118-132) — respect $sale->currency if Foo is multi-currency (Paymob, for example, keys integration ids per currency: <dest>_egp_integration_id / <dest>_usd_integration_id).

frontend/apps/orascom-shopper-app/src/components/reservation-fee/use-online-payment.ts: handlePaymentToken switches on country (:56-77) — Egypt & Oman do an iframe redirect (window.location.href = NX_PAYMOB_IFRAME_URL / NX_THAWANI_IFRAME_URL + token), Montenegro does a hidden-form POST (montenegro-online-payment-form.submit()). Return handling is here too: the Oman handler reads ?reserve-unit=true|false (the param the backend’s redirectSuccessUrl appends, PaymentService.php:118-134), while the Egypt handler reads Paymob’s own ?success= iframe param. Add a Foo case (iframe redirect vs hidden-form POST), a return-querystring handler if its params differ, and NX_FOO_IFRAME_URL to frontend/.env.example.

config/payment.php:3-37 has one array group per gateway. Add a 'foo' => [...] group reading FOO_* env names (api_url, secret_key/hmac_secret, success_url, cancel_url, fail_url, …); add the placeholders to backend .env.example (real secrets live in AWS Secrets Manager). If Foo’s key is per-project, mirror Thawani’s override: $sale->unit->project->payment_api_secret_key ?? config('payment.foo.secret_key') (ThawaniService.php:25).

Backend: CountryEnums/CurrencyEnums (if new) · TransactionTypes (add FOO) · config/payment.php (+ .env.example) · app/Services/FooService.php · PaymentSale.php (the country case) · CallbackController (add foo(...) + verification) · routes/api.php (the callback route) · (if the country pays add-ons/EOIs) AddonPayment/EOIPayment.

Frontend: use-online-payment.ts (the country case + return handler) · frontend/.env.example (NX_FOO_IFRAME_URL) · (if hidden-form POST) the form-rendering component.

  1. Staticphpstan analyse + php-cs-fixer fix (backend); yarn nx affected -t lint build (frontend).
  2. Happy path (local)POST paymentUnitSale for a sale in Foo’s country → assert a PENDING transactions row (type=foo, payment_reference_id set) + unit_sale.transaction_id linked → simulate GET foo/callback/success?payment_reference_id=… → assert SUCCESS, status_after_success_transaction set, redirect to …/reserve-unit/{unit}?reserve-unit=true|false.
  3. Verification test — send a callback with a bad signature → assert it’s rejected and the transaction stays PENDING (the guard the other three lack).
  4. Currency — assert the amount sent to the gateway is in the right minor unit and Transaction.currency matches.