The online payment pipeline
This is the payment sub-flow that the shopper-buy card path
hands off to. One backend pipeline takes money for three different things through three
different gateways, writes a single transactions ledger row, and — only after the gateway calls
back — submits the result to the CRM. Everything here is verified against the current backend code
(paths per the evidence in docs-site/_evidence/flow-payment.md).
The whole trip, in one diagram
Section titled “The whole trip, in one diagram”sequenceDiagram
actor S as Shopper
participant SPA as Shopper SPA
participant API as Backend (PaymentSale)
participant GW as Gateway (Paymob/Thawani/WSPG)
participant CB as CallbackController
participant CRM as CRM middleware
S->>SPA: Pay reservation fee (amount)
SPA->>API: POST .../{sale}/payment { amount }
Note over API: gateway by unit country<br/>StoreTransaction → PENDING
API-->>SPA: gateway token / session / signed form fields
SPA->>GW: redirect (Paymob/Thawani) or browser-POST (WSPG), pay
GW->>CB: success callback (POST paymob · GET {gw}/callback/success)
Note over CB: mark Transaction SUCCESS<br/>stamp reservation_amount + CREDIT_DEBIT_CARD
CB->>CRM: reserveUnitCrm / submitAddonToCrm / updateEOIToCrm
CRM-->>CB: 200 (reserved) — or non-200 (failed)
Note over CB: status_after_success_transaction = 1 (or 0)
CB-->>S: Redirect::away → SPA ?reserve-unit=true|falseInitiation — one entry point, three gateway shapes
Section titled “Initiation — one entry point, three gateway shapes”For a UnitSale, the SPA calls UnitSaleController::paymentUnitSale()
(app/Http/Controllers/Api/Shopper/UnitSaleController.php:112-143), which delegates to
PaymentSale::handle($sale, $request->get('amount')). A falsy result → HTTP 400 “failed to get
payment key… contact your admin” (:136-140) — the usual symptom of a missing gateway config key.
Every gateway creates the Transaction row first (status PENDING) and links it back onto the
sale, then returns the gateway handoff payload to the browser.
Three sequential HTTP calls (injected Guzzle Client), in PaymobService.php:
- Authenticate —
getAuthenticationToken()(:24-45) POSTs the destination API key topayment.paymob.auth_url→{merchant_id, token}. - Register order —
registerOrder()(:47-76) POSTspayment.paymob.order_urlwithamount_cents = intval($amount*100)andmerchant_order_id = "{id}__{name}__{now()}"(:58); returns the Paymob order id. - Get payment key —
getPaymentKey()(:78-142) creates theTransactionfirst viaStoreTransactionwithpayment_reference_id = (string) paymobOrder,type=paymob,status=PENDING(:84-92), links it with$sale->update(['transaction_id' => …])(:94), then POSTspayment.paymob.payment_key_urlwith the per-destination-and-currencyintegration_idand returns the paymenttoken.
Credentials are per Egyptian destination (el-gouna, makadi-heights, o-west) and resolved
from config/payment.php:3-22: payment.paymob.<slug>_api_key, _hmac_secret, and
_<egp|usd>_integration_id (PaymobService.php:226-252) — EGP and USD select different Paymob
integrations. The SPA then full-page-redirects to ${NX_PAYMOB_IFRAME_URL}{token}.
payment_reference_id for Paymob is the Paymob order id (an integer, stored stringified).
createSession() (ThawaniService.php:21-73) POSTs payment.thawani.create_session (header
thawani-api-key; secret from unit->project->payment_api_secret_key ?? config(payment.thawani.secret_key))
with unit_amount = intval($amount*1000) (baisa). It generates $uniqueUuid = Str::uuid() (:23)
and uses it twice: as the payment_reference_id query param on the success/cancel return URLs
(:38-39) and as the Transaction’s payment_reference_id (type=thawani, currency=OMR
hard-coded at :65, status=PENDING, :59-68). Returns a session_id; the SPA redirects to
${NX_THAWANI_IFRAME_URL}{token}?key=<per-project public key>.
payment_reference_id for Thawani is the app-generated UUID.
getSignatureKey() (WSPGService.php:13-48) makes no HTTP call — it builds a signed payload for
the browser to POST. It generates $uniqueUuid = Str::uuid() as the Transaction
payment_reference_id (type=wspg, status=PENDING, :15-26, linked back at :28), computes a
hash('sha512', …) signature over shop_id . secret . uuid . secret . intval($amount*100) . secret
(:30-43), and returns {shop_id, amount, shopping_cart_id=uuid, signature, success_url, cancel_url, fail_url} — each URL carrying ?payment_reference_id=<uuid> (:37-47). The SPA fills a hidden
<form method="post" action={NX_WSPAY_PAYMENT_URL}> and .submit()s it.
payment_reference_id for WSPG is the app-generated UUID (also echoed in the return URLs).
The transactions row (the ledger)
Section titled “The transactions row (the ledger)”Every initiation writes one Transaction (app/Models/Transaction.php). It is a polymorphic
MorphTo back to the model (:29-32), and its $fillable are model_id, model_type, payment_reference_id, amount, currency, callback, type, status, status_after_success_transaction.
status(cast toTransactionStatus,:24-27): pendingsuccessfailurecanceledtype(cast toTransactionTypes):paymob·thawani·wspg.
This row is the durable record of the payment — the CRM does not hold the gateway transaction, so reconciliation starts here.
The callback model
Section titled “The callback model”Paymob is split in two. The server-to-server POST webhook (CallbackController@paymob) is
what actually writes the status; it decides success on if ($data['obj']['success'] == 'true')
(PaymobService.php:153). The browser redirect (paymobResponseCallback, :23-28) is
decoupled: PaymentService::handlePaymobResponseCallback() (PaymentService.php:100-116) sleep(10)s,
looks the transaction up by $data['order'], reads its already-persisted status, and
Redirect::away()s the shopper. If the webhook is late or failed, the browser reads a stale PENDING
and the match (:114) returns null → a blank/no redirect even though the charge may still
settle. Always reconcile from the webhook-written row, not the user’s screen.
Post-payment: mark, stamp, dispatch, redirect
Section titled “Post-payment: mark, stamp, dispatch, redirect”On success, handleSuccessTransactionCallback() (PaymentService.php:36-73) runs in order:
- Mark the ledger —
$transaction->update(['status' => success, 'callback' => $data])(:45-48), then notify FINANCEPaymentNotifierrows for the unit’s destination — unless the model is anEOIRequest(:53-55). - Stamp the model —
$model->update(['reservation_amount' => $transaction->amount, 'payment_method' => CREDIT_DEBIT_CARD])(:57-60). - Dispatch by model class (
:62-67) — the CRM submit:UnitSale→reserveUnitCrm()(:211-243):ReserveSaleCrm; on CRM 200 →UnitSale.status = PENDING_INFO, returnstrue.UnitAddonSale→submitAddonToCrm()(:245-270):SubmitUnitAddon; 200 →true.EOIRequest→updateEOIToCrm()(:272-327):UpdateEOIwithpayment_status=PAID,payment_method=PAYMENT_LINK; 200 → sendsConfirmedEOIPayment+EOISuccessPaymentEmail(:319-321), returnstrue.
- Record the outcome flag —
$transaction->update(['status_after_success_transaction' => $submitStatus ? 1 : 0])(:70). This is the field support reads. - Redirect the shopper —
redirectSuccessUrl($model, $submitStatus)(:118-134)Redirect::away()s toSHOPPER_FRONTEND_URLwith a per-model path and?<key>=true|false(below).
| Model | Redirect path | Success/fail param |
|---|---|---|
UnitSale |
/reserve-unit/{unit_id} |
?reserve-unit= |
UnitAddonSale |
/units/{unit_id}/addons |
?buy-addon= |
EOIRequest |
/pay-eoi/{launch_id}/{source_id} |
?eoi-payment= |
The failure path handleFailTransactionCallback() (:75-98) marks FAILURE (or CANCELED when the
callback came in via the cancel segment), notifies admins (or, for EOI, notifyEOIWhenPaymentFailed
EOIFailedPaymentEmail), and redirects with?failed=true/?canceled=true.
Reconciliation
Section titled “Reconciliation”The transactions table is the ledger. Match a payment by payment_reference_id:
- Paymob → the Paymob order id (integer, stored as a string).
- Thawani / WSPG → the app-generated UUID (also visible in the gateway’s return URL as
?payment_reference_id=).
Read the row in three fields:
| Field | Means |
|---|---|
status |
last callback outcome — pending | success | failure | canceled |
status_after_success_transaction |
1 = charged and reserved in the CRM · 0 = charged but the CRM reserve failed (manual reconcile) |
callback (JSON) |
raw gateway payload. Paymob stores {payment_reference_id, data:{obj:{…}}} (PaymobService.php:154-157); Thawani/WSPG store the return query params ($request->all()). Cross-check amount / currency / the gateway txn id against the gateway dashboard. |
Hazards to keep in mind when reconciling (all present in current code):
- The browser return can lie — the
sleep(10)race (PaymentService.php:102) can show the shopper a stalePENDING/ blank redirect while the webhook later settles. Trust the webhook-written row. - The amount is not price-bound — a row’s
amountis whatever the client sent (see above), so reconcile the charged amount against the expected reservation fee. - No idempotency guard —
handleSuccessTransactionCallbackdoes not check whether the row is alreadysuccessbefore reprocessing (:36-73), so a re-delivered/refreshed success callback can re-submit to the CRM and re-fire notifications for the samepayment_reference_id. If you see duplicate CRM reservations or duplicate EOI emails, this is why.
