EPIDOM
EPIDOM is a free, all-in-one operating system for small Indonesian food & beverage businesses — warung, café, restaurant, cookie bar, home kitchen. The wedge is a customizable public storefront page (replaces Linktree + Google Drive menu + WhatsApp ordering). The ladder upsells to POS, operations (shift, KDS, inventory), and finance reports.
Tech Stack
| Layer | Technology |
|---|
| Framework | Next.js 16 (App Router, Turbopack), React 19 |
| Language | TypeScript 5 |
| Database | PostgreSQL 16 + Prisma ORM 7 (pg driver adapter) |
| Auth | Better Auth (email/password + Google OAuth) |
| Payments | Stripe (SaaS billing) · Xendit (QRIS/GoPay/OVO/DANA) |
| State | TanStack Query v5 |
| UI | shadcn/ui (New York) · Tailwind CSS 4 · Radix · Lucide |
| Theme | next-themes · epi-navy dark tokens · epi-cream light |
| i18n | Custom provider — fr primary, then id, en |
| Email | Resend |
| Storage | Vercel Blob |
| Background jobs | Inngest |
| Hosting | Vercel |
Developer Setup
Prerequisites
- Node.js 22
- pnpm 10 (pnpm 9 rejects the config-only
pnpm-workspace.yaml)
- PostgreSQL 14+ (local) or a Neon/Vercel Postgres database
1. Install
pnpm install
2. Environment Variables
Copy .env.example to .env and fill in values. The two most critical for database:
# Pooled endpoint — used by the app at runtime
DATABASE_URL="postgresql://user:[email protected]/db?sslmode=require"
# Direct endpoint — used by Prisma CLI for migrations
DIRECT_URL="postgresql://user:[email protected]/db?sslmode=require"
See .env.example for the complete reference.
3. Database
pnpm prisma migrate deploy # apply all migrations
# demo data: POST /api/admin/seed-demo with header x-seed-secret = SEED_DEMO_SECRET ([email protected] / password123)
4. Run
pnpm dev # Turbopack dev server → http://localhost:3000
pnpm build # production build (runs migrate deploy + changelog sync first)
pnpm start # serve production build
5. Other Scripts
pnpm type-check # tsc --noEmit
pnpm lint # ESLint
pnpm test # Vitest (500+ test files)
pnpm prisma studio # DB GUI
pnpm prisma migrate reset # ⚠️ wipes local DB — dev only
Project Structure
src/
├── app/
│ ├── (app)/ # Authenticated app surface
│ │ ├── (auth)/ # Login, register, password reset, onboarding
│ │ ├── admin/ # Admin panel (master accounts only)
│ │ ├── owner/ # Redirect → Finance "All outlets" scope
│ │ ├── profile/ # User profile & subscription
│ │ ├── stores/ # Store selector
│ │ └── store/[storeId]/(dashboard)/
│ │ ├── dashboard/ # Stock overview, alerts, production chart
│ │ ├── data/ # Products · Materials · Recipes · Suppliers
│ │ ├── management/# Deliveries · Production · History · Stock
│ │ ├── alerts/ # Low-stock & critical alerts
│ │ ├── storefront/# Storefront editor + analytics
│ │ ├── staff/ # Staff management
│ │ ├── shifts/ # Shift management
│ │ └── finance/ # Finance reports + All outlets roll-up
│ │ └── store/[storeId]/(pos-mode)/
│ │ ├── pos/ # POS cashier · orders/ · kds/ · operational/ (shift, schedule, clock)
│ │ └── tables/ # Table management + reservations
│ │ (+ store/[storeId]/pos/display — chrome-free customer display)
│ ├── (marketing)/ # Public marketing site
│ ├── (public)/ # Public storefronts /@slug
│ └── api/ # REST API routes
├── features/ # Feature modules (one folder per domain)
├── components/ # Shared UI (shadcn primitives, providers)
├── hooks/ # Shared custom hooks
├── lib/ # Auth, Prisma, services, validation, utils
├── locales/ # fr.ts (primary) · id.ts · en.ts
└── types/ # TypeScript DTOs
Product Overview
Goal, in one layer per row
| Layer | Who it serves | The job it does |
|---|
| Storefront (the wedge) | Any merchant, day 1 | Replace Linktree + a Google Drive PDF menu + WhatsApp ordering chaos with one link |
| POS | Merchants who serve in person | Take the order, take the money, feed the kitchen — on the phone or tablet they own |
| Operations | Merchants with staff and recipes | Know the cost of a dish, who worked when, and what is about to run out |
| Finance (Operations plan) | Owners of one outlet or several | Consolidate margin across outlets and channels, and pay per channel honestly |
| Enterprise (service) | Groups of 4+ outlets; businesses that need their own system | Scope and build it with the Prionation team, hosted on Prionation infrastructure |
Market priority: France primary, Indonesia secondary, worldwide via en + USD (see docs/STRATEGY.md §3). Money is stored and reasoned about in a base currency and rendered in any of 156 ISO-4217 currencies through useCurrency().
Plans & pricing
| Plan | Monthly | Yearly (save 16%) | Trial | Who it is for | Upgrade trigger |
|---|
| FREE | Rp 0 / $0 | — | n/a, no card | Solo warung, home baker, food truck | Customers start asking to order online |
| POS | Rp 229,000 / $14.99 | $12.49/mo | 14 days, card required | Café or warung with a cashier | They hire a cashier and can't track orders by hand |
| OPERATIONS | Rp 459,000 / $29.99 | $24.99/mo | 14 days, card required | Multi-staff café or restaurant, up to 3 outlets | Second shift or second outlet; ingredient cost starts to hurt |
| ENTERPRISE | Custom, admin-quoted | Custom | Talk to us (WhatsApp) | A group of 4+ outlets, or a business that wants its own system built | A 4th outlet, or a custom build, integration or SLA |
- IDR is the pricing base. Every other display currency is derived live from it, not hardcoded — so all 156 currencies work, not a chosen few.
- Trial is a real Stripe
trial_period_days: 14 on POS and OPERATIONS checkout. /pricing?trial=true#plans auto-opens the POS trial confirm.
- Outlets are capped per plan (since 2026-09-27): FREE 1, POS 1, OPERATIONS up to 3, ENTERPRISE unlimited (
PLAN_MAX_STORES + minPlanForStores in src/lib/plans/entitlements.ts, read by getStoreLimit / canCreateStore). Checked on store creation and when a store ownership transfer is accepted. A downgrade never removes or locks a store: a business over its new limit keeps every store but can't create or receive another, and Billing shows "Over your plan's limit". Prices are unchanged; whether to publish an ENTERPRISE "from" price is open.
- Enterprise is sales-led: unlimited outlets plus a service (a service since 2026-09-26).
FEATURE_MIN_PLAN gates nothing to ENTERPRISE; what it unlocks is a 4th outlet and beyond. It is also your own internal system or website scoped and built with the Prionation team (the company behind Epidom; see /build-with-us) and hosted on Prionation infrastructure, plus custom integrations (accounting, delivery apps, payments), priority support, onboarding and data migration, a dedicated account manager and custom SLAs. It starts from "Talk to us" (WhatsApp) on /pricing or a Custom Development request, which any paid plan can send.
- Enterprise pricing is an offer, not a catalog price. An admin sets
Subscription.customPrice*; the old subscription is canceled, access suspends (customPricePendingAt), and requirePlan routes the user to Billing, not /pricing, because /pricing cannot sell them the quoted price.
- Beta / admin-granted accounts (
admin_/free_-prefixed Stripe customer ids) switch plans instantly with no payment method, via POST /api/subscriptions/beta-plan.
⚠️ STRIPE_CONFIG.PLAN_LIMITS in src/config/stripe.config.ts is only half live. Its limits are read (getStoreLimit / canCreateStore / getProductLimit; maxStores comes from PLAN_MAX_STORES), but its name / price fields (EUR 29/79, "Starter"/"Pro") predate a price rise and nothing reads them. The live prices are PLAN_PRICE_IDR (src/lib/constants/plan-pricing.ts) + the /pricing copy.
How a plan is actually enforced — three independent layers
| Layer | Where | What it does |
|---|
| Entitlement map | src/lib/plans/entitlements.ts FEATURE_MIN_PLAN, PLAN_MAX_STORES | The single source of truth: feature → minimum tier, and stores per plan |
| Server gate | requirePlan(storeId, "OPERATIONS") in the page/layout | Redirects to the right upgrade URL before anything renders |
| Nav gate | src/config/navigation.config.ts requiredPlan | Sidebar shows the item in a locked state rather than hiding it |
Public endpoints re-check the owner's plan (api/public/orders, api/public/reservations) so a stale acceptsOrders flag on a downgraded account can't leak a paid feature to customers. The Finance report APIs do the same through requireStoreFeatureApi(storeId, "finance") (src/lib/auth/require-store-feature.ts): the store owner's plan, ACTIVE only.
Feature catalog
Status legend — ✅ Live · 🔸 Live, opt-in (off by default behind a store/device toggle) · 🟡 Partial (narrower than the docs claim) · ⚪ Planned
Public storefront & customer ordering
| Feature | What it does | Goal | Plan | Status |
|---|
Storefront page /@slug | Logo, hero, theme colour, font, tagline, description, opening hours | Give the merchant a shareable identity in 5 minutes | FREE | ✅ |
| Link hub | Instagram, TikTok, WhatsApp, GoFood, GrabFood, ShopeeFood, Google Maps, arbitrary custom links | Replace Linktree outright | FREE | ✅ |
Public menu /[slug]/menu | Categories, photos, prices, descriptions, modifiers, featured, sold-out toggle | Replace the Google Drive PDF | FREE | ✅ |
| QR code download | Printable asset pointing at the storefront | Table tents, packaging, shopfront stickers | FREE | ✅ |
| Storefront analytics | VIEW / MENU_VIEW / ITEM_VIEW / WHATSAPP_CLICK, unique visitors, trend vs. previous period, top items by orders and by views | Prove day-1 value so the merchant keeps the link up | FREE | ✅ |
| Direct ordering + tracking | Cart, checkout, dine-in/takeaway/delivery, /[slug]/order/[orderId], order lookup, public receipt /r/[orderId] | Take the order without a WhatsApp thread | POS | ✅ |
| Table reservations | Public booking form → reservations dashboard | Stop losing bookings in DMs | POS | ✅ |
Privacy note: analytics uses a daily-rotating sha256(ip:userAgent:date:storefrontId) fingerprint. No raw IP is stored and no cookie is set, so no consent banner is required — the trade-off is that "unique visitors" over a range is a sum of daily-uniques, not a lifetime-unique count.
docs/FEATURES.md lists direct ordering and reservations as FREE-tier (Phase 2). The code gates both at POS. The code is the behaviour that ships.
Point of sale & service
| Feature | What it does | Goal | Plan | Status |
|---|
| Cashier | Item grid by category and department, cart, guest count, holds, notes | Ring up an order faster than a notebook | POS | ✅ |
| Checkout | Discounts (% or fixed), service charge and tax presets, split/multi-tender, refunds, line edits | Handle the money exactly as the shop already does | POS | ✅ |
| Payment methods | CASH, QRIS, GOPAY, OVO, DANA, SHOPEEPAY, LINKAJA, BANK_TRANSFER, STRIPE_CARD, CHEQUE, TITRE_RESTAURANT, PAYPAL, APPLE_PAY, GOOGLE_PAY, PAY_LATER — bucketed by PaymentMarket (INDONESIA / FRANCE / INTERNATIONAL) | One POS for three markets | POS | ✅ |
| Pay Later (tabs) | Serve now, settle after delivery | Warungs that run tabs for regulars | POS | 🔸 Store.payLaterEnabled |
| Order queue | Unified walk-in + online + aggregator queue over SSE; CONFIRMED → IN_PRODUCTION → READY → DELIVERED, plus HELD / CANCELLED | One screen that is always the truth | POS | ✅ |
| Kitchen Display (KDS) | Live tickets, Kitchen/Bar department routing, per-station settings | Stop shouting orders across the pass | POS | 🔸 Store.kitchenDisplayEnabled (on by default; off ⇒ paid orders go straight to DELIVERED, for counter-only stands) |
| Customer-facing display | Second screen at the counter showing the line just rung up, running receipt, total, and a thank-you on settle | Let the customer verify the order as it is entered | POS | 🔸 per-device toggle (/pos/display) |
| Receipts | ESC/POS thermal (58 mm / 80 mm), PDF/print views, email receipt, WhatsApp receipt | Every shop's receipt habit, unchanged | POS | ✅ |
| Offline mode | Orders queue locally and sync on reconnect; PWA + service worker | Indonesian tills lose the network constantly | POS | ✅ |
| Tables | Zones, table states, assign/move orders, reservations dashboard | Dine-in floor control | POS | ✅ |
| Menu management |
Operations — stock, recipes, people
| Feature | What it does | Goal | Plan | Status |
|---|
| Data catalogue | Products, Materials, Recipes, Suppliers — CRUD, bulk actions, CSV export, SKU collision check, category management | The reference data everything else costs against | OPERATIONS | ✅ |
| AI Smart Import | Analyse a CSV/spreadsheet, propose a mapping, execute, and remember the merchant's corrections (AIImportMemory) | Onboard a real shop's messy spreadsheet in minutes | OPERATIONS (POS for the onboarding CSV step) | ✅ |
| Two-tier stock | Product.stockMode — BATCH_PRODUCED draws a counted finished-goods balance; MADE_TO_ORDER explodes its recipe into raw materials on every sale; UNTRACKED does neither | A croissant and a latte are not the same inventory problem | OPERATIONS | ✅ |
| Recipes & HPP/COGS | Recipe builder, ingredient quantities, automatic cost per dish, 30-day POS demand badge | Know the margin on the thing you just sold | OPERATIONS | ✅ |
| Automatic deduction | Stock is drawn on order → DELIVERED inside a serializable transaction | Stock that matches reality without data entry | OPERATIONS | ✅ |
| Movements ledger | Polymorphic StockMovement across material and product: PURCHASE, PRODUCTION_IN/OUT, SALE, ADJUSTMENT, WASTE, RETURN, source-tagged with the POS order # or batch # | An audit trail for every unit | OPERATIONS | ✅ |
| Waste & loss | EXPIRED, DAMAGED, SPOILED, OVERPRODUCTION, QUALITY_CONTROL, OTHER | The only way shrinkage gets expensed under sale-recognised COGS | OPERATIONS | ✅ |
| Prep list & count sheet | Suggests minStock − currentStock netted against outstanding shortfall debt; one-tap start/complete; a count sheet to reconcile | Batch-produced stock only exists if somebody prepares it | OPERATIONS | ✅ |
| Production batches |
Finance & multi-outlet
| Feature | What it does | Goal | Plan | Status |
|---|
| P&L summary | Revenue, COGS, gross margin % over any date range, daily/weekly/monthly | The number the owner actually wants | OPERATIONS | ✅ |
| Report cuts | By category, by department, by item margin, by payment method, by staff shift, by roster shift-block, by waste reason, cash reconciliation, top items | Find where the margin went, not just that it went | OPERATIONS | ✅ |
| Channel margin | Per-channel revenue across MANUAL, STOREFRONT, POS, GOFOOD, GRABFOOD, SHOPEEFOOD, TOKOPEDIA, with commission and net | Aggregator commission is invisible until you price it | OPERATIONS | ✅ |
| Aggregator ingestion | Inngest + OpenAI parse aggregator order emails into source-tagged orders (AggregatorConnection, AggregatorEmail) | One queue instead of four tablets | Not gated | 🟡 email parsing (v1) live; direct partner APIs not built |
| All outlets roll-up | Owner-only "All outlets" scope in Finance (?scope=all; /owner redirects there): totals plus a sortable by-outlet table | Multi-outlet owners stop opening five dashboards | OPERATIONS | ✅ |
| Export & print | CSV / Excel export and a dedicated print view for every report | Accountants want a file, not a screenshot | OPERATIONS | ✅ |
| Custom development requests | NEW → IN_REVIEW → QUOTED → IN_PROGRESS → COMPLETED / DECLINED, with admin triage | Start an Enterprise build without leaving the product | POS | ✅ |
| Customer analytics | Repeat/behaviour analytics per store (dashboard card) | Know who comes back | All | ✅ |
Finance moved from ENTERPRISE to OPERATIONS on 2026-09-26, and the old Owner dashboard became its All outlets scope. That scope shows only to the business owner (not staff personas) when the business has more than one store: revenue, gross profit and margin, waste loss, net profit, outlet count, orders awaiting payment, and a by-outlet table with totals that links into each outlet's own report for the same dates. Each outlet uses the same calculation as its single-outlet report; outlets in different currencies are shown in their own currency and never added together.
The top items, by department and finance settings APIs stay open on every plan, because the main dashboard, storefront editor and POS use them; the Finance page and its other report APIs require OPERATIONS. Aggregator ingestion checks no plan: the inbound email webhook routes any storefront slug.
Account, platform & cross-tier
| Feature | What it does | Plan | Status |
|---|
| Auth | Better Auth — email/password + Google OAuth, email verification with resend, password reset; OAuth failures land on /login with a readable toast | All | ✅ |
| Guided onboarding | 5 steps (business → logo → menu → theme → publish), AI-assisted profile analysis, menu suggestion, and logo generation; hasOnboarded makes the redirect server-side | All | ✅ |
| Multi-store | Store selector; multiple outlets under one Business | OPERATIONS (up to 3), ENTERPRISE (4+) | ✅ |
| Profile & preferences | Contact, business details, timezone (browser-detected, timezoneUpdatedAt records that it was really set), locale, currency, "resume where I left off" | All | ✅ |
| Owner PIN | Business.ownerPin + OTP reset — how the real owner steps back out of a staff persona | All | ✅ |
| Account deactivation | Soft delete → 30-day self-service reactivation → up to 1 year support-quoted recovery → nightly purge job | All | ✅ |
| Billing surface | Stripe Checkout + Customer Portal, plan switch, trial state, custom-price offers; Xendit handles customer payments (storefront online payment is switched off for now: orders are pay-at-cashier) and is kept in a separate module by rule | All | ✅ |
| Stripe Connect | Merchant onboarding, status and express dashboard links from Profile | All | 🟡 plumbing live; the 80/20 revenue split is not switched on |
| i18n | id, en, fr — code-split per locale (2.79.0); marketing renders its locale server-side for crawlers | All | ✅ |
| PWA + install prompt | Service worker (cache-first static, network-first navigation, never intercepts /api/), topbar install button that hides when already installed | All | ✅ |
| Feedback & changelog | In-app BUG / FEATURE_SUGGESTION / GENERAL_FEEDBACK with admin triage; in-app "what's new" from CHANGELOG | All | ✅ |
Roles & access
Four independent authorities. They are not one ladder — a platform admin is not a store owner, and a staff persona is a restriction layered on top of the owner's own session, not a separate login.
| Role | How it is established | Scope | Can reach |
|---|
| Platform admin | User.isAdmin, or an address in HARDCODED_ADMIN_EMAILS (src/lib/admin.ts) | The whole platform | /admin — users, subscriptions, revenue, capacity, platform usage, backups, feedback, custom-development, demo seed |
| Account owner | The User that owns the Business → Stores. Any authenticated session with no active staff PIN session | Every store they own | Everything, including the three owner-only pages below |
| Staff persona | A StaffMember row + verified PIN → a server-side StaffSession cookie on this browser | One store, and only the pages resolved for them | Their allowedPages override, or their role's default template |
| Public customer | Anonymous — no account, ever | One published storefront | /@slug, the menu, checkout, order tracking, the public receipt, and the reservation form |
Staff roles and their default page access
StaffRole has four values. The role sets a default template; the owner can then override any individual's access per page (StaffMember.allowedPages), and rename their title for display (customRoleLabel, e.g. "Assistant Manager") without changing what the role grants.
| Role | Default pages | Intended job |
|---|
| OWNER | All staff pages | A StaffMember row that is the owner — functionally unrestricted |
| MANAGER | /dashboard /storefront /pos /pos/orders /pos/kds /tables /menu /management /production /data /alerts /schedule | Runs the floor and the stockroom; publishes rosters; corrects attendance |
| CASHIER | /pos /pos/orders /tables /schedule | Rings up orders, runs the till, clocks in |
| KITCHEN | /pos/kds /schedule | Works tickets and clocks in — nothing else |
Never grantable to any staff persona: /profile, /billing, /staff. These are enforced separately by requireOwnerOnly / requireNoActiveStaffPersona, not by the page checklist — so no override can hand a cashier the subscription or the staff roster. A blocked persona is redirected to the first page they can actually see, not to a fixed fallback that would just bounce them again.
Where each gate lives
Order matters: a chrome-free route outside the (dashboard) group applies them itself — getSession → verifyStoreOwnership → requirePlan → requireStaffPageAccess.
Role → feature matrix
✅ full · 🔹 own records only · ⚙️ default-off, owner enables · — no access
The matrix covers role-granted access inside the app. The public storefront is open to anyone with the link, by design — that is the wedge.
| Surface | Platform admin | Owner | Manager | Cashier | Kitchen | Customer |
|---|
| Storefront editor & analytics | — | ✅ | ✅ | — | — | — |
| Public storefront, checkout, order tracking, receipt, reservation form | — | — | — | — | — | ✅ |
| POS cashier & checkout | — | ✅ | ✅ | ✅ | — | — |
| Order queue | — | ✅ | ✅ | ✅ | — | — |
| KDS | — | ✅ | ✅ | — | ✅ | — |
| Tables & reservations | — | ✅ | ✅ | ✅ | — | booking form only |
| Menu editing | — | ✅ | ✅ | — | — | — |
| Data (products, materials, recipes, suppliers) | — | ✅ | ✅ | — | — | — |
| Management & stock movements | — | ✅ | ✅ | — | — | — |
| Production batches |
Owner-only store toggles
Independent of the subscription tier — the plan sets the ceiling, these set the intent. Both must be true for the workflow to appear.
| Toggle | Default | Effect when off |
|---|
kitchenDisplayEnabled | on | Paid orders skip the kitchen stages and go straight to DELIVERED — for counter-only stands |
productionEnabled | off | /production shows an explainer instead of the batch workflow |
customProductsEnabled | off | The Data page's Custom Products tab shows a "name it and enable" form |
customProductsShowOnStorefront | off | The custom line reaches the cashier but not the public menu |
payLaterEnabled | off | PAY_LATER is not offered at checkout |
syncFinanceWithBusiness | off | The store keeps its own fees/taxes rather than inheriting the business-wide config |
standardWorkMinutesPerDay | 480 | Daily minutes beyond this count as overtime |
Known gaps — documented but not shipped
Recorded here so they are not mistaken for working behaviour.
| Claim | Reality |
|---|
Per-tier resource limits (orders/month, storage and notification quotas) in docs/FEATURES.md | Not enforced. Only the store count (PLAN_MAX_STORES) and the inventory-product count (maxProducts, via canCreateProduct) are checked; storefront menu items, orders, storage and notifications are not capped |
| "WhatsApp notifications", "WhatsApp receipt", "low-stock alerts via WhatsApp" | Every WhatsApp path is a wa.me deep link the user taps — there is no automated Business API. Merchant new-order alerts go out through MagicBell + email |
| Aggregator dashboard | Email parsing (v1) only; no direct GoFood/GrabFood/ShopeeFood API integration |
| Stripe Connect 80/20 revenue split | Onboarding and status plumbing exist; the split is not switched on and needs legal review |
| Custom domains, white-label | Not built |
Source-of-truth files, when this section and the code disagree — the code wins:
Database Schema (key relationships)
User → Business → Store → Storefront → MenuItem → MenuCategory
→ Product ← RecipeProduct → Recipe → RecipeIngredient → Material
→ Order → OrderItem
→ StockMovement
→ ProductionBatch
→ Staff · Shift · Table · Reservation
→ Alert · Subscription
All queries scope to storeId. Money uses Prisma.Decimal (never Float).
Key Patterns
- API handler —
withApiHandler() in src/lib/api-handler.ts — auth, rate-limiting, store ownership, error serialization
- Plan gating —
requirePlan(storeId, "OPERATIONS") in layout; sidebar shows locked state gracefully
- Currency —
CurrencyProvider shares React Query cache with useProfile; formatPrice() auto-converts; propagates instantly on profile update
- i18n —
useI18n() everywhere; fr.ts primary, then id.ts and en.ts
Deployment (Vercel)
- Add
DATABASE_URL + DIRECT_URL (Neon pooled vs direct endpoints) in Vercel env vars
pnpm build runs prisma migrate deploy && tsx scripts/sync-changelog.ts && next build automatically
- Service worker at
/public/sw.js — cache-first static, network-first navigation, never intercepts /api/