Muzer (muzer.ir) is a booking platform for men's barbers that I built. A customer picks a city, browses its barbers' work, ratings and next free time, and requests a service at a time. The barber runs hours, services, requests, calendar and wallet from their "desk". Underneath is a Django project on my own base template, docker-core, with PostgreSQL, Redis and Celery. This post covers the core: the life of one booking, and where the money is at every moment.
Why payment waits for the barber
A Muzer booking has three steps: request, the barber's approval, payment. Not taking the money up front is deliberate:
- A request holds no time. Several customers can ask for the same slot and the barber picks one; charging all of them means refunding all but one.
- The money terms are fixed at approval. The barber may change the final price, and the cooperation mode (commission or subscription) and commission percent are stored on the order at that moment.
- A "no" costs nothing. If the barber declines or doesn't answer within 24 hours, nothing has to come back. The site's own description says it: pay nothing until the barber accepts.
The cost is an asynchronous flow, which made push for customers a priority. Barbers who don't want the round trip turn on instant booking (Barber.auto_accept): requests are approved on the spot at the list price.
The order's state machine
Order moves through request, awaiting_payment, reserved, completed, rejected, expired and three kinds of cancellation (customer, barber, site). The only code that changes an order's status or money is order_service.py: every function runs in transaction.atomic, locks the order row with select_for_update first, and raises OrderError when the change doesn't fit the current state. Approval is the transition that matters most (shortened):
@transaction.atomic
def approve(order, *, final_price=None, instant=False):
order = _lock(order)
if not order.barber_can_answer():
raise OrderError(...)
_check_still_free(order)
# ...
if barber.get_cooperation_mode(at) == Order.CooperationChoices.SUBSCRIPTION:
order.commission_percent = Decimal("0")
order.amount_payable = system_money("booking_fee")
else:
order.commission_percent = barber.get_commission_percent(at)
order.amount_payable = price
order.status = STATUS.AWAITING_PAYMENT
minutes = system_number("awaiting_payment_expiry_minutes")
order.datetime_expires = min(at + timedelta(minutes=minutes), order.start_at)
order.save()
reject_requests_overlapping(barber, order.start_at, order.end_at, exclude=order.pk)
From here the booking holds its time, the same barber's overlapping requests are rejected in the same transaction, and the customer has 30 minutes to pay, or until the appointment if that comes first. After that, the expiry task frees the time.
Free slots
A barber's week is simple or has several ranges per day. schedule_service.py lays the ranges on a grid of slot_step minutes and keeps every start where the whole service fits and nothing busy overlaps. Each app registers what makes a barber busy with register_busy_source: time off, orders that hold time, and walk-ins. A request is validated by the same test, check_slot, so the page and the server never disagree about what is free.
The wallet: three buckets, one choke point
The wallet comes from docker-core and has three buckets: balance (settled money), locked (the part of it set aside for a payment in flight) and pending (money not yet settled). Nothing writes those columns directly; everything goes through Wallet._move:
@transaction.atomic
def _move(self, *, d_balance=0, d_locked=0, d_pending=0):
wallet = Wallet.objects.select_for_update().get(pk=self.pk)
new_balance = wallet.balance + d_balance
new_locked = wallet.locked + d_locked
new_pending = wallet.pending + d_pending
if new_balance < 0 or new_locked < 0 or new_pending < 0:
raise ValueError(...)
if new_locked > new_balance:
raise ValueError(...)
# ... the three columns are saved together
On top of it, wallet_service.py is the domain's vocabulary, and each call leaves exactly one WalletTransaction:
| Call | Effect |
|---|---|
debit | paid entirely from the wallet |
reserve_payment | the wallet part, while the customer is at the bank (locked) |
capture_payment | the gateway payment succeeded |
release_payment | the payment failed; locked is freed |
hold_earning | the barber's share once reserved (pending) |
settle_earning | done: pending into balance |
cancel_earning | canceled: pending dropped |
credit | a refund or an earning (balance) |
The key to consistency is transaction_reference, which is unique. Every movement gets a deterministic reference from the order's code, such as order-{code}-refund. If the same call arrives twice (a retried task, a gateway callback racing a reverify), it finds the first row and does nothing again.
Where the money sits
Request and approval move no money. At payment the customer's wallet is used first and the gateway covers the rest. The barber's share waits in pending until the booking is done (by the barber, or automatically a day after the appointment), and the site's share is just a number on the order (platform_share). On commission, the customer pays the full price online and the site keeps a percentage (10% by default, 0% in a barber's first six months); on a subscription, only a booking fee is paid online and the service is paid at the chair. Cash is the exception, and its commission becomes the barber's debt.
Cancellation and refund
When the barber or the site cancels, it is not the customer's fault, so all of it goes back to their wallet:
paid = order.amount_paid
if order.is_reserved() and not order.is_cash() and (paid > 0 or order.promo_discount > 0):
wallet_service.cancel_earning(f"order-{order.code}-earning", reason="booking canceled")
if by == "customer":
refund, barber_part = customer_cancel_split(
paid, order.promo_discount, refund_percent_for(order.hours_left())
)
else:
refund, barber_part = paid, Decimal("0")
if refund > 0:
wallet_service.credit(
order.customer, refund,
type_=_txn_types().REFUND,
reference=f"order-{order.code}-refund",
)
That is one transaction on a locked order row; if any step fails, none of it sticks. Two cancels at once, or a cancel racing the automatic completion, run one after the other, and the second sees the new status and gets an OrderError. Notifications and SMS go out through transaction.on_commit, so a rolled-back change tells nobody anything.
A customer's own cancellation follows the admin-edited RefundRule table. Out of the box: 24 hours or more ahead, 100% back; 12 hours, 75%; 6 hours, 50%; one hour, 25%; later, nothing. The rest is split half and half between barber and site. In the test, a 200,000 Toman booking canceled three hours ahead returns 50,000 to the customer and splits the other 150,000 as 75,000 and 75,000. The cancel page shows that figure before you confirm.
A refund always lands in the wallet first; if the customer wants it on a card, a withdrawal is requested right away through the normal payout path. One road back for money, not two. "Close a day" on the barber's desk calls the same cancel, as the barber, for every booking that day.
Money that arrives late
The gateways are wired through my own package, django-iranian-payment. A customer can sit on the bank's page while the booking expires or is canceled. settle_online_payment locks the payment attempt and applies a success only once (is_applied). If the order is still awaiting payment, it is reserved; otherwise the whole amount is credited to the customer's wallet. Money never gets lost.
Automation and subscriptions
Celery beat expires unpaid bookings every 5 minutes and rejects unanswered requests every 10; every task is safe to run twice at once, because it locks its row and checks the state again. Instead of commission, a barber can buy a subscription (SubscriptionPlan). The cooperation mode isn't stored on the barber: it is derived from dates and frozen on the order at approval, so buying or losing a subscription never reprices an approved booking.
Push and the PWA
Every event creates a UserNotification, and a post_save signal pushes that row to the person's devices, after commit and in Celery; the order code doesn't know push exists. I wrote Web Push without a push library, from RFC 8030, RFC 8291 and RFC 8292. Since the browser hands us the endpoint and anyone can send one, requests go only to known push-service hosts.
There are two installable apps, both the same Django site: the barber's opens on the desk; the customer's exists mostly for notifications, since an iPhone only delivers push to an installed site. Each has its own service worker and push subscription; the Android versions are Trusted Web Activities.
What I'd keep
One place that changes state, one choke point that moves money, and a unique reference for every movement. The gateway, Celery and push sit on top of those three, and any of them can run twice without anyone's balance going wrong.