Skip to content

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).

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|false
Online card payment — initiation → callback → CRM → redirect (verified)

Initiation — 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:

  1. AuthenticategetAuthenticationToken() (:24-45) POSTs the destination API key to payment.paymob.auth_url{merchant_id, token}.
  2. Register orderregisterOrder() (:47-76) POSTs payment.paymob.order_url with amount_cents = intval($amount*100) and merchant_order_id = "{id}__{name}__{now()}" (:58); returns the Paymob order id.
  3. Get payment keygetPaymentKey() (:78-142) creates the Transaction first via StoreTransaction with payment_reference_id = (string) paymobOrder, type=paymob, status=PENDING (:84-92), links it with $sale->update(['transaction_id' => …]) (:94), then POSTs payment.paymob.payment_key_url with the per-destination-and-currency integration_id and returns the payment token.

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).

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 to TransactionStatus, :24-27): pendingsuccessfailurecanceled
  • type (cast to TransactionTypes): paymob · thawani · wspg.

This row is the durable record of the payment — the CRM does not hold the gateway transaction, so reconciliation starts here.

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:

  1. Mark the ledger$transaction->update(['status' => success, 'callback' => $data]) (:45-48), then notify FINANCE PaymentNotifier rows for the unit’s destination — unless the model is an EOIRequest (:53-55).
  2. Stamp the model$model->update(['reservation_amount' => $transaction->amount, 'payment_method' => CREDIT_DEBIT_CARD]) (:57-60).
  3. Dispatch by model class (:62-67) — the CRM submit:
    • UnitSalereserveUnitCrm() (:211-243): ReserveSaleCrm; on CRM 200UnitSale.status = PENDING_INFO, returns true.
    • UnitAddonSalesubmitAddonToCrm() (:245-270): SubmitUnitAddon; 200 → true.
    • EOIRequestupdateEOIToCrm() (:272-327): UpdateEOI with payment_status=PAID, payment_method=PAYMENT_LINK; 200 → sends ConfirmedEOIPayment + EOISuccessPaymentEmail (:319-321), returns true.
  4. Record the outcome flag$transaction->update(['status_after_success_transaction' => $submitStatus ? 1 : 0]) (:70). This is the field support reads.
  5. Redirect the shopperredirectSuccessUrl($model, $submitStatus) (:118-134) Redirect::away()s to SHOPPER_FRONTEND_URL with 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.

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 stale PENDING / blank redirect while the webhook later settles. Trust the webhook-written row.
  • The amount is not price-bound — a row’s amount is whatever the client sent (see above), so reconcile the charged amount against the expected reservation fee.
  • No idempotency guardhandleSuccessTransactionCallback does not check whether the row is already success before reprocessing (:36-73), so a re-delivered/refreshed success callback can re-submit to the CRM and re-fire notifications for the same payment_reference_id. If you see duplicate CRM reservations or duplicate EOI emails, this is why.