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 |
getAuthenticationToken → registerOrder → getPaymentKey |
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:
- Start the payment — auth/session/signature per the gateway’s API. Read creds from
config('payment.foo.*'). Generate apayment_reference_id(Thawani/WSPG use(string) Str::uuid(); Paymob uses the gateway order id). - Persist a PENDING transaction via
StoreTransaction(app/Actions/Transaction/StoreTransaction.php) withstatus => PENDING,type => TransactionTypes::FOO,payment_reference_id,amount,currency,model_type => UnitSale::class,model_id => $sale->id. - 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.
4. Currency / amount units
Section titled “4. Currency / amount units”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).
5. Frontend redirect
Section titled “5. Frontend redirect”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.
6. Config / env
Section titled “6. Config / env”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).
Files you touch
Section titled “Files you touch”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.
Test / verify
Section titled “Test / verify”- Static —
phpstan analyse+php-cs-fixer fix(backend);yarn nx affected -t lint build(frontend). - Happy path (local) —
POSTpaymentUnitSalefor a sale in Foo’s country → assert aPENDINGtransactionsrow (type=foo,payment_reference_idset) +unit_sale.transaction_idlinked → simulateGET foo/callback/success?payment_reference_id=…→ assertSUCCESS,status_after_success_transactionset, redirect to…/reserve-unit/{unit}?reserve-unit=true|false. - Verification test — send a callback with a bad signature → assert it’s rejected and the transaction
stays
PENDING(the guard the other three lack). - Currency — assert the amount sent to the gateway is in the right minor unit and
Transaction.currencymatches.
