Online ordering and a digital menu, served from the shop's own origin - #497
Merged
Merged
Conversation
…nd hours Fixes a P0 first. qrOrderModel guarded ordering with `!branchDoc.kiosk || !branchDoc.kiosk.store_id`, but branch.kiosk is an ARRAY in every write path in the application - declared Array, seeded as an array, written through kiosk.$[elem] with arrayFilters. `.store_id` on an array is undefined, so the guard fired for every branch and refused every QR order. It went unnoticed because live kiosk traffic still reaches the legacy PHP API, and because the unit fixture mocked an object shape nothing writes. public-routes.test.js pinned the defect by source regex, so it now asserts the property instead. The kiosk bundle moves into the tenant app at order/ and is served at /order and /menu, same origin as the API it calls. That removes the CORS problem, and it removes a real one: IndexedDB is per-origin, so every shop shared qr.posnic.io storage and the page had to wipe its catalogue whenever it noticed a different branch. config.js now derives the API base from the page origin, with the legacy host named as the one exception for codes already printed and stuck on tables. Adds the availability model as four independent layers rather than one switch: the module, the mode (order or menu), a schedule, and a manual pause. They compose into one state computed in a single place and used by both the page and the order endpoint, so the two cannot drift. The page hides its cart; the server refuses. Only the second is a control. Design choices worth keeping: - pause stores a resume time, never a boolean, because a boolean flipped during a Friday rush is still on the following Tuesday - hours are a LIST of windows per weekday, because lunch and dinner is the ordinary day here, and windows may cross midnight - mode is an enum, because 'false' as a string reads as ON through `!== false` and this estate has paid for that - every closed state still shows the menu and says when it opens - new fields are written out at creation, because sync replaces whole documents and a field the winner does not carry is deleted Also fixes the store-id duplicate check colliding with the branch's own id, which made that settings tab a one-shot save. Adds .prettierignore. The pre-commit hook runs prettier over every staged file, and on files it has never formatted that is a whole-file rewrite: the first attempt at this commit reformatted 11,516 of the 11,920 lines of settings_write.html and every byte of the vendored bundle. The two commits before it touched that file in 2 and 42 lines. api/ is left out of the ignore, because its files really are formatted and the hook does the small job it was meant to do there. Nothing renames a database field yet, and the legacy host keeps working.
Nobody is using this channel yet, so nothing here is migrated, dual-read or kept alive for old clients. The old shapes are deleted. ONE RESOURCE. Three endpoints named after how the customer happened to arrive - POST /items/accessQr, POST /items/accesskiosk, POST /sales/qrOrder - become GET /online-ordering/:storeId and POST /online-ordering/:storeId/orders. The transport is not the resource: a customer reaches the same storefront from a code on a table, a link in a message, or a terminal in the shop, and none of those deserves its own noun. The storefront is a GET, so a shop's menu is finally a URL that can be linked and cached; it used to be a POST with the store address in a JSON body. The store address in the path is the only way in. The old lookup fell back to the branch's raw database id, which appears in every authenticated response and is no secret, so a branch that had deliberately never opened a channel could still be read by anyone who had seen its id. Worse, the catalogue was narrowed to online-ticked items ONLY when the caller used the store address - the same branch reached by id answered with the whole catalogue, back-of-house lines included. ONE OBJECT. branch.kiosk was an Array that never held more than one entry, matched by a branch_id stored inside a document that was already that branch. It is branch.online_ordering, a plain object. That shape is what let one reader treat it as an array and another as an object, and the disagreement refused every online order this API ever received. Payment method flags are public - the page cannot draw a checkout without them and they are on/off, not credentials - and are coerced from the strings 'true'/'false' that a truthy test would have read backwards. The printer name is device-only, behind the kiosk key. Fulfilment types (dine in, takeaway, pickup, delivery) replace the restaurant-or-retail question. The difference between a restaurant and a clothes shop is which of these they offer, not which mode the software is in, so the vertical stops being a concept and becomes configuration. Also: the bundle names no host at all now, so one shop's page cannot talk to another shop's server; accessKiosk's image-healing is dropped because the /uploads route already heals on demand; and the item checkbox that actually controls online visibility stops being labelled "Kiosk" while a half-dead one beside it says "Sales channel". sales_channel itself is left alone: it feeds internal staff pickers, and removing it is a different job from renaming this channel.
…hines TWO THINGS. First: sales_channel is gone. It was a boolean pretending to be a multi-channel selector, and the code carried three separate comments recording where its filter had already been ripped out for silently hiding items from New Sale. What survived was one live filter on getOnlineSalesItems, a checkbox labelled "Sales channel" that no owner could distinguish from the one beside it, and a badge on the item view. Field, filter, checkbox, badge, seeded defaults and the comments about it are all removed. Nobody is using the channel it was meant to select, so there is nothing to migrate. Second, and a correction: the owner pointed out that kiosk is not a synonym for online ordering - there are machines in shops and real sales come from them. He is right, and the previous commit's labels were wrong. The kiosk report's own method filter offers "Kiosk" and "Self-Order" side by side, so it has always covered both channels, and retitling it "Online orders" erased the machines. The same mistake was in the module toggle and the item flag, both of which govern a storefront that a kiosk machine and a customer's phone each display. So those three now name both: "Kiosk and online orders", "Kiosk and online ordering", "Kiosk and online". The API keeps its /online-ordering resource with the machine's door at /:storeId/device behind the kiosk key - one storefront, two ways in, which is the shape that was right before and stays right.
I deleted POST /items/accesskiosk on the premise that nobody was using this channel. That was wrong: the machines in shops are live and take real money, as the owner pointed out. They order through POST /sales/kioskOrder, which never moved, and they read their menu through the route I removed - so they were left able to sell but unable to load a menu, which surfaces as a shop ringing support rather than as a failing test. It is back, behind the kiosk key, answering in the shape it always answered in, because the machines are deployed and cannot be updated from here. Underneath it runs the new storefront query, so there is still one implementation rather than two that can drift. GET /online-ordering/:storeId/device is what anything new should use. Two tests now pin it: the machines keep both endpoints, and the kiosk report keeps counting BOTH self-service channels (sale_method Kiosk from a machine, Self-Order from a customer's own phone). If that list is ever narrowed, a whole channel stops being reported and the totals just get quietly smaller.
One free-text `sale_method` was answering three questions at once and
none of them completely. `Kiosk` said where an order was typed but not
whether the customer ate in; `Self-Order` said the customer typed it but
not on what; and nothing could express a Swiggy order at all. The obvious
fix - adding `Swiggy` as a fifth value - makes every new aggregator a
release and puts a 25% commission in the same field as a till sale that
has none.
So a sale now carries:
channel pos, kiosk, tableside, online, phone, whatsapp,
marketplace, ecommerce - where it was captured
channel_partner swiggy, zomato, ondc, opencart - which outside
business it came through, when one did
fulfilment dine_in, takeaway, pickup, delivery - how it reaches
the customer, independent of the channel, so a QR code
at a table and the same page from a sofa need not be
different channels
Oracle Simphony calls the first an order channel and defines it as "where
an order is placed, such as at a kiosk, drive thru window, inside a
restaurant, or via a delivery aggregator". Square calls it an order
source, Shopify a sales channel. The concept is settled; only the word
varies.
HISTORY KEEPS COUNTING. Years of sales carry only `sale_method`, so every
read goes through channelOf/channelFilter, which match the new field and
fall back to the four legacy values for documents that have none. A
filter on `channel` alone would have shown a shop its trading history as
an empty page, with no error anywhere. `sale_method` is still written, in
step, by the one function that writes the others.
MARKETPLACE IS A CHANNEL BECAUSE OF MONEY, not tidiness. A 500 rupee
order through an aggregator at 25% is 375 to the shop. The channel type
is what tells a report to look for a commission at all, and the rate is
stored on the sale rather than recomputed, because a partner's rate
changes and last month's report must not change with it.
Settings get their own group, `channels`, holding which channels the shop
uses and the partner list with commission rates. A partner is data, never
a release: adding Zomato is a row, not a deploy.
Also folds the duplicate FULFILMENT list in online-ordering into this one
definition, since fulfilment is not a property of that channel - a till
sale is taken away and a Swiggy order is delivered.
The customer URL was the weakest part of this feature: `?branch=AZ100`, a query string holding the primary identity under a parameter named after something else. It is now a path segment, and for most shops it is not needed at all. https://azure.posnic.io/order the shop's default branch https://azure.posnic.io/order/AZ100 a named branch https://azure.posnic.io/menu/AZ100 the same, browse only `?branch=` still resolves, because it costs one line and anything already printed keeps working. THE DEFAULT IS THE POINT. Most shops have one branch, and making all of them print a code - in a URL and inside a QR code, where every character is a denser pattern - to say which of their single branch they mean is friction paid by the many for the few. So `/order` resolves server-side: a setting the shop made wins, then the only configured branch if there is exactly one, and a chain with several and no default gets a sentence saying the address is incomplete rather than a guess. Serving the wrong branch's menu, prices and hours is worse than saying so. express.static answers 404 for `/order/AZ100`, since no such file exists, so the page is served for a path that LOOKS like a store address and only then. Not a catch-all: `/order/notafile.js` stays a 404, because HTML answered where a script was expected fails in the browser as a syntax error and sends whoever debugs it looking somewhere else entirely. Verified against a real server rather than assumed. Also removes a dead end. With no address the page logged a warning to a console nobody reads and left a spinner turning, driven by a text input and a submit button that had been deleted from index.html releases earlier - dead code pointing at dead markup, which reads as a working feature in a diff. It now asks the shop for its default and, if there isn't one, says so on the page. Two stale entries come out of the anonymous-route allowlist: /accessQr, which no longer exists, and /accesskiosk, which now sits behind the kiosk key. Names that outlive their routes are how a security list quietly permits something later. The i18n baseline moves 100% to 98% for all seventeen packs. No translation was lost - 2920 translated keys before and after - the denominator grew by this feature's new English.
/menu was /order with a class on <html> that hid the cart. That is a worse menu than the paper it replaces: it drags 1,500 lines of IndexedDB, a cart and two payment integrations along to render a list of dishes, and it reads as a shop that has taken its ordering away rather than as a menu. So they are two bundles with two jobs. /order transacts. /menu is read-only and built to be read at a table: categories that travel with you and light up as you scroll, one search box over names and descriptions with the count announced to a screen reader, two-line descriptions on the card and the whole thing in a detail sheet, dark mode from the phone's own setting, and no framework, because a menu is a list and a phone on a bad connection should not pay for one. Two gaps had to be filled first. items.show_on_menu, separate from ecommerce. That flag decides whether a thing can be ORDERED; this decides whether it is LISTED, and they are different questions - a restaurant lists the dish that is off tonight, because a menu with holes in it reads as a kitchen that has run out. It defaults to true, so a shop excludes the few lines that are not dishes rather than opting each dish in one at a time. items.diet - veg, non_veg, egg, vegan, or empty. Not decoration: Indian menus mark this by law and customers look for it before they read the name. Empty stays empty, because a wrong mark on a dish somebody cannot eat is worse than an honest blank. Store addresses are now checked against the words this resource uses for its own paths. A shop that chose `menu` would have answered its own menu route, and the clash would have been invisible until that one shop could not be reached. Verified against a real server: /menu/, /menu/AZ100, /order/, /order/AZ100 and both bundles' assets each serve the right page, and a missing file under either still 404s rather than being answered with HTML.
Everything until now was static checks. Running it - a seeded database, the real repository query, the real page in a real browser - found three faults in ten minutes that no amount of reading would have. Sections came out ALPHABETICAL, which put Breads before Starters. Stable, and wrong in a way any restaurant would notice on sight. Categories carry no sort field in this schema, so the order now comes from the categories collection itself: a shop creates Starters, then Mains, then Breads, then Desserts, because it builds a menu in the order it thinks about one, and an ObjectId sorts by creation time. A sort_order on the category wins where a shop has arranged them deliberately, and a category that no longer exists sorts last rather than taking its dishes off the menu. A shop with no logo showed an empty grey circle. `hidden` is only a default of display:none, so the .shop-logo display:block beat it. Said once now, at the top of the sheet, rather than remembered at each element that might be hidden. And the search icon was U+26B2, a lantern, not a magnifier. It is an inline SVG now. Verified: 12 dishes across four sections in the shop's own order, the veg dot green, non-veg red and egg amber, the unavailable dish dimmed, the packaging line correctly excluded by show_on_menu, and the channel state reading "Closed right now. Opens at 11:00 AM." from real opening hours.
SERVING PERIODS. Breakfast, lunch, dinner: a dish says which periods it belongs to, and the periods carry the hours. The other shape - a week of hours on every item - is data entry no shop will ever do, and the first time breakfast moves half an hour they would edit two hundred dishes. It is also how every system a restaurant already knows works: Toast calls them menu schedules, Square availability periods, the aggregators menu timings. The hours engine is the one already written for opening hours, midnight crossing included, because "served 7pm to 1am" is exactly as ordinary as a bar being open then. A dinner period running past midnight is still serving at half past twelve, and there is a test for it. A dish outside its window is SHOWN and told on, not hidden. Someone reading the menu at four in the afternoon wants to know breakfast exists and runs seven to eleven - that is a reason to come back, where an unexplained grey card reads as a kitchen that has run out. The card says "Breakfast only". And the order endpoint refuses it. The page greying a dish out is a courtesy; a stale tab, a shared link or a direct post all reach the endpoint with a dosa in the basket long after the griddle is cold, and the kitchen should not find out when the ticket prints. The refusal names the dish and when it is served, because "something in your order is unavailable" sends a customer hunting through their own basket. THE KITCHEN NOTE. items.prep_note is a standing instruction printed on every ticket for that dish - "serve with mint chutney", "always ask how they want it cooked". This did not exist: sale.item_description is the CUSTOMER's note on one order line, which is a different thing and already worked. items.prep_minutes goes with it, so an order can say when it will be ready. ONE CLICK TO STOP. A "Stop taking orders" button that sets the pause to the end of today. Deliberately not for ever: a switch with no end is one somebody flips during a Friday rush and finds still off on Tuesday with nobody able to say why the orders stopped, which is why the field underneath is a moment and not a flag. All three fields are restaurant-only, on the same switch that already shows the KOT report. A grocer has no breakfast menu, and a form full of questions that do not apply teaches a shop to skip the section. Rendered against a seeded database to check it reads: at 1am the breakfast dishes are dimmed and marked "Breakfast only", the all-day dishes are live, and prep times show where the shop has set them.
Two pieces of the hotel tie-up, built and tested but not yet wired into the storefront: the venue model and the charges model. PARTNER VENUES. A hotel down the road puts a QR in every room. Those orders come through the same storefront but are not the same orders: the hotel expects a cut, the customer is charged more to cover it, and somebody has to work out what is owed at month end. The venue is named in the URL rather than inferred from the code. The first cut read it from a PREFIX - RCroom123 meant Royal Club because it started with RC - which worked and carried a quiet failure: a restaurant naming a table RC1 would have started billing that table to the hotel, and nobody would notice until an invoice was too big. It needed a conflict checker, a longest-prefix rule and a warning on the settings screen, all to defend a guess. Naming the venue in the path removes the guess entirely, so all of that came out. MARKUP AND COMMISSION ARE TWO NUMBERS. The obvious model gives a venue one percentage for both. That is one deal and not the only one: a restaurant may mark up 12 and pay 10, keeping two points; or mark up nothing and pay 8 out of its own margin to win the tie-up. One field decides that negotiation for them. Commission is taken on what the customer actually paid, because that is the number printed on the bill and the only one both sides can check. An unknown venue code falls back to house prices and no commission. A customer charged the normal price is a bad QR code; a customer charged a markup nobody agreed is a complaint. DELIVERY FEES ARE KEYED ON FULFILMENT, not on channel. The same online storefront charges for delivery, nothing for pickup, and nothing again for a customer sitting at a table - put the fee on the channel and a shop taking dine-in QR orders bills delivery on food carried six feet. Nor is fulfilment alone enough: delivery through an aggregator is their rider and their fee, so the shop must add nothing or the customer pays twice, once to each of us. Hence a shop-wide table by fulfilment that any partner may override, and an override REPLACES rather than merges, because a merge cannot express zero. Three numbers per fulfilment - fee, free_above, min_order - which is what every app a customer has already used has. A minimum not met refuses the fulfilment rather than surcharging it. Deliberately not distance bands: those need an address, a map and a geocoding bill.
Two shops, two right answers. A busy counter wants every order on the griddle the second it lands, because a human gate becomes the slowest part of the kitchen. A restaurant taking orders from a hotel across the road wants to look first - is the kitchen still open, is that dish really on, is this a prank at 2am - because a ticket printed is food started and food started is money spent. So it is a setting, and both answers are legitimate. THE TICKET IS WHAT IS GATED, NOT THE ORDER. The order is always saved. Refusing to save would lose a customer's order on a network they cannot see and cannot retry into; saving and holding means the worst case is a wait and a visible queue somebody can act on. That also fixes the failure direction. If the setting cannot be read - no document, a bad value, a database blip - it answers AUTO, because an order that reaches the kitchen and should not have is a conversation, while an order silently waiting for an approval screen nobody knows to open is a customer in a hotel room wondering where dinner is. THE SOUND IS THE FEATURE. This is the one part of the product where nobody is standing in front of the till when the event happens; every other sale has a person ringing it up. A badge on a page nobody is facing is not a notification, it is a record of something that was missed. Two sounds, deliberately different. "Received" is a short rising chime - information, not a task, and a shop doing forty covers an hour must not be alarmed forty times. "Waiting" is three insistent notes, repeated every twenty seconds, because until somebody answers it the customer is waiting and the kitchen does not know they exist. One sound for both would train staff to ignore the one that matters, which is how alarms stop working. It gives up after five minutes rather than becoming background noise: an alarm that never stops is one somebody mutes at the speaker, and then it is gone for every future order too. The queue count stays, and that is the part that must not be silenceable. Nothing blocks the screen - a modal that steals focus mid-sale is worse than the missed order it was meant to prevent. The tones are synthesised as a WAV in memory rather than shipped as audio files: two more binaries in the installer, licences to track and a packaging filter to keep right, for two beeps. Also merges develop, which brought the event-driven KOT printing this gates.
A restaurant with nine tables ties up with the hotel across the road. The
hotel's rooms order through the same storefront and are not the same orders:
the room pays an agreed markup, the hotel is owed a cut, the food has to reach
a room number, and somebody has to be able to work out what is owed at the end
of the month.
The venue is named in the URL, never inferred from the code:
/order/AZ100 the shop
/order/AZ100/table/5 its own table five
/order/AZ100/venue/RC/123 Royal Club Hotel, room 123
The first design read the venue out of a prefix on the code, so RCroom123 meant
Royal Club because it started with RC. It worked, and a restaurant that named a
table RC1 would have started billing that table to the hotel with nobody
noticing until an invoice was too big. Naming the venue in the path removes the
guess: there is nothing to collide and nothing to check.
Markup and commission stay two numbers. A restaurant may mark up 12 and pay 10,
keeping two points; it may mark up nothing and pay 8 out of its own margin to
win the tie-up. One field would decide that negotiation on its behalf.
Fees are keyed by fulfilment rather than channel, because a delivery charge
exists when somebody drives the food somewhere, not because the order arrived
through a particular app.
The URL can lie, so the customer confirms where the food goes. A guest
photographs the code in room 123 and sends it to a friend in 456; a code gets
stuck on the wrong door during a refit. The checkout shows the destination and
lets it be corrected, and the order records both what the link claimed and what
the guest confirmed.
Approval gates the ticket, not the record. The order is always saved: refusing
would lose it on a network the customer cannot see and cannot retry into, where
holding means the worst case is a wait and a queue somebody can act on. Anything
that is not exactly "manual" is auto, deliberately - an order that reaches the
kitchen and should not have is a conversation, and one that silently waits for a
screen nobody knows to open is a customer wondering where dinner is.
The alarm is two synthesised tones, not shipped audio: a short chime for an
order already printing, three insistent notes every twenty seconds for one that
is waiting. It gives up after five minutes, because an alarm that never stops is
one somebody mutes at the speaker and then it is gone for every future order
too. The queue on the screen is the part that must never be silenceable.
Both bundles now carry a <base href>. They are served for all three URL shapes
from one index.html, and without it a relative asset three segments deep
resolves under a directory that does not exist: the page loads blank with
nothing in the console anybody reads. In the menu that also meant handling the
category chips in JavaScript, because a base makes the browser resolve #cat-x
against it and a chip would have navigated off the branch.
Four things this found:
- service-point.js resolved the URL on every read, so the second read on an
arrival URL rebuilt the point and threw away the room the customer had just
corrected. Resolved once per page load now.
- Arriving at a plain shop address has to clear a stored venue rather than
inherit it, or a guest who orders from room 123 and then scans a table code
downstairs is charged the room markup at the table.
- tests/tools/i18n-load-time.js threw "Cannot access 'list' before
initialization" the moment it found something, which is the case it was
written for. It had never fired before.
- The pre-commit hook reformatted the whole of src/main.js for a 25-line
change: 2,254 insertions and 1,505 deletions. src/ and the two frontend build
maps join .prettierignore for the same reason frontend/ is already there.
Signed-off-by: Sridhar Bala <sridharkalaibala@gmail.com>
Four required checks were red and blocking the merge. The packaging one was a real bug, and the only one that would have reached a customer: src/order-alert.js was required by src/main.js but missing from build.files, so the packaged app would have started and then failed with "Cannot find module" the first time an online order arrived. The check exists because that failure only shows up on an installed machine, never in development, where the app runs from source. docs/API.md and docs/openapi.json are generated from the routes and had not been regenerated since the approval queue and commission report were added. That failed twice: once on its own check, and once through the badge check, which compares the README's endpoint count against the generated docs and found 615 against 608. The prettier failure was five API files, four of them a line or two. api/ is deliberately not in .prettierignore because its files really are formatted.
docs/API.md and docs/openapi.json are written by api/scripts/generate-api-docs.js and checked by CI with docs:check, which regenerates them and diffs against what was committed. The pre-commit hook was reformatting them on the way in - padded table columns, _and_ for *and* - so the committed file could never match the generator again and docs:check would fail on every pull request that touched a route. Same argument as languages/, which is already there for the same reason: a generated file with two writers is a file that is always wrong.
…nto feat/qr-shop-origin-menu-mode
Three README conflicts, one of them real. The real one: develop still linked Posnic Cloud as /pricing.html, while this branch had already corrected it to /pricing. The page's own rel="canonical" in web-frontend/pricing.html says /pricing, so that is the one that stays. The other two were not about anything. The pre-commit hook pads markdown tables, so bumping the endpoint badge on this branch reformatted 64 lines of a README that is not prettier-formatted in the repository, and those lines then collided with develop's own link corrections. Resolved by taking develop's README whole and re-applying the two changes this branch actually needs - the badge count and the pricing link - which leaves a two-line diff instead of sixty-four. README.md joins .prettierignore so it cannot happen again. develop's own move to www.posnic.com had already made every other correction these branches were both reaching for, so nothing else was in dispute.
api/src/services/demo-dataset.js came in with the website-hostname normalisation on develop (f1d8fd7) without going through prettier, so the Lint check is red on develop itself and every branch cut from it inherits that. Eight lines. api/ is deliberately outside .prettierignore because its files really are formatted, and this is the check doing its job.
Contributor
|
Merged to Try it at https://develop.posnic.io, or run it yourself: git fetch origin develop && git checkout develop
npm install && npm --prefix api install
npm run dev # then http://localhost:3000When you have tested it, say what you did and what happened, and set Reporting that something is broken is as useful as fixing it. It is |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Nine commits. The short version: the QR kiosk becomes an Online Ordering channel served by the shop's own process, plus a separate read-only digital menu, and a sale now records where it came from.
It fixes a P0 nobody had noticed
qrOrderModelguarded with!branchDoc.kiosk || !branchDoc.kiosk.store_id, butbranch.kioskis an array in every write path..store_idon an array isundefined, so the guard fired for every branch and refused every order this API has ever received. It survived because live traffic still went to the legacy PHP host, and because the unit test mocked an object shape nothing writes.What changed
/items/accessQr,/items/accesskioskand/sales/qrOrderbecomeGET /online-ordering/:storeId,GET /online-ordering/:storeId/menuandPOST /online-ordering/:storeId/orders. The storefront is a GET, so a menu is a URL.azure.posnic.io/order/AZ100,/menu/AZ100, or just/orderand/menuusing the shop's default branch. Same origin means no CORS list and no shared browser storage between shops.branch.kiosk→branch.online_ordering, an object rather than an array of one.channel,channel_partner,fulfilment,channel_commission. Reports read throughchannelOf/channelFilter, which also see the years of sales that carry onlysale_method./menu: categories that follow you as you scroll, search, detail sheets, dark mode, the veg mark. Its own bundle, not the ordering page with the cart hidden.items.show_on_menuanditems.diet. Being listed and being orderable are different questions, and no Indian menu ships without the veg dot.items.sales_channeldeleted - a misused boolean whose filter had already been ripped out of three queries for silently hiding items from New Sale.Verified, not asserted
Rendered against a seeded database in a real browser: 12 dishes in four sections in the shop's own order, dots correct, unavailable dish dimmed,
show_on_menuexcluding the packaging line, and "Closed right now. Opens at 11:00 AM." from real opening hours. That run found three faults static checks had missed.1,428 root tests and 9,982 API tests green.
Notes for review
/items/accesskioskand/sales/kioskOrderunchanged: they are deployed and cannot be updated from here. I removed that route mid-way and had to put it back..prettierignoreis new. The pre-commit hook was rewriting whole files it had never formatted - one attempt reformatted 11,516 of the 11,920 lines ofsettings_write.html.Second batch: hotel tie-ups, fees, approval and the alarm (2026-09-11)
The owner's brief: a restaurant with nine tables ties up with the hotel across
the road; the hotel's rooms order through the same storefront, pay a markup, and
the restaurant needs a report of what it owes. Plus delivery fees, orders that
wait for a person, and a noise so a waiting order is not missed.
The venue is named in the URL, never inferred from the code.
The first design read the venue out of a prefix on the code, so
RCroom123meant Royal Club because it started with
RC. It worked, and a restaurant thatnamed a table
RC1would have started billing that table to the hotel withnobody noticing until an invoice was too big. It needed a conflict checker, a
longest-prefix rule and a warning on the settings screen, all to defend a guess.
Naming the venue in the path removes the guess entirely.
Markup and commission are two numbers. A restaurant may mark up 12 and pay
10, keeping two points; it may mark up nothing and pay 8 out of its own margin
to win the tie-up. One field would decide that negotiation for it.
Fees are keyed by fulfilment, not channel. A delivery charge exists because
somebody drives the food somewhere, not because the order came through a
particular app. The same storefront serves a table, a takeaway and a room.
The customer confirms the destination. A guest photographs the code in room
123 and sends it to a friend in 456. The order records both what the link
claimed and what the guest confirmed, so a delivery that goes wrong can be read
afterwards rather than argued about.
Approval gates the ticket, not the record. The order is always saved.
Anything that is not exactly
manualis auto, deliberately: an order thatreaches the kitchen and should not have is a conversation; one that silently
waits for a screen nobody knows to open is a customer wondering where dinner is.
The alarm gives up after five minutes. An alarm that never stops is one
somebody mutes at the speaker, and then it is gone for every future order too.
The queue on the screen is the part that must never be silenceable.
New surfaces
sales/pendingOnlineOrdersandPOST sales/:id/approval, with a card-basedqueue screen (
#/onlineorders) that only appears for a shop holding orders.sales/commissionReport, as a Commission tab on the Money report: one row perhotel and per aggregator, with what they brought in, what is owed and what the
shop keeps.
Verified
tests/menu-render.test.jsbuilds the menu page in jsdom and reads it back:a hotel URL asks for the right branch, the room is told whose prices these
are, and the marked-up price is what appears on the card.
tests/channel-settings-roundtrip.test.jsrenders the venue and fee rows andreads them back through the save path, because that contract is two functions
200 lines apart and a renamed class would silently store every hotel at zero.
tests/order-service-point.test.jscovers the URL rules, including the onethat costs money: arriving at a plain shop address clears a room from an
earlier scan.
1,480 root tests and 10,067 API tests green.
Three things this found
service-point.jsresolved the URL on every read, so the second read on anarrival URL threw away the room the customer had just corrected. Found by a
test, not by a person.
tests/tools/i18n-load-time.jsthrewCannot access 'list' before initializationthe moment it actually found something, which is exactly thecase it was written for. It had never fired before.
src/main.jsfor a 25-linechange (2,254 insertions, 1,505 deletions).
src/and the two frontend buildmaps join
.prettierignorefor the same reasonfrontend/is already there.