← all work

HVAR

An order for a water pump passes through four systems before it reaches a door in Egypt: the store, the ERP, a call center and a warehouse. Each one holds a different truth about it. HVAR is the organism that keeps those truths from contradicting each other, with the customer's phone number as the one key they share.

719 ordersfrom 777 customers, live

Role
Built and run the platform — commerce, ops, and AI infrastructure
Span
Jun ’25 — now
Kind
Technical owner · platform
Status
live
Stack
Bun · Hono · Astro · React · TypeScript · MySQL · Flask · Python · Laravel · PHP · MCP · Bosta API

Where is this order, according to whom?

For most tracked things, where is it now? has a place for an answer. HVAR sells home appliances in Egypt, and for its orders the honest answer is a second question: according to whom?

The store knows what the customer bought. The ERP knows what was invoiced and what’s on the shelf. The call center knows what the customer said on the phone. The warehouse knows which parcel physically left, and the courier knows where it is now. Each of these is true, and each can be out of date. The Hub’s own README puts it in one line: “Not a CRM. A reconciliation layer between three realities.”

Three truths about one order. The accented step is the only door from the order world into the ticket world.

Behind the diagram are four repositories: a Bun and Astro storefront (200 commits, 2026-06 to 2026-09), a Laravel ERP (181), a Flask and React ops hub (150), and an MCP server (10). Live, the store holds 719 orders from 777 customers.

Everything below follows one order through them, using screens from demo instances on scratch databases. The names and numbers are made up; the behaviour is the real code.

The customer is a phone number

In Egypt, a phone number is how people sign up for everything. HVAR’s store uses nothing else: a known number logs in and a new number creates an account. There’s no password and no code sent by SMS.

The store's login page on a phone: the heading «أهلاً بيك في هفار», welcome to HVAR, the line «ادخل برقم موبايلك», sign in with your mobile number, one phone field and a red «متابعة» continue button. There is no password field and no code step.
the store · sign in
The checkout's payment step on a phone: three options, cash on delivery selected, card, and installments through Valu, Souhoola and Aman, then a red button to review the order.
checkout · paying
Left: one field, the phone number, and nothing after it. Right: cash on delivery is the first option, because that's how most Egyptians pay for an appliance they haven't seen.

A login with no secret is a trade, and the code says so. The endpoint allows 12 attempts a minute per IP (auth.ts:492), so a stranger can’t walk a range of numbers to find out who shops here.

The order waits for a person before the ERP sees it

The obvious design sends every order to the ERP at checkout. HVAR used to, and stopped. The comment where that call used to be explains why:

the checkout route, where the ERP call used to be
// ERP draft creation is no longer auto-fired at checkout. Orders land in
// the admin queue with erp_status='unsent'; an admin reviews and clicks
// "Send to ERP" (single, bulk, or select-all) on /admin/orders. This
// avoids draft drafts for orders that get cancelled before payment
// clears, and lets ops scrub addresses before the ERP draft is created.

A cancelled order that already reached the ERP is a draft someone has to find and delete by hand. So a person looks first.

When the admin sends it, the store checks the ERP’s stock again, because the shelf may have changed since checkout. A short item stops the send and names the product. Then the order goes into an outbox table in the same database transaction, and a worker delivers it, with up to 8 attempts.

The hard case is a customer who cancels while the order is still in the outbox. A delete could arrive before the create it’s meant to cancel, and the ERP would then create a draft nobody ever cancels. So the store never sends that delete. If the create hasn’t been delivered yet, it marks it dead in place, “superseded by cancellation”, and the ERP never hears about the order at all (webhook-outbox.ts:63-77).

The store's admin outbox console, «سجل الإرسال (Webhooks)», with tabs for pending, delivered and dead events. One row: a cancel event, marked «تم الإرسال», delivered, with its attempt count.
the store's outbox console
The outbox isn't hidden in the database. Pending, delivered and dead events each have a tab. On the live store, 519 events have gone through it, and none are dead.

One trap is written down where the payload is built: the ERP matches products by website_product_id, not by its own id. Send the wrong one and the ERP answers 200 OK and creates nothing (erp-payload.ts:12). A silent success like that is why the send goes through a queue that records every event.

