#r05 — Backend Platform, Domain Services, and the AI Layer
Bottom line: the backend is buildable by a small team, but the schedule is dominated by three things that AI assistance barely accelerates — the menu/pricing/tax engine, the offline-first order domain, and per-country fiscal/accounting compliance. Infra cost is a non-issue (€3–9 per location per month at every scale we care about). The genuinely differentiating AI feature is menu ingestion from PDF/photo, which costs ~€0.20–0.55 per restaurant in tokens against €200–1,200 of human menu-build labour — build it before most of the POS.
Label key: [verified] = checked this session with a source; [known] = confident from training data; [estimate] = reasoned guess.
#1. Multi-tenancy, isolation, residency, config
#1.1 The hierarchy — and the one mistake everyone makes
org billing/contract entity, one legal owner, one data-residency cell
└ brand menu namespace, branding, default locale, tax classes (1..n per org)
└ location site: address, jurisdiction, timezone, business-day start, fiscal device
└ station physical device or logical terminal (POS-1, KDS-hot, kiosk-2, printer group)
└ employee_session who is logged into what, right now
employee belongs to org; assigned to 1..n locations with per-location rolesThe mistake: conflating brand and location. Menu structure belongs to the brand; price, availability, tax, and layout belong to the location (or a location group). Vendors that put price on the item ship a product that cannot handle "same menu, three sites, different prices" — which is table stakes for 2–20-site chains, our stated segment.
Franchise/enterprise does not fit a strict tree: a franchisor reads aggregate data across franchisee orgs it does not own. Model that as a permission graph, not a parent org:
scope_grant(principal_id, principal_type, scope_type /* org|brand|location|location_group */,
scope_id, role_id, granted_by, expires_at)location_group is a free-form, many-to-many tagging of locations (region, franchise cohort, "the ones with a drive-thru"). Price lists, menu publishes, and reports all target groups, never enumerated location lists.
#1.2 Isolation model
| Option | Verdict |
|---|---|
| DB per tenant | No. 10,000 Postgres databases is an ops project, not a product. Kills cross-tenant migrations and pooled connections. |
| Schema per tenant | No. Same problem at 1/3 the pain. Migration time scales linearly with tenant count. |
Shared schema, org_id on every row | Yes. With hard app-layer scoping + Postgres RLS as defence-in-depth. |
Concretely:
org_id uuid NOT NULLis the leading column of every composite primary key and every index. Not a trailing filter column — leading. This makes every index scan tenant-local and makes future sharding-by-org mechanical.- All DB access goes through a repository layer that takes a
TenantContextas a required first argument. There is no code path that constructs a query without it. Enforce with a lint rule + a test that fails if any raw SQL string lacksorg_id. - RLS policies (
USING (org_id = current_setting('app.org_id')::uuid)) are the backstop for the bug you will eventually write, not the primary mechanism. Set viaSET LOCAL app.org_idinside the transaction — works with PgBouncer transaction pooling. Accept ~3–8% planner overhead on complex joins [estimate]; exempt the analytics replica. - Every background job carries the tenant context in its payload. A worker that "processes all pending X" without a tenant loop is a leak waiting to happen.
#1.3 Data residency — cells, not row-level
Do not try to keep EU and US rows in one database with a residency column. Deploy cells: a complete, independent stack per region (eu-central, us-east, later eu-west/uk). A tiny global control plane holds only (org_id → cell, custom_domain → cell) for routing, and nothing else. An org is pinned to a cell at signup and cannot move without an export/import.
| Concern | Handling |
|---|---|
| Guest PII (GDPR Art. 17 erasure) | Guest profiles live in a separate guest service with its own DB. Orders carry guest_id only. Erasure = delete the guest row + set orders.guest_id = NULL + write a tombstone. Fiscal records (the receipt itself) must be retained 5–10 years in most EU states [known] — you pseudonymise the link, you do not delete the receipt. |
| Cross-border franchise | Not supported at v1. Say so in the contract. |
| LLM inference geography | Anthropic's first-party API supports inference_geo of "us" or "global" — there is no EU pin today [known, from current API docs]. For a DE/FR mid-market sale this is a real objection. Mitigation: never send PII to the model (aggregates + pseudonymous IDs only), DPA + SCCs, and a per-tenant "AI off" switch. See §7.12. |
Marginal cost of the second cell: ~2 eng-months (config plumbing, per-cell CI/CD, cross-cell auth for internal tooling) plus the infra. Cheap insurance; do it before the first US customer, not after.
#1.4 Per-tenant config and feature flags
Two mechanisms, deliberately different:
Config is a typed, versioned document resolved server-side down the chain org → brand → location_group → location → station, with a JSON-Schema'd shape and per-key merge semantics. The device never resolves inheritance — it receives one flat, immutable snapshot with an ETag and swaps atomically. Every config change is an audit event with actor + diff.
Flags split in two:
- Entitlements (what the plan includes:
inventory,multi_location,ai_analytics, seat counts). DB-backed, enforced server-side at the API boundary, cached ≤60s. Never trust the client. - Release flags (kill switches, % rollouts, per-tenant beta). Eventually consistent is fine. Use OpenFeature with a small internal provider or self-hosted Flagsmith/Unleash. Do not pay LaunchDarkly's per-MAU pricing when your unit is a €69/mo location [estimate: LD would be a meaningful % of gross margin at SMB scale].
#2. Stack recommendation
#2.1 Language
| TypeScript/Node 22 | Go | Elixir/Phoenix | Kotlin/JVM | |
|---|---|---|---|---|
| Type sharing with client apps | Best — one @rb/domain package used by server, RN POS, web back-office | None | None | None (unless KMP) |
| Realtime fanout / stateful per-location processes | Adequate (uWS, ~30–50k conns/node) | Good | Best (Channels, Presence, per-location supervision, hot reload) | Good |
| Money/decimal correctness | Weak by default — must ban number for money and use integer minor units | OK | OK | Best (BigDecimal, mature) |
| Hiring in UA/PL/CZ/RO | Best | Good | Thin | Good |
| AI-assisted codegen quality | Best (largest training corpus, best tooling) | Good | Weaker | Good |
| Ops footprint | Medium | Best (single static binary) | Medium (BEAM releases) | Heavy |
S1 (bootstrapped, founder + 2–4 eng): TypeScript everywhere. One language beats per-service optimality when four people own the whole platform. Node 22+, Fastify (not NestJS — the decorator/DI layer costs more than it gives at this size), Kysely or Drizzle over raw SQL (not Prisma — you will need CTEs, window functions, and partition-aware queries that Prisma fights).
S2 (funded, 15–30): same core, carve out two Go services when load justifies — (a) the device gateway (WebSocket fanout + printer/KDS routing; a Go binary handles 200k connections on one box), (b) nothing else. Resist the microservice reflex; a modular monolith with clean package boundaries plus 2–3 extracted services is the right shape until ~3,000 locations.
Elixir is genuinely the best technical fit for the realtime layer and the wrong business answer in this geography — you cannot hire for it in Kyiv/Warsaw at the rate you need, and AI assistance is materially weaker on it.
#2.2 The one non-negotiable: a single pricing/tax engine
The pricing + tax + modifier-resolution engine must be one implementation that runs identically on the POS device (offline) and on the server.
If you write it twice you will have penny mismatches forever, they will surface as till discrepancies at close, and you will spend the rest of the company's life debugging them. Options:
| Approach | Fits when |
|---|---|
Pure TypeScript module, zero deps, integer minor units, no Date, no floats | Client is React Native / Electron / web. Recommended for S1. |
| Rust → WASM (server via napi/wasmtime, iOS/Android via FFI) | Client is native Swift/Kotlin. ~+2 eng-months, worth it only if native is forced. |
Rules for the module: all money is bigint minor units; all time is { instant: epochMillis, tz: IANA } and never a bare Date; the whole engine is a pure function (cart, menuSnapshot, context) → PricedOrder; 100% branch coverage plus property-based tests (fast-check) asserting invariants like "sum(line totals) + tax + service − discounts === order total" and "reordering modifier selection never changes the price".
#2.3 Postgres
Postgres 17/18. One primary + 1–2 read replicas per cell.
Partitioning (do it at day one, not at 2,000 locations):
| Table | Strategy | Retention in Postgres |
|---|---|---|
order | RANGE on business_date, monthly | 13 months, then drop partition (data lives in ClickHouse) |
order_line, order_line_option | same key, same partitions | 13 months |
order_event (append-only) | RANGE monthly | 90 days hot, then archive |
payment, refund | RANGE monthly | 7 years (fiscal) — keep, it's small |
stock_movement | RANGE monthly | 90 days (this is the volume monster — see §4) |
time_entry | RANGE monthly | 7 years (labour law) |
menu_*, item, option_* | not partitioned | forever |
Sizing sanity check at 10,000 locations × 250 orders/day: 2.5M orders/day, ~8 lines each → ~20M line rows/day, ~600M/month. That is past comfortable for one Postgres primary. Two answers, both needed: (a) shard by org_id across 4–8 primaries per cell once you cross ~2,000 locations (Citus or app-level routing — app-level is simpler and you designed for it by putting org_id first in every key), (b) offload everything older than 90 days to ClickHouse.
IDs: every entity the client can create offline uses a client-generated ULID, not a sequence. Order creation is an idempotent upsert on (org_id, client_order_id). Sequences are reserved for fiscal document numbers, which need their own device-block allocation scheme (§6.3).
Pooling: PgBouncer 1.21+ (or Supavisor) in transaction mode. Verify prepared-statement support before committing to a driver.
Read replicas carry reporting under ~200 locations. Past that, reporting goes to ClickHouse and replicas exist only for HA and long-running exports.
#2.4 Realtime transport
Buy vs build: at 10,000 locations × ~6 devices = 60,000 concurrent connections with continuous KDS/order traffic. Ably/Pusher/Pusher-alikes price per connection-minute and per message; at that shape the hosted bill runs into five figures per month [estimate — model it against a real quote before dismissing]. More importantly, restaurants need sub-second KDS delivery over flaky venue wifi, and you need control of reconnect/backfill semantics. Self-host.
- WebSocket, not SSE. You need bidirectional: KDS acknowledges tickets, the server pushes device commands (open drawer, reload config, force sync), and the device streams heartbeats + health telemetry.
- One connection per device, authenticated with a device token, subscribed to
location:{id}plusstation:{id}. - Every message carries a monotonic per-location
seq. On reconnect the device sends its lastseqand the gateway replays the gap from a bounded buffer. Without this you get silent ticket loss on wifi blips, which is the #1 way a KDS product gets thrown out. - Fanout: Postgres
LISTEN/NOTIFYup to ~200 locations (genuinely fine, zero new infra), then NATS JetStream.
Why NATS over Kafka/Redpanda: thousands of low-volume, per-location ordered streams with small payloads is exactly NATS's shape. A 3-node JetStream cluster is one ~40 MB binary, gives per-subject ordering, at-least-once delivery, KV, and object store. Kafka/Redpanda is the right answer only when you're feeding a real analytics firehose at >100k msg/s — which is the analytics path, not the device path, and by then you can run both.
#2.5 Queues and durable workflows
Start with Postgres. A transactional outbox (outbox(id, org_id, topic, payload, created_at, published_at)) written in the same transaction as the domain change, plus SELECT … FOR UPDATE SKIP LOCKED workers, handles ~2,000 jobs/s and gives you exactly-once-into-the-bus semantics for free. Use pg-boss or graphile-worker (TS). Do not introduce a broker for job scheduling.
Temporal: the honest answer for S1 is no. Real candidates for durable execution are end-of-day close, PSP capture/refund sagas, menu-ingestion pipelines, and nightly accounting exports — four flows. Four flows do not justify Temporal's operational surface (self-hosted: frontend/history/matching/worker services + Cassandra or Postgres) or Temporal Cloud's floor of $100/mo Essentials (1M actions, 1 GB active / 40 GB retained storage, 99.9% SLA); Business from $500/mo; actions from $50 per million [verified]. Write the four flows as explicit state machines in Postgres with a resume-on-crash worker; that is ~3 eng-weeks total.
Revisit at S2 when you have >8 long-running flows and someone to own it. If you do, also evaluate Restate and DBOS — Postgres-native durable execution with a fraction of Temporal's footprint.
#2.6 Monorepo and contracts
pnpm workspaces + Turborepo (or Nx). Packages:
packages/
domain/ pure: money, tax, pricing, modifier resolution, availability. No I/O. 100% covered.
contracts/ Zod schemas → generated OpenAPI 3.1 → generated typed clients
sync/ the offline sync protocol: envelope, seq, conflict rules. Shared device+server.
ui/ design system (web + RN where shareable)
apps/
api/ Fastify, REST (OpenAPI-first)
gateway/ WebSocket + device commands + print routing
worker/ outbox consumers, jobs, exports
ai-gateway/ all LLM traffic (§7.12)
pos/ kds/ kiosk/ backoffice/API style: OpenAPI-first REST, defined from Zod schemas, clients generated with openapi-fetch. tRPC is tempting under TS-everywhere and it locks you out of native clients, partner integrations, and any future non-TS service — and you will have partners (aggregators, accountants, franchisors). One protocol, generated both ways.
#3. The menu/catalog engine — the part everyone underestimates
This is 30–40% of the backend and it is the part that decides whether a chain can actually use the product. Model it properly or rewrite it in year two.
#3.1 Core entities
item(id, org_id, brand_id, name_key, category_id, tax_class_id, portion_scheme, kind)
-- kind: standard | combo | open_price | prep_only
-- portion_scheme: whole | halves | quarters
item_variant(id, org_id, item_id, attrs jsonb, sku, plu, is_active, sort)
-- attrs = the ordered attribute tuple, e.g. {"size":"L","crust":"thin"}
-- the variant matrix is the cartesian product of the item's attribute axes,
-- MINUS explicitly suppressed combinations
item_attribute_axis(item_id, axis_key, values text[], sort)
option_group(id, org_id, brand_id, name_key,
min_select int, max_select int NULL, -- NULL = unlimited
free_count int DEFAULT 0,
free_policy enum('cheapest','most_expensive','first_selected'),
max_per_option int NULL, -- e.g. max 3 espresso shots
charge_policy enum('per_unit','flat'),
fraction_counts_as enum('full','fraction'), -- half-and-half interaction
display enum('list','grid','stepper','toggle'))
option(id, org_id, group_id, name_key, ref_variant_id NULL, sort, default_selected,
child_group_ids uuid[]) -- nesting
item_option_group(item_id|variant_id, group_id, sort,
override_min, override_max, override_free_count)Size is a variant, not a modifier. If size is a modifier you cannot do per-size recipes, per-size stock depletion, per-size barcodes, or per-size delivery pricing. This is the single most common data-model error in small POS products and it is unfixable later.
#3.2 Nested modifier groups: the pricing walk
Depth is capped at 3 in validation. Unbounded nesting destroys the touch UI and makes the pricing recursion unreviewable.
Pricing one line is a recursive walk. The awkward parts, in order of how often they bite:
- Free-count with a policy. "3 toppings free, then €1.50 each, free ones should be the most expensive." Sort selected options by effective unit price DESC, zero the first
free_countunits (not options — 2× extra cheese consumes 2 free slots), charge the remainder. - Quantity within a group.
max_per_optionandcharge_policy = per_unitvsflat. - Nested groups only price when their parent option is selected, and de-selecting the parent must cascade-remove children from the cart deterministically.
- Option price can be inherited from a referenced variant (
ref_variant_id) — "add a side salad" priced from the salad's own takeaway price, which changes with the channel.
#3.3 Half-and-half pizza (and the general fractional-topping problem)
Model it as fraction-weighted option application, not as two child orders:
order_line_option(order_line_id, option_id, quantity int,
portion_mask int, -- bitmask over the item's portion scheme
price_applied_minor bigint,
free_units_applied int)portion_mask for halves is 2 bits (0b01 left, 0b10 right, 0b11 whole); for quarters, 4 bits. Then the charge policy is brand configuration, not code — every chain has an opinion and they will churn if you hardcode the wrong one:
| Policy | Charge for a topping on one half |
|---|---|
proportional | unit_price × popcount(mask) / total_bits |
max | full price if on any portion |
average | avg across portions of that portion's topping set |
higher_base | base price = max of the two halves' base prices (orthogonal, applies to the item price) |
fraction_counts_as decides whether half a topping consumes a full free slot or half a slot. Both answers exist in the wild.
#3.4 Price levels: channel × time × location group
price_list(id, org_id, brand_id, name,
channels text[], -- {dine_in,takeaway,delivery_own,ubereats,glovo,kiosk,drive_thru,qr}
location_group_id NULL,
priority int,
valid_from date, valid_to date,
recurrence jsonb, -- {byday:[MO..FR], start:"16:00", end:"18:00"} in location tz
rounding_rule enum('none','nearest_10','psych_49_99'))
price(price_list_id, priceable_type enum('variant','option','combo'),
priceable_id, amount_minor bigint, currency char(3))Resolution: collect all price lists matching (channel, location, now-in-location-tz), order by priority DESC, specificity DESC, take the first hit per priceable. Happy hour is just a price list with a recurrence.
Store absolute prices, never percentage uplifts. A "+20% delivery" markup computed at read time produces rounding disputes with the operator, makes the aggregator menu sync (which takes absolute prices) non-deterministic, and breaks audit. Instead ship a UI action — "apply +20% and round to .49/.99" — that materialises real rows. Keep the rule on the price list so the operator can regenerate.
#3.5 Tax: where EU and US diverge hard
tax_class(id, org_id, code) -- food_standard, food_reduced, hot_drink, alcohol, nonfood, deposit
tax_rule(jurisdiction_id, tax_class_id, channel,
rate_bp int, -- basis points
inclusive bool,
compound_after_rule_id NULL,
effective_from date, effective_to date)
jurisdiction(id, country, region, locality, external_ref,
rounding_scope enum('line','tax_group','order'),
rounding_mode enum('half_up','half_even','down'))EU: VAT is inclusive in the displayed price, and the rate depends on both item class and channel — eat-in vs takeaway. DE 19% eat-in vs 7% takeaway; UK 20% eat-in vs 0% on cold takeaway; FR 10% vs 5.5%; PL reduced rates on prepared food [all known — confirm current rates per market with local counsel before shipping]. The channel dependency means that switching a check from eat-in to takeaway recomputes the entire order, and the guest-facing price may or may not change depending on whether the operator prices gross-constant or net-constant. Both behaviours must be configurable.
US: sales tax is exclusive, added at the end, and the rate is a function of state + county + city + special districts, ~13,000+ jurisdictions, sourced by delivery address for delivery, with prepared-food/soda/candy exceptions per state [known]. Nobody computes this themselves. You integrate Avalara AvaTax, Vertex, or TaxJar. That is a per-location vendor cost of roughly $50–500/location/month [estimate — get a real quote; it may be per-transaction instead]. At a €59–129/location SaaS price this can exceed your own revenue and is a genuine reason the US may be economically unviable at the SMB tier without renegotiated rates or ~6–9 eng-months building and maintaining your own rate engine.
Other tax details that will bite:
- Rounding scope and mode are jurisdiction properties, not global constants. Per-line vs per-tax-group rounding produces different totals and is a fiscal-audit item.
- Discounts reduce the taxable base proportionally across tax classes. A €5 off a check containing 20% and 7% items splits pro-rata.
- Service charge is usually taxable; tips usually aren't. Deposits (DE Pfand) are a separate non-taxable line.
- Compounding (QC GST+QST, some US locales) needs
compound_after_rule_id.
#3.6 Availability / 86 / dayparts
availability_override(scope enum('brand','location','station'), scope_id,
priceable_type, priceable_id,
state enum('available','86','low'),
until timestamptz NULL, -- auto-restore at time or at day close
reason, actor_id, created_at)
menu_schedule(menu_id, location_group_id, byday text[], start_time time, end_time time)86 must propagate to POS, KDS, kiosk, own web ordering and every delivery aggregator. Each aggregator (Uber Eats, Glovo, Bolt Food, Wolt, Deliveroo) has its own suspend-item endpoint, its own auth, and its own propagation latency of ~30s–5min [estimate]. This is a per-channel adapter behind a queue job, plus a periodic reconcile loop that re-asserts state because aggregators silently drop updates. Budget ~2–4 eng-weeks per aggregator, and expect them to change the API on you annually.
#3.7 Multi-language, allergens, images
Language is not name_en/name_pl columns. Use translation(entity_type, entity_id, locale, field, value) with a fallback chain location.locale → brand.default_locale → 'en'. Non-negotiable for a UA/CEE beachhead (uk, pl, cs, ro, en, ru) and unavoidable in Western EU. Critical detail: one order renders in ≥2 locales at once — the kitchen ticket in the kitchen-staff language, the receipt in the guest language. Design the renderer for that from day one.
Allergens: EU FIC Regulation 1169/2011 requires the 14 allergens to be declared for non-prepacked food [known]. The only maintainable model is to hold allergens on the ingredient and derive to item level through the recipe DAG — with a mandatory manual override layer, because the chef knows the fryer is shared:
allergen_state(item_id|variant_id, derived text[], added text[], removed text[], may_contain text[])Nutrition (calories) becomes mandatory for some channels/jurisdictions; same derivation path, plus a per-ingredient nutrition table you will mostly import rather than curate.
Images: originals in S3-compatible storage (Hetzner Object Storage or Cloudflare R2), derivatives via imgproxy or Cloudflare Images, three aspect ratios (1:1 POS tile, 4:3 kiosk, 16:9 delivery hero). Devices need an offline cache: ship a content-hashed manifest with the menu snapshot and give the device a ~500 MB budget with LRU eviction.
#3.8 POS layout / screen builder
Kept deliberately separate from the catalog:
screen(id, org_id, scope /* brand|location */, scope_id, kind enum('pos','kiosk','kds'), name, version)
screen_cell(screen_id, row, col, span_r, span_c,
kind enum('item','variant','category','screen_link','function','combo','spacer'),
ref_id NULL, label_override NULL, color, image_id NULL, sort)Layouts version and publish like menus. Hard requirement: a layout must survive its referenced item being 86'd, unpriced, or deleted — the cell renders disabled with a reason, never crashes and never blocks the whole screen. This is the single most common crash class in POS software.
#3.9 The publish model — the most important call in this document
The menu is an immutable, versioned, compiled artifact.
Editors mutate a draft graph. "Publish" runs validation, then compiles a denormalised menu_snapshot per (brand, location, channel-set, locale) with modifier trees, price lists, tax rules, availability, layout, and image manifest resolved into one document. Devices download by content hash and swap atomically.
Why this matters:
- The device does zero resolution logic (except the clock check for time-based price lists — see below), so offline pricing cannot drift from server pricing.
- You can diff two versions, roll back instantly, and show the operator exactly what changed.
- The same artifact feeds your own web ordering and (transformed) the aggregator menu sync.
- Menu correctness becomes testable: golden snapshots + property tests.
Costs: a 400-item menu with 300 modifier options compiles to roughly 1.5–4 MB JSON, ~300–700 KB gzipped [estimate]. Build time ~50 ms per location [estimate]; 10,000 locations = a parallelisable 10,000-job fan-out on publish, not a problem.
The one exception: time-varying prices cannot be baked to a single value. Ship all applicable price lists inside the snapshot and let the device select by now() in the location timezone. That requires the device to carry the IANA tz database and do DST-safe local-time comparison — a small, contained, and heavily-tested piece of the shared domain package.
#4. Inventory, recipes, COGS
ingredient(id, org_id, brand_id, name, base_unit enum('g','ml','ea'),
default_cost_minor_per_base_unit, yield_pct, allergens text[])
prep_item(id, org_id, name, recipe_id, batch_yield_base_units) -- sub-recipes: sauces, doughs
recipe(id, org_id, target_type enum('variant','option','prep_item'), target_id, version)
recipe_line(recipe_id, component_type enum('ingredient','prep_item'), component_id,
qty_base_units numeric, waste_pct)
stock_level(location_id, ingredient_id, on_hand numeric, avg_cost_minor, updated_at)
stock_movement(location_id, ingredient_id, delta numeric,
reason enum('sale','waste','transfer','count','receipt','production','void'),
ref_type, ref_id, cost_minor, occurred_at, business_date)Depletion timing: on order finalisation, not on order open — otherwise voids double-count. Explode each line's variant plus its selected options through the recipe DAG to leaf ingredients.
Volume warning. One 8-line order explodes to ~25–60 leaf movements. At 10,000 locations × 250 orders/day that is 60–150M movement rows per day. Therefore:
- Depletion is computed asynchronously from the
order_finalizedevent, never in the order write path. stock_movementis partitioned monthly with 90-day Postgres retention; full history lives in ClickHouse.stock_levelis maintained by a batched incremental aggregate (per location, per minute), not a per-order UPDATE — otherwise you serialise on hot ingredient rows during the lunch rush.
Costing: weighted average cost (WAC), recomputed on each receipt. It is the only method SMB operators understand and it avoids FIFO layer bookkeeping. Offer FIFO only when a chain demands it and charge for it.
Purchasing: PO → receipt (partial receipts, price-variance flagging) → invoice match. Supplier integration is where cost explodes — every distributor has a different format. Realistic v1: CSV/XLSX price-list import plus LLM-based PDF/email invoice ingestion (§7.1 extension). EDI (EDIFACT ORDERS/DESADV/INVOIC) only for the 2–3 dominant distributors per country, ~4–6 eng-weeks each [estimate]. In UA/PL you are more likely to integrate a wholesaler web portal, or nothing.
Honest verdict: inventory is a v2 feature. Independents rarely maintain it; 2–20-site chains do, and they are also the segment that pays. Ship theoretical COGS (recipe × sales, no live stock) first — it needs no counting discipline and delivers most of the menu-engineering value. Shipping half-working live stock is negative value: wrong numbers destroy trust in every other report.
#5. Labor
#5.1 Clock-in and time
time_entry(org_id, location_id, employee_id, job_role_id, clock_in, clock_out, breaks jsonb, source, approved_by, edited_from).
- Must work offline — buffer locally, sync later, resolve on the server by device clock + drift correction.
- Rounding to nearest 5/15 minutes is common and is illegal in some US states if it systematically favours the employer [known]. Make it configurable per jurisdiction and default to none.
- Every edit to a time entry is an append-only correction with actor and reason. This is a wage-claim evidence trail.
#5.2 Roles, permissions, manager approvals
RBAC with per-location role assignment plus per-action overrides. The actions that need explicit gating: void_after_send, discount > N%, comp, no_sale_drawer_open, refund, reopen_closed_check, price_override, 86_item, export_data, edit_time_entry.
Two approval channels, both required:
- Offline manager PIN at the device. Hashed PIN + permission bitmask ships inside the menu/config snapshot. Without this, every approval blocks when the wifi drops — which is exactly when a manager override is most likely.
- Online push approval to a manager's phone, with the request context. Nicer, auditable, but online-only.
#5.3 Tips — a legal minefield, not a feature
Model tip distribution as a small declarative rule, versioned and immutable per distribution:
tip_pool_rule(location_id, version, contributors jsonb /* [{role, pct_of_tips}] */,
distribution_basis enum('hours_worked','points','net_sales','equal'),
recipients jsonb, rounding, effective_from)
tip_distribution(location_id, business_date|period, rule_version, computed_at,
lines jsonb /* [{employee_id, basis_value, amount_minor}] */, locked bool)Every distribution must be reproducible from stored inputs. The rules themselves differ per country: US FLSA tip-pooling and tip-credit rules plus per-state credit-card-fee-deduction legality; DE tips tax-free under conditions; PL and UA have their own treatment [all known — none of this should be implemented without local counsel]. Do not guess. Budget 2–3 eng-months for the engine plus per-market legal review.
#5.4 Scheduling — don't build it in v1
Forecast-driven shift building, availability, swaps, labour-cost guardrails, and compliance warnings (EU Working Time Directive 11h daily rest / 48h weekly average; US predictive-scheduling ordinances in Seattle/SF/NYC/Oregon requiring 14-day notice and predictability pay [known]) is an entire product — Deputy, 7shifts, Planday all exist and are cheap.
v1 = clock-in + labour-cost-vs-sales reporting only. Integrate the rest. If you later build it, 6–9 eng-months plus the optimiser (§7.4).
#5.5 Payroll export (not payroll)
One canonical internal payroll period model, plus per-target adapters:
| Market | Target | Effort [estimate] |
|---|---|---|
| PL | Symfonia, enova365, Comarch Optima (file import) | 2–3 eng-weeks each |
| DE | DATEV LODAS / Lohn und Gehalt formats | 3–4 eng-weeks |
| UA | BAS / MEDoc export (1C is legacy and effectively off the table) | 2–4 eng-weeks each |
| US | Gusto / ADP / Paychex APIs; or Check (checkhq.com) as embedded payroll — a revenue line, not a cost | 3–5 eng-weeks, more for embedded |
#6. Reporting, end-of-day, accounting
#6.1 OLTP → OLAP
| Scale | Architecture |
|---|---|
| < 200 locations | Read replica + materialised views refreshed every 5–15 min. Costs nothing. Do this first. |
| 200 – 2,000 | Add ClickHouse (self-hosted). Feed it from the outbox domain-event stream, batched every 5s, not raw CDC. |
| > 2,000 | Per-cell ClickHouse cluster + AggregatingMergeTree pre-aggregates for the 20 reports everyone actually runs. |
Domain events over CDC. Debezium/logical-replication CDC couples your analytics schema to your OLTP schema and replays every column rename as an outage. Publish explicit events (order_finalized, payment_captured, stock_moved, shift_closed, menu_published) with versioned payloads. You already have the outbox.
Why ClickHouse over the alternatives:
- DuckDB — excellent embedded: run it in the browser (wasm) for a franchise dashboard over a downloaded parquet extract, or in a single-tenant back-office node. Not a multi-tenant server.
- BigQuery — technically great, but GCP-only, per-query pricing makes a click-around dashboard scary, and EU-residency-plus-US-parent is a Schrems II conversation you don't want in a German enterprise deal.
- ClickHouse Cloud — $0.2181 / $0.2985 / $0.3903 per compute-unit-hour on Basic/Scale/Enterprise (1 CU = 2 vCPU + 8 GiB), storage $25.30 per TB/month [verified]. That is ~$152–273/month per always-on CU. Two CUs + 1 TB ≈ $330–570/month — irrelevant at 1,000+ locations, but a meaningful fixed cost at 100. Self-host on Hetzner until ~500 locations, then reconsider.
#6.2 The 20 reports that matter
Sales by daypart / category / item / employee / channel; product mix (PMIX); void, discount and comp report; labour % of sales; hourly sales vs labour; tender summary; tax summary by rate; theoretical vs actual COGS; waste report; table turn time; ticket time (ordered → ready → served); guest count and average check; top/bottom movers; payment reconciliation; cash variance by employee.
Build these as a metric layer (named, parameterised, tenant-scoped SQL functions with a typed dimension/filter/period signature), not as 20 hand-written endpoints. You need this layer anyway for the AI analytics feature (§7.8) — that is where most of its value is.
#6.3 Business date, Z-report, and gapless numbering
Three things that must be exactly right or the product is unsellable in CEE.
Business date. Locations close at 02:00. Define day_start_time per location (e.g. 04:00 local), stamp business_date on every order at creation, and never derive it later. Every report, close, and export keys off it.
End-of-day close runs per location and produces an immutable, sequentially-numbered daily_close:
- Freeze the business date for new orders.
- Verify every registered device has synced. Block the close if any device holds unsynced orders — or force-close with a documented, signed exception. Silently closing over an offline tablet is how you lose a day's revenue.
- Sum by tender, tax rate, category, employee; compute cash expected vs counted.
- Write the
daily_closerecord with a per-location sequential number, hash-chained to the previous close.
Gapless sequential document numbering per location is a hard fiscal requirement in PL, RO, IT, HU, and others [known]. This is genuinely hard offline. The solution: pre-allocate number blocks to each device (device A gets 100000–100999, device B 101000–101999). An offline device can still issue correctly numbered receipts; unused numbers within a closed block are auditable and explainable. Design this in the schema at day one — retrofitting it means reissuing every fiscal document.
Fiscalization itself (fiscal printers PL/CZ/IT, RO ANAF, HU NAV real-time reporting, DE TSE/KassenSichV, FR NF525 certification, UA PRRO/Checkbox) is another agent's scope, but it imposes one hard constraint on this one: an order is immutable after fiscalization, and every change is a documented correction document. Which means the order domain must be an append-only event log with a projected read model, not a mutable row. That same design is what gives you offline sync and audit for free — so it is not a cost, it is the correct design anyway.
Cash reconciliation: cash_drawer_session(location_id, station_id, employee_id, opened_at, opening_float_minor, closed_at, counted_minor, expected_minor, variance_minor) plus paid_in/paid_out, safe_drop, and till_count(denomination, qty). Every drawer open logs actor + reason.
#6.4 Accounting exports
One canonical internal accounting_document (journal lines: account, cost centre, tax code, amount, business date, location) plus per-target adapters. Never let QuickBooks' object model leak into the core.
| Target | Mechanism | Effort [estimate] |
|---|---|---|
| QuickBooks Online | REST + OAuth2; JournalEntry or SalesReceipt per business date per location | 3 eng-weeks |
| Xero | REST + OAuth2 | 3 eng-weeks |
| DATEV (DE) | EXTF CSV Buchungsstapel with per-customer SKR03/SKR04 account mapping + a mapping UI | 4–6 eng-weeks — and it must be perfect or the Steuerberater rejects the file. A real DE market-entry barrier. |
| Fakturownia (PL) | REST API, straightforward | 1–2 eng-weeks |
| wFirma / iFirma / Comarch Optima (PL) | Mixed API/file | 2–3 eng-weeks each |
| UA | BAS export, MEDoc for e-documents; 1C is legacy/off the table | 2–4 eng-weeks each |
| RO | SAF-T D406 monthly XML to ANAF + e-Factura | 6–10 eng-weeks — heavy |
#7. The AI layer — what pays, what's theatre
Scores are 1–5. Value = willingness-to-pay + retention impact. Effort in eng-months. Risk = chance it produces a wrong output that costs the customer money or reputation.
| # | Feature | Value | Effort | Risk | Verdict |
|---|---|---|---|---|---|
| 1 | Menu ingestion from PDF/photo | 5 | 2–3 | 3 | Build first, before most of the POS |
| 2 | Demand/prep forecasting | 3.5 | 2–3 | 2 | Build the naive baseline day 1, ML at month 9 |
| 3 | Auto-86 / stockout prediction | 3 | 0.5–1 | 3 | Only for chains with real inventory |
| 4 | Scheduling optimisation | 4 | 4–6 | 4 | Defer to v3 or partner |
| 5 | Menu-engineering analytics | 4.5 | 1–1.5 | 1 | Build — best value/effort ratio in the list |
| 6 | Guest-message generation | 2 | 0.5 (+3–4 for the CRM plumbing) | 3 | The plumbing is the product; LLM copy is a bolt-on |
| 7 | Review response | 3 | 1–1.5 | 3 | Build — cheap, demoable, good CRM surface |
| 8 | NL analytics ("why was Tuesday down?") | 4 | 2–3 | 4 | Build — but the value is the metric layer |
| 9 | Voice ordering (phone) | 3.5 | 6–10 | 4 | Not v1. Phone-takeaway only, never drive-thru. |
| 9b | Voice ordering (drive-thru) | 3 | 12+ | 5 | No. |
| 10 | AI onboarding beyond menu (tax config, layout gen, POS migration) | 5 | 1–2 marginal | 2 | Build — kills the switching cost |
| 11 | Agentic back-office | 3 | 4–6 | 5 | v3, funded scenario only |
#7.1 Menu ingestion — the one that changes the unit economics
Onboarding a restaurant means building 200–600 items with modifier groups and prices by hand: 8–40 hours of skilled labour, €200–1,200 at EU rates [estimate — but every POS vendor's "free installation" is exactly this cost]. It is the reason POS sales cycles are long and the reason churn early in the lifecycle is fatal.
Pipeline:
upload PDF / photos / competitor CSV export
→ Claude (document + image blocks, citations enabled, output_config.format = JSON Schema)
→ structured draft: categories, items, variants, option groups, prices, allergens
→ human review UI: extracted field ⟷ source crop, confidence per field
→ publish (draft never auto-publishes)Cost, computed against current published rates [model prices verified; token counts estimated]:
| Model | Assumed 6-page menu: ~30k input tok, ~15k output tok | Cost per menu |
|---|---|---|
claude-opus-5 ($5 / $25 per MTok) | 30k×$5/1M + 15k×$25/1M | $0.53 |
claude-sonnet-5 ($3 / $15; intro $2/$10 through 2026-08-31) | at intro rates | $0.21 |
claude-haiku-4-5 ($1 / $5) | — | $0.11 (too weak for this; use for classification passes) |
Even with five verification passes you are under $3 per restaurant against €200–1,200 of human labour. Use claude-opus-5 here — this is the highest-stakes extraction in the product and the cost difference is noise.
Risk management (this is where it goes wrong):
- Enable citations on the document block so every extracted field carries a
page_location. Show the source crop beside the field in the review UI. - Never auto-publish. Block publish on any low-confidence price field until a human touches it.
- Run a numeric cross-check pass: a second call whose only job is to verify extracted prices against the source, with disagreement flagged rather than silently reconciled.
- Realistic accuracy: 85–95% of fields correct [estimate]. The win is 40h → 3–4h, not 40h → 0.
Extensions, same pipeline, near-zero marginal effort:
- POS migration — ingest a Lightspeed/Square/Toast/SumUp export and reproduce the configuration. This is the actual switching-cost killer and the reason a customer says yes.
- Supplier invoice ingestion — PDF/photo invoice → line items → fuzzy-match to
ingredient→ update WAC. This is what makes inventory adoptable (§4) and it is worth more than the forecasting. - Config bootstrapping — propose tax classes from country + category, generate the POS screen layout from the menu tree, infer modifier groups from menu prose.
#7.2 Forecasting — do not use an LLM for the numbers
A gradient-boosted model (LightGBM) over 8–12 weeks of per-location, per-item, per-daypart history with features {dow, hour, holiday, weather, local_event, promo_flag, lag_1/7/28, trailing_mean} beats an LLM at this, costs ~nothing to run, and is explainable to a sceptical owner. The LLM's job is to narrate the forecast, not produce it.
Sequencing matters: the naive baseline — "same weekday, trailing 4-week mean, ±trend" — captures ~70–80% of the value [estimate] and ships in a week with no data requirement. The ML model needs ≥8 weeks of clean per-location history, so it cannot ship at launch; build it when your earliest cohort has data. Pooled cross-location model with per-location calibration is materially better than per-location models once you have >100 sites.
#7.5 Menu-engineering analytics — best ratio in the list
Classify every item by margin × popularity (star / plowhorse / puzzle / dog) from PMIX + recipe COGS, then recommend price moves, menu placement, and deletions. This is almost entirely SQL over data you already have — no new pipeline, no new data collection, no ML.
The LLM adds only the narrative: "Your Caesar salad is a plowhorse — high volume, 24% margin against a 61% category average. A €0.80 increase costs roughly 4% of units at the elasticity we observe on your other salads and adds ~€340/month." That paragraph is worth more to the owner than the classification chart.
#7.7 Review response
Pull reviews via the Google Business Profile API (read + reply), TripAdvisor, and the aggregators' own review feeds; draft a reply in the brand voice; owner approves.
- Cost: ~$0.003 per review on
claude-haiku-4-5[estimate from token counts × verified rates]. - Hard rule: never auto-send below 4★. A badly auto-generated reply to a 1★ review is a PR incident that will be screenshotted. Auto-send 4–5★ optionally; queue everything else for approval.
- Most of the effort is OAuth and connector maintenance, not the model.
#7.8 Natural-language analytics — safe architecture only
There are two products here and only one is shippable.
| Approach | Verdict |
|---|---|
| Text-to-SQL over the open schema | No. Brittle; a wrong revenue number that looks right destroys trust permanently and you will never win it back. Also a tenant-isolation hazard. |
| Tool-calling over a fixed, parameterised, tenant-scoped metric layer | Yes. |
The model calls get_sales(dims, filters, period), compare_periods(...), get_labor(...), get_pmix(...). You wrote the SQL. You enforce the tenant scope in the tool implementation, not in the prompt. The model chooses the tool and its parameters, receives numbers, and writes prose.
"Why was Tuesday down?" is then not an LLM question at all — it is a scripted decomposition in code (traffic vs average check vs mix shift vs weather vs staffing vs a competitor promo), handed to the model as a set of computed deltas so it can write the paragraph. Cost ~$0.01–0.05 per question on claude-sonnet-5 with the schema and metric catalogue as a cached prefix.
Non-negotiables: generated SQL never touches the database; another tenant's data never enters the context; every answer links to the underlying report so the owner can verify.
#7.9 Voice ordering
Phone ordering for takeaway/pizza is real value — missed calls are lost orders — and the economics work:
| Component | Rate | 3-minute call |
|---|---|---|
| STT (Deepgram Nova-3 realtime, monolingual) | $0.0077/min, ~$0.46/hr; multilingual $0.0092/min; batch $0.0043/min; Growth tier ~$0.0065/min [verified] | $0.023 |
| LLM turn-taking + menu grounding | claude-sonnet-5, cached menu prefix | ~$0.05 [estimate] |
| TTS | provider-dependent | ~$0.03 [estimate] |
| Total | ~$0.10–0.20/call [estimate] |
The cost is fine; the engineering is not. Sub-800 ms turn latency, barge-in, noisy-line robustness, accent coverage across UA/PL/CZ/RO, menu grounding that never invents an item, and payment (or pay-on-collect). 6–10 eng-months to something you'd let a real restaurant run unattended, competing with well-funded specialists.
Drive-thru is categorically harder — 70–85 dB ambient, multiple speakers, vehicle noise, sub-500 ms latency, integration with the incumbent timer/headset hardware, and a public track record of failure (the McDonald's/IBM drive-thru AI pilot was wound down in 2024 [known]). Out of scope. Say no.
#7.11 Agentic back-office
"Every Monday: check last week's variance, draft the Bidfood order, flag waste outliers, email me." High value for a 5–20 site operator, near-zero for a single café owner who does this in his head. The risk is that an agent places a wrong €4,000 order.
Requires durable execution, a curated tool surface, spend caps, and human approval on anything that costs money. If/when you get there, Anthropic's Managed Agents (scheduled deployments on a cron, per-session dollar budgets, vault-held credentials that never enter the sandbox) removes most of the harness you'd otherwise build. v3, funded scenario only.
#7.12 Cross-cutting AI architecture
One ai-gateway service. Every LLM call goes through it. Nothing else in the codebase imports the Anthropic SDK.
Responsibilities: per-tenant monthly spend cap (a runaway agent loop on a €69/mo plan eats the whole margin), per-feature model routing, prompt versioning + eval harness, full request/response logging with PII scrubbing, and a global kill switch.
| Feature | Model | Why |
|---|---|---|
| Menu ingestion, invoice ingestion | claude-opus-5 | Highest-stakes extraction; cost is noise |
| NL analytics agent | claude-opus-5 (tool-calling), effort high | Tool selection quality matters |
| Menu-engineering narrative, review replies, campaign copy | claude-sonnet-5 | Good enough, 40% cheaper |
| Classification, ingredient matching, short labels | claude-haiku-4-5 | $1/$5 per MTok |
| Anything nightly/batch | Batch API | 50% discount [known] |
Cost levers, in order of impact:
- Prompt caching. The menu snapshot, metric catalogue, and brand voice are a stable prefix; cache reads cost ~0.1× and writes 1.25× [known]. Put tenant-varying content last. Minimum cacheable prefix: 512 tokens on
claude-opus-5, 1024 onclaude-sonnet-5[known]. - Batch API for everything not user-facing (nightly digests, review drafts, forecast narratives).
- Effort tuning —
low/mediumonclaude-opus-5is strong and much cheaper than thehighdefault.
Realistic AI spend at steady state: €0.30–1.50 per location per month [estimate] for the analytics/review/narrative features, plus a one-off ~€0.50–3.00 per onboarding. Against a €59–129/mo price point that is 0.5–2% of revenue. AI cost is not a risk; AI correctness is.
#8. Infra cost per location per month
Assumptions per location: 250 orders/day, 8 lines/order, 6 devices (3 POS, 2 KDS, 1 back-office), 14 open hours. Self-hosted on Hetzner (EU cell), offline-first devices.
Pricing caveat: Hetzner raised cloud server prices in April 2026 — CCX33 reportedly moved from €62.49 to €138.49/month [verified, single secondary source — confirm on hetzner.com before budgeting]. The tables below use post-increase figures. If the increase is smaller than reported, every number improves.
#100 locations (S1, one EU cell)
| Component | Spec | €/mo [estimate] |
|---|---|---|
| App servers (API + gateway + worker) | 2× CCX23 (4 vCPU / 16 GB) | 110 |
| Postgres primary + replica | 2× CCX33 (8 vCPU / 32 GB) + NVMe volumes | 320 |
| NATS / Redis (or skip NATS entirely at this scale — use LISTEN/NOTIFY) | 3× CX32 shared | 45 |
| Object storage + CDN | 200 GB + Cloudflare | 15 |
| Observability (self-hosted Grafana/Loki/Prometheus) | 1× CCX23 | 55 |
| Offsite backups | 500 GB | 10 |
| Infra subtotal | ~555 | |
| Vendor (Sentry, transactional email, SMS) | ~80 | |
| AI | ~50–150 | |
| Total | ~€690–790 → €6.90–7.90 / location / month |
#1,000 locations
| Component | Spec | €/mo [estimate] |
|---|---|---|
| App servers | 6× CCX33 behind LB | 830 |
| Postgres | 1× CCX53 primary (32 vCPU / 128 GB) + 2× CCX43 replicas + NVMe | 1,400 |
| ClickHouse (self-hosted) | 3× CCX43 + 2 TB | 750 |
| NATS 3-node + Redis 3-node | 6× CX42 | 180 |
| Object storage + CDN | 2 TB | 90 |
| Observability | 2× CCX33 | 280 |
| Backups | 5 TB | 60 |
| Infra subtotal | ~3,590 | |
| Vendor + AI | ~1,800–3,200 | |
| Total | ~€5,400–6,800 → €5.40–6.80 / location / month |
#10,000 locations (two cells: EU + US)
| Component | Spec | €/mo [estimate] |
|---|---|---|
| App tier (both cells) | ~30 nodes | 4,200 |
| Postgres | 4–6 sharded primaries + replicas per cell | 12,000 |
| ClickHouse | 6–9 nodes per cell, 30–60 TB | 9,000 |
| Messaging + cache | NATS + Redis clusters, both cells | 1,200 |
| Object storage + CDN | 30 TB + egress | 1,400 |
| Observability | dedicated cluster per cell | 1,800 |
| Backups + DR | 900 | |
| Infra subtotal | ~30,500 | |
| Vendor + AI | ~6,000–18,000 | |
| Total | ~€36,500–48,500 → €3.65–4.85 / location / month |
#What this means
- Infra is never the constraint. At a €59–129/location/month price point, infra is 3–8% of revenue and falls with scale. Stop optimising it and optimise support cost, hardware logistics, and payments margin instead.
- Managed everything costs 3–5× more [estimate]: AWS RDS + MSK + EKS + ClickHouse Cloud for the 1,000-location workload lands around €18–28k/mo vs ~€3.6k self-hosted. Meaningful at S1, irrelevant at S2. Managed Postgres alternatives for reference: Neon at $0.106/CU-hour + $0.35/GB-month storage, Supabase Pro from $25/mo/org [both verified] — both are fine for the control plane, neither is the right home for a partitioned 600M-row/month OLTP store.
- The offline-first design is what keeps this cheap. If the POS must reach the server for every keystroke you need 5–10× the capacity and 99.99% uptime and a 24/7 on-call rota. Offline-first means the backend can be down for an hour and no restaurant stops selling. That is simultaneously a cost argument, a risk argument, and a sales argument.
- US latency: Hetzner has Ashburn and Hillsboro, but if you sell US enterprise you will be asked for AWS/GCP. Budget the US cell at 2–3× the EU cell's unit cost.
#Build effort
Engineer-months for backend platform + domain services + AI layer only. Excludes POS/KDS/kiosk client apps, hardware certification, payment terminal integration, aggregator/delivery integrations, web ordering, and the marketing site — those belong to other domains.
Two scopes: S1-cut = one country (UA or PL), café/QSR segment, no live inventory, no scheduling, no US. Full v1 = multi-country CEE, SMB + small chains, inventory and labour included.
| # | Work item | S1-cut | Full v1 | What drives the variance |
|---|---|---|---|---|
| 1 | Multi-tenancy, auth, RBAC, config resolution, feature flags | 2.5 | 3.5 | Franchise permission graph; cell/residency plumbing |
| 2 | Menu/catalog engine — model, modifier resolution, price lists, publish/snapshot compiler, editor API | 9 | 16 | Half-and-half policies, combos, variant matrices, per-channel pricing. The single biggest item and the one AI helps least with. |
| 3 | Tax engine | 2 | 5 | EU-only inclusive VAT (2) vs + US exclusive multi-jurisdiction + Avalara (5) |
| 4 | Order domain: append-only events, projections, offline sync protocol, conflict rules, device number blocks | 7 | 9 | Table service (courses, splits, transfers, seats) roughly doubles this vs counter-service |
| 5 | Payments orchestration (PSP-agnostic; excl. terminal drivers) | 3 | 5 | Whether you take payments margin (adds settlement + chargeback ledger, +6–10 separately) |
| 6 | Realtime gateway, KDS routing, print routing, device commands | 3 | 4 | Reconnect/backfill semantics; multi-printer failover |
| 7 | POS layout / screen builder backend | 1.5 | 2 | Kiosk + KDS layout variants |
| 8 | Inventory, recipes, prep items, COGS, waste, purchasing | 0 | 7 | Cut entirely in S1. Supplier EDI adds 1–1.5 per distributor. |
| 9 | Labour: clock-in, approvals, tip pooling | 2 | 5 | Tip rules are per-jurisdiction + legal review. Scheduling excluded from both. |
| 10 | Reporting: metric layer, Z-report/EOD close, cash reconciliation | 4.5 | 7 | Fiscal close rules per country |
| 11 | Analytics pipeline (events → ClickHouse, pre-aggregates) | 1.5 | 3 | Only needed above ~200 locations |
| 12 | Accounting + payroll exports | 1.5 | 4 | 1 format (S1) vs 4–5 (DATEV and RO SAF-T alone are 10–16 eng-weeks) |
| 13 | AI: gateway + menu/POS-migration ingestion + review UI | 3 | 4 | Grounding/citation UI and the verification pass |
| 14 | AI: menu-engineering analytics + NL analytics (metric-layer tool calling) | 2 | 4 | Metric layer is shared with #10 |
| 15 | AI: review response + guest messaging copy | 0 | 2 | Depends on CRM/consent plumbing existing |
| 16 | Forecasting (baseline → LightGBM pipeline + backtesting) | 0.5 | 3 | Baseline is a week; ML needs 8+ weeks of production data first |
| 17 | Platform: CI/CD, IaC, observability, on-call, backup/DR, load testing | 4 | 6 | Multi-cell doubles the deploy surface |
| 18 | Data residency: second cell, control plane, routing | 0 | 2 | Only when the first US/DE customer appears |
| Total (conventional estimate) | ~47 | ~91 | ||
| With heavy AI-assisted development | ~30–36 | ~58–68 | See multiplier note below |
On the AI-assist multiplier — be honest with yourself. Strong AI-assisted development realistically delivers 1.5–2.5× on greenfield CRUD, adapters, exports, and glue, and only 1.1–1.3× on the parts that dominate the critical path: the pricing/tax engine's correctness, the offline sync conflict model, and fiscal compliance. Do not apply a flat 3× to the total. Items 2, 3, 4, and 10 are ~45% of the effort and are the least accelerated.
S1 reality check. Founder + 3 engineers × 24 months = ~84–96 eng-months across all domains — backend, POS client, KDS, back-office UI, hardware certification, integrations, and the go-to-market work. The backend S1-cut at 30–36 eng-months consumes 35–45% of that. That is feasible only with the cuts above (single country, counter-service, no inventory, no scheduling, no US, one accounting format). Full v1 at 58–68 eng-months of backend alone is an S2 scope.
#Open questions / what would change this answer
- Which single beachhead — UA or PL? UA is fastest to iterate (lowest compliance surface via Checkbox/Vchasno PRRO, existing network, cheapest support) but ARPU is perhaps 1/5 of PL [estimate]. PL is ~10× the revenue per location and drags in fiscal printers, JPK_V7, Fakturownia/Optima, and Polish labour law — roughly +6–10 eng-months in year one. This choice moves the S1 backend estimate by ±8 eng-months and decides whether the company is default-alive at 100 locations.
- Do we take payments margin? If yes, add 6–10 eng-months (settlement ledger, chargebacks, reserves, KYC/onboarding, reconciliation-to-the-cent) and probably a PSP/PayFac partnership — but it typically doubles or triples revenue per location and is how every successful modern POS actually makes money. If no, the SaaS price must carry everything and the US becomes near-impossible (§3.5). This is the single biggest unresolved input to the business model.
- Counter-service only, or full-service? Table management, coursing, seat-level ordering, check splitting, and transfers roughly double item #4 and add ~30% to the menu engine. Cafés/QSR is the far cheaper v1 and a defensible beachhead.
- Inventory in v1 or v2? The difference between ~30 and ~40 eng-months of backend in the S1 scope. Recommendation: theoretical COGS in v1, live stock in v2 — but if the target segment is 5–20-site chains, they will ask for it in the first demo.
- Native iOS POS, or React Native / web? Decides whether the shared pricing engine can stay TypeScript (cheap) or must become Rust→WASM (+2 eng-months, better long-term). Also decides whether Apple's 15–30% cut applies to any in-app purchase surface.
- What accuracy on AI menu ingestion is good enough to claim "onboard in an hour"? If 90%-with-review is enough, the whole onboarding cost model changes and it becomes the headline feature. If operators insist on 99%, it's a nice time-saver and nothing more. This is testable in two weeks with 20 real menus and should be the very first thing built.
- Is there an EU inference region for the LLM provider by contract time? Today the first-party Anthropic API pins to
usorglobal, with no EU option [known]. A DE/FR mid-market deal can stall on this. Workarounds (aggregates only, no PII, DPA + SCCs, per-tenant AI kill switch) cover most cases; verify current availability before promising anything in an enterprise RFP. - Confirm the Hetzner April 2026 price increase. The reported CCX33 move from €62.49 → €138.49/month [verified, single source] roughly doubles the compute line in §8. It does not change the conclusion (infra stays under 8% of revenue) but it does change whether self-hosting is 5× cheaper than managed or only 2.5×.