PETPET · Vouchers and checkout
When everyone wants the last voucher
The hard part wasn’t calculating a discount. It was making a claim fair when requests arrive together, getting the voucher into the customer’s wallet reliably, and stopping two orders from spending the same one.
October 30, 2026 · 7 min read
There are two races, not one
A voucher can look like a code plus a discount value, right up until a campaign has limited stock. Several customers may try to claim the last available voucher at once. Then, later, one customer can submit two checkouts that both selected the same voucher from their wallet.
These are different problems. The first is about admitting claims against campaign stock. The second is about making an order and a customer’s wallet change together. I had to give each one its own consistency boundary instead of trying to solve both with a single “check, then update” in application code.
It also helped to separate the data involved. The campaign template describes the offer. The wallet record represents a voucher actually issued to a customer. The invoice records the order that eventually uses it. Keeping those stages distinct made it possible to reason about what should happen if the process pauses between them.
First, decide who gets to claim
The API starts by checking whether the campaign is active and whether the customer is eligible. Those checks are useful, but they do not protect stock by themselves: two requests can both read “one voucher left” before either writes anything.
For the admission decision, I used a Redis Lua script. It checks the remaining campaign stock and the customer’s claim count, then updates the counters and records a pending claim in the same Redis operation. Redis runs that script atomically, so two competing requests cannot slip between the check and the counter update.
Once Redis accepts a claim, the API enqueues a job for wallet issuance. The claim is accepted at that point, but the MongoDB wallet record is created by a worker afterward. That small gap is intentional: the mobile request does not have to wait for the whole issuance path, and the system has an explicit pending state to represent the work that remains.
Pending is a real part of the wallet
If the app said a claim succeeded but the wallet stayed empty until a worker finished, the customer would have no way to tell whether to wait or try again. The wallet response can include pending claims, so that short delay has a visible state instead of looking like a lost voucher.
The worker reloads the campaign before issuing anything. It checks that the queued job still refers to the same template, that the offer was claimable at the time the claim was accepted, and that the customer remains eligible. If those checks pass, it creates the wallet voucher, updates the issued count, and clears the pending record. If they fail, it cancels the pending claim and returns the reserved capacity.
Carrying the accepted time with the job matters. A queue delay should not rewrite history and make a valid claim look invalid just because the campaign window changed while the worker was waiting.
When a job doesn’t finish, check what actually happened
Redis and MongoDB can’t commit the claim counter and the wallet document as one transaction. So instead of assuming the two stores always move in lockstep, the system keeps a pending record and has a reconciliation path to compare it with the durable wallet state.
If reconciliation finds that the wallet voucher already exists, it clears the leftover pending marker. If the voucher is missing but the original claim is still valid, it queues issuance again. If the customer or campaign is no longer eligible, it cancels the claim and gives the reserved capacity back. A duplicate-key response during issuance is also treated as evidence that the wallet entry already exists, not as a reason to create a second one.
This is why stock isn’t just a number to trust forever. The service can rebuild the remaining amount from the campaign total, wallet vouchers already issued, and claims still pending. Redis makes the admission decision quick; the wallet records and pending claims give the system something durable to reconcile against.
At checkout, the server recalculates the offer
The discount shown while someone is editing a cart is only a preview. Prices or cart contents may have changed by the time the order is placed, so the backend rebuilds the invoice totals from current product data and validates the selected voucher against the customer’s wallet and the actual checkout context.
The rules can depend on things like which products are in the cart, the payment method, the delivery location, and the eligible subtotal. The backend calculates the discount itself: a percentage discount respects its cap, a fixed amount cannot exceed the eligible product value, and a shipping discount cannot exceed the shipping fee. Product and shipping discounts are kept as separate parts of the result.
Promotions and vouchers can also conflict. If the promotion applied to an order does not allow a voucher on top, checkout rejects that combination rather than letting a client-submitted total decide how the offers stack.
Then protect the wallet voucher inside the order transaction
This is the second race. Two checkout requests may both have read the voucher as available. The important check happens when the backend writes: it conditionally reserves the selected wallet voucher only if it is still available and belongs to that customer, and ties that reservation to the order being created.
Invoice creation, reservation, and voucher consumption run in the same MongoDB transaction. After reservation, the backend inserts the invoice and changes the voucher from locked to used, recording the discount against that order. If another checkout already got there first—or one of the writes fails—the transaction aborts. There is no successful invoice with only half of its voucher state written.
A voucher follows the order after checkout too
The wallet voucher moves from available to locked for the order, then to used when checkout commits. If the transaction fails, the lock does not become a successful redemption. If an order is cancelled, the cancellation path releases the voucher associated with that user and source order. Expired checkout locks are also unlocked, and vouchers that have passed their validity period are expired separately.
There is one more edge after the transaction commits: promotion settlement happens afterward. If that step fails, the service has a compensation path that removes the affected invoice and restores only the vouchers used for that exact customer and order. It is a separate recovery step, not part of the MongoDB transaction, so keeping it narrowly tied to the order matters.
What I took away from it
I ended up with two kinds of atomicity for two different moments: Redis handles the fast decision about who gets to claim limited stock; MongoDB makes the invoice and wallet redemption move together. The pending claim connects the stores before checkout, while order-scoped release and compensation handle what can still go wrong afterward.
It is more moving parts than decrementing a counter and inserting a row in one request. But every part has a clear job, and a failed step leaves the system with a state it can inspect and recover—not an ambiguous success that the customer and the backend interpret differently.