Skip to content

Online ordering and a digital menu, served from the shop's own origin - #497

Merged
sridharkalaibala merged 22 commits into
developfrom
feat/qr-shop-origin-menu-mode
Sep 11, 2026
Merged

sridharkalaibala merged 22 commits into
developfrom
feat/qr-shop-origin-menu-mode

Conversation

@sridharkalaibala

@sridharkalaibala sridharkalaibala commented Sep 10, 2026

Copy link
Copy Markdown
Contributor

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

qrOrderModel guarded with !branchDoc.kiosk || !branchDoc.kiosk.store_id, but branch.kiosk is an array in every write path. .store_id on an array is undefined, 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

  • One resource. /items/accessQr, /items/accesskiosk and /sales/qrOrder become GET /online-ordering/:storeId, GET /online-ordering/:storeId/menu and POST /online-ordering/:storeId/orders. The storefront is a GET, so a menu is a URL.
  • Served from the shop's origin. azure.posnic.io/order/AZ100, /menu/AZ100, or just /order and /menu using the shop's default branch. Same origin means no CORS list and no shared browser storage between shops.
  • Availability as four layers, not one switch: the module, the mode (order or menu), opening hours, and a pause that stores a resume time rather than a boolean. Computed in one place that both the page and the order endpoint call.
  • branch.kioskbranch.online_ordering, an object rather than an array of one.
  • Channels on every sale: channel, channel_partner, fulfilment, channel_commission. Reports read through channelOf/channelFilter, which also see the years of sales that carry only sale_method.
  • A real digital menu at /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_menu and items.diet. Being listed and being orderable are different questions, and no Indian menu ships without the veg dot.
  • items.sales_channel deleted - 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_menu excluding 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

  • The self-service machines keep /items/accesskiosk and /sales/kioskOrder unchanged: they are deployed and cannot be updated from here. I removed that route mid-way and had to put it back.
  • .prettierignore is new. The pre-commit hook was rewriting whole files it had never formatted - one attempt reformatted 11,516 of the 11,920 lines of settings_write.html.
  • The i18n baseline moves 100% → 98% on all seventeen packs. No translation was lost (2920 keys before and after); the denominator grew.

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.

/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. 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 manual is auto, deliberately: an order that
reaches 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/pendingOnlineOrders and POST sales/:id/approval, with a card-based
    queue screen (#/onlineorders) that only appears for a shop holding orders.
  • sales/commissionReport, as a Commission tab on the Money report: one row per
    hotel and per aggregator, with what they brought in, what is owed and what the
    shop keeps.
  • Channels settings gains venues, per-fulfilment charges and the approval mode.

Verified

  • tests/menu-render.test.js builds 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.js renders the venue and fee rows and
    reads 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.js covers the URL rules, including the one
    that 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.js resolved the URL on every read, so the second read on an
    arrival URL threw away the room the customer had just corrected. Found by a
    test, not by a person.
  • tests/tools/i18n-load-time.js threw Cannot access 'list' before initialization the moment it actually found something, which is exactly 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, 1,505 deletions). src/ and the two frontend build
    maps join .prettierignore for the same reason frontend/ is already there.

…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.
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.
@sridharkalaibala
sridharkalaibala merged commit ccd76d2 into develop Sep 11, 2026
9 checks passed
@github-actions github-actions Bot added the ready for QA Merged to develop and live on develop.posnic.io - anyone can test it label Sep 11, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Merged to develop. Anyone can test this - you do not need write access.

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:3000

When you have tested it, say what you did and what happened, and set
QA passed or QA failed. If you cannot set labels, just comment -
a maintainer will.

Reporting that something is broken is as useful as fixing it. It is
better found here than by a shopkeeper.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

ready for QA Merged to develop and live on develop.posnic.io - anyone can test it

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant