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 one flag that decides everything
Section titled “The one flag that decides everything”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) |
Decision tree
Section titled “Decision tree”-
Find the transaction. Match the shopper’s payment to a
transactionsrow bypayment_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.
-
Read
status.pending→ no successful callback has landed yet. For Paymob this can be thesleep(10)browser-return race (see hazards) — the server-to-server webhook may still writesuccessa 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.
-
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.0→ this is the incident. The money was captured but the CRM reserve failed. Continue.
-
Confirm the money actually settled. Before you reserve anything by hand, open the
callbackJSON on the row (raw gateway payload) and cross-check the amount, currency, and gateway transaction id against the gateway dashboard. Asuccessrow 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. -
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 durablestatus_after_success_transaction = 0is 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. |
Access & escalation
Section titled “Access & escalation”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 |
Who owns the fix
Section titled “Who owns the fix”| 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.)
