Offers that add up, from the menu to the bill
Restaurants on Laayo can now run their own offers: a percentage off, free delivery, buy some get some free, or a free dish above a spend. Here is how we made every one of them land on the bill exactly as promised.
An offer is a promise. When a restaurant card says “Buy one, get one” and the cart says “FREE” next to a plate of momo, the bill at the end has to agree. If it does not, the customer feels cheated, the restaurant takes the blame, and our support team spends the evening issuing apologies.
For a long time Laayo’s offers were simple and mostly run by us. A restaurant that wanted a promotion had to ask, and only one kind of discount really worked end to end. This year we rebuilt offers so restaurants can create them from the partner app, so several offers can exist side by side, and so every kind of offer is priced by the same rules on the phone and on the server.
This post is about the parts that were harder than they looked.
Four kinds of offer#
Restaurants can now create four kinds of offer from the Offers and Discounts screen in the partner app:
- Percent off, for the whole menu or for chosen items and categories.
- Free delivery above a spend the restaurant chooses.
- Buy some, get some free, for example buy one plate, get one free.
- Spend and get a free dish, where the customer picks a free item once the cart passes a set amount.
Each offer has a time window, an audience (everyone, new customers, returning customers) and an optional mealtime, so a restaurant can run a breakfast offer without it leaking into dinner. Restaurants can pause an offer with one tap and pick it up again later.
The rule for who pays is simple and fixed in the server: an offer a restaurant creates is paid for by that restaurant. Offers we run as a platform can split the cost, and that split is recorded on every order so payouts reconcile without anyone doing arithmetic by hand.
One resolver, not five opinions#
Once several offers can be live at the same restaurant, someone has to decide which one applies. The first version of that decision lived in five places: the restaurant card, the menu, the cart, the checkout and the order placement code. They mostly agreed. “Mostly” is how a card ends up advertising one discount while the bill applies another.
So we made one function the single source of truth. Given a restaurant and, when we know it, a customer, it picks the best live offer: inside its time window, approved, not paused, and open to this customer’s audience. Every screen and every price calculation now asks that function, and nothing is allowed to compute “the current discount” on its own. When we added audiences and mealtimes later, we changed one place, and the whole app followed.
We also added an optional approval step. By default a restaurant’s offer goes live as soon as it is saved, because restaurants want to react to a slow afternoon right away. If we ever need to review offers first, a setting turns on an approval queue without any app change.
The free dish that was not free#
The trickiest bugs were in the “get something free” offers, because there the customer takes part in the pricing. They pick which item is free, and that choice changes the cart they are looking at.
Here is the one that taught us the most. A restaurant ran a “buy one, get one free” offer on the same dish. A customer added a few plates, the app showed the picker, the customer chose their free plate, and the cart happily showed FREE next to it. The bill still charged full price.
What happened was two versions of the same formula. Before the pick, the app counted every plate as “paid” and decided a free one was earned. After the pick, the free plate was no longer counted as paid, so the same formula, run again, decided there were not enough paid plates to earn it. The label stayed, but the discount quietly went to zero.
The fix was to admit what “buy one, get one” really means when both plates are the same dish: you need enough plates in the cart to cover both the ones you pay for and the ones you get. Counting the whole group before and after the pick gives the same answer, so the free plate stays free. When the free item can be a different dish, the old way of counting is still correct, because the paid and free pools never overlap. The code now branches on that one question, on the phone and on the server alike.
Two offers, one path#
The “spend and get a free dish” offer had its own version of the same problem. It looks different to customers, but underneath it works exactly like “buy some, get some”: the customer picks an item, and the cart marks part of it as free. One part of the server only sent the buy-some offer through the code that understands picked items. The spend offer fell into the ordinary discount branch, which saw no percentage and returned nothing.
Now both offers travel the same path everywhere: the picker, the discount calculation, the auto-apply logic in the cart, and the group order flow. We also stopped a generic “exclusive offer for you” suggestion from popping up over the picker, because two overlays asking for a decision at once confused people.
Savings that read naturally#
A smaller lesson was about the “you saved” line. Internally, a freed plate carries two kinds of discount: any per-item discount on the dish, and the value of being free. If you add both to the savings banner, the customer sees a number that does not match what they can add up in their head.
So the cart keeps two figures. One is for the bill math, so the total is always exactly what the server will charge. The other is for display, and counts per-item discounts only on the plates you are paying for, while the free plate’s value shows up once, on the offer line. The customer’s mental sum and the receipt now agree.
Measuring offers honestly#
Restaurants want to know whether an offer worked. Orders do not record which offer “caused” them, so we attribute by time window: orders placed while an offer was live are counted towards it. That is exact when a restaurant runs one offer at a time, and approximate when offers overlap, and the partner app says so instead of pretending to precision it does not have.
When we run the same offer across many restaurants at once, the admin panel now groups those rows into one campaign, so our team sees a single line with combined results instead of a long list of copies. That grouping is purely for reporting. The customer’s price never reads it, which made it safe to add later.
What’s next#
We want restaurants to see a simple before and after for each offer, and to get a gentle nudge when an offer has run for a while without moving anything. We are also looking at letting a restaurant save an offer as a template, so the Friday deal is two taps away every Friday.