The call center hears the customer’s version

The ERP now holds a draft. The Hub pulls those drafts every 20 minutes. Before it starts, it takes a named MySQL lock with a zero timeout, so if two servers wake up together, one syncs and the other skips (erp_sync_worker.py:322).

Every pulled order lands in a queue, and an agent calls the customer:

The call center queue: a table of orders, each with the customer's name and phone, «بيع - يحتاج تأكيد», sale, needs confirmation, the amount and governorate. The call button on some rows carries a small badge with 1, 2 or 3, and those rows are faded.
the call center queue
The small number on the call button is how many times nobody answered. Those rows fade, but they don't leave the queue.

A call that isn’t answered doesn’t change the order. It logs an attempt and pushes the next try 4 hours later, and the status stays where it was (call_center_api.py:1277-1289). Three missed calls don’t make an order disappear; an agent still has to decide.

The attempt history for one order, «سجل المحاولات»: three entries, each «لم يرد», no answer, with a time and the agent's name, while the order behind them is still marked «جديد», new.
one order · three no-answers
Three «لم يرد», no answer, each with a time and an agent. The order is still «جديد», new.

If the customer asks to be called back, the agent schedules it, and the order carries the appointment in the queue:

The scheduled tab of the call center: one order, its status «مجدولة», scheduled, with a date and time of 6:00 PM and the label «موعد مجدول», scheduled appointment.
a callback the customer asked for
«موعد مجدول», scheduled appointment, 6:00 PM. The customer set the time, not the queue.

One door from an order to a ticket

A customer saying yes on the phone doesn’t create any work yet. The agent’s confirmation saves what was agreed, the items, the amount and the address, as a snapshot on the order, and the order waits for a team leader:

The call center's confirmed tab: three orders waiting, each with its customer, the item count, the amount and a view button.
confirmed · waiting for a leader
Three orders the customers confirmed. None of them is a ticket yet.

The leader opens the snapshot, can correct it, and approves. The dialog says what the button does before anyone presses it:

The leader's approval dialog, «تأكيد الموافقة على الطلب», confirm approval: the customer Sherif, 2 items, collect 2,900 EGP, the call notes listing a water pump and a 5-stage filter with their SKUs, and the line «سيتم إنشاء تذكرة خدمة جديدة بناءً على بيانات هذا الطلب. هذه العملية لا يمكن التراجع عنها.», a new service ticket will be created from this order's data; this can't be undone. The button reads «موافقة وإنشاء تذكرة», approve and create ticket.
the leader approves
«هذه العملية لا يمكن التراجع عنها», this can't be undone. The amount, 2,900, and the two items are the ones the customer agreed to on the call.

This is the only way an order becomes a ticket, and it works once. Every path that would convert an order checks first whether it already has a ticket, and refuses with ORDER_ALREADY_CONVERTED if it does. A double click or two leaders on the same order can’t send a customer two pumps.

The warehouse gets exactly what was agreed

The approved order shows up in the warehouse as a new ticket: same customer, same amount, same two items. This is where one team hands the order to the next:

The warehouse screen, «مخزن هفار»: the new-sales column holds one ticket, HVS260926006, for Sherif, marked confirmed and from the call center, cost +2,900 EGP, 2 products, with a start-preparation button. The scan panel is beside it.
warehouse · the new ticket
The start-preparation dialog, «بدء التحضير», for the same ticket: the customer, the ticket number, and the two items to send, a water pump and a filter, each with its SKU and quantity 1.
starting preparation
Left: the ticket arrives with the call's numbers, +2,900 and 2 products. Right: preparation lists the exact two items, so the picker packs what the customer agreed to and not what the store page said.

Stock follows a rule that’s easy to misread: a confirmed sale reserves parts only. Finished products are skipped and are allowed to go negative on purpose, as a backorder (stock_manager.py:11). So Sherif’s pump and filter reserved nothing. HVAR would rather take the order and restock than refuse a customer who already said yes on the phone.

The scanner typed a tab

A parcel leaves when it’s scanned. Once packed, tickets wait in a column beside the scan panel:

The warehouse's sales column filtered to «جاهز للإرسال», ready for dispatch: four ticket cards, each with a customer, a tracking number, a cost and a cancel button, next to the scan panel.
ready for dispatch
Four tickets ready to leave. The scan panel next to them is how each one leaves.

In May 2026, 36 replacement tickets stopped leaving. The barcode scanners were typing a tab before every number, and lookups compared tracking numbers exactly, so no ticket ever matched. The fix went in at three layers: every entry point strips whitespace, the lookup trims again in SQL, and a one-time cleanup fixed 1,341 records that already held a leading space or tab.

The warehouse after a scan: the search field was sent a tracking number with a tab in front of it, and the result is maintenance ticket HVM260926001, found, with its customer, its tracking numbers and a confirm-delivery button.
a tab-prefixed scan, found
The return inspection dialog, «تصنيف العناصر المرتجعة», sort the returned items: one returned filter, with a choice between «سليم», intact, and «تالف», damaged, and a confirm button.
a return, sorted by hand
Left: a tracking number sent with a tab in front, the way the scanners type it, and the ticket is found. Right: when a parcel comes back, each item is sorted «سليم» or «تالف», intact or damaged, before anything goes back into stock.

Four kinds of ticket, one table of rules

A sale isn’t the only thing that goes out. A replacement sends a new unit and collects the old one. A repair collects a unit and sends it back fixed. A return only collects. HVAR runs all four through one function, _validate_state_transition(), and one table of allowed moves:

the transition table, service_manager.py
'return': {
  'PENDING': ['CONFIRMED', 'CANCELLED'],
  'CONFIRMED': ['IN_PROCESS', 'CANCELLED'],
  'IN_PROCESS': ['COMPLETED', 'CANCELLED'],
  'COMPLETED': [],
  'CANCELLED': []
},

A return has no dispatch step, because nothing is sent out. Replacement, repair and sale are three more blocks like this one in the same table. A move that isn’t listed raises an error that names both states.

The ticket list: rows typed sale, replacement, maintenance and return, each with a ticket number whose prefix shows its type, HVS, HVR, HVM or HVT, the customer, the status and the tracking number.
every ticket, one list
The prefix on each ticket number says which workflow it's in: HVS sale, HVR replacement, HVM maintenance, HVT return. One list and one set of rules for all four.

An assistant that can read but not write

The last system is for people who’d rather ask than click. hvar-mcp exposes the Hub and Bosta, the courier, as 101 tools for AI assistants, so a question like “what happened today?” becomes a few calls instead of an afternoon in dashboards.

32 of those tools change something: they approve an order, cancel a ticket, adjust stock. Those tools aren’t hidden behind a permission check. Unless a flag is set, they’re never registered, so an assistant can’t call a tool it can’t see:

tool registration, packages/shared/src/mcp/serve.ts
for (const tool of tools) {
  if (tool.mutation && !allowWrites) {
      skipped++;
      continue;
  }

allowWrites comes from the environment and defaults to off. The comment above it: “Default is locked — safe for public deployments.”

What is not done

  • The ERP’s order webhook is unauthenticated. An empty POST gets a 200. It’s filed as an issue on the ERP’s repository and isn’t fixed yet.
  • The Hub reads ERP drafts from a screen’s data endpoint (/sells/draft-dt), not from an API built for it. It works, but only as long as that screen doesn’t change.
  • Courier tracking in the Hub is a pull, best-effort. The ERP is the side that pushes orders to Bosta and receives its delivery webhooks.
  • Tests are thin. The storefront, the MCP server and the Hub’s Python side have none; the Hub’s front end has four test files. The MCP server’s open weaknesses are filed as an issue on its repository.
  • A fresh install doesn’t run. Standing these demos up exposed columns that the code uses but no migration creates, and ERP build assets that aren’t in the repository. Both are written up for the team.
  • The ERP is a fork, and it isn’t on this page. It’s a customized UltimatePOS, so it appears in the diagram and not in the screenshots.
  • All four repositories are private.