Compensation and recovery
A booking touches several things that can fail on their own: capacity, the payment provider, and customer messages. Sessiq keeps them consistent with three simple mechanisms instead of a general saga engine:
- One database transaction for everything that lives in the database.
- The outbox for everything that leaves it (emails, webhooks, retries).
- Compensation for money that has already moved, such as a payment taken for a booking that could not be confirmed.
Earlier versions of these docs described a saga orchestrator with saga execution and checkpoint tables. It was never wired into the booking flow and has been removed. The patterns below are what the code does today.
1. Keep database changes in one transaction
Holding capacity, confirming a booking, recording a payment and cancelling all run inside a single tenant transaction. If any step fails, the whole change rolls back and there is nothing to undo.
The rule for new code: if two changes must succeed or fail together and both are in the database, put them in the same withTenant block.
2. Send side effects through the outbox
Anything outside the database is written to the outbox in that same transaction and sent afterwards by a worker, with retries and a dead-letter queue. A rolled-back booking never sends an email, and a committed one always gets its messages eventually. See Outbox pattern.
3. Compensate money that has moved
Some failures happen after a payment provider has already taken money. The compensation engine in the payments module turns those into refunds or voids. It picks a strategy by the situation:
| Situation | What happens |
|---|---|
| A paid booking is cancelled | Refund according to the cancellation policy. |
| Payment succeeded but confirmation failed | Refund or void the payment so the customer is not charged. |
| A hold or partly paid booking timed out | Release the capacity and refund anything already paid. |
| Staff issue a refund by hand | Refund the amount staff chose. |
Each attempt is retried a few times with a growing delay. Offline payments (cash, invoice) cannot be refunded by the system; they are flagged for staff to settle by hand instead of failing.
Refunds that keep failing
When a refund fails during a cancellation, the cancellation itself still completes. The handler writes a refund.retry_needed message to the outbox (destination internal) in the same transaction. The outbox worker retries the refund later and, after its last retry, moves the message to the dead-letter queue for a person to look at.
Hold expiry
A background job finds holds whose time has run out, releases their capacity and marks them expired. If expiring a hold fails, the job hands it to compensation so capacity and any partial payment are not left behind. It also cleans up bookings that were partly paid and then abandoned.
Staff tools
Staff can inspect and drive compensation for a booking. The POST actions need the admin:refunds:process permission.
| Method | Path | Description |
|---|---|---|
GET | /admin/compensation/bookings/{bookingId}/status | Compensation state for a booking. |
POST | /admin/compensation/bookings/{bookingId}/process | Run compensation for a booking. |
POST | /admin/compensation/bookings/{bookingId}/refund | Refund a booking through compensation. |
POST | /admin/compensation/bookings/{bookingId}/retry | Retry a failed compensation. |
For everyday refunds, staff use the admin UI. See Payments and refunds.
Writing new flows
- Put every database change for one user action in one transaction.
- Never call an external service inside that transaction. Write an outbox message.
- If money can move before the flow finishes, add a compensation path and make it idempotent: running it twice must not refund twice.
- Log a correlation ID in the outbox message
metadataso the steps of one action can be traced together.