Grand Bazaar
A Turkish and Mediterranean food importer in Henderson, Auckland, supplying dairies, restaurants, cafés and grocers. Salix built the public catalogue, the account-gated ordering portal, the staff console behind it and the accounting integration underneath all three. Live since September 2026.
Trade only: no retail sales and no card payments anywhere in the system. Customers order on approved credit accounts and are invoiced on the 20th of the month following.
- Products in the catalogue
- 979
- Category pages
- 22
- Old URLs still resolving
- 618
- Price data on public pages
- None
Counted on the live site on 15 September 2026, from the catalogue itself and the published sitemap rather than from anybody’s recollection.
What is on the site,
and who decides it.
The catalogue started as a migration and became something the accounting system drives, which is a bigger change than it sounds.
It began as a migration, and stopped being one
The first pass lifted 559 products, their categories and 306 distinct images off the storefront the client had been running, re-encoding 107.9 MB of source to 18.2 MB of WebP and mapping 618 old addresses so that nothing a customer had bookmarked or a search engine had indexed went dark. Those 618 are still asserted on every release, against the live site, because a redirect map that silently rots is worth nothing.
Then the accounting system turned out to hold the real catalogue
Counting what the accounting system actually held, rather than assuming it matched the website, found 1,024 items that were sold, priced and not retired, against 559 on the site. The site was showing a little over half the business, and a buyer searching for something the warehouse stocks found nothing. The sync now creates a product for an item the catalogue has never had: 419 arrived on the first pass, and the catalogue stands at 979.
Two authorities decide what is orderable, and both must agree
Xero decides what is sellable: it must hold a live item for that stock code, sold, not retired, priced above zero. Staff decide what is visible, and a nightly job must never overrule them, or a product somebody deliberately hid is republished at 3am. Two decisions, two columns, and a buy button that appears only when both say yes. The Xero half is stored as a yes or no, never as a number, so the public database still holds no price anywhere.
A product that stops being sellable keeps its page
It holds its address and says it is no longer available, rather than becoming a dead link. Fifty products were delisted on the first pass. Deleting them would have thrown away the inbound links and the search ranking that the whole migration existed to preserve, in exchange for a tidier grid. It is also the reversible choice: failing the rule hides a buy button, it does not destroy a page.
An item with no name is skipped, on purpose
Ninety-seven items in the ledger carry a stock code and a price and no name. A page headed with a bare code has nothing to read, nothing to search and nothing to build a readable address from, so those wait until somebody names them. Addresses are built from the product name rather than the code, because the name is what a person types.
Not hidden.
Absent.
A price hidden by the presentation layer is not hidden. Four independent layers stand between a competitor and this wholesale list, and three of them are in the database.
The public product table has no price column
Not hidden in the page: absent from the schema. Asking the public catalogue for a price is a build error rather than an incident, and there is no runtime state in which it answers the question.
The schema that does hold prices is not published to the API
A request for the price list returns 404, not an empty list. That distinction is the whole point: an empty list would mean a single policy was the only thing standing between a competitor and the wholesale list, and policies are one careless migration from being loosened.
The grants are revoked, not merely filtered
Including the default privileges, so a table added by a future migration inherits nothing. A careless later change lands closed instead of open, which is the only safe direction for a mistake nobody has made yet.
Public pages ship no database key to the browser at all
The data layer is fenced to the server and the environment names are not the browser-visible ones, so a component that would run in the browser and imports it fails the build. No key in the page means no key to point at anything.
The priced catalogue lives behind sign-in rather than on the public product pages. Putting prices on those pages would have turned 979 structurally price-free addresses into 979 addresses that were price-free only while a session check kept working. An approved buyer pays one click for that, and it is the right trade.
Nine checks, on every release and again each night
They run against the deployed site as well as in the pipeline, and a red result stops the release. Most of them prove the gate shuts. One proves it opens, and that one is the control. One more tests the path that none of the four layers can reach, which is what leaves by email.
- 00The price schema answers 404 to an anonymous request, and a 200 with an empty body is recorded as a failure rather than a pass.
- 01The catalogue tables do return rows, so a green run against an empty or wrong database cannot masquerade as a clean result.
- 02A signed-in account still under review is refused prices, and cannot promote itself to an administrator.
- 03An approved account does get its prices. This is the control: a gate tight enough to refuse everybody is not security, it is a broken product.
- 04Every address in the sitemap is crawled with no cookies and the returned HTML searched for anything money-shaped.
- 05The built JavaScript is searched for price identifiers and secrets, where no server-side check could help.
- 06No public table accepts an anonymous write.
- 07Every email template is rendered and checked, because a price leaving by email is gated by none of the four layers above.
- 08No money-shaped class names in the source, which had three times written a number into an attribute that the crawl then read as a price.
A status,
never a number.
A wrong number is worse than no number, and a count on a website is a promise about a warehouse that the website cannot keep.
A status, never a number
Nothing shows a quantity on hand to anybody, buyer or staff. A count in a portal is a promise about a warehouse, and the website is not the warehouse. A number on a staff screen also gets read down the phone to a buyer, at which point internal-only has stopped being a property of it.
Four states, and the common one is silent
Low stock, out of stock and call us each show a chip. In stock shows nothing. Badging all 979 products as in stock is 979 pieces of noise that train a buyer to stop reading badges, and then the one chip that matters lands in a field nobody looks at. Silence is the default, so a chip means something is different.
Staff mark the exceptions, not the rule
A product with no stock row is in stock. The alternatives were worse: unknown on everything is a portal that tells a buyer nothing, and out of stock on everything is a shop that refuses every order until somebody has done a day of data entry. The staff editor still distinguishes nobody has looked from somebody decided this is in stock, and the audit log keeps the two apart.
The refusal is in the database, not on the button
Adding an out-of-stock product is refused by the database function, with the product named. The call-to-order message on the page is a courtesy; a disabled button is a suggestion. Verified by posting straight at the function as an approved buyer with the page bypassed entirely. Removing a line is always allowed, because a gate that traps a line in a basket is a gate that generates a phone call.
Submitting has no stock check, deliberately
A line already in a basket when the product goes out still submits. Refusing a whole order at 6am because one line of thirty went out overnight is a door slammed on a dairy at dawn. Short supply is a decision a staff member makes on a real order, with the buyer told what changed.
Stock comes from the ledger, and only where it means something
Only items the accounting system tracks as inventory carry a stock status. An untracked item has no meaningful quantity on hand, and writing out of stock from a field that was never a stock level is how a site ends up telling its customers that half the catalogue is unavailable.
An order book,
not an enquiry form.
A buyer at 6am is placing an order, not starting a conversation. Everything below exists because of a way orders actually go wrong.
The basket is the order, in draft
There is no cart table. A cart that is not an order is a second place for quantities to live, and the two disagree the first time somebody adds a line in one tab and submits in another. One row, one truth.
One draft per account, not per person
A dairy with an owner and a manager is one customer placing one order. Two private baskets produce two deliveries and a phone call. History works the same way: two people at one business see one history, because it is the business that buys.
Approval is read on every query, never stamped into a token
Whether an account may see prices is decided by the database each time it is asked. Suspend a customer and the prices stop on their next request, not in an hour when a login token happens to expire.
Sign-in is a link to an address you already hold
No passwords anywhere in the system. The form answers identically whether or not the address exists, so nobody can use the sign-in page to find out who buys from you.
Minimums are applied on the server, which says what it stored
The function returns the quantity it actually wrote rather than trusting the number it was sent. A minimum order quantity a browser can post around is not a minimum.
Quantity changes are ordinary form posts
The basket works with JavaScript switched off. That is not a purity exercise: the locked-down counter PC in the back of a shop is the machine this system is actually for, and a basket that needs a modern browser loses that order.
Prices move until submit, then they freeze
Nothing has been agreed while an order is a draft, so a price change reaches an unsent basket. At submit every line is re-priced and the result is snapshotted, because that is the moment the price becomes a promise. What a buyer was charged in March does not move because the list changed in April.
GST is computed once, on the total
Not per line and summed. Rounding every line and adding them up produces a number that does not match the invoice, and explaining why the website said $412.47 is not a conversation anybody wants to have with a customer.
Submit is one transaction, and it does the arguing
It locks the order row so two tabs pressing send serialise, re-prices every line, refuses unpriced or inactive lines and says how many, refuses an empty order, allocates the order number and writes the audit row. A resubmitted order is refused by the database rather than by a disabled button, and the confirmation page cannot be re-posted by pressing refresh.
Over the credit limit flags the order, it does not refuse it
The order is accepted and marked for someone to look at. A distributor does not want its website turning away a dairy at 6am over a limit the office would have waved through at nine, and an order you never received is worse than one you have to make a decision about. The buyer is never told they tripped it; the staff email says so in the subject line.
The delivery address is not a free-text box
It is taken from the account at submit. Letting a buyer retype an address into an open field is how a delivery goes to a building the driver has never seen.
What the staff
can and cannot do.
The office screen is where an order is worked, and most of its design is about the moves it refuses to offer.
The status machine is a table, not a chain of conditions
Submitted or under review, then confirmed, picking, dispatched, with cancel available at any point before dispatch. The legal moves live in one function as data. Written as scattered conditions they drift, and the failure mode is an order marked dispatched that was never picked. The buttons offer only the moves the database will accept, so a staff member is never shown one that always errors.
Short supply may only reduce, and the reason is sent word for word
A staff member raising a quantity is selling something the buyer did not order, so the form cannot do it. A reason is required and the buyer is told exactly what was typed, because paraphrasing it in code puts words in a staff member’s mouth. The agreed price is kept: re-pricing a line while short-supplying it would change what was agreed at submit, which is the one thing the snapshot exists to prevent.
The before and after come from the row, not the screen
The database reads the quantity it is about to change while holding the row, and that is what the email quotes. Passing the numbers down from the page would be faster and occasionally wrong: two staff on the queue, or one tab left open five minutes, and the customer is told 12 reduced to 6 when it was 8 reduced to 6.
Dispatch is stamped once, and lines freeze before it
Lines cannot be edited once an order reaches picking. The dispatch time is the operational record of when the goods left and is never re-stamped. Every move, by whom and from what to what, is written to an audit log.
Who gets told, and when
- Order received
- Buyer and staff, saying different things.
- Over the credit limit
- Staff only, flagged in the subject. The buyer is never told.
- A line reduced or removed
- Buyer, with the reason a staff member typed, word for word.
- Confirmed, picking, dispatched
- Nobody. Four emails about one delivery is how a customer starts filtering you.
Xero owns the money.
The site owns the order.
The accounting integration is the part that goes wrong quietly, so the rules it holds are worth stating one at a time.
The shape of it, in one pass
- 01A buyer submits an order on the portal. It is confirmed, emailed and in the staff queue immediately, with no accounting system involved.
- 02Within five minutes a scheduled pass raises a draft invoice in Xero, against the customer’s existing contact record.
- 03A person at Grand Bazaar reviews that draft, approves it and sends it, in Xero.
- 04The hourly read-back sees the draft is no longer a draft and marks the order invoiced. That is the only way an order can reach that state.
This system never invoices anybody. It hands Xero a draft and keeps it in step until a person takes it over.
One direction per fact
Prices and stock come in from Xero. Customer records and draft invoices go out to it. Nothing is authoritative in two places, because two places to edit a price is two prices. The price list is therefore read-only in the admin console, and that is a design decision rather than a missing feature.
Prices match on the stock code, and every change is recorded
Matched on the product code, which is unique across the catalogue, so the match is exact rather than approximate. A price change writes an audit entry, because when did this go up is a question somebody will ask and the price list itself keeps no history. That audit is how two real prices were recovered after a test overwrote them.
Every customer pays the same price, and the integration rests on it
Confirmed with the client before the sync was written. A Xero item holds exactly one sales price, so prices can flow inward. If per-customer tiers are ever introduced, this direction is wrong and no amount of patching fixes it, which is why it is written down rather than assumed.
A customer is looked up before one is ever created
The client has invoiced these businesses for years, and 447 of them were imported from the existing ledger. Creating a second contact for a customer who already has one splits their statement in two, so the lookup comes first, always.
A submitted order raises a draft, and the system never sends it
The invoice appears in Xero as a draft, built from the prices snapshotted at submit rather than from the current list, with the total Xero returns checked against the order total to the cent. A person reviews it, approves it and sends it, in Xero.
No staff member can mark an order invoiced
There is deliberately no transition into that state. The only thing that may set it is the read-back, once Xero reports the draft is no longer a draft. Add the missing transition and the word stops meaning Xero has issued this and starts meaning somebody clicked a button.
It cannot invoice twice
The claim on an order is recorded before the call is made, so a second pass finds it taken. A connection that dies after Xero has already committed is reconciled by looking the invoice up rather than by retrying and hoping. A duplicate invoice number is reconciled rather than treated as a failure.
Xero is never on the critical path of an order
The draft is raised by a scheduled pass, not inside the submit. A synchronous call to a third party during the most important action a buyer takes is a third party that can take your website down. With the accounting connection unreachable an order still places, still confirms, still emails and still reaches the staff queue. The only thing that does not happen is a draft appearing, and it appears when the connection returns.
The two schedules, and why the slower one is slower
Drafts go out every five minutes, which costs one database round trip and no accounting call at all when nothing has been ordered. The read-back runs hourly, and that interval is a rate limit rather than a preference: a draft nobody has authorised is re-checked until it resolves, and at five-minute intervals a backlog would run through the daily call allowance several times over.
The credential can reach three things and not a fourth
A connection scoped to this one organisation, carrying contacts, invoices and settings. Payroll is unreachable by construction rather than by configuration, and a preflight check reads the token itself and fails on anything beyond the permitted set. An earlier connection that had been granted 41 permissions, payroll among them, was deleted rather than narrowed, because permissions are fixed when a connection is created.
Staff can see whether it is working
A console page shows the state of the sync: anything unsynced for more than 24 hours is red, and a claim ninety seconds old is the system working normally. A draft a person has taken over and edited is shown as information rather than as an error, so nobody tries to fix something that is not broken.
The part most
case studies leave out.
No outcome figures, because there are none worth printing
One portal is not a sample, nothing here has been measured against a before, and any number we printed about orders won or errors avoided would be invented. Everything above is what was built and what was checked.
The accounting path is proven against a stand-in, and that has limits
Every branch is driven end to end against a substitute Xero, including the hard ones: a person editing the draft, the connection dying after Xero has already committed, a rate limit, a duplicate invoice number. What a stand-in cannot prove is that it resembles the real thing, so the first live drafts are read by the person who used to key those invoices in by hand, before the path is left to run on its own. That is a smaller check than it would have to be if this system sent invoices, and it is smaller because it does not.
Photographs are the honest gap
Most of the products that arrived from the ledger came bare: no photograph, no category, no brand. They are findable by search and by stock code, which is how a trade buyer with a product code actually navigates, and they are promoted into the category grids as the pictures arrive. The count was put in front of the client before anything was designed around it, rather than discovered afterwards.