How PDP prevented duplicate booking payments
PDP added duplicate-payment protection at two levels: in the application business logic and in the database. Discrepancies between the bank and the system were handled separately.

For PDP, we added double protection against duplicate payments: a check in the application and a database constraint that prevents overlapping active bookings for the same unit.
The problem here is not unpleasant because of the “Pay” button itself. You can disable the button on the frontend, you can show a loader, you can hide the repeated redirect to the payment link. But money and bookings live on the backend, and that is where races remain: two requests arriving almost at the same time, a repeated callback from the bank, the user returning from the payment form through a different route, the deal state not having switched yet.
For PDP, the rule was strict: there can be only one active deal for a given unit. If this rule is violated, the business meaning of the booking breaks down: one property cannot be sold to two buyers, even if both payments technically went through.
Where one check was not enough
We kept the first layer of protection in the application business logic. Before creating a deal or moving it to an active state, we check that there is no other active deal for the unit. This is a convenient place for a clear error: we can return a normal status to the user without letting the situation escalate to a database exception.
But this check by itself does not close the race. If two processes read the state at the same time and both see that there is no active deal, they can both continue. At the code level everything looks clean: check, create, update the status. In reality, there is a window between SELECT and the write.
This is exactly where it is easy to start looking in the wrong place. The most tempting option is to strengthen the frontend: disable the button, store a submission flag, prevent the user from clicking twice. That helps UX, but it is not payment protection. The second option is to rely only on the idempotency of the payment handler. That is necessary, but it does not answer the question of whether a second booking can take the same unit.
So we added the second layer where the state is shared by all processes: in the database. The constraint forbids overlapping bookings if they target the same unit and are in an active state. The application can get the call order wrong, requests can arrive simultaneously, the bank handler can repeat — the database still must not accept a second active deal.
What happens when the bank and the system diverge
Another part of the task was payments that the bank considers successful, but our system cannot correctly attach to a valid active deal. For example, because the unit is already taken or there is a state conflict.
For this case, we added a separate status: “paid with error”. This is not a successful booking and not a regular payment error. The money is in the bank, but inside the system the deal requires manual or separate automated processing.
The longest debate was exactly about this status. It is very tempting to reduce everything to a binary model: paid / not paid. But it describes the situation poorly when the bank has already said “yes”, while the business rule for the unit does not allow the booking to be considered valid. If you hide this inside a generic error status, support and backend engineers will reconstruct the chain from logs. If you mark it explicitly, such deals are immediately visible as a separate class of incidents.
What changed in the state model
After the changes, protection became two-layered:
- the application checks for active deals before transitioning to the booking state;
- the database forbids overlapping active bookings for the same unit;
- a successful payment in case of a conflict is not lost, but moves into the “paid with error” status;
- the business rule “one unit — one active deal” lives not only in the scenario code, but also in the data store.
This approach does not replace careful handling of payment links and bank callbacks. It does something else: it removes the booking integrity’s dependence on the order in which events arrive.
Now, in an ambiguous situation, the system does not try to quietly “force” the deal into a successful state. It either creates one active booking, or records the conflict in a separate state that can then be handled further.