Savor began as a copy of Wayline and kept most of what isn’t about rides: the module layout, the accounts and sign-in, the cards and the payment provider, text messages, push notifications, the live channel, the map, the operations console and hosting the app in a browser. This chapter covers what a food-delivery service changes. For the parts the two share, it points at the Wayline chapter, which is true of Savor with the names swapped.
Running it
The project is in scripts/savor, and the Initializr generates it under your own
package and name. Start the server on the development profile, which seeds the demo
accounts and restaurants and simulates the cards, the kitchens and a courier:
CN1_PROFILE=dev backend/server.sh run
Then run the app in the simulator, from the project’s directory:
mvn verify -Psimulator -DskipTests -Dcodename1.platform=javase
backend/server.sh run --web builds the app for a browser as well and serves it at
http://localhost:8080/. Running on a device, and the compiled server, work as
Running it describes.
The modules
The three modules are Wayline’s: shared holds the contract, the data transfer
objects and the @RestClient interfaces both sides compile against; common is
the app; backend is the server. The backend’s packages follow the domain:
| Package | What it holds |
|---|---|
| Restaurants, their menus and photographs, the search that lists the restaurants that deliver to a point, and applications to list a restaurant. |
| Carts priced into quotes, orders and their state machine, promo codes, saved addresses, the order history and the simulated kitchen. |
| Deliveries and their state machine, offers to couriers and the simulated courier. |
| Couriers' shifts and earnings, and applications to deliver with their documents. Wayline’s driver code under new names. |
| Taken from Wayline and adapted where an order differs from a ride. |
An order and its delivery
A ride is one thing that moves from requested to finished. An order is two. The order is what the customer bought and the kitchen cooks, the delivery is what a courier carries, and each has its own states:
| Order | Delivery |
|---|---|
|
|
The delivery is Wayline’s ride, almost unchanged: it picks up at the restaurant and drops off at the door. Keeping it that way let the whole courier side — offers, shifts, the moving marker, earnings, the guards on two answers at once — come across without being rewritten.
What couples the two is when a delivery starts. A customer places an order, and the card is held:
/// Places an order: prices it from the menu, holds the total on the card,
/// and writes it. A hold whose order could not be written is let go.
public OrderDto place(String customer, CartDto cart) throws IOException {
String id = attemptOf(cart);
CustomerOrder already = find(id);
if (already != null) {
return placedBefore(customer, already);
}
Quotes.Priced priced = quotes.quote(customer, cart);
Restaurant restaurant = priced.restaurant;
if (!Quotes.open(restaurant, quotes.minuteOfDay(System.currentTimeMillis()))) {
throw new ResponseStatusException(409, restaurant.getName()
+ " is not taking orders right now");
}
if (priced.quote.subtotalCents() < restaurant.getMinimumCents()) {
throw new ResponseStatusException(400, restaurant.getName()
+ " needs a bigger order");
}
if (cart.promoCode() != null && cart.promoCode().trim().length() > 0
&& priced.promo == null) {
throw new ResponseStatusException(400, priced.quote.promoMessage());
}
// The same refusals store() makes under its locks, asked first without
// them so an order that could never be accepted puts no hold on the
// card and costs no call to the payment processor. store() still asks
// again: two orders placed at once both pass here.
eligible(customer);
String[] method = payments.choose(customer, cart.paymentMethodId());
String hold = payments.authorize(id, customer, method[0], priced.quote.totalCents(),
priced.quote.currency());
try {
attempt(() -> store(id, customer, cart, priced, method, hold));
} catch (IOException | RuntimeException failed) {
CustomerOrder raced = find(id);
if (raced != null && customer.equals(raced.getCustomer())) {
// The same checkout sent twice at once: the other one wrote the
// order. Its hold was asked for under the same key and may be
// this very one, so it is not let go.
return view(customer, false, id);
}
// Nothing was written, so the hold is let go by its reference.
payments.releaseHold(id, hold);
throw failed;
}
CustomerOrder order = find(id);
tell(order);
return view(customer, false, id);
}
/// The order's id: the checkout's own attempt id when the app sent one, so
/// a placement sent again after its answer was lost finds the order it
/// wrote instead of writing and charging another. Anything not shaped like
/// a server id gets a fresh one.
private static String attemptOf(CartDto cart) throws IOException {
String attempt = cart.attemptId();
if (attempt == null || attempt.length() != 32) {
return Ids.next();
}
for (int iter = 0; iter < attempt.length(); iter++) {
char c = attempt.charAt(iter);
if (!(c >= '0' && c <= '9' || c >= 'a' && c <= 'f')) {
return Ids.next();
}
}
return attempt;
}
/// The answer to a checkout placed again: the order it already wrote. An
/// id another customer's order holds is refused without saying whose.
private OrderDto placedBefore(String customer, CustomerOrder order) throws IOException {
if (!customer.equals(order.getCustomer())) {
throw new ResponseStatusException(409, "Start the checkout again");
}
return view(customer, false, order.getId());
}
/// The refusals [#store] makes, without its locks. See [#place].
@Transactional(readOnly = true)
private Void eligible(String customer) {
if (!enabled(customer)) {
throw new ResponseStatusException(403, "This account is blocked");
}
if (orders.countByCustomerAndStateIn(customer, ACTIVE) >= MAX_ACTIVE) {
throw new ResponseStatusException(409, "You already have " + MAX_ACTIVE
+ " orders on the way");
}
Profile profile = profiles.findById(customer);
if (profile == null || !profile.isPhoneVerified()) {
throw new ResponseStatusException(403, "Verify your phone number before you order");
}
return null;
}
@Transactional
private Void store(String id, String customer, CartDto cart, Quotes.Priced charged,
String[] method, String hold) throws IOException {
Quotes.Priced priced = charged;
// Held first, so two orders placed at once count each other and not
// just what had committed before either began.
profiles.hold(customer);
// The card was held with no transaction open, and that can take a
// while: the account may have been blocked, or the restaurant paused
// or suspended, since the checks before it. Asked again under the
// locks those changes take, a refusal here lets the hold go.
if (!enabled(customer)) {
throw new ResponseStatusException(403, "This account is blocked");
}
Restaurant restaurant = restaurants.hold(priced.restaurant.getId()) == 0 ? null
: restaurants.findById(priced.restaurant.getId());
if (restaurant == null
|| !Quotes.open(restaurant, quotes.minuteOfDay(System.currentTimeMillis()))) {
throw new ResponseStatusException(409, priced.restaurant.getName()
+ " is not taking orders right now");
}
if (orders.countByCustomerAndStateIn(customer, ACTIVE) >= MAX_ACTIVE) {
throw new ResponseStatusException(409, "You already have " + MAX_ACTIVE
+ " orders on the way");
}
Profile profile = profiles.findById(customer);
if (profile == null || !profile.isPhoneVerified()) {
// The courier calls this number from the door.
throw new ResponseStatusException(403, "Verify your phone number before you order");
}
// The menu was read before the card was held. Priced again under the
// restaurant's lock, which every dish edit takes too (Restaurants.editing):
// a dish now sold out or gone throws, a price that moved refuses, and either
// way the hold is let go rather than charging for a menu that has changed.
Quotes.Priced again = quotes.quote(customer, cart);
if (again.quote.totalCents() != charged.quote.totalCents()) {
throw new ResponseStatusException(409, "The menu changed while you were paying."
+ " Check your cart and try again");
}
// The minimum may have been raised while the card was held, and it
// leaves the total alone, so the comparison above does not see it.
if (again.quote.subtotalCents() < restaurant.getMinimumCents()) {
throw new ResponseStatusException(400, restaurant.getName()
+ " needs a bigger order");
}
// The card may have been removed while it was held: Payments.remove
// takes the profile lock held here and found no active order then.
// Asked again, a card that is gone refuses with 400.
payments.choose(customer, method[0]);
priced = again;
QuoteDto quote = priced.quote;
long now = System.currentTimeMillis();
CustomerOrder order = new CustomerOrder();
order.setId(id);
order.setCustomer(customer);
order.setRestaurantId(priced.restaurant.getId());
order.setRestaurantName(priced.restaurant.getName());
order.setState(OrderState.PLACED);
order.setSubtotalCents(quote.subtotalCents());
order.setDeliveryFeeCents(quote.deliveryFeeCents());
order.setServiceFeeCents(quote.serviceFeeCents());
order.setSmallOrderCents(quote.smallOrderCents());
order.setTaxCents(quote.taxCents());
order.setDiscountCents(quote.discountCents());
order.setTipCents(quote.tipCents());
order.setTotalCents(quote.totalCents());
order.setCommissionCents(priced.commissionCents);
order.setCurrency(quote.currency());
order.setPromoCode(priced.promo == null ? "" : priced.promo.getCode());
order.setDropoffLat(cart.dropoffLat());
order.setDropoffLng(cart.dropoffLng());
order.setDropoffAddress(Text.required(cart.dropoffAddress(), 255, "the address"));
order.setNote(Text.optional(cart.note(), 255, "the note"));
order.setMethodId(method[0]);
order.setMethodLabel(method[1]);
order.setAuthorizationRef(hold);
order.setPaymentStatus(Payments.CASH.equals(hold) ? Payments.PENDING : Payments.AUTHORIZED);
order.setPrepMinutes(priced.restaurant.getPrepMinutes());
order.setPlacedAt(now);
order.setUpdatedAt(now);
order.setReason("");
orders.insert(order);
for (int iter = 0; iter < priced.lines.size(); iter++) {
OrderLineDto dto = priced.lines.get(iter);
OrderLine line = new OrderLine();
line.setId(Ids.next());
line.setOrderId(id);
line.setDishId(dto.dishId());
line.setName(dto.name());
line.setOptions(dto.options());
line.setQuantity(dto.quantity());
line.setUnitCents(dto.unitCents());
line.setLineCents(dto.lineCents());
line.setPosition(iter);
lines.insert(line);
}
if (priced.promo != null) {
// Keyed by code and account, so a second order racing this one
// with the same code fails its insert and is rolled back.
if (promoUses.existsById(Identifier.of(priced.promo.getCode(), customer))) {
throw new ResponseStatusException(409, "You have already used that code");
}
PromoUse use = new PromoUse();
use.setCode(priced.promo.getCode());
use.setUsername(customer);
use.setOrderId(id);
promoUses.insert(use);
}
events.record(id, OrderState.PLACED, customer);
return null;
}
No courier is looked for yet. A courier sent to a restaurant that then turns the order down has wasted a trip, so the delivery is created when the kitchen accepts, in the same transaction:
/// The kitchen takes an order: it says how long it needs, and the order's
/// delivery starts looking for a courier in the same transaction.
public OrderDto accept(String actor, String restaurantId, String id, int prepMinutes)
throws IOException {
if (prepMinutes < 1 || prepMinutes > 180) {
throw new ResponseStatusException(400, "Say how many minutes it needs");
}
attempt(() -> accepted(actor, restaurantId, id, prepMinutes));
tell(find(id));
// Once committed, as an offer is always made: a courier asked from
// inside the transaction could be shown an order that then rolled back.
deliveries.search(id);
return view(actor, false, id);
}
@Transactional
private Void accepted(String actor, String restaurantId, String id, int prepMinutes)
throws IOException {
CustomerOrder order = kitchens(restaurantId, id);
if (orders.accept(id, OrderStateMachine.sources(OrderState.ACCEPTED),
OrderState.ACCEPTED, prepMinutes, System.currentTimeMillis()) != 1) {
throw new ResponseStatusException(409, "That order is "
+ describe(order.getState()));
}
events.record(id, OrderState.ACCEPTED, actor);
deliveries.dispatch(find(id), restaurants.findById(restaurantId));
return null;
}
/// Creates the delivery for an order the kitchen has just accepted, with the
/// order's id, from the restaurant to the order's door. Runs inside the
/// transaction that accepts the order, so the two commit together; [#tick]
/// finds the delivery a courier from then on, while the kitchen cooks.
///
/// The courier is paid the delivery fee and the tip, so that is the fare a
/// courier is offered.
public void dispatch(CustomerOrder order, Restaurant restaurant) throws IOException {
long now = System.currentTimeMillis();
Delivery delivery = new Delivery();
delivery.setId(order.getId());
delivery.setState(DeliveryState.REQUESTED);
delivery.setCustomer(order.getCustomer());
delivery.setCourier("");
delivery.setPickupLat(restaurant.getLat());
delivery.setPickupLng(restaurant.getLng());
delivery.setPickupAddress(address(restaurant.getName() + ", " + restaurant.getAddress()));
delivery.setDropoffLat(order.getDropoffLat());
delivery.setDropoffLng(order.getDropoffLng());
delivery.setDropoffAddress(address(order.getDropoffAddress()));
double meters = Quotes.roadMeters(restaurant.getLat(), restaurant.getLng(),
order.getDropoffLat(), order.getDropoffLng());
delivery.setDistanceMeters(meters);
delivery.setDurationSeconds(meters / Quotes.METERS_PER_SECOND);
delivery.setFareCents(order.getDeliveryFeeCents());
delivery.setFeeCents(order.getDeliveryFeeCents());
// A tip given at checkout is part of the order's total, taken with it,
// so it is in the courier's share. The delivery's own tip is the one
// given after it arrives, charged on its own: counting the checkout
// tip there too would pay it to the courier twice.
delivery.setTipCents(0L);
delivery.setCourierCents(order.getDeliveryFeeCents() + order.getTipCents());
delivery.setCurrency(order.getCurrency());
delivery.setMethodId(order.getMethodId());
delivery.setMethodLabel(order.getMethodLabel());
delivery.setPaymentStatus(order.getPaymentStatus());
delivery.setNote(order.getNote());
delivery.setRequestedAt(now);
delivery.setUpdatedAt(now);
delivery.setSearchUntil(now + searchMillis);
deliveries.insert(delivery);
event(order.getId(), DeliveryState.REQUESTED, "");
}
The delivery shares the order’s id, so either side can find the other without a
join table. From then on the couriers are asked while the kitchen cooks, and the
order follows its delivery: the courier collecting the food moves the order to
PICKED_UP, and only from READY, so nobody leaves with an order the kitchen has
not finished; the delivery completing moves the order to DELIVERED. A delivery
that finds no courier ends the order with NO_COURIERS and lets the hold go.
Every move of either state is a conditional update that names the states it may come from, so two answers at the same moment can’t both win — a customer cancelling while the kitchen accepts, or two couriers taking one offer. It’s the pattern Two answers at the same moment explains:
private boolean tryMove(String id, DeliveryState to, String actor, String ownerField,
String newCourier, Long newOfferExpiresAt) throws IOException {
DeliveryState[] sources = DeliveryStateMachine.sources(to);
if (sources.length == 0) {
return false;
}
long now = System.currentTimeMillis();
int moved;
if (newOfferExpiresAt != null) {
moved = deliveries.offer(id, sources, to, now, newCourier, newOfferExpiresAt.longValue());
} else if (CUSTOMER.equals(ownerField)) {
moved = deliveries.moveAsCustomer(id, sources, to, now, actor);
} else if (!COURIER.equals(ownerField)) {
moved = deliveries.moveByServer(id, sources, to, now);
} else if (newCourier != null) {
moved = deliveries.giveBack(id, sources, to, now, actor, newCourier);
} else if (DeliveryState.ACCEPTED == to) {
// An offer that has lapsed cannot be taken, even in the moment
// before the sweep takes it back.
moved = deliveries.takeOffer(id, sources, to, now, actor);
} else {
moved = deliveries.moveAsCourier(id, sources, to, now, actor);
}
if (moved != 1) {
return false;
}
event(id, to, actor);
return true;
}
Hold now, charge on arrival
A rider pays at the end of a ride, for a fare known by then. A customer pays for food before anyone cooks it, but shouldn’t be charged for an order that never arrives. The card is therefore authorized for the total when the order is placed and captured when it’s delivered:
/// Holds an order's total on the customer's card as the order is placed,
/// with no transaction open. Nothing is taken until the order arrives
/// ([#settle]); an order that never does lets the hold go ([#release]).
///
/// @return the hold's reference, or [#CASH] for an order paid in cash
/// - `ResponseStatusException` 402: when the card was refused
public String authorize(String orderId, String username, String methodId, long amountCents,
String currency) throws IOException {
if (CASH.equals(methodId)) {
return CASH;
}
String[] references = chargeable(username, methodId);
if (references == null) {
throw new ResponseStatusException(402, "The card is no longer on the account");
}
try {
return cut(provider.authorize("order-" + orderId, references[0], references[1],
amountCents, currency, "Savor order"), 64);
} catch (PaymentDeclinedException declined) {
throw new ResponseStatusException(402, declined.getMessage());
} catch (IOException unknown) {
throw unavailable("card check", unknown);
}
}
The provider interface Wayline’s billing chapter describes gained the two calls
that make it possible, authorize and capture, and a release for a hold that
is let go:
public interface PaymentProvider {
/// `simulated` or `stripe`: what the app is told, so it knows how to add a
/// card.
String name();
/// Opens the processor's record of a customer and returns its reference.
String createCustomer(String username, String displayName) throws IOException;
/// Deletes the processor's record of a customer, and the cards saved to it
/// with it. A record that is already gone is deleted: this is asked again
/// when the first answer was lost.
void forgetCustomer(String customer) throws IOException;
/// Begins saving a card to `customer`. `setupId` is the server's own id
/// for the attempt, for the processor to hand back.
Setup startCardSetup(String customer, String setupId) throws IOException;
/// Finishes a setup and returns the card it saved.
///
/// @param reference what [#startCardSetup] returned as the setup's reference
/// @param typed the card as typed into the app, for a processor whose setup
/// is not hosted; ignored by one whose setup is
/// - `PaymentDeclinedException`: when the card was refused or the setup was
/// not finished
Card finishCardSetup(String customer, String reference, CardDto typed) throws IOException;
/// Takes a saved card off `customer`, so that it can't be charged again.
/// A card that is already off, or gone, is detached: this is asked again
/// when the first answer was lost.
///
/// @param token what [#finishCardSetup] returned as the card's token
void detachCard(String customer, String token) throws IOException;
/// Charges a saved card with its owner absent.
///
/// @param key names this charge and no other. A processor asked twice with
/// the same key charges once, so a request repeated after a timeout
/// cannot take the money twice.
/// @return the processor's reference for the charge
/// - `PaymentDeclinedException`: when the card was refused
/// - `IOException`: when the processor could not be asked
String charge(String key, String customer, String token, long amountCents, String currency,
String description) throws IOException;
/// Returns `amountCents` of a charge to the card it came from.
///
/// A processor may take the refund and only later say whether the bank
/// returned the money: such a refund comes back [Refund#pending], and is
/// asked about with [#refundStatus] until it has an answer.
///
/// @return the refund as the processor took it
/// - `IOException`: when the processor could not be asked, or refused the
/// refund outright
Refund refund(String chargeReference, long amountCents) throws IOException;
/// What became of a refund that was [Refund#pending]: [#REFUND_SUCCEEDED],
/// [#REFUND_PENDING] while the processor still does not know, or
/// [#REFUND_FAILED] when the money did not go back.
String refundStatus(String refundReference) throws IOException;
/// Holds `amountCents` on a saved card without taking it, for an order
/// that is paid for only once it arrives. Keyed as [#charge] is.
///
/// @return the processor's reference for the hold, for [#capture] or [#release]
/// - `PaymentDeclinedException`: when the card was refused
String authorize(String key, String customer, String token, long amountCents,
String currency, String description) throws IOException;
/// Takes `amountCents`, at most what was held, from a hold. A hold that was
/// already captured is captured: this is asked again when the first answer
/// was lost.
void capture(String reference, long amountCents) throws IOException;
/// Lets a hold go, so the money is the cardholder's again. A hold that is
/// already let go is released.
void release(String reference) throws IOException;
Placed:
Payments.authorizeholds the total. A refused card answers 402 and nothing is written. An order that couldn’t be written lets its hold go at once.Rejected or cancelled:
Payments.releaselets the hold go. Nothing was taken, so there is nothing to refund.Delivered:
Payments.settlecaptures the hold. A capture can still fail — a hold expires, or a bank declines then — and the receipt then says so, for an administrator to follow up.After delivery: a tip is a charge of its own; a refund, full or partial, is made against the captured payment.
The receipt is kept with the delivery. Before a delivery exists, a customer’s receipt is built from the order itself and shows the hold.
With Stripe, a hold is a PaymentIntent created with manual capture. Card holds expire after about seven days, far longer than any meal takes. Turning Stripe on, and the hosted page that keeps a card number away from the server, are exactly as Billing describes.
Restaurants near the door
A rider is matched to the nearest driver. A customer chooses a restaurant, from the ones that deliver to their door. The search takes a box around the point from the database and then measures each restaurant in it, as Wayline does to find the drivers near a pickup:
/// The approved restaurants that deliver to a point, filtered by cuisine and
/// by a word in the name, the cuisine or a dish, and sorted.
@Transactional(readOnly = true)
public List<RestaurantSummaryDto> nearby(String viewer, double lat, double lng,
String cuisine, String text, String sort) throws IOException {
if (!Geo.isPoint(lat, lng)) {
throw new ResponseStatusException(400, "Choose where to deliver to");
}
double straight = Quotes.MAX_METERS / Quotes.ROAD_FACTOR;
double dLat = straight / Geo.METERS_PER_DEGREE;
double dLng = dLat / Math.max(0.01d, Math.cos(Math.toRadians(lat)));
List<Restaurant> box = restaurants.inBox(APPROVED, lat - dLat, lat + dLat, lng - dLng,
lng + dLng);
String word = text == null ? "" : text.trim();
Set<String> dishMatches = word.length() == 0 ? Collections.<String>emptySet()
: dishMatches(box, word);
Set<String> kept = favoriteIds(viewer);
PricingDto pricing = quotes.pricing();
int minute = quotes.minuteOfDay(System.currentTimeMillis());
List<RestaurantSummaryDto> out = new ArrayList<RestaurantSummaryDto>();
for (int iter = 0; iter < box.size(); iter++) {
Restaurant restaurant = box.get(iter);
if (cuisine != null && cuisine.length() > 0
&& !cuisine.equalsIgnoreCase(restaurant.getCuisine())) {
continue;
}
if (word.length() > 0 && !contains(restaurant.getName(), word)
&& !contains(restaurant.getCuisine(), word)
&& !dishMatches.contains(restaurant.getId())) {
continue;
}
double meters = Quotes.roadMeters(restaurant.getLat(), restaurant.getLng(), lat, lng);
if (meters > Quotes.MAX_METERS) {
continue;
}
out.add(summary(restaurant, meters, pricing, minute,
kept.contains(restaurant.getId())));
}
sort(out, sort);
return out;
}
Couriers are found the same way, near the restaurant rather than the customer, because the restaurant is where they go first.
Prices
order/Quotes prices a cart, and does so on the server alone: the app shows the
quote it’s given and sends only the dishes, their choices, the promo code and the
tip. The total is:
The food, from the menu as it’s when the order is placed, with each dish’s choices checked against its groups' minimum and maximum.
The delivery fee, a base plus a price per kilometer by road from the restaurant, times a surge an administrator can raise.
A service fee, a percentage of the food.
A flat small-order fee for food under a threshold.
Tax, a percentage of the food after the discount.
A promo code’s discount, once per account.
The tip.
An administrator sets the fees, the commission and the tax under Pricing. The commission is what the service keeps of the food; the merchant’s sales show the rest.
Merchants
Applying to list a restaurant creates it waiting for approval, owned by the account that asked:
/// Asks for a restaurant to be listed. It waits, unseen by customers, for
/// an admin to approve it; the applicant then joins its staff and gets the
/// merchant role. One application at a time per account.
@Transactional
public RestaurantDto apply(String username, RestaurantEditDto form) throws IOException {
// Two submissions at once would both find no application below and
// both insert one; holding the profile first makes the second wait
// and then see the first.
profiles.hold(username);
Restaurant last = restaurants.findFirstByApplicantOrderByCreatedAtDesc(username);
if (last != null && !Catalog.REJECTED.equals(last.getStatus())) {
throw new ResponseStatusException(409, Catalog.PENDING.equals(last.getStatus())
? "Your application is waiting for review" : "You already have a restaurant");
}
Restaurant restaurant = new Restaurant();
restaurant.setId(Ids.next());
restaurant.setStatus(Catalog.PENDING);
restaurant.setApplicant(username);
restaurant.setReason("");
restaurant.setPhotoId("");
long now = System.currentTimeMillis();
restaurant.setCreatedAt(now);
restaurant.setUpdatedAt(now);
fill(restaurant, form);
restaurants.insert(restaurant);
return catalog.detail(restaurant, username, 0d, 0d, true);
}
Approving it grants the account the merchant role. Everything a merchant does is
under /api/merchant/restaurants/{id}/…, and each request checks that the account
is a merchant of that restaurant, so one restaurant’s staff can’t read another’s
orders. The rules are in the same table as the rest of the server’s:
.authorizeHttpRequests(auth -> auth
.requestMatchers(AntPathRequestMatcher.antMatcher("POST",
"/api/account/register")).permitAll()
.requestMatchers("/api/courier/**").hasRole(Roles.COURIER)
.requestMatchers("/api/admin/**").hasRole(Roles.ADMIN)
.requestMatchers("/api/merchant/**").hasRole(Roles.MERCHANT)
.anyRequest().authenticated())
The merchant’s screen is built on the operations console’s Console, so it’s a
list beside a detail on a wide screen and a list that opens a screen per order on a
phone. A new order reaches it as a frame on the live channel, which makes it read
the queue again: the frames say what changed, never the data, as in Wayline.
Menu photographs are uploaded the way couriers' documents are, stored by the server and served from it, so the seed data carries its own photographs rather than pointing at someone else’s site.
Simulated kitchens and couriers
A development server simulates the two people a customer would otherwise wait for.
order/DemoKitchen accepts, cooks and readies the orders of each restaurant that has
no merchant, and delivery/DemoFleet takes deliveries while no real courier
is online. Both are on only for the development profile, the way the simulated cards
are: the test profile turns them off, because the tests play the kitchen and the
courier themselves. A restaurant with a merchant is left to its staff, and the
simulated courier stands aside while a real one is online.
Tests
mvn -pl shared,backend -Dcodename1.platform=backend test
mvn -pl shared,backend -Dcodename1.platform=backend test -Dcn1.backend.compiledTests=true
./run-e2e.sh
./run-web-e2e.sh
The backend tests run the server on the JVM and then compiled, against the order
and delivery state machines, the holds and captures, the quotes and promo codes and
who may call what. run-e2e.sh starts a server on the test profile and drives the
app in the simulator through every role — an order placed, accepted, collected and
delivered, a card held and charged, a tip and a refund — taking the screenshots in
the user’s guide. run-web-e2e.sh does the same for the app in a browser.
Making it yours
The ways to make Wayline your own apply here too: the name, icon and colors, the languages, the text-message and push providers, the database, hosting. A few apply to food in particular:
One restaurant or a small chain. Ordering and delivery under the restaurant’s own name, which Making it yours: A restaurant’s own delivery service describes.
Pickup. An order with no delivery is one the kitchen hands over itself. Mark the order as collected, skip
dispatchwhen the kitchen accepts it, and let the merchant move it fromREADYtoDELIVERED, which captures the hold.Scheduled orders. Store the time the customer wants the food, and accept the order for the kitchen only when its preparation time before that’s reached.
Before it’s a product. A real service needs restaurant payouts, tax rules for the places it sells in, menus that change by the hour, allergens and age checks. None of them are in the sample.
Making it yours: A restaurant’s own delivery service
A marketplace takes a share of every order and keeps the customer. A restaurant, or a chain of a few, that runs its own ordering and delivery keeps both: the app carries its name, the customers' accounts and order history are its own, and the couriers are its staff or a courier company it contracts. Savor is most of that service already. What changes is that there is one brand instead of many, and nobody applies to sell. This section says what to change, and the class each change belongs in.
One brand, one or a few kitchens
A chain is a restaurant row per branch, each with its own address, hours,
preparation time and menu. Seed them in place of catalog/DemoRestaurants, or
create them through the admin console. Nothing else in the catalog changes:
Catalog.nearby already answers the branches that deliver to the customer’s
door, nearest first, and the order goes to the branch the customer picked.
The customer’s first screen changes. With one kitchen, open its menu instead of
the list: where Nav.build makes the customer’s home, call
RestaurantForm.show with the restaurant’s id. With a few, keep the list, which
is now a list of branches, and drop the row of cuisines and the sort above it
in CustomerForm, which only make sense for many restaurants.
The name, the icon and the colors are the ones Running it and Wayline’s
chapter describe. Use the restaurant’s own photographs: replace the files in
backend/photos, which the development server reads into the database the first
time it starts, and name them in the seed. In production, upload them from the
merchant’s menu editor.
Nobody applies to sell
Restaurants no longer join, so close that door:
Remove the restaurant applications from the server’s rules and from
Restaurants:apply,applicationandapplicationPhoto. The admin’s Applications pane then lists couriers only.Set the commission to zero under Pricing, or with
savor.fee.commissionPercent=0. The commission is what a marketplace keeps of the food; a restaurant selling its own food keeps it all, and the merchant’s sales should say so.Make each branch’s staff merchants of their branch from the admin console. A merchant account works on one restaurant, the one
Restaurants.minefinds. An owner who watches several branches needs a branch picker onMerchantFormand the restaurant’s id in the merchant’s calls in place ofmine.
Couriers of its own
A restaurant’s couriers are known people, so the courier side works unchanged,
with the application step done by an admin. Approve couriers from
Applications, or give the role straight from People, and remove
Deliver with Savor from the customer’s menu in CustomerForm, and the
courier choice from RegisterForm, if the public shouldn’t apply.
Offers go to the nearest courier online, as before. With two or three couriers,
lengthen savor.offer.seconds and savor.search.seconds, because the one
courier nearby may be at a door.
Many restaurants deliver only part of the time, or hand deliveries to a courier
company at busy hours. Deliveries.dispatch is the one place a delivery
starts: send it to the company’s API there instead of offering it to couriers,
and move the delivery with the updates the company sends back.
Pickup
A customer who collects the order needs no courier. Mark the order as collected
at checkout, skip dispatch when the kitchen accepts it in Orders.accept,
and let the merchant move it from READY to DELIVERED, which captures the
hold the way a delivery does.
Booking a table
A restaurant that delivers usually takes bookings too, and the parts are all in the sample. A booking isn’t in it, so this is the outline:
The record. A
Reservationentity — restaurant, account, time, party size, a note and a state of requested, confirmed, declined, seated or cancelled — in a new migration, with a state machine of conditional moves like the order’s, so a guest cancelling while the restaurant confirms can’t both win.The contract. A
@RestClientinterface insharedfor the guest’s calls (the free times, book, cancel, their own bookings) and calls under/api/merchant/restaurants/{id}/…for the staff, behind the same rules as the merchant’s other calls.Free times. Slots inside the restaurant’s opening hours, each with the number of covers the room takes, less the bookings already confirmed.
The staff’s screen. A tab beside the order queue in
MerchantForm, refreshed by a frame on the live channel when a booking arrives, as a new order is.Telling the guest. A text message through
SmsSenderand a push through thepushpackage when a booking is confirmed, and a reminder before it.
A deposit for a large party is a card hold, as an order is: authorize when the booking is made, capture it for a no-show, and release it when the guest arrives.