Restaurant
In the restaurant industry, business models are typically categorized by their service style, price point, and operational flow. Based on the architecture we are building—which includes real-time WebSockets, physical table management, kitchen routing, and third-party delivery integration—we can support almost every major category.
Document version: 1.2 · Last updated: 2026-03-24 · See Related artifacts for migration files and specs. Normative MVP = Phases 1–3; see Quick start scope.
Quick start (MVP)
For Phase 1–3 implementation (Foundation, Bills & splits, Kitchen & print):
- Read Schema status and migration requirements
- Add migrations per Reference schema
- See MVP scope for in-scope vs optional
- Follow Implementation phases table
Key tables: order, order_item, dining_area, dining_table, order_bill, order_bill_mapping, order_bill_payment, kitchen_station, product_station_assignment, print_job. Add sale.session_id if missing.
Scope of this document:
- Normative MVP (Phases 1–3): Schema status and migration requirements, Reference schema, MVP scope, Part B: Core flows, Implementation phases table, API and WebSocket overview, Related artifacts. These sections are authoritative for implementation.
- Future / exploratory (Phases 4–6): Bridge app, Kiosks, People & aliases, optional structures (e.g.
order_guest,order_party,product_recipe), and strategy/industry sections in Part C and Part D. Implement only when explicitly planned.
Part A: Design decisions
Canonical terminology
- Bill vs check: In this document we use bill for the sub-invoice (what the guest pays). Tables:
order_bill,order_bill_mapping. Industry terms "split check" and "check" refer to the same concept. - Merged tables: We recommend Option B —
order.parent_order_id— for combined tables (one parent order; merged orders reference it). Option A (order_tablesmany-to-many) is possible if you need multiple tables per order without a single "primary" order.
Shared catalogs vs restaurant-only tables
Design principle: Keep table structures for the restaurant module separate from the retail document flow, but share common catalogs. No duplicate product or inventory; one source of truth for master data.
| Role | Tables | Notes |
|---|---|---|
| Shared (catalogs & drawer) | business, location, user, product, category, inventory, inventory_detail, inventory_ledger, currency, tax_definition, unit_of_measure, supplier, brand, customer, booking_platform, document_counter, parameter_catalog, entity_parameter, payment_method, production_run, production_run_input, production_run_output, register, cash_register_session, safe, cash_movement | Catalogs: used by both retail and restaurant (e.g. order_item → product). Drawer/session: same register, session, safe and cash_movement are used for both retail sales and restaurant bill payments — one drawer, one shift; closing and Z reports include both. |
| Retail-only (documents) | sale | Retail sales document (sale_detail, payment_detail, session_id — add via migration if not present). Restaurant does not create sales for orders; it uses order_bill + order_bill_payment linked to the same session. |
| Restaurant-only (documents / flow) | dining_area, dining_table, order, order_item, order_bill, order_bill_mapping, order_bill_payment, kitchen_station, product_station_assignment, print_job, and optionally product_modifier_group, product_modifier, product_recipe | Restaurant-specific structures. Bill payments use order_bill_payment (Option A). They reference shared catalogs and use the same register/session/safe. |
- order_item stores
product_id(FK to sharedproduct); modifiers/notes are on the order item. Recipe and inventory deduction use sharedproductandinventory(and optionalproduct_recipe/production_run). - Pricing: Restaurant can use
product.price(and optional futureproduct_location_price) from the shared catalog; no separate “restaurant product” table. - Reporting: Shared catalogs allow unified reporting (e.g. sales by product across retail sales and restaurant orders) while keeping document models (sale vs order/bill) separate.
Table of contents
Part A: Canonical terminology, Shared catalogs, Schema status, MVP scope, Reference schema
Part B: FlowPOS structures, How restaurant integrates, Processes
Part C: Restaurant types · Core structures · Split checks · Offline/print · Bridge app
Part D: Multi-branch · People and variations · Supported vs gaps · Kiosks Phase 6 · Implementation phases · API/WebSocket · Related artifacts
Schema status and migration requirements
This section records schema decisions and pending migrations so the document and codebase stay in sync.
Tables to add via migrations
| Migration scope | Tables / changes | Notes |
|---|---|---|
| Restaurant core | order, order_item, dining_area, dining_table, order_bill, order_bill_mapping, order_bill_payment, kitchen_station, product_station_assignment, print_job | Restaurant tables are not in the onboarding migration; add them in a new migration file. |
Existing sale table | Add session_id (FK to cash_register_session) | Links retail sales to the active cash register session for reconciliation. Add via ALTER TABLE sale ADD COLUMN session_id uuid REFERENCES cash_register_session(id). |
product_recipe | New table | Links product_id to ingredient_ids (products with inventory_type = 'RawMaterial'). Requires its own migration and must be consistent with production_run, inventory_ledger, and production_run_input / production_run_output. |
Bill payment integration (chosen: Option A)
Option A is the chosen approach for restaurant bill payments:
- Add
order_bill_payment(or equivalent) with:order_bill_id,amount,payment_method_id(FK topayment_method),cash_register_session_id, optionalsafe_id. - Session totals and closing logic include these payments directly; no
saledocument is created for bill payments. - Shift closing and Z reports must include retail sales (
sale) plus restaurant bill payments (order_bill_payment) for the session.
Enum naming convention
Migrations must follow the existing FlowPOS enum naming pattern (e.g. sale_status, inventory_types). Use plural/suffixed names:
| Concept | Enum name in migrations |
|---|---|
| Order status | order_statuses |
| Order item status | order_item_statuses |
| Order type | order_types |
| Dining table status | dining_table_statuses |
| Bill status | order_bill_statuses |
| Print job status | print_job_statuses |
Define TypeScript enums that map to these DB enums and use them consistently in the application layer.
MVP scope
| Scope | Phase | In scope | Out of scope (optional) |
|---|---|---|---|
| Phase 1–3 | Foundation, Bills, Kitchen | dining_area, dining_table, order, order_item, order_bill, order_bill_mapping, order_bill_payment, kitchen_station, product_station_assignment, print_job, sale.session_id | order_guest, order_party, product_recipe, modifiers |
| Phase 4 | Bridge app | KDS + printer profiles | — |
| Phase 5 | People & aliases | Optional: order_guest, order_party, order_item.seat_no, dining_table.alias, order.order_alias | — |
| Phase 6 | Kiosk | Optional: device, kiosk_config, kiosk_session, fulfillment tables | — |
Reference schema
Canonical SQL for core restaurant tables (use in migrations):
-- Enums (use order_statuses, order_item_statuses, order_types, dining_table_statuses, order_bill_statuses, print_job_statuses)
CREATE TYPE order_statuses AS ENUM ('draft','sent_to_kitchen','partially_served','served','paid','cancelled');
CREATE TYPE order_item_statuses AS ENUM ('pending','preparing','ready','served','cancelled');
CREATE TYPE order_types AS ENUM ('dine_in','take_out','delivery_internal','delivery_third_party');
CREATE TYPE dining_table_statuses AS ENUM ('available','occupied','reserved','dirty');
CREATE TYPE order_bill_statuses AS ENUM ('pending','paid','cancelled');
CREATE TYPE print_job_statuses AS ENUM ('pending','sent','printed','failed');
-- dining_area (groups tables per location)
CREATE TABLE dining_area (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
location_id uuid NOT NULL REFERENCES location(id) ON DELETE CASCADE,
name varchar NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL,
updated_at timestamptz,
updated_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- dining_table
CREATE TABLE dining_table (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
area_id uuid NOT NULL REFERENCES dining_area(id) ON DELETE CASCADE,
table_number varchar NOT NULL,
capacity int NOT NULL DEFAULT 4,
status dining_table_statuses NOT NULL DEFAULT 'available',
metadata jsonb DEFAULT '{}',
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL,
updated_at timestamptz,
updated_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- order
CREATE TABLE "order" (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
location_id uuid NOT NULL REFERENCES location(id) ON DELETE CASCADE,
status order_statuses NOT NULL DEFAULT 'draft',
order_type order_types NOT NULL DEFAULT 'dine_in',
table_id uuid REFERENCES dining_table(id) ON DELETE SET NULL,
waiter_id uuid REFERENCES "user"(id) ON DELETE SET NULL,
parent_order_id uuid REFERENCES "order"(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NOT NULL REFERENCES "user"(id) ON DELETE CASCADE,
updated_at timestamptz,
updated_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- order_item (unit_price_snapshot and tax_amount_snapshot for audit-safe billing; set when item is added)
CREATE TABLE order_item (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES "order"(id) ON DELETE CASCADE,
product_id uuid NOT NULL REFERENCES product(id) ON DELETE CASCADE,
status order_item_statuses NOT NULL DEFAULT 'pending',
course_number int NOT NULL DEFAULT 1,
quantity quantity_dec NOT NULL DEFAULT 1,
unit_price_snapshot money_minor NOT NULL,
tax_amount_snapshot money_minor NOT NULL DEFAULT 0,
modifiers jsonb DEFAULT '[]',
notes text,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- order_bill
CREATE TABLE order_bill (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid NOT NULL REFERENCES "order"(id) ON DELETE CASCADE,
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
bill_number varchar(50),
status order_bill_statuses NOT NULL DEFAULT 'pending',
subtotal money_minor NOT NULL,
tax_amount money_minor NOT NULL DEFAULT 0,
tip_amount money_minor NOT NULL DEFAULT 0,
total money_minor NOT NULL,
is_exported_to_fel boolean DEFAULT false,
created_at timestamptz NOT NULL DEFAULT now()
);
-- order_bill_mapping
CREATE TABLE order_bill_mapping (
bill_id uuid NOT NULL REFERENCES order_bill(id) ON DELETE CASCADE,
order_item_id uuid NOT NULL REFERENCES order_item(id) ON DELETE CASCADE,
split_quantity quantity_dec NOT NULL,
PRIMARY KEY (bill_id, order_item_id)
);
-- order_bill_payment (Option A: direct payments, no sale document)
CREATE TABLE order_bill_payment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_bill_id uuid NOT NULL REFERENCES order_bill(id) ON DELETE CASCADE,
amount money_minor NOT NULL,
payment_method_id uuid NOT NULL REFERENCES payment_method(id) ON DELETE RESTRICT,
cash_register_session_id uuid NOT NULL REFERENCES cash_register_session(id) ON DELETE RESTRICT,
safe_id uuid REFERENCES safe(id) ON DELETE SET NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- kitchen_station
CREATE TABLE kitchen_station (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
location_id uuid NOT NULL REFERENCES location(id) ON DELETE CASCADE,
name varchar NOT NULL,
is_active boolean NOT NULL DEFAULT true,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL,
updated_at timestamptz,
updated_by uuid REFERENCES "user"(id) ON DELETE SET NULL
);
-- product_station_assignment (routes products or categories to kitchen stations for send-to-kitchen)
CREATE TABLE product_station_assignment (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
business_id uuid NOT NULL REFERENCES business(id) ON DELETE CASCADE,
location_id uuid NOT NULL REFERENCES location(id) ON DELETE CASCADE,
station_id uuid NOT NULL REFERENCES kitchen_station(id) ON DELETE CASCADE,
product_id uuid REFERENCES product(id) ON DELETE CASCADE,
category_id uuid REFERENCES category(id) ON DELETE CASCADE,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid REFERENCES "user"(id) ON DELETE SET NULL,
CONSTRAINT product_station_assignment_source CHECK (product_id IS NOT NULL OR category_id IS NOT NULL)
);
CREATE INDEX idx_product_station_assignment_station ON product_station_assignment (station_id);
CREATE INDEX idx_product_station_assignment_product ON product_station_assignment (product_id) WHERE product_id IS NOT NULL;
CREATE INDEX idx_product_station_assignment_category ON product_station_assignment (category_id) WHERE category_id IS NOT NULL;
-- print_job
CREATE TABLE print_job (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_item_id uuid REFERENCES order_item(id) ON DELETE SET NULL,
station_id uuid NOT NULL REFERENCES kitchen_station(id) ON DELETE CASCADE,
status print_job_statuses NOT NULL DEFAULT 'pending',
attempts int NOT NULL DEFAULT 0,
last_error text,
created_at timestamptz NOT NULL DEFAULT now(),
printed_at timestamptz
);
-- Indexes for list/filter performance (normative MVP)
CREATE INDEX idx_dining_area_location ON dining_area (location_id);
CREATE INDEX idx_dining_table_area_status ON dining_table (area_id, status);
CREATE INDEX idx_order_location_status ON "order" (location_id, status);
CREATE INDEX idx_order_table ON "order" (table_id) WHERE table_id IS NOT NULL;
CREATE INDEX idx_order_created_at ON "order" (created_at);
CREATE INDEX idx_order_item_order ON order_item (order_id);
CREATE INDEX idx_order_bill_order ON order_bill (order_id);
CREATE INDEX idx_order_bill_payment_bill ON order_bill_payment (order_bill_id);
CREATE INDEX idx_order_bill_payment_session ON order_bill_payment (cash_register_session_id);
CREATE INDEX idx_kitchen_station_location ON kitchen_station (location_id);
CREATE INDEX idx_print_job_station_status ON print_job (station_id, status);
Session close logic: system_expected_amount = opening_amount + sum(sale totals where sale.session_id = session) + sum(order_bill_payment.amount where order_bill_payment.cash_register_session_id = session) + sum(cash_movement amounts). FEL is per order_bill, not per order_bill_payment; one DTE per paid bill.
Register and restaurant: Bill payments that use cash must link to cash_register_session_id (and optionally safe_id for safe drops). Card payments may also link to session for reconciliation. All payments that affect the drawer use the same register / cash_register_session as retail.
dining_area and location: dining_area has location_id; each area belongs to one location. Multi-branch: each location has its own dining areas and tables.
Part B: Core flows
Existing FlowPOS structures and processes (drawer & session — shared)
These structures and behaviours already exist in FlowPOS and are shared by both retail and the restaurant module: same register, session, safe and cash movements. The restaurant model uses them for bill payments and shift closing; it does not replace them. One drawer, one shift; closing and Z reports include retail sales and restaurant bill payments.
Tables (existing)
| Table | Purpose |
|---|---|
register | POS terminal per location. Columns: business_id, location_id, name, is_active, optional safe_id. One open cash register session per register at a time. |
cash_register_session | Shift for a cashier at a register. Columns: register_id, cashier_id, status (open/closed), opened_at, closed_at, opening_amount, closing_amount, system_expected_amount, difference, discrepancy fields, cash_denomination_breakdown (json). Immutable once closed. |
safe | Physical cash safe per location. Columns: business_id, location_id, name, is_active. Used for safe drops and cash movements. |
cash_movement | In-session movements. Columns: session_id, type (cash_in, cash_out, safe_drop, tip_in, tip_out, expense), amount, optional safe_id. All movements belong to a session. |
sale | Retail sales document. Columns: business_id, location_id, customer_id, status, sale_detail (json: items), payment_detail (json: payments by method), cost_detail, optional session_id (FK to cash_register_session; add via migration if not present), document_number, FEL fields. |
Behaviours (existing)
- Session linking: When creating a sale, if
session_idis not provided, the backend can resolve an open session bycreatedBy(cashier) andlocationIdand attach it. Sales are thus tied to the active shift for reconciliation. - Parameter:
REQUIRE_CASH_REGISTER_SESSION_FOR_ADD_DOCUMENT(entity parameter per business/location/document type). When true, the PWA (e.g. SalePage) blocks creating a new sale until there is at least one open cash register session for the current cashier and location. This enforces “no sale without an open drawer.” - Safe and movements: Cash movements (cash in/out, safe drop, tips, expense) reference
session_idand optionallysafe_id. Registers can have a defaultsafe_idfor safe drops. Deleting a safe is blocked if any cash movement references it. - Document number:
document_counteris used to generatedocument_numberper business and document type (e.g. SALE). - FEL: Sale has FEL-related columns; certification is triggered per sale document.
Processes (existing, unchanged)
- Open session: Cashier selects location and register, enters opening cash (and optional denomination breakdown). System creates
cash_register_sessionwith statusopen. - During shift: Sales created with optional/auto
session_id; cash movements (cash in/out, safe drop, etc.) recorded withsession_idand optionalsafe_id. - Close session: Cashier enters counted cash; system computes
system_expected_amountfrom session’s sales and movements, compares toclosing_amount, recordsdifferenceand optional discrepancy reason/approval. Session becomesclosedand immutable. Z report and session PDF can be generated.
Restaurant features must not remove or bypass these flows. Payments against restaurant bills (order_bill) that involve cash should integrate with the same session (and safe where applicable) so that closing and Z reports remain correct.
How restaurant integrates with existing flows
-
Retail vs restaurant document model
- Retail: One sale = one document with
sale_detail(items) andpayment_detail(payments). Optionally linked to one cash_register_session viasale.session_id. - Restaurant: One order = many order_items over time. One order can have many order_bills (split checks). Each bill is a sub-invoice; payments apply to a bill, not directly to the order. So: Order → Order_bill(s) → Payment(s) per bill.
- Retail: One sale = one document with
-
Cash and session integration
- When a guest pays an order_bill (especially with cash), that payment should be tied to the same cash register session and drawer as retail. Chosen approach: Option A (see Schema status and migration requirements).
- Option A (chosen): Add
order_bill_paymentwithorder_bill_id,amount,payment_method_id(FK topayment_method),cash_register_session_id, optionalsafe_id. Session totals and closing logic include these payments directly. Nosaledocument is created for bill payments.
- Option A (chosen): Add
- Shift closing and Z reports must include all cash (retail sales + restaurant bill payments) for the session.
- When a guest pays an order_bill (especially with cash), that payment should be tied to the same cash register session and drawer as retail. Chosen approach: Option A (see Schema status and migration requirements).
-
FEL
- Each order_bill that requires a DTE gets its own FEL certificate (same pattern as one DTE per sale). Existing FEL/certifier integration applies per bill; no change to retail sale FEL.
-
Document numbers
- Use document_counter for a new document type (e.g.
ORDER_BILLorBILL) to generatebill_numberfor order_bill, so bill numbers are unique per business and auditable.
- Use document_counter for a new document type (e.g.
-
Entities and parameters
- Location and business stay as they are. Restaurant orders and tables are scoped by
business_idand, where needed,location_id(e.g. dining_area per location). - Optional entity parameters for restaurant (e.g. require open session before accepting bill payment, or default order type) can follow the same pattern as
REQUIRE_CASH_REGISTER_SESSION_FOR_ADD_DOCUMENT(used today in the retail Sale flow: the PWA blocks creating a sale when the parameter is true and there is no open cash register session for the current cashier and location).
- Location and business stay as they are. Restaurant orders and tables are scoped by
-
Register, session, safe, cash_movement (shared)
- Register, cash_register_session, safe and cash_movement are used by both retail and the restaurant module. The same physical drawer and shift handle retail sales and restaurant bill payments; session totals and closing must include both. No separate restaurant drawer unless the business explicitly adds another register.
Processes (retail vs restaurant)
| Process | Retail (existing) | Restaurant (to add) |
|---|---|---|
| Document | Sale: one-off basket → sale_detail + payment_detail → one document, one FEL (if applicable). | Order: ongoing basket; order_item(s) added over time; one or more order_bill(s); each bill can be paid separately; each paid bill → one FEL (if applicable). |
| Session | Sale optionally/automatically linked to open cash_register_session (cashier + location). | Bill payment (cash/card) should link to same cash_register_session (and safe when applicable) so closing includes restaurant cash. |
| Shift open | Cashier opens session on a register; opening amount recorded. | Same; no change. |
| Shift close | Cashier counts cash; system compares to expected (sales + movements); discrepancy handling; session closed. | Same; expected amount must include restaurant bill payments that are tied to this session. |
| Safe | Cash movements (safe_drop, etc.) reference safe_id. | Restaurant cash payments that go to a safe use the same safe and movement types. |
| FEL | One DTE per sale when required. | One DTE per order_bill when required (split checks = multiple DTEs). |
Structures at a glance
| Layer | Shared (catalogs) | Retail-only | Restaurant-only |
|---|---|---|---|
| Tenant / branch | business, location | — | — |
| People / org | user, customer, supplier | — | — |
| Products & inventory | product, category, inventory, inventory_detail, inventory_ledger, production_run* | — | — |
| Pricing / tax | currency, tax_definition, unit_of_measure, payment_method | — | — |
| POS / drawer | register, cash_register_session, safe, cash_movement | — | Same drawer/session/safe for both retail and restaurant bill payments. |
| Sales document | — | sale (sale_detail, payment_detail, session_id) | — |
| Space | — | — | dining_area, dining_table |
| Order | — | — | order, order_item (reference shared product) |
| Bills / splits | — | — | order_bill, order_bill_mapping |
| Menu / modifiers | product, category | — | product_modifier_group, product_modifier, product_recipe (optional; reference shared product) |
| Kitchen | — | — | kitchen_station, product_station_assignment, print_job |
| Numbering / config | document_counter, parameter_catalog, entity_parameter | — | Same counters/params; add e.g. ORDER_BILL type |
* Production runs are shared (e.g. bakery); restaurant orders consume products that may come from shared inventory and production.
Part C: Domain model (mix: normative concepts + future/exploratory)
1. Types of Restaurants (Industry Standard) (context only)
- Full-Service (Fine & Casual Dining): High focus on table management, multi-course pacing, and split checks.
- Quick Service (QSR / Fast Food): Focus on speed, counter service, and immediate kitchen notification.
- Fast Casual: A hybrid where you order at the counter but sit down; food is brought to you.
- Ghost Kitchens (Dark Kitchens): 100% delivery-focused, often running multiple "brands" out of one kitchen.
- Bars & Pubs: Focus on "open tabs" and high-volume beverage inventory.
- Bakeries & Cafes: High volume of small items, often involving raw material processing (flour to bread).
2. Which ones can we support with our structure?
Our current PostgreSQL + NestJS architecture is specifically designed to handle the most complex scenarios, making it highly versatile:
A. Full-Service & Casual Dining
- Supported by: The
dining_areaanddining_tablestructures. - Why: We can manage physical table layouts, track "occupied" status in real-time via WebSockets, and handle complex "Split Checks" through the
order_billandorder_bill_mappingtables.
B. Ghost Kitchens & Delivery-First Models
- Supported by: The
booking_platformtable and third-party integration layer (Uber Eats, Rappi). - Why: The structure includes
commission_percentageand external mapping fields, allowing a single kitchen to track orders coming from multiple digital storefronts simultaneously.
C. Bakeries & Production-Heavy Cafes
- Supported by: The
production_runandinventory_typesstructures. - Why: We have a robust system for tracking "Raw Materials" and "Finished Goods". The
production_runtable (supportingbaking,mixing, andassembly) allows these businesses to track the transformation of ingredients into sellable products, which is a major pain point in standard POS systems.
D. Quick Service (QSR)
- Supported by: The "Hybrid" Bridge App (KDS + Printer).
- Why: By bypassing table management and sending orders directly to the
kitchen_stationqueue, the system supports the high-speed "Order → Pay → Prepare" flow.
E. Bars & High-Volume Beverage
- Supported by: The
order_itemstatus tracking (pending→ready→served). - Why: Waiters can keep an order "Open" while continuously adding items via the WebSocket-enabled PWA, with the Bar station KDS receiving the drink orders instantly.
Summary: Our "Niche" Strength
While we can support any of these, our structure is strongest for "Production-Retail-Service" hybrids.
Example: A restaurant that makes its own bread (Production), sells wine by the bottle (Retail), and offers a sit-down menu (Service). Most POS systems handle one or two of these well; our schema handles all three in a single source of truth.
To transition your system from a retail-centric model to a robust restaurant platform using the PostgreSQL + WebSockets approach, you add new layers for physical space, menu customization, and order state. Existing retail structures (sale, register, cash_register_session, safe, cash_movement) stay as-is; see Existing FlowPOS structures (drawer & session) and How restaurant integrates.
Below are the new structures and the architectural plan, while keeping real-time performance.
1. Physical Space & Table Management
In a restaurant, the "Order" is often tied to a physical location before it is tied to a customer.
dining_areaTable: Groups tables (e.g., "Terrace", "Main Room", "Bar").dining_tableTable:id,business_id,area_id.table_number(String).capacity(Int).status: (Enumdining_table_statuses:available,occupied,reserved,dirty).metadata:jsonb(For X/Y coordinates to render the map in your React PWA).
2. The "Menu" Layer (Modifiers & Recipes)
Unlike retail products, restaurant items are highly customizable.
product_modifier_groupTable: (e.g., "Toppings", "Sides", "Doneness").min_selection,max_selection(For UI validation).
product_modifierTable: (e.g., "Extra Cheese", "Medium Well").price_adjustment: (Uses yourmoney_minordomain).
product_recipeTable:- Links a
product_id(finished good) to variousingredient_ids (products withinventory_type = 'RawMaterial'). - Logic: When an order is sent/paid, the system deducts ingredients from stock (or via
production_run). - Migration:
product_recipeis not in the onboarding migration. Add it in its own migration and ensure consistency withproduction_run,inventory_ledger,production_run_input, andproduction_run_output.
- Links a
3. Real-time Order & Check Management
This is where you replace Firebase. By using a jsonb column for "modifiers" and "notes," you get the flexibility of NoSQL inside the safety of PostgreSQL.
Order lifecycle (state diagram):
stateDiagram-v2
[*] --> draft
draft --> sent_to_kitchen: Send to kitchen
sent_to_kitchen --> partially_served: Some items ready
partially_served --> served: All items served
served --> paid: All bills paid
draft --> cancelled: Cancel
sent_to_kitchen --> cancelled: Cancel
paid --> [*]
cancelled --> [*]
Bill payment flow:
sequenceDiagram
participant Waiter as PWA (Waiter)
participant API as NestJS API
participant DB as PostgreSQL
participant Session as cash_register_session
Waiter->>API: POST /order-bills/:id/payments
API->>DB: Insert order_bill_payment (session_id, amount, payment_method)
API->>DB: Update order_bill.status = paid
API->>Session: Session totals now include order_bill_payment
API->>Waiter: 200 OK
Split check flow:
flowchart LR
O[Order] --> OI1[order_item 1]
O --> OI2[order_item 2]
O --> OI3[order_item 3]
OI1 --> OB1[order_bill A]
OI2 --> OB1
OI2 --> OB2[order_bill B]
OI3 --> OB2
OB1 --> OBP1[order_bill_payment]
OB2 --> OBP2[order_bill_payment]
OBP1 --> S[Session]
OBP2 --> S
orderTable updates:status: (Enumorder_statuses:draft,sent_to_kitchen,partially_served,served,paid,cancelled).order_type: (Enumorder_types:dine_in,take_out,delivery_internal,delivery_third_party).table_id: Nullable (for non-dine-in).waiter_id: Link touser.id.
order_itemTable:status: (Enumorder_item_statuses:pending,preparing,ready,served,cancelled— see Reference schema).course_number: (Int: 1 for Drinks, 2 for Starters, 3 for Mains).unit_price_snapshot,tax_amount_snapshot: (money_minor) — set when item is added for audit-safe billing.modifiers:jsonb(Stores the array of selectedproduct_modifierobjects).notes:text(e.g., "Allergy to nuts").
- Split checks (bills): Use
order_billandorder_bill_mapping(see Split checks & merged tables). Each bill is a sub-invoice; payments link toorder_bill.id.
4. Kitchen & Printing Logic
kitchen_stationTable: (e.g., "Cold Kitchen", "Pizza Oven", "Bar").product_station_assignment: Mapping products or categories to stations.print_jobTable (Queue for your React Native Desktop App):- When an order is "Sent to Kitchen," NestJS creates records here.
- The Desktop App polls or receives a WebSocket event, prints, and marks as
printed.
5. Third-Party Integrations (Uber Eats, Rappi)
To manage external platforms alongside your internal operations:
external_platform_mappingTable:product_id(Internal).platform_id(Referencesbooking_platform.id).external_sku: The ID used by Uber/Rappi.
external_order_metadataTable:- Stores platform-specific info like
courier_name,estimated_arrival, andplatform_order_number.
- Stores platform-specific info like
6. The "PostgreSQL + WebSockets" Alternative
How it works without Firebase:
- NestJS Gateway: Create a WebSocket Gateway in NestJS.
- State Sync: When a waiter adds an item, the PWA sends a
add_itemevent via WebSocket. - Persistence: NestJS immediately updates the PostgreSQL
order_itemtable. - Broadcast: Upon successful DB update, NestJS "emits" the updated order state to all connected clients in that
business_id(Waiters see the table as "occupied," Kitchen sees a new ticket). - Local Storage: The PWA can use IndexedDB to store the draft locally if the connection drops, and "replay" the events once the WebSocket reconnects.
This architecture ensures that your PostgreSQL database remains the single source of truth for both active service and final financial reporting in Metabase.
This is the most common point of failure in a restaurant. Because you are using a Queue-based approach (print_job table) instead of a direct "fire and forget" message, you have a built-in safety net.
Here is how you should handle these "Offline" scenarios to ensure no burger is ever forgotten.
1. The "State-Based" Print Queue
Don't just delete a job once it's sent. Use a status lifecycle in your print_job table.
Updated print_job Schema:
id: UUID.order_item_id: Link to the food.station_id: Which printer it belongs to.status: (Enum:pending,sent,printed,failed).attempts: Integer (increment every time the Desktop App tries to print).last_error: Text (e.g., "Out of paper", "Printer not found").
2. Scenario A: The App is Closed (KDS Offline)
Since the app is closed, it won't receive the WebSocket "New Job" event.
- The "Startup Sync": When the React Native Desktop App is opened, the first thing it should do is a "Catch-up" API call:
GET /print-jobs?status=pending&station_id=XYZ. It fetches everything it missed while it was closed. - The "Heartbeat" (Backend Awareness):
- The Desktop App should send a "ping" to the NestJS server every 30 seconds.
- NestJS stores this in a durable store (
kitchen_station.last_seen_atin MVP; Redis/table also valid in future variants). - Result: If a waiter tries to send an order and the station hasn't been seen in 2 minutes, the PWA shows a warning: "⚠️ Warning: Pizza Kitchen is offline. Order will be queued."
3. Scenario B: The Printer is Off / Out of Paper (App is Open)
In this case, the Desktop App receives the WebSocket event but the hardware fails.
- Hardware Error Catching: Your React Native app (using a library like
react-native-esc-posor a native bridge) will receive an error from the printer driver. - Status Update: The Desktop App immediately calls the backend:
PATCH /print-jobs/:id { status: 'failed', last_error: 'No paper' }. - Real-time Alert: NestJS receives this update and emits a WebSocket event to the Waiters and the Manager Dashboard.
- Notification: "Printer 'Bar' is out of paper! 1 pending job."
4. Handling "Zombie" Jobs (The Reconciliation Logic)
What if the app received the job, the printer started printing, but the power went out halfway through?
- Acknowledgment (ACK) Pattern:
- Backend sends job via WebSocket.
- Desktop App prints.
- Desktop App sends an "ACK" back to Backend.
- Only then does the Backend mark the job as
printed.
- The Cleanup Job: Every 5 minutes, a Cron job in NestJS looks for jobs that have been in
sentstatus for more than 2 minutes but never moved toprinted. It resets them topendingso the Desktop App can try again.
5. Local Redundancy (The "Last Resort")
Since you are building the Desktop App in React Native/Electron, you can use a local database (like SQLite or WatermelonDB) inside the app.
- If the App is open but the Internet goes down, the app can still display the orders on the KDS screen because it already received them via WebSocket (or had them in its local buffer).
- Once the printer is turned back on, the app loops through its local "Unprinted" list and clears the queue.
Summary of Workflow for Reliability
| Problem | Solution |
|---|---|
| App Closed | GET /pending-jobs on startup + Backend Heartbeat check. |
| Printer Off | App catches driver error -> Updates DB to failed -> Alerts Waiter. |
| Internet Down | App stores jobs in local SQLite buffer; prints once reconnected. |
| Power Outage | Cron job resets sent jobs back to pending for retry. |
By implementing the Heartbeat and the Status Lifecycle, you turn a "broken printer" from a restaurant nightmare into a simple, trackable system notification.
To support the dynamic "merging and splitting" typical of restaurant operations, the current structures need a Parent-Child relationship for Orders and a Junction Table for Items.
In a retail POS, 1 Order = 1 Payment = 1 Invoice. In a restaurant, you need a layer between the "What was eaten" and "Who is paying."
Here is how to update your structures to handle these complex scenarios:
1. Handling "Split Checks" (1 Order to N Bills)
To split a check (by item or by amount), you should not duplicate the items. Instead, introduce a bill structure that acts as a container for specific items from the order.
order_billTable:id: UUID.order_id: Referencesorder.id.status: (Enum:pending,paid,cancelled).total_amount:money_minor.tax_amount:money_minor.fel_uuid: (To store the specific Guatemala FEL certificate for this part of the split).
order_bill_mappingTable (The Junction):bill_id: Referencesorder_bill.id.order_item_id: Referencesorder_item.id.split_quantity:quantity_dec(e.g. split a 1L bottle of wine between 2 bills).
Workflow: When the server clicks "Split Check," the PWA allows them to select items. The backend creates two order_bill records. Items are linked to these bills. When one bill is paid, the items on that bill are marked as "Paid," but the main order remains "Open" until all bills are settled.
2. Handling "Combined Tables" (N Tables to 1 Order)
Sometimes a large group pushes three tables together. Your current order table likely has a table_id. To support merging, you have two choices:
- Option A (Flexible): Change
order.table_idto a many-to-many relationship viaorder_tables. - Option B (Simple, recommended): Add a
parent_order_idto theordertable.- When Table 5 joins Table 6, you mark Order #6 as "Merged" and set its
parent_order_idto Order #5. All new items are then added to the Parent Order.
- When Table 5 joins Table 6, you mark Order #6 as "Merged" and set its
Recommendation: Use Option B unless you need to query "all orders that used this table" without following parent links. Option B keeps implementation and reporting simpler.
3. Updated Schema for Splits and Merges (Future / optional — MVP uses Reference schema only)
For MVP (Phases 1–3) the canonical order_statuses enum does not include merged. Merged tables are represented by order.parent_order_id only (Option B). If you add a distinct merged status later (Phase 5+), use:
-- Optional: add 'merged' to order_statuses only if needed for reporting (not in MVP reference schema)
ALTER TYPE order_statuses ADD VALUE 'merged';
The following is redundant with the Reference schema order_bill/order_bill_mapping; use the Reference schema for migrations.
-- Structure to handle the actual Bill generation (the fiscal trigger)
CREATE TABLE order_bill (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
order_id uuid REFERENCES "order"(id),
business_id uuid REFERENCES business(id),
bill_number varchar(50), -- Internal tracking
subtotal money_minor NOT NULL,
tip_amount money_minor DEFAULT 0,
total money_minor NOT NULL,
is_exported_to_fel boolean DEFAULT false,
created_at timestamptz DEFAULT now()
);
-- Junction: which items (and how much) belong to which bill. Supports splitting 1 item across 2 bills.
CREATE TABLE order_bill_mapping (
bill_id uuid REFERENCES order_bill(id),
order_item_id uuid REFERENCES order_item(id),
split_quantity quantity_dec NOT NULL,
PRIMARY KEY (bill_id, order_item_id)
);
4. Logic for "The Move" (Moving Items Between Tables)
Waiters often move a guest from the "Bar" to a "Table" once it becomes available.
- The Structure Support: Because your
order_itemis linked to anorder_id, you simply need atransfer_itemsfunction in your NestJS service. - The WebSocket Trigger: When items are moved, the NestJS server emits a
table_updateevent. The Bar KDS removes the items, and the Dining Room KDS displays them (or alerts the waiter to move the physical drinks).
5. Why this is better than "Copy-Pasting" Data
- Inventory Integrity: If you split a check, you aren't "creating" new items. You are just assigning who pays for them. Your inventory deduction (via the
product_recipeorproduction_run) only happens once. - Audit Trail: In Metabase, you can see that Order #100 was split into 3 Bills. This prevents "Employee Theft" where a waiter might delete an item from one bill and try to pocket the cash.
- FEL Compliance: In Guatemala, each split bill requires its own DTE (Documento Tributario Electrónico). This structure allows you to generate N different invoices for 1 single table session.
Summary: By adding the order_bill and order_bill_mapping tables, you gain the ability to handle Split by Item, Split by Seat, and Equal Splits, all while keeping your core order and order_item tables as the single source of truth for the kitchen.
[Future — Phase 4] In the context of a restaurant, you should definitely build one single app (a unified "Kitchen & Hardware Module") rather than two separate ones.
However, you should design it with "Mode Switches" or "Profiles."
Here is why a single app is the better architectural move for your stack, and how to handle the overlap you mentioned.
1. Why a Single App?
- Shared Hardware Bridge: Both the KDS and the Printer need to talk to the local network. By having one React Native Desktop app, you only have to manage one connection to the NestJS WebSocket and one set of local hardware drivers.
- Easier Deployment: When you update your printing logic or the way you handle order JSONs, you only have to deploy one update to the restaurant's computers.
- Device Versatility: Many small restaurants use a single tablet or touch-screen PC at the bar. They want to see the orders (KDS) and have that same device trigger the receipt printer.
2. The "Profile" Approach
Instead of separate apps, let the user configure the app's Role upon login:
- Role A: Printer-Only (Hidden): The app runs in the system tray. It has no UI. It just listens for
print_jobevents and sends them to the thermal printer. - Role B: KDS-Only: A full-screen interactive UI for the chef. It displays the tickets.
- Role C: Hybrid (KDS + Print): The chef sees the ticket on the screen, and the moment they "Accept" or "Complete" the ticket, the app automatically triggers a "Pick-up Slip" on the attached printer.
3. Handling the "Same Product" Scenario
You mentioned that sometimes you need to both print and display the same product. In your PostgreSQL schema, this is handled by routing rules rather than app logic.
Example: The "Pizza" Workflow
- NestJS Logic: An order arrives with a "Pepperoni Pizza."
- Routing: Your backend sees that
Category: Pizzais mapped toStation: Pizza_Oven. - Action: NestJS creates a
kitchen_ticket(for the KDS) and aprint_job(for the printer) both assigned to thePizza_Ovenstation ID. - The App: The app at the Pizza station receives both. It displays the pizza on the screen so the chef can see the "No Onions" note, and it prints a sticky label to put on the pizza box once it’s done.
4. Technical Implementation in React Native (Desktop)
Since you are using React Native for Desktop, you can leverage a single code base with conditional rendering:
JavaScript
`// Simplified Logic const App = () => { const { role } = useConfiguration(); // 'kds', 'printer', or 'hybrid'
return (
{/* UI layer only shows if it's a KDS or Hybrid */}
{(role === 'kds' || role === 'hybrid') && <KDS_Interface />}
{/* If it's printer-only, show a simple status dashboard or minimize to tray */}
{role === 'printer' && <PrinterStatusMiniView />}
</View>
); }`
5. Strategic Advantage for Retail
Because you also have a Retail version, this "Hardware App" becomes your "Peripheral Bridge."
- In Retail mode, the app handles the Barcode Scanner and the Cash Drawer.
- In Restaurant mode, the same app handles the KDS and Kitchen Printers.
Verdict: Build one app called "FlowPOS Bridge" or "FlowPOS Station." It reduces your maintenance cost by 50% and allows for the "Hybrid" scenarios that restaurants love.
Yes, the current structures are fundamentally designed around locations (branches) to handle variations in stock, production, and operations.
The database schema explicitly includes a location table that acts as a child of the business, allowing each branch to have its own physical address, tax number, and active status (available to buy/sell).
Here is how the system currently accounts for branch-level variations:
1. Multi-Branch Operations
- Production Variation: The
production_runtable includes alocation_id. This means you can track where a "Baking" or "Assembly" run is happening. A recipe made at the "Central Kitchen" branch can be tracked differently than one made at a "Small Express" branch. - Location States: Each branch can be independently "frozen" (e.g., for a physical inventory count) without affecting the others. This is critical for restaurant chains where one branch might be closed for maintenance while others remain open.
2. Location-Specific Inventory
- Stock Tracking: Although the full
stocktable details aren't shown in the snippet, the system is designed to link inventory and production events to specificlocation_idvalues. - Operational Control: You can toggle whether a specific branch is
available_to_selloravailable_to_buy. This allows you to have "Warehouse-only" locations that don't appear in the POS or "Front-of-house" branches that don't receive raw supplies.
3. What we need to add for Full Branch Variation
While the foundation is there, to fully support a complex restaurant chain, we should ensure the following enhancements are added to the "PostgreSQL + WebSockets" structure:
- Location-Specific Pricing: Currently, the
producttable is linked to thebusiness_id. We should add aproduct_location_pricetable if you want the same burger to cost more in a "Zone 10" branch than in a "Rural" branch. - Regional Menus: If certain locations have different menus (e.g., a "Breakfast" branch vs. a "Late Night" bar branch), we will use a junction table between
menu_categoryandlocation_id. - Regional Tax Rules: The
tax_definitionincludes acountry_id, but adding alocation_idlink would allow the system to handle different municipal taxes if the business expands across different regions.
Summary: The structure already treats the Location as a primary entity for production and physical state. Expanding this to pricing and menus will be a straightforward addition to the existing PostgreSQL schema.
Part D: Advanced (future / exploratory unless referenced by MVP)
A restaurant POS can vary a lot depending on service style (full service, fast casual, bar, delivery, catering). Below are common variations/features you can support, including the “people/alias” ideas you mentioned.
People, guests, and aliases
1) Guest count and seats
guest_countat order level (simple)- Seat-based ordering: each item can be assigned to a seat number (
seat_no)- Great for splitting checks and tracking who ordered what
2) Associate people with an order
Different “people roles” can be linked:
server_id(waiter)cashier_idhost_idrunner_idbartender_idkitchen_lead_id- optional:
customer_id(if known)
Implementation pattern:
- Either columns on
order - Or a generic
order_partytable:order_id,role,user_id/customer_id
3) Aliases / nicknames
You can allow:
- Table alias: “VIP”, “Birthday”, “Team A”, “Smith Family”
- Guest alias per seat: “Mom”, “Kid 1”, “Allergy—Peanuts”
- Order alias: “To-go John”, “Uber #9281”
Schema ideas:
dining_table.alias(optional) +order.table_alias_snapshotorder_guesttable:order_id,seat_no,guest_name(alias), notes, allergies
Snapshotting matters so history doesn’t change if you rename later.
Service-flow variations
4) Courses & pacing
- Course routing: drinks → appetizers → mains → dessert
- “Fire course” / “Hold” logic:
- items can be
hold_until_fired = true - kitchen releases when server triggers
- items can be
5) Order lifecycle differences
Some restaurants want:
-
sent_to_kitchenstepOthers do:
-
auto-send to kitchen on item add
Bars might do:
-
send to bar immediately, kitchen later
So you might support a setting:
send_mode:manual|auto
6) Tabs / open checks (bar style)
- One guest opens a tab, adds items over time, pays at end
- Pre-auth card (optional)
- Transfer tab to another server
Payment and check variations
7) Split checks in multiple ways
- Split by seat
- Split by items
- Split equally
- Split by percent
- Split by course
This affects data model:
order_bill+order_bill_mappingwithsplit_quantityis the most flexible
8) Tips and gratuity rules
- Tips per check, per payment
- Auto-gratuity for large parties
- Tip pooling and distribution rules
9) Multiple tenders / partial payments
- One check paid with 2 cards + cash
- Partial payment with remaining balance open
Discounts, comps, and promos
10) Comps & voids (audit-heavy)
Restaurants care about:
- void item (before send)
- cancel item (after send)
- comp item (free but still counted)
Each needs:
- reason code
- authorizer user id (manager)
- timestamps
11) Happy hour / time-based pricing
Price rules based on:
- time window
- day of week
- dining area (bar vs dining room)
Kitchen operations variations
12) Station routing rules
- By product
- By category
- By modifier (e.g., “medium rare” routes to grill)
- By dining area (patio has its own printer)
13) Kitchen Display System vs printing
-
KDS screen (queue + timers)
-
Printers (tickets)
Many places want both.
14) Expo & running
- “Ready” items become a queue for runners
- Track who delivered (runner_id)
- “Bump” flow (kitchen marks done, expo approves)
Delivery and off-premise variations
15) Takeout / delivery timing
- promise time / pickup time
- driver assignment
- order packing status
16) Third-party marketplaces
- mapping SKUs
- commission and payout tracking
- special instructions handling
Reservations & seating variations
17) Reservations + waitlist
- reservation linked to table + time
- walk-in waitlist with SMS notify
- table turn time metrics
18) Table transfer / merge / split
- move order from table A → table B
- merge two tables into one party
- split party across tables
This is why “table alias snapshot” + seat numbers are useful.
Operational controls (manager needs)
19) Shift management & cash drawer
- open/close shift
- cash drop
- drawer reconciliation per cashier
20) Permissions & approvals
- manager approval for:
- comps
- discounts above threshold
- reopen closed check
- void after send
A clean way to model “people + aliases” (simple & scalable)
If you want one flexible pattern:
orderguest_counttable_alias_snapshot
order_guestorder_id,seat_no,guest_alias,notes,allergies_jsonb
order_partyorder_id,role,user_id(server/runner/bartender)
That covers:
- assigning people to order
- nicknames
- seat-based split
- audit trail
If you tell me what style you’re targeting first (full service restaurant vs fast casual vs bar), I can recommend the smallest MVP set of these variations so you don’t overbuild.
Mostly yes — your structure already supports a lot of those variations. A few need small extension tables/fields (especially seats/guests, split checks, and multi-staff roles).
✅ Already supported by your current structure
Table + area + floor map
dining_area,dining_tablewithstatusandmetadata jsonbcovers:- floor plan coordinates
- table status (available/occupied/dirty/reserved)
- assigning an order to a physical table
Assigning staff to an order
order.waiter_idcovers the common case (server assigned).
Item-level customization
order_item.modifiers jsonb+notessupports:- “no onions”, “allergy”, “extra cheese”
- future modifier schema changes without migrations
Kitchen workflow
order_item.statussupports:- pending → preparing → ready → served
Station + printing
kitchen_station,product_station_assignment,print_jobsupports:- routing items to stations and printers/KDS-like flows
Third-party delivery
external_platform_mapping+external_order_metadatasupports:- Uber/Rappi SKU mapping + order metadata
⚠️ Partially supported (works, but you’ll want 1–2 extra fields)
Aliases (table/person/order)
You can do this, but decide where you want the data to live:
Table alias
-
Add:
dining_table.alias(optional)or store it in
dining_table.metadata.
Order alias
- Add:
order.alias(e.g., “Birthday party”, “To-go John”)
Person/guest alias
- Needs a table (see next section).
❌ Not fully supported yet (recommended additions)
1) Seat-based ordering / assigning people to items
If you want “Seat 1 ordered this” or guest nicknames:
Add order_guest
idorder_idseat_no(int)guest_alias(text) // “Mom”, “Kid”, “VIP”notes/allergies(text or jsonb)
Then add to order_item:
guest_idorseat_no
This unlocks:
- split by seat
- tracking allergies per person
- item ownership
2) Multiple staff roles on the same order
Right now you only have waiter_id. If you want bartender/runner/host:
Add order_party
order_idrole(enum: server, runner, bartender, host, cashier, etc.)user_id
This avoids adding 10 nullable columns to order.
3) Split checks (the “correct” flexible way)
Use order_bill (sub-invoice) and order_bill_mapping with split_quantity (see Canonical terminology). Same concept as "check" + "check_item"; payments link to order_bill.id. This supports:
- split by seat
- split by items
- split evenly / percent
- partial payments cleanly
4) Table moves, merges, split parties
You can do basic moves by updating order.table_id.
If you want history/audit:
- add
order_table_history(optional)
Quick verdict
✅ Yes, your structure supports the variations at a good baseline.
To fully cover “people + aliases + split-check + multi-role staff” you should add:
order_guest(+order_item.guest_id/seat_no)order_partyorder_bill+order_bill_mappingwithsplit_quantity(qty-based split)
If you want, I can propose the exact columns + constraints for those 3 additions so they match your existing style (multi-tenant business_id, audit fields, status enums).
[Future — Phase 6] Yes — the model can support self-service kiosks, but you need a few extra structures to handle:
- device identity & pairing
- customer session / cart ownership
- menu availability by kiosk/location/time
- payments (intent, retries, failures)
- order routing (send to kitchen, pickup screens)
Below is a complete, clean set of tables you can add (Postgres), designed to work with what you already described (dining_area, dining_table, order, order_item, modifiers, kitchen stations, print jobs, external platforms, WebSockets).
0) Key idea
Kiosk orders are just orders where:
order.channel = 'kiosk'order.customer_session_idpoints to a kiosk session/cart- no waiter is required
- payment is usually attempted before the order is “paid/sent”
1) Enums (recommended)
-- Device + sessions
CREATE TYPE device_type AS ENUM ('kiosk','kds','printer','pos_terminal');
CREATE TYPE device_status AS ENUM ('active','inactive','maintenance','blocked');
CREATE TYPE kiosk_session_status AS ENUM ('active','abandoned','checked_out','expired');
-- Order + items (use plural enum names per FlowPOS convention: order_statuses, order_item_statuses, order_types)
CREATE TYPE order_channel AS ENUM ('pos','kiosk','mobile_app','web','external_platform');
CREATE TYPE order_types AS ENUM ('dine_in','take_out','delivery_internal','delivery_third_party');
CREATE TYPE order_statuses AS ENUM (
'draft','sent_to_kitchen','partially_served','served','paid','cancelled'
);
CREATE TYPE order_item_statuses AS ENUM ('pending','preparing','ready','served','cancelled');
-- Payments
CREATE TYPE payment_status AS ENUM ('pending','authorized','captured','failed','cancelled','refunded','voided');
CREATE TYPE payment_method_type AS ENUM ('cash','card_present','card_not_present','wallet','voucher','gift_card');
-- Pickup / fulfillment
CREATE TYPE fulfillment_status AS ENUM ('created','in_kitchen','ready','picked_up','cancelled');
2) Device registry (works for kiosks + printer app + KDS)
device
CREATE TABLE device (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
location_id uuid NOT NULL,
device_type device_type NOT NULL,
name text NOT NULL,
status device_status NOT NULL DEFAULT 'active',
-- For pairing / auth
public_key text NULL,
pairing_code text NULL,
pairing_expires_at timestamptz NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb, -- screen size, OS, etc.
last_seen_at timestamptz NULL,
created_at timestamptz NOT NULL DEFAULT now(),
created_by uuid NULL
);
CREATE INDEX idx_device_business_location ON device (business_id, location_id);
CREATE INDEX idx_device_type_status ON device (device_type, status);
CREATE INDEX idx_device_last_seen ON device (last_seen_at);
3) Kiosk configuration
kiosk_config
One row per kiosk device (or you can store in device.metadata, but config table is cleaner).
CREATE TABLE kiosk_config (
kiosk_id uuid PRIMARY KEY REFERENCES device(id) ON DELETE CASCADE,
menu_id uuid NULL, -- optional if you manage menus as a separate entity
allow_dine_in boolean NOT NULL DEFAULT true,
allow_take_out boolean NOT NULL DEFAULT true,
default_order_type order_types NOT NULL DEFAULT 'take_out',
-- If dine-in is enabled, allow selecting table from map/QR
allow_table_selection boolean NOT NULL DEFAULT false,
require_table_for_dine_in boolean NOT NULL DEFAULT false,
-- Payment behavior
require_payment_before_send boolean NOT NULL DEFAULT true,
tip_enabled boolean NOT NULL DEFAULT true,
default_tip_percent numeric NULL,
-- Pickup experience
show_order_number_on_completion boolean NOT NULL DEFAULT true,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
4) Kiosk customer “cart session”
kiosk_session
CREATE TABLE kiosk_session (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
location_id uuid NOT NULL,
kiosk_id uuid NOT NULL REFERENCES device(id) ON DELETE RESTRICT,
status kiosk_session_status NOT NULL DEFAULT 'active',
-- Optional: link to created order once checkout begins
order_id uuid NULL,
-- Useful for “resume cart” or debugging
started_at timestamptz NOT NULL DEFAULT now(),
last_activity_at timestamptz NOT NULL DEFAULT now(),
expires_at timestamptz NOT NULL,
-- Optional: customer contact (for pickup notifications)
customer_name text NULL,
customer_phone text NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_kiosk_session_kiosk_status ON kiosk_session (kiosk_id, status);
CREATE INDEX idx_kiosk_session_activity ON kiosk_session (last_activity_at);
5) Menu availability (optional but very useful for kiosks)
If you don’t have a menu system yet, this is a lightweight “what can kiosk sell right now”.
menu
CREATE TABLE menu (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
name text NOT NULL,
is_active boolean NOT NULL DEFAULT true,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
menu_item
CREATE TABLE menu_item (
id uuid PRIMARY KEY,
menu_id uuid NOT NULL REFERENCES menu(id) ON DELETE CASCADE,
product_id uuid NOT NULL, -- references product table in your system
sort_order int NOT NULL DEFAULT 0,
is_available boolean NOT NULL DEFAULT true,
available_from time NULL,
available_to time NULL,
metadata jsonb NOT NULL DEFAULT '{}'::jsonb
);
CREATE INDEX idx_menu_item_menu_sort ON menu_item (menu_id, sort_order);
CREATE INDEX idx_menu_item_product ON menu_item (product_id);
kiosk_menu_assignment
CREATE TABLE kiosk_menu_assignment (
kiosk_id uuid NOT NULL REFERENCES device(id) ON DELETE CASCADE,
menu_id uuid NOT NULL REFERENCES menu(id) ON DELETE CASCADE,
PRIMARY KEY (kiosk_id, menu_id)
);
6) Orders (extend your existing order table)
Add only what kiosks need.
order additions
ALTER TABLE "order"
ADD COLUMN channel order_channel NOT NULL DEFAULT 'pos',
ADD COLUMN kiosk_id uuid NULL REFERENCES device(id),
ADD COLUMN kiosk_session_id uuid NULL REFERENCES kiosk_session(id),
ADD COLUMN order_alias text NULL, -- “Pickup John”, “Party VIP”
ADD COLUMN guest_count int NULL,
ADD COLUMN table_alias_snapshot text NULL, -- snapshot of table label/alias at order time
ADD COLUMN version int NOT NULL DEFAULT 1; -- for WS concurrency
Keep your existing:
status(order_statuses)order_type(order_types)table_id(nullable)waiter_id(nullable for kiosk)
For kiosks:
waiter_idis NULL,channel='kiosk',kiosk_session_idis set.
7) Order items (keep what you proposed)
Your structure already supports kiosk item customization.
order_item additions (recommended)
ALTER TABLE order_item
ADD COLUMN base_unit_price bigint NULL, -- money_minor
ADD COLUMN modifiers_total bigint NOT NULL DEFAULT 0,
ADD COLUMN final_unit_price bigint NULL, -- base + modifiers
ADD COLUMN seat_no int NULL; -- if you support seat-based later
You already planned:
modifiers jsonb(snapshot)notes textstatus(order_item_statuses)course_number
8) Payments (kiosk checkout needs this)
Even if you already have payments for retail, kiosks benefit from a clean “intent” layer.
payment
CREATE TABLE payment (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
location_id uuid NOT NULL,
order_id uuid NOT NULL,
status payment_status NOT NULL DEFAULT 'pending',
method payment_method_type NOT NULL,
amount bigint NOT NULL, -- money_minor
tip_amount bigint NOT NULL DEFAULT 0,
currency_code text NOT NULL, -- or iso_currency domain
-- Gateway fields (Stripe/Adyen/etc.)
provider text NULL,
provider_payment_id text NULL,
provider_raw jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_payment_order ON payment (order_id);
CREATE INDEX idx_payment_status ON payment (status);
payment_attempt (optional but recommended)
Tracks retries/failures without corrupting main payment row.
CREATE TABLE payment_attempt (
id uuid PRIMARY KEY,
payment_id uuid NOT NULL REFERENCES payment(id) ON DELETE CASCADE,
status payment_status NOT NULL,
error_code text NULL,
error_message text NULL,
provider_raw jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_payment_attempt_payment ON payment_attempt (payment_id);
9) Fulfillment / pickup flow (kiosk-friendly)
Kiosks usually show an order number and a pickup screen.
fulfillment_ticket
CREATE TABLE fulfillment_ticket (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
location_id uuid NOT NULL,
order_id uuid NOT NULL,
ticket_number text NOT NULL, -- e.g. A102
status fulfillment_status NOT NULL DEFAULT 'created',
created_at timestamptz NOT NULL DEFAULT now(),
updated_at timestamptz NOT NULL DEFAULT now(),
UNIQUE (business_id, location_id, ticket_number)
);
CREATE INDEX idx_fulfillment_order ON fulfillment_ticket (order_id);
CREATE INDEX idx_fulfillment_status ON fulfillment_ticket (status);
10) Kitchen dispatch + printing (you already have it)
Your kitchen_station, product_station_assignment, and print_job tables fit kiosks perfectly.
One tweak: make print_job generic with device routing.
print_job (recommended columns)
idbusiness_id,location_idstation_id(optional)target_device_id(printer device)source_type= 'order'source_id= order idpayload jsonb(render-ready ticket lines)status= pending/printed/failedcreated_at,printed_at,error
11) Optional but powerful: order events (audit + recovery)
This helps WS reliability and debugging.
order_event
CREATE TABLE order_event (
id uuid PRIMARY KEY,
business_id uuid NOT NULL,
location_id uuid NOT NULL,
order_id uuid NOT NULL,
event_type text NOT NULL, -- item_added, status_changed, payment_captured, etc.
payload jsonb NOT NULL DEFAULT '{}'::jsonb,
created_at timestamptz NOT NULL DEFAULT now()
);
CREATE INDEX idx_order_event_order ON order_event (order_id, created_at);
Does this cover kiosk-specific variations?
Yes:
- Self-service flow:
kiosk_sessionowns the cart → createsorderon checkout → payment → send to kitchen → pickup ticket. - Aliases:
order_alias,table_alias_snapshot, optional customer_name. - No waiter:
waiter_idnullable;order.channel='kiosk'. - Real-time UI: WS broadcasts
order.updated,fulfillment.updated,table.updated. - Offline resilience: PWA stores draft events locally; session can be recovered by
kiosk_session.
If you want, I can also provide a minimal MVP subset (the smallest set of tables/fields to ship kiosks in one iteration) vs the full version above.
Note (Phase 6): Kiosk tables (device, kiosk_config, kiosk_session, menu, menu_item, payment, fulfillment_ticket) are optional and belong to Phase 6. Use enum names order_statuses, order_types, order_item_statuses consistently; kiosk-specific enums (order_channel, device_type, kiosk_session_status, etc.) are additive.
Backend module architecture
The restaurant backend module follows hexagonal architecture (ports & adapters) with strict layer boundaries.
Module structure
apps/backend/src/restaurant/
├── restaurant.module.ts # Root module
├── restaurant.gateway.ts # WebSocket gateway (/restaurant)
├── modules/
│ ├── dining.module.ts # Dining areas & tables
│ ├── kitchen.module.ts # Kitchen stations, print jobs, assignments
│ └── order.module.ts # Orders, items, bills, payments
├── domain/ # Ports (interfaces) — no framework deps
│ ├── order-repository.domain.ts
│ ├── order-item-repository.domain.ts
│ ├── order-bill-repository.domain.ts
│ ├── dining-area-repository.domain.ts
│ ├── dining-table-repository.domain.ts
│ ├── kitchen-station-repository.domain.ts
│ └── print-job-repository.domain.ts
├── application/ # Use cases / services
│ ├── orders.service.ts
│ ├── order-bill.service.ts
│ ├── order-bill-payment.service.ts
│ ├── dining-area.service.ts
│ ├── dining-table.service.ts
│ ├── kitchen.service.ts
│ └── ...
├── infrastructure/ # Adapters (Kysely repositories)
│ ├── order.repository.ts
│ ├── order-item.repository.ts
│ ├── order-bill.repository.ts
│ └── ...
└── interfaces/ # HTTP controllers, DTOs, queries
├── orders.controller.ts
├── order-bills.controller.ts
├── dining.controller.ts
├── kitchen.controller.ts
├── menus.controller.ts
├── reservations.controller.ts
└── dtos/
Dependency injection pattern
Domain interfaces use Symbol-based DI tokens (framework-agnostic):
// domain/order-repository.domain.ts
export const ORDER_REPOSITORY = Symbol("ORDER_REPOSITORY");
export interface IOrderRepository { ... }
// modules/order.module.ts
{ provide: ORDER_REPOSITORY, useClass: OrderRepository }
// application/orders.service.ts
@Inject(ORDER_REPOSITORY) private readonly orderRepo: IOrderRepository
Swagger / OpenAPI
All controllers have @ApiOperation, @ApiParam, and @ApiQuery decorators. All DTOs have @ApiProperty / @ApiPropertyOptional. The Swagger UI is available at /api when running in development.
API and WebSocket overview
REST endpoints
Orders
| Method | Path | Purpose |
|---|---|---|
POST | /orders | Create order |
GET | /orders | List orders (paginated) |
GET | /orders/:id | Get order by ID |
PATCH | /orders/:id | Update order |
DELETE | /orders/:id | Delete order |
PATCH | /orders/:id/customer | Attach customer to order |
POST | /orders/:id/items | Add order item |
GET | /orders/:id/items | List order items |
PATCH | /orders/:id/items/:itemId | Update order item |
DELETE | /orders/:id/items/:itemId | Remove order item |
POST | /orders/:id/items/:itemId/void | Void order item |
POST | /orders/:id/items/:itemId/comp | Comp order item |
POST | /orders/:id/items/:itemId/fire | Fire order item to kitchen |
POST | /orders/:id/items/fire-course | Fire all items by course |
POST | /orders/:id/send-to-kitchen | Send order to kitchen |
POST | /orders/:id/collect-payment | Collect payment (without bill) |
GET | /orders/:id/station-completion | Station completion status |
GET | /orders/station-completion/batch | Batch station completion |
GET | /orders/held-counts/batch | Batch held item counts |
GET | /orders/guests/batch | Batch get guests |
Order discounts
| Method | Path | Purpose |
|---|---|---|
POST | /orders/:id/items/:itemId/discount | Apply line-item discount |
DELETE | /orders/:id/items/:itemId/discount | Remove line-item discount |
POST | /orders/:id/discount | Apply cart-level discount |
DELETE | /orders/:id/discount | Remove cart-level discount |
Order bundles
| Method | Path | Purpose |
|---|---|---|
GET | /orders/:id/bundles/evaluate | Evaluate eligible bundles |
POST | /orders/:id/bundles/apply | Apply bundle to order |
DELETE | /orders/:id/bundles/remove | Remove bundle from order |
POST | /orders/:id/bundles/add-combo | Add combo as order items |
Order bills & payments
| Method | Path | Purpose |
|---|---|---|
POST | /order-bills | Create bill (split) |
GET | /order-bills | List bills by orderId |
GET | /order-bills/:id | Get bill by ID |
POST | /order-bills/:id/payments | Record bill payment |
GET | /order-bills/:id/qr-code | Generate QR code for bill |
POST | /order-bills/validate-qr | Validate scanned QR code |
POST | /orders/:id/dispatch | Mark order as dispatched |
Kitchen & print
| Method | Path | Purpose |
|---|---|---|
POST | /kitchen-stations | Create kitchen station |
GET | /kitchen-stations | List kitchen stations |
PATCH | /kitchen-stations/:id | Update kitchen station |
DELETE | /kitchen-stations/:id | Delete kitchen station |
POST | /kitchen-stations/:id/heartbeat | Station heartbeat |
GET | /kitchen-stations/health | Station health status |
GET | /kitchen-stations/load | Station workload distribution |
GET | /kitchen-stations/:id/aggregated-items | Aggregated items for KDS |
GET | /analytics | Kitchen analytics |
GET | /print-jobs | List print jobs |
PATCH | /print-jobs/:id | Update print job status |
Dining
| Method | Path | Purpose |
|---|---|---|
POST | /dining-areas | Create dining area |
GET | /dining-areas | List dining areas |
GET | /dining-areas/:id | Get dining area by ID |
PATCH | /dining-areas/:id | Update dining area |
DELETE | /dining-areas/:id | Delete dining area |
POST | /dining-tables | Create dining table |
GET | /dining-tables | List dining tables |
GET | /dining-tables/:id | Get dining table by ID |
PATCH | /dining-tables/:id | Update dining table |
DELETE | /dining-tables/:id | Delete dining table |
Menus
| Method | Path | Purpose |
|---|---|---|
POST | /menus | Create menu |
GET | /menus | List menus |
GET | /menus/:id | Get menu by ID |
PATCH | /menus/:id | Update menu |
DELETE | /menus/:id | Delete menu |
POST | /menus/:menuId/items | Create menu item |
GET | /menus/:menuId/items | List menu items |
GET | /menus/:menuId/items/:itemId | Get menu item |
PATCH | /menus/:menuId/items/:itemId | Update menu item |
DELETE | /menus/:menuId/items/:itemId | Delete menu item |
POST | /locations/:locationId/menus | Assign menu to location |
DELETE | /locations/:locationId/menus/:menuId | Unassign menu |
GET | /locations/:locationId/menus | List location menus |
Other restaurant resources
| Method | Path | Purpose |
|---|---|---|
CRUD | /reservations | Reservation management |
CRUD | /waitlist-entries | Waitlist management |
CRUD | /price-rules | Time/day-based price rules |
CRUD | /product-modifier-groups | Modifier group management |
CRUD | /product-modifiers | Modifier management |
CRUD | /product-station-assignments | Product-to-station routing |
CRUD | /external-platform-mappings | Delivery platform mappings |
CRUD | /orders/:orderId/guests | Order guest management |
CRUD | /orders/:orderId/party | Order party (server/host) |
PWA form routes (MVP)
To make restaurant pages reachable in the existing PWA form router (MainPage), use:
/forms/restaurantDining/forms/restaurantOrders
If menu entries are remote-configured (e.g. pwaMenu), include these two route paths so users can open dining and order/bill flows directly.
WebSocket events
| Event | Emitted when | Payload |
|---|---|---|
order.created | New order created | order |
order.updated | Order or items changed | order |
order.sent_to_kitchen | Order sent to kitchen | order, print_jobs |
order_item.updated | Item status changed | order_item |
table.updated | Table status changed | dining_table |
print_job.new | New print job for station | print_job |
print_job.updated | Job printed/failed | print_job |
Scope events by business_id and location_id so clients subscribe only to their location.
Implementation phases
| Phase | Scope | Key deliverables |
|---|---|---|
| 1. Foundation | Space + core order model | dining_area, dining_table; order / order_item status, order_type, table_id, waiter_id; modifiers/notes (jsonb). |
| 2. Bills & splits | Payment and FEL | order_bill, order_bill_mapping, order_bill_payment (Option A); optional order.parent_order_id for merged tables. Integrate with existing: bill payments via order_bill_payment link to cash_register_session_id and optional safe_id; use document_counter for bill numbers; one FEL per paid bill when required. |
| 3. Kitchen & print | Reliability | kitchen_station, product_station_assignment, print_job (lifecycle: pending → sent → printed/failed); startup sync + heartbeat; ACK + cron for zombie jobs. |
| 4. Bridge app | KDS + printer | Single "FlowPOS Bridge" app with profiles: printer-only, KDS-only, hybrid. |
| 5. People & aliases | Optional | order_guest, order_party; order_item.seat_no; dining_table.alias, order.order_alias / table_alias_snapshot. |
| 6. Location & kiosk | Multi-branch / self-service | Location-specific pricing/menus (if needed); kiosk: device, kiosk_config, kiosk_session, order.channel, fulfillment/payment tables. |
Before implementing: See Schema status and migration requirements. Restaurant tables (order, order_item, dining_area, dining_table, order_bill, order_bill_mapping, order_bill_payment, etc.), sale.session_id, and product_recipe must be added via migrations. Use enum names order_statuses, order_item_statuses, order_types, etc.
Related artifacts
| Artifact | Path / notes |
|---|---|
| Feature spec | specs/010-restaurant/ (create if not exists) |
| Migrations | packages/backend/database/src/migrations/ — add e.g. YYYY-MM-DD-restaurant-core.mjs, YYYY-MM-DD-sale-session-id.mjs, YYYY-MM-DD-product-recipe.mjs |
| Existing onboarding | 2024-06-02t23:35:16.678z-onboarding-tables.mjs |
| Production runs | 2026-02-12t12:00:00.000z-production-runs.mjs |
| Retail sale flow | apps/frontend-pwa/.../SalePage.tsx, apps/backend/src/sales/ |
Document version: 1.2 · Last updated: 2026-03-24 · Changelog: v1.0 — Initial improvements. v1.1 — Scope labels (Normative MVP vs Future); normalized payment_method_id; order_statuses MVP excludes merged (parent_order_id only); order_item_statuses includes cancelled; added product_station_assignment DDL and indexes to Reference schema; added order_item.unit_price_snapshot and tax_amount_snapshot for audit-safe billing; Related artifacts path specs/010-restaurant/. v1.2 — Added backend module architecture section (hexagonal, domain interfaces, DI tokens); expanded REST endpoint table with all 80+ endpoints including discounts, bundles, QR codes, batch operations, kitchen analytics; documented Swagger/OpenAPI coverage.