Skip to content

Runbook: paid but not reserved

Symptom: a shopper says they paid the reservation fee, but the unit shows as unreserved (or they landed on a “reservation failed” screen after a successful card charge). This is the highest-value payment incident because money moved and the CRM didn’t record the reservation — the shopper is owed either a reservation or a refund. This runbook assumes the online payment pipeline is understood.

The backend writes two independent facts on a payment row:

Column Question it answers
status Did the gateway callback say the charge succeeded? (pending · success · failure · canceled)
status_after_success_transaction After a successful charge, did the CRM reservation succeed? (1 = reserved · 0 = charged but not reserved)
  1. Find the transaction. Match the shopper’s payment to a transactions row by payment_reference_id:

    • Egypt / Paymob → the Paymob order id (an integer, stored as a string).
    • Oman / Thawani and Montenegro / WSPG → the app-generated UUID (also echoed in the gateway’s return URL as ?payment_reference_id=<uuid>).

    No row at all → the shopper never got past initiation (the payment key call failed, HTTP 400 “failed to get payment key… contact your admin”). That’s an initiation problem, not a reconciliation one — check gateway credentials for that country/destination.

  2. Read status.

    • pending → no successful callback has landed yet. For Paymob this can be the sleep(10) browser-return race (see hazards) — the server-to-server webhook may still write success a moment later. Re-check the row before concluding anything; reconcile from the webhook-written row, not the shopper’s screen.
    • failure / canceled → the charge did not succeed (or was cancelled). No reservation is expected. If the shopper insists they were charged, confirm on the gateway dashboard — a mismatch there is a gateway-settlement question, not a portal bug.
    • success → go to step 3.
  3. Read status_after_success_transaction.

    • 1 → the CRM reservation succeeded; the unit is reserved. If the shopper still can’t see it, it’s a read/display issue (the unit’s status comes back from the CRM), not a lost reservation.
    • 0this is the incident. The money was captured but the CRM reserve failed. Continue.
  4. Confirm the money actually settled. Before you reserve anything by hand, open the callback JSON on the row (raw gateway payload) and cross-check the amount, currency, and gateway transaction id against the gateway dashboard. A success row is the last callback outcome, not cryptographic proof of settlement (callbacks are not signature-verified today — see hazards), so confirm before you act on money.

  5. Reserve manually (or refund). With settlement confirmed, have the CRM team place the reservation against the unit for that sale_source_id, or issue a refund if the unit is no longer available. There is no in-app “retry reserve” button — the durable status_after_success_transaction = 0 is your worklist.

Reconciliation hazards baked into the current code

Section titled “Reconciliation hazards baked into the current code”

These are real behaviours of the current pipeline — know them before you diagnose. Each is tracked as a code bug in the KT tracker.

Hazard What it does to your diagnosis
sleep(10) browser-return race The Paymob browser return blocks 10s hoping the webhook already wrote the status, then reads it. If the webhook is late, the shopper sees a stale pending and may get no redirect at all — even though the charge later succeeds. Never diagnose from the shopper’s screen; read the row.
Amount is client-supplied The charged amount is whatever the client sent — it is not bound to the unit’s reservation fee server-side. Reconcile the charged amount against the expected fee, not just gateway-vs-DB.
No idempotency / replay guard A duplicate success callback (Paymob may retry; Thawani/WSPG success URLs are plain GETs a browser can refresh) re-runs the CRM submit and re-fires notifications. A single payment can produce duplicate reserve attempts / duplicate EOI emails. Watch for repeats on one payment_reference_id.
Callbacks aren’t signature-verified Paymob HMAC verification is commented out; Thawani/WSPG trust the URL {status} segment + echoed reference id. So a success row is not proof of payment on its own — the gateway dashboard is the source of truth for settlement.

Environment-specific links are not in the repo — fill them in for your environment with your ops lead; the structure is fixed.

Signal How to reach it Environment-specific bit to fill in
The ledger The transactions table — query by payment_reference_id; read status, status_after_success_transaction, callback DB access for staging/prod
Backend log LOG_CHANNEL (stack); the callback path logs the raw gateway payload and the CRM submit result Where staging/prod logs ship
Gateway dashboards Paymob (Egypt) · Thawani (Oman) · WSPG/WSPay (Montenegro) — confirm settlement + the gateway txn id Per-gateway merchant console URLs
The CRM The reservation lives in Dynamics; a manual reserve is a CRM-team action against the sale_source_id Dynamics org URL per country
Fault Owner
status_after_success_transaction = 0 — charged but not reserved Backend + CRM team (manual reserve; add a retry)
Duplicate reserve / duplicate email from one payment Backend team (add idempotency on payment_reference_id)
Charge succeeded on the gateway but no transactions row / no callback Backend team (callback delivery) + gateway
Wrong amount captured Backend team (bind amount to unit price) + Product

(Add the concrete team owners / Slack channels / on-call for your org next to each row.)