RetailOSRetailOS · User Manual

RetailOS — Detailed User Manual (Forms, Fields & Connections)

Companion to the Usage Guide. The usage guide explains what the system does and how the flows work; this manual is the field-level reference: for every form it lists each input — name, type, whether it's required, its default, its validation — and, crucially, how that form connects to the others (what it looks up, what it feeds, and what happens on submit). This manual is served at /manual. Base URL: https://goretailos.com. Admin paths need a signed-in staff session; storefront paths carry a shop slug (/store/<shopSlug>/…).

How to read this manual

💡 Prefer learning inside the app? Open Quick Tours (/admin/tours, under Administration in the sidebar): a guided walkthrough of the whole system plus a detailed step-by-step tour for every module your shop has enabled — POS, Restaurant, E-commerce, Inventory, Purchasing, Accounting, HR & Payroll, Growth and the AI assistant. Each step takes you to the real screen and tells you exactly what to do there — tours never change your data, and your progress is saved so you can resume any time.

Each form is documented as:

ColumnMeaning
UI labelWhat the user sees on screen.
Field keyThe underlying form/database field name.
Typetext, number, money, integer, select, multi-select, toggle, date, file, textarea, color, computed.
ReqRequired (shown as ● or "Yes") · optional (○ or "No") · auto = set by the system, not typed by the user.
DefaultPre-filled value, if any.
Validation / optionsConstraints, or the choice list for selects.
NotesAnything else worth knowing.
Convention used everywhere: a field ending in _id is a pointer to another form's record. The manual names the human form (e.g. "Category") and the pointer (category_id) together.

The tenant model (read this first)

Everything hangs off one root record: the shop. Every table in the system carries a shop_id, and the server always filters by the shop you have active in the header switcher. A single login can be a member of several shops (see Team & Roles) and switch between them; data never crosses between shops. Branch-bound staff are further limited to one branch within a shop.

Header controls. The top bar carries the organization switcher (your own shops appear under My organizations; platform-level accounts also see an All organizations (platform) group with search), a branch switcher, a notifications bell, a language selector, and a Toggle theme button that flips the admin between light and dark appearance (saved per browser). Switching organizations swaps everything at once — data, currency and timezone — with no cross-shop leakage; your newest organization starts completely empty.

Because of this, shop_id (and often branch_id) is an implicit, auto field on every form below — set from your session/active shop, never typed — so the per-form tables don't repeat it unless it behaves unusually.

Data model at a glance

The forms wire together along these spines (arrow = "points at / looks up"):

Catalog spine

Selling spine

Inventory spine

Purchasing spine

Accounting spine

Storefront + AI

A full table-by-table foreign-key index is in Appendix B.


Contents

  1. Accounts, Team & Shop Settings
  2. Catalog
  3. Inventory
  4. Purchasing & Accounting
  5. Sales — POS, Registers, Orders & Sync
  6. Storefront Editor & AI (Admin)
  7. Storefront — Customer Forms
  8. Reports
  9. Promotions, Loyalty & Marketing
  10. Manufacturing (BOM & costing)
  11. Restaurant
  12. CRM, Segments & Analytics
  13. HR & Payroll
  14. Business Intelligence
  15. Advanced Procurement

Accounts, Team & Shop Settings

Sign in / Sign up — /auth (?mode=signup)

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Full namefullNametext● (signup only)""—Passed as options.data.full_name → user metadata → profiles.full_name. Hidden in sign-in mode.
Emailemailemail●""trimmedUsed for both signup and signin
Passwordpasswordpassword●""Supabase policy (min 6)—
—modesearch param—(signin)"signup" toggles viewAlso toggled by "Create a new account" link

Connections

Staff sign-in

After signing in you land on the admin dashboard (/admin) — KPI cards with trends, a 14-day sales chart, category mix, profitability, AI insights and an alert centre.

Admin dashboard


Forgot password — /auth (inline action)

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Emailemailemail●""non-empty (reuses the sign-in Email input)Always shows generic "if that email has an account…" message

Connections


Reset password — /reset-password (?next=<relative path>)

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
New passwordpasswordpassword●""min 6 chars—
Confirm new passwordconfirmpassword●""must equal password—
—nextsearch param—/adminmust start with / and not // (same-origin only)Redirect target after success

Connections


Onboarding — create shop — /onboarding (auth-gated)

Fields (authoritative = createShop zod schema)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Shop namenametext●""2–80 chars (button disabled < 2)—
Store address (URL slug)slugtext●auto from name2–60 chars; server re-slugifies ([^a-z0-9-]→-) & checks uniquenessClient defaults to name lowercased-hyphenated
Currencycurrency_codeselect●"USD"2–5 chars; USD/PKR/QAR/AED/SAR/INR/EUR/GBP—
—currency_symboltext● (auto)"$"1–5 charsauto — derived client-side from selected currency
Timezonetimezoneselect●"UTC"UTC/Asia/Karachi/Riyadh/Qatar/Dubai/Kolkata/Europe/London/America/New_York—
Default languagedefault_localeselect●"en"enum en/ar/ur/hiFrom SUPPORTED_LOCALES
Storefront languagessupported_localescheckbox[]●["en"]array of en/ar/ur/hi, min 1Falls back to ["en"] if none checked
Phonephonetext○""≤ 40 chars—
Emailemailtext○""≤ 120 chars (not validated as email)—
Addressaddresstext○""≤ 240 chars—
—created_byuuidautocontext.userId—Server-set
—settingsjsonbautoDEFAULT_SHOP_SETTINGS—Large default object (tax/receipt/inventory/pricing/etc.)

Connections


Accept invitation — /invite/$token

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
—tokenURL param●—≥ 10 charsRoute param $token
(Accept invitation button)—action——requires signed-in email == invite email (case-insensitive)Disabled/blocked if revoked, accepted, expired, or email mismatch

Preview returns (read-only, auto): status, expired, role, email, shopName.

Connections


Invite a staff member — /admin/team

Team members

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Emailemailemail●""z.string().email(); lowercased on insert—
Roleroleselect●"staff"enum owner/admin/manager/cashier/accountant/inventory/staff—
BranchbranchIdselect○"" → nulluuid nullable; "Any branch" = nullOptions from Branches (shop-scoped)
—shopIduuidautoactive shop—From useAdminShop
—originurlautowindow.location.origin—Used to build invite link
—tokentextautogen_random_bytes(24) hex—DB-generated
—statustextauto"pending"pending/accepted/revoked/expired—
—expires_attimestamptzautonow + 14 days——
—invited_byuuidautocontext.userId——

Connections


Revoke invitation — /admin/team (Invitations list)

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
(Revoke button)invitationIduuid●—uuidOnly shown when status='pending'
—shopIduuidautoactive shop——

Edit / deactivate member — /admin/team (Members list)

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Role (inline select)roleselect○member's currentenum owner/admin/manager/cashier/accountant/inventory/staffSaves on change
Branch (inline select)branchIdselect○member's currentuuid nullable; "Any branch"=nullOptions from Branches
Deactivate / ReactivateisActiveboolean○member's currenttoggles is_activeButton label flips on state
—memberIduuid●—the shop_users.id—
—shopIduuidautoactive shop——

Connections


Shop Settings — /admin/settings

Shop settings

Business profile — settings section business

Writes shops columns + settings.business.tax_id.

UI labelField keyTypeReqDefaultValidation / optionsNotes
Shop namenametext (shop col)●shops.nametrimmed, non-empty—
Store address (slug)slugtext (shop col)—shops.slugread-only"Contact support to change"
Legal / registered namelegal_nametext (shop col)○"" → null——
Tax ID / registration numbertax_idtext (settings.business)○""—Only settings-jsonb field here
Emailemailemail (shop col)○"" → null——
Phonephonetext (shop col)○"" → null——
Business logologo_urlimage upload (shop col)○"" → nullvia AdminImageUpload (folder branding)Used on receipts/invoices/storefront
Addressaddresstextarea (shop col)○"" → null——
Timezonetimezonetext (shop col)●shops.timezonefree-text—

Localization — settings section localization (writes shops columns)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Default languagedefault_localeselect●shops.default_localeen/ar/ur/hiDrives RTL
Currency codecurrency_codetext●shops.currency_codeuppercased—
Currency symbolcurrency_symboltext●shops.currency_symbol——
Symbol positioncurrency_positionselect●beforebefore / after—
Decimal placesdecimal_placesnumber●20–4—
Decimal separatordecimal_separatortext●"."——
Thousand separatorthousand_separatortext●","——
Date formatdate_formattext●"dd/MM/yyyy"——
Time formattime_formatselect●"HH:mm"24h / 12h—

Tax — settings section tax (+ inline tax_rates editing)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Tax registeredenabledtoggle○true——
Prices include taxinclusivetoggle○false——
Tax registration numberregistration_numbertext○""——
Rounding ruleroundingselect○"nearest_0.05"none / nearest_0.05 / nearest_0.10 / nearest_1—

Tax rates sub-table (writes tax_rates directly): rate (number, saved on blur), is_default (radio — one default).

Receipts — settings section receipt

UI labelField keyTypeReqDefaultValidation / optionsNotes
Header textheadertextarea○""——
Footer textfootertextarea○""——
Show tax breakdownshow_tax_numbertoggle○true——
Show logo on receiptshow_logotoggle○true——
Roll widthroll_widthselect○"80mm"58mm / 80mm—
Reprint policyreprint_policyselect○"allowed"allowed / manager_approval / blocked—

Printer (this device only, saved to localStorage — not DB): Printer type (browser / escpos-bridge), Paper width, Bridge URL, Printer name, Auto-cut, Kick cash drawer.

Point of sale — settings section pos

UI labelField keyTypeReqDefaultValidation / optionsNotes
Default branchdefault_branch_idselect○""from BranchesClears register on change
Default registerdefault_register_idselect○""from Registers filtered by branch—
Allow line-item discountsallow_line_discountstoggle○true——
Maximum discount %max_discount_percentnumber○200–100Cashier ceiling
Require customer on salerequire_customertoggle○false——
Cash roundingcash_roundingselect○"none"none / nearest_0.05 / nearest_0.10—
Offline modeoffline_mode_enabledtoggle○true——

Inventory — settings section inventory

UI labelField keyTypeReqDefaultValidation / optionsNotes
Allow negative stockallow_negative_stocktoggle○false—Shop-wide switch; still governs the online-store checkout
Who can oversell (negative stock)oversell_rolesmulti-select (roles)○owner/admin/managerrole checklistOnly these roles may complete a till sale that drives stock below zero (enforced client- and server-side); owner/admin always included
Default locationdefault_location_idselect○default locationfrom Inventory locations—
Low-stock threshold (default)low_stock_thresholdnumber○5≥ 0—
Reorder pointreorder_pointnumber○10≥ 0—
Reorder quantityreorder_quantitynumber○20≥ 0—

Delivery charges — settings section delivery

UI labelField keyTypeReqDefaultNotes
Enable deliveryenabledtoggle○falseoff → checkout shows no delivery step
Charge modecharge_modeselect○flatflat (one amount) or slabs (tiered by order value)
Flat chargeflat_chargenumber○ (flat)0delivery fee in flat mode
Slabsslabs[]rows○ (slabs)—tiered charge bands by order value
Free delivery abovefree_delivery_thresholdnumber○0orders at/above ship free (0 = never)
Zoneszones[]rows——per country / city override; blank city = whole country
Show free-delivery bannershow_free_delivery_bannertoggle○truestorefront header banner promoting the free-delivery threshold

Storefront effect: the checkout country / city selectors pick the matching zone; the header shows the free-delivery banner when enabled and a threshold is set.

Pricing & discounts — settings section pricing

UI labelField keyTypeReqDefaultValidation / optionsNotes
Default markup %default_markup_percentnumber○30——
Price roundingprice_roundingselect○"none"none / nearest_0_05 / nearest_0_50 / nearest_1 / charm_99—
Max line discount % (cashier)max_line_discount_percentnumber○10——
Max order discount % (cashier)max_order_discount_percentnumber○15——
Require manager approval above limitsrequire_manager_overridetoggle○true——
Allow selling below costallow_below_costtoggle○false——

Purchasing — settings section purchasing

UI labelField keyTypeReqDefaultValidation / optionsNotes
Default supplier payment terms (days)default_payment_terms_daysnumber○30——
Require approval before sending a POrequire_po_approvaltoggle○true——
Approval required above amountpo_approval_thresholdnumber○00 = every PO needs approval—
Allow receiving more than orderedallow_over_receipttoggle○false——
Update item cost on goods receiptauto_update_cost_on_receipttoggle○true——
Costing methodcosting_methodselect○"weighted_average"weighted_average / last_cost—

Bank transfer — settings section payments

Bank-account details shown to customers who pick bank transfer at online checkout. Owner/admin only.

UI labelField keyTypeReqDefaultNotes
Bank namebank_nametext○""—
Account titleaccount_titletext○""—
Account numberaccount_numbertext○""—
IBANibantext○""—
Transfer instructionsinstructionstextarea○""Shown to customers who choose bank transfer at checkout
Payment reference notereference_notetext○""Optional — e.g. "Use your order number as the reference."

Connections — surfaced on the storefront checkout when the shopper selects Bank transfer, and on the order confirmation so they can complete the transfer.

Expenses — settings section expenses

Approval policy for expenses recorded in Accounting. Owner/admin only.

UI labelField keyTypeReqDefaultNotes
Require approval before expenses post to the ledgerrequire_approvaltoggle○falseWhen off, expenses post immediately (default)
Only require approval at or above this amountapproval_thresholdnumber○00 = every expense needs approval when the toggle is on

Connections — governs Record expense (/admin/accounting): above-threshold expenses wait for approval before posting to the ledger.

Customers & credit — settings section customers

Customers list

Governs customer accounts and whether staff may sell on credit / udhaar (pay later) at the till. Owner/admin only.

UI labelField keyTypeReqDefaultValidation / optionsNotes
Allow credit sales (udhaar)credit_allowedtoggle○false—Master switch; when off the POS credit tender is hidden
Default credit limit (suggestion)default_credit_limitnumber○0≥ 0Pre-fills the limit when creating a customer; 0 = unlimited. Enforcement always uses each customer's own credit_limit
Who can sell on creditcredit_rolesmulti-select (roles)○owner/admin/managerrole checklistOnly these roles see and can use the credit tender; owner/admin always included

Connections — read by the POS Payment screen to decide whether the signed-in user may sell on credit; the unpaid balance of a credit sale posts to the customer's Accounts Receivable and is settled later from Sales → Record payment. See Payment screen → Credit (pay later).

Document numbering — settings section documents

UI labelField keyTypeReqDefaultValidation / optionsNotes
Sales order prefixorder_prefixtext○"SO-"——
Receipt prefixreceipt_prefixtext○"RCP-"——
Purchase order prefixpurchase_order_prefixtext○"PO-"——
Stock transfer prefixtransfer_prefixtext○"TR-"——
Number paddingnumber_paddingnumber○55 → SO-00001—
Reset sequences each yearreset_yearlytoggle○false——
This section writes only to shops.settings.documents. The live document_sequences rows (their own prefix/padding/next_number) are created at onboarding and are not edited here.

Business hours — settings section hours

Weekday/weekend open & close times (text, e.g. "09:00"), closed days (comma-separated), holiday notice (textarea).

Notifications & alerts — settings section notifications

Low stock alerts, Daily briefing, End-of-day summary — all toggles, default on.

Branches & registers — read-only summary

Read-only overview of branches and registers; "Manage registers →" links to /admin/registers.

Connections (Shop Settings overall)

Tables owned: shops (PK id; created_by→auth user; unique slug), profiles (PK id→auth.users), shop_users (shop_id→shops, user_id→auth user, branch_id→branches), staff_invitations (shop_id→shops, branch_id→branches, invited_by/accepted_by→auth users, unique token), branches (shop_id→shops), document_sequences (shop_id→shops, unique (shop_id,doc_type)), storefront_settings (shop_id→shops, unique). Provisioned/referenced: registers, inventory_locations, tax_rates, payment_methods, accounts, ai_permissions, storefront_navigation, storefront_policies, expense_categories, audit_logs (all shop_id→shops).


Store Data Manager — /admin/data-manager

Data manager

Workflow

  1. Pick a table — grouped as Catalog, Customers & suppliers, Operations, Sales & finance, Config. A couple (team members, audit log) are view-only for safety.
  2. Filter — build conditions (column · operator · value); operators include equals / not-equals, greater / less, contains, in (comma list) and is-empty / not-empty, with a Match ALL (AND) / Match ANY (OR) toggle. A compiled query preview shows exactly what will run.
  3. Preview — returns the matching row count plus a sample of rows before you change anything.
  4. Bulk update sets one or more columns on every matching row; Bulk delete requires typing DELETE to confirm. Both re-check the previewed count before applying, are capped at 50,000 rows, and are audited.
  5. Add / seed rows — paste JSON (an array of objects) or CSV (first row = column names); shop_id is added automatically.

Foreign-key dependency resolver — if a delete is blocked by related records (a foreign key), a popup lists the referencing rows per table with Delete these or Set null (unlink). If clearing one of those is itself blocked, a nested popup opens — resolve down the chain, then delete again.

Reset (owner only) — wipe Transactional, Catalog & CRM, or Everything for the store, keeping setup/config (accounts, tax, registers, branches, locations, payment methods, storefront, team). Type RESET to confirm; a blocked reset offers the same dependency resolver.

Safety model: owner/admin only (Reset owner-only) · own-store only (always shop_id-scoped) · no raw SQL · typed confirmations (DELETE / RESET) · a 50,000-row cap · protected read-only tables (team members, audit log) · every action audited.


Subscription & usage — /admin/subscription

Catalog

Products list

Available for sale (hide internal items). Products, categories and brands each carry an Available for sale toggle (is_online, default on). Turn it off to make an item internal-only — e.g. raw materials, packing supplies, or a supplier-only brand/category: it is hidden from both the online storefront and POS product search, while staying fully usable in inventory, purchasing and the admin catalog. This is separate from Active (the archive flag): Active off = archived everywhere (also removed from POS and product selectors); Available for sale off = kept internally but not sold. Because the default is on, existing items are unaffected until you switch it off.

New / Edit Product — /admin/products

Fields (product level)

UI labelField keyTypeReqDefaultValidation / optionsNotes
—shopIduuidYesactive shop—auto
—productIduuidNonull—auto; edit only
Product namenametextYes—1–200 charsdrives slug + SKU seed
Categorycategory_iduuid (select)NonullEntitySelect categoriesFK → categories
Brandbrand_iduuid (select)NonullEntitySelect brandsFK → brands
Selling unitunittextNopcsmax 24server falls back to pcs
StatusstatusenumYesactive (UI)active / draftDB column default draft
Short descriptionshort_descriptiontextNonullmax 500—
Full descriptiondescriptiontextareaNonullmax 20000—
Tagstagsstring[]No[]≤30 tags, each 1–40comma-separated input
Tax ratetax_rate_iduuid (select)NonullEntitySelect tax_ratesresolves variant→product→category→shop default
Available for saleis_onlinebooleanNotrue—OFF = internal-only — hidden from storefront and POS search; still usable in inventory/purchasing. Separate from Status (archive)
Featuredis_featuredbooleanNofalse—merchandising flag
New arrivalis_new_arrivalbooleanNofalse——
Best selleris_best_sellerbooleanNofalse——
—slugtext—slugify(name)unique per shopauto on create only
—has_variantsboolean—variants.length>1—auto computed
Media(images)sub-formNo—≤30 imagessee Product Images
Variantsvariantssub-formYes—min 1see Variant Builder

Variant Builder sub-form (src/components/admin/VariantBuilder.tsx)

UI labelField keyTypeReqDefaultValidation / optionsNotes / target
—iduuidNonull—auto; edit only
VariantnametextNonullmax 120auto = attribute combo label ("Black / M")
SKUskutextYes<BASE>-<suffix>1–80; unique per shop; no in-product dupesauto-seeded → product_variants.sku
BarcodebarcodetextNonullmax 64; unique per shop→ barcodes (is_primary); "Gen" button generates
CostcostnumberNo00–10,000,000→ variant_costs.cost_price & avg_cost (never public)
PricepricenumberNo00–10,000,000→ product_variants.price
Sale pricesale_pricenumberNonull0–10,000,000>0 → prices (type='sale'); ≤0/empty deactivates
WeightweightnumberNonull0–1,000,000→ product_variants.weight
Taxtax_rate_iduuid (select)Nonulltax rate listcollected + bulk-appliable but not persisted per-variant
Opening stockopening_stocknumberNo00–10,000,000create-only → inventory_movements (opening) + inventory_balances + a balanced GL journal (Dr Inventory 1200 / Cr Owner Equity 3000) for its value
Activeis_activebooleanYestrue—→ product_variants.is_active
—attributesrecord<string,string>—{}from attribute matrix→ product_variants.attributes (jsonb)
—is_defaultboolean—index===0—auto; first variant is default

Attribute matrix: the attribute-name field is a creatable combobox — choose an existing attribute (EntitySelect attributes), or type a new name and press Enter to create it inline (ensureAttributeFn → product_attributes + product_attribute_values); then tick its values → Generate variants builds the cartesian product. A bulk-apply bar sets price/cost/sale_price/weight/opening_stock/tax_rate_id/is_active across all or selected rows.

Number fields select-on-focus. Numeric inputs in the builder (cost, price, sale price, weight, opening stock) select their existing value when you click into them, so typing cleanly replaces a leading "0".
Opening stock posts to the ledger. Entering opening stock with a cost sets on-hand quantity and average cost and books a balanced journal entry Dr Inventory (1200) / Cr Owner Equity (3000) for the opening value (Σ opening_stock × cost) when the product is created, so the GL inventory asset matches the operational balance. Posting is best-effort — if the shop's chart of accounts lacks those accounts or the target period is closed, the stock still saves and the journal is skipped. (Changing a product's stock later via the editor posts a quantity adjustment only, with no journal — matching manual stock adjustments; value inventory changes through a Purchase order → Supplier invoice or an opening-balance journal.)

Product Images sub-form (saveProductImagesFn)

UI labelField keyTypeReqDefaultValidation / optionsNotes
(upload)urltextYes—1–1000 charspublic URL (bucket product-images)
—storage_pathtextNonullmax 400storage key
AltalttextNonullmax 200—
—iduuidNonull—auto on existing rows
—positionint—array index—auto; index 0 = cover

Connections

Save errors show the real reason. When a save or submit fails — here or anywhere else in the admin — the message now names the actual cause (e.g. a duplicate SKU, a validation problem, or a permission block) instead of a generic "Could not save…".

Barcode generate / attach — /admin/products (variant panel + builder "Gen")

UI labelField keyTypeReqDefaultValidation / optionsNotes
—shopIduuidYesactive shop—auto
—variantIduuidYes——target variant
(Generate EAN-13 / CODE128)typeenumYesean13ean13 / code128—
Attach manual barcodecodetextYes (attach)—1–64; EAN-13 needs valid GS1 check digit; CODE128 ≤48duplicate active code rejected
—(check digit)—auto—GS1 mod-10 (ean13CheckDigit)auto
—sourcetextautogenerated / manual—auto per path
—is_primarybooleanautotrue if first active—deactivate promotes next barcode
—is_activebooleanautotrue—soft-delete keeps history

Generation uniqueness: barcode.server uses a shop-scoped sequence (ean13FromSequence, prefix 20); catalog.server uses random makeEan13 (prefix 200). "Bulk-generate missing" issues EAN-13 for every variant lacking one. Scan to find (Products page): lookupBarcodeFn resolves an active code to variant+product for search/POS.


Category create / edit — /admin/categories

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes—required—
Parent categoryparent_iduuid (entity)NonullEntitySelect categories; not selfFK → categories
SlugslugtextNoslugify(name)unique per shopauto when blank; dedup-suffixed
Display orderpositionnumberNo0intsort order
Default tax ratetax_rate_iduuid (entity)NonullEntitySelect tax_ratescategory-level tax fallback
Category imageimage_urlimageNonullupload folder categories—
DescriptiondescriptiontextareaNonull——
Available for saleis_onlinebooleanNotrue—OFF = internal-only category — hidden from storefront and POS; still usable in inventory/purchasing
Visible on storefrontis_activebooleanNotrue—archive toggle

Connections — self parent_id; tax_rate_id→tax_rates; used by products (category_id), storefront menu/category pages, filters. Delete blocked while referenced by products or child categories (archive instead).


Brand create / edit — /admin/brands

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes—required—
SlugslugtextNoslugify(name)unique per shopauto when blank
Brand logologo_urlimageNonullfolder brands—
DescriptiondescriptiontextareaNonull——
Available for saleis_onlinebooleanNotrue—OFF = internal-only brand — hidden from storefront and POS; still usable in inventory/purchasing
Visible on storefrontis_activebooleanNotrue—archive toggle

Connections — used by products (brand_id), storefront brand filters/pages. Delete blocked while products reference it.


Attribute + values — /admin/attributes

Attribute

UI labelField keyTypeReqDefaultValidation / optionsNotes
Attribute namenametextYes—required—
CodecodetextNoslugify(name)unique per shopauto when blank

Attribute value sub-form

UI labelField keyTypeReqDefaultValidation / optionsNotes
ValuevaluetextYes—required—
—attribute_iduuidYesselected attribute—auto from panel scope

Connections — value attribute_id→product_attributes; feeds VariantBuilder → product_variants.attributes. Attribute delete blocked while it has values.


Tax rate create / edit — /admin/tax

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes—requiredplaceholder "VAT 15%"
Rate (%)ratenumberYes0numeric(6,3); step 0.001—
Prices include this taxis_inclusivebooleanNofalse——
Use as shop defaultis_defaultbooleanNofalseexclusiveauto unsets other defaults
Activeis_activebooleanNotrue—archive toggle

Connections — used by products, categories, and variant/line tax resolution across POS & online; fires tax_version trigger. Delete blocked while referenced by products, categories or sold order lines; default must be reassigned before delete.


Barcode labels print — /admin/labels

UI labelField keyTypeReqDefaultValidation / optionsNotes
(variant select)selectedcheckbox set—noneper variantdrives label queue
Copiescopies[id]numberNo1min 1per selected variant
Label sizesizeIdenumYes40x3040x30, 50x25, 58roll, a4-3x8, a4-4x10mm dims from LABEL_SIZES
Show → shop name/logofields.shopbooleanNotrue——
Show → product namefields.productNamebooleanNotrue——
Show → variantfields.variantNamebooleanNotrue——
Show → SKUfields.skubooleanNotrue——
Show → pricefields.pricebooleanNotrue——
Show → barcodefields.barcodebooleanNotrue—format auto-resolved (EAN13/CODE128)

Connections — reads primary barcodes.code, variant price/sku, product name, shop name/logo; output goes to window.print only.


Bulk import — /admin/import

Wizard controls

UI labelField keyTypeReqDefaultValidation / optionsNotes
TypekindenumYesproductsproducts / suppliers / inventorystep 1
File(upload)fileYes—.csv, .xlsx, .xls; ≤5000 rowsparsed client-side
Column mappingmappingrecord—auto-guessedfile header → target fieldper-field select

Products import target fields

UI labelField keyReqValidation / optionsNotes / target
Product nameproduct_nameYesnon-emptygroups rows into one product; slug-matched for upsert
SKUskuYesnon-empty; dedup in file; existing → update→ product_variants.sku
Variant labelvariantNo—→ product_variants.name
AttributesattributesNoColor:Red;Size:Mparsed → jsonb attributes
CategorycategoryNomatched by name (warns if not found)→ category_id
BrandbrandNomatched by name (warns if not found)→ brand_id
CostcostNonumeric, default 0→ variant_costs
PricepriceYesnumeric→ product_variants.price
Sale pricesale_priceNonumeric→ prices (sale)
Opening stockopening_stockNonumeric, default 0new variants only → movements+balances + one GL journal (Dr Inventory 1200 / Cr Owner Equity 3000) for the imported opening value
BarcodebarcodeNodedup in file; cross-product clash = error→ barcodes

(Suppliers import → suppliers; Inventory import → inventory_*.) Validate cross-checks existing categories, brands, variant SKUs and barcodes, returning per-row ok/warning/error counts. Commit skips error rows, imports ok+warning, audit-logged; products grouped by name → one product with N variants.

Tables owned: products (category_id→categories, brand_id→brands, tax_rate_id→tax_rates; unique shop_id+slug), product_variants (product_id→products; unique shop_id+sku; extra DB cols not in UI: compare_at_price, track_inventory, low_stock_threshold, reorder_point, reorder_quantity), product_attributes (unique shop_id+code), product_attribute_values (attribute_id→product_attributes), product_images (product_id→products, variant_id→product_variants), prices (variant_id→product_variants; price_type='sale'), variant_costs (variant_id→product_variants; private cost), barcodes (variant_id→product_variants; unique shop_id+code, partial-unique on active; type/source/is_primary/is_active), categories (self-FK parent_id, tax_rate_id; unique shop_id+slug), brands (unique shop_id+slug), tax_rates (is_default exclusive). Import also touches inventory_movements/inventory_balances and suppliers.


Inventory

Stock movements

Manual stock adjustment — /admin/inventory

Fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Productvariant_idselect (uuid)Yes""variants that already have a balance row→ movement + balance variant_id
Quantity (+/-)quantitytext→numberYes""Number(quantity) must be non-zero; signedpositive adds, negative removes
ReasonnotetextNo""—→ inventory_movements.note
Locationlocation_id—autoshop's is_default locationlimit 1"No inventory location configured" if none
Movement typemovement_type—auto'adjustment'enumfixed
On-hand afterbalance quantity—autoexisting + deltarunning balanceupserts inventory_balances

Connections


Reorder / stock defaults — /admin/settings (Inventory section)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Allow negative stockallow_negative_stocktoggleNofalse—lets sales pass below zero (and governs the online-store checkout)
Who can oversell (negative stock)oversell_rolesmulti-select (roles)Noowner/admin/managerrole checklistroles allowed to push a till sale below zero; others are capped at on-hand (owner/admin always included)
Default locationdefault_location_idselectNodefault locationactive inventory_locations"No default" allowed
Low-stock threshold (default)low_stock_thresholdnumberNo5≥ 0fallback when variant has none
Reorder pointreorder_pointnumberNo10≥ 0used by AI reorder insights
Reorder quantityreorder_quantitynumberNo20≥ 0suggested order size

Per-variant overrides (low_stock_threshold, reorder_point, reorder_quantity) exist on product_variants but are not editable in the current UI. These feed AI reorder recommendations and Dashboard "Low stock priority" (variant value ?? default).


Create transfer (draft → send → receive) — /admin/transfers

Header fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
From locationfrom_location_idselectYes""active locations; must differ from To—
To locationto_location_idselectYes""active locations, excludes From—
NotesnotestextNo—max 500—

Line items (transferItemSchema)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Productvariant_idselectYes""active variants (limit 500)—
QtyquantitynumberYes""0.001–999999; rows ≤0 dropped; ≥1 line required—
Avail(computed)—auto—balance.quantity − reserved at Fromdisplay-only guide
Unit costunit_costnumberNo00–999999in schema/server; not in create UI

Auto/computed record fields: transfer_number (nextDocNumber(..., "transfer", "TR-"), unique per shop), status (default draft; enum draft/requested/approved/shipped/received/cancelled), created_by, per-transition *_by/*_at stamps, and per-line received_quantity (default 0, set at receipt, editable). The Receive step sends receivedQuantities keyed by transfer-item id; Ship validates source availability.

Connections


Stock count (start → count → review → post) — /admin/stock-counts

Create fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Locationlocation_idselectYes""active locationssets expected snapshot source
Scopemode (UI only)selectYes"all"all / categorymaps to fields below
— (all)all_productsboolean—true when scope=all—seeds every variant
Category checkboxescategory_idsuuid[]cond.[]shown when scope=categoryseeds variants of those categories
NotenotetextNo—max 500—
—variant_idsuuid[]No—in schema, not in UIexplicit variant seed

Count-sheet line items (stock_count_items)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Productvariant_id—autoseeded at create——
Expectedexpected_qtynumeric(14,3)autobalance at creation—snapshot, not editable
Countedcounted_qtynumberYes0≥ 0editable until completed
Difference(computed)—autocounted − expected—highlighted if ≠ 0

Save progress sets status='counting', counted_by. Approve/post posts a count-type movement for every non-zero diff (qty = counted − expected), sets status='completed', approved_by, approved_at, completed_at. It then values the net variance at each variant's cost (variant_costs.avg_cost, else cost_price) and posts one balanced GL journal so the perpetual Inventory account tracks the reconciliation: shrinkage (stock down) posts Dr COGS 5000 / Cr Inventory 1200, overage (stock up) posts Dr Inventory 1200 / Cr COGS 5000. Period-guarded and best-effort — balances are the source of truth and are never rolled back if the chart of accounts lacks 1200/5000 or the period is closed.

Connections


Location create / edit — /admin/locations

UI labelField keyTypeReqDefaultValidation / optionsNotes
Location namenametextYes—placeholder "Main store"—
Branchbranch_identity selectNonullreferences branchesFK, nullable
Default location for salesis_defaultbooleanNofalse——
Activeis_activebooleanNotrue—archivable

Connections — references Branch via branch_id; owns the inventory_balances/inventory_movements rows; supplies location options to transfers, counts, adjustments, and the settings default location.


Branch create / edit — /admin/branches

UI labelField keyTypeReqDefaultValidation / optionsNotes
Branch namenametextYes———
CodecodetextNonull——
PhonephonetextNonull——
AddressaddresstextareaNonull——
Default branchis_defaultbooleanNofalse——
Activeis_activebooleanNotrue——
—business_hoursjsonb—[]—not in this UI

Connections — referenced by inventory_locations.branch_id and registers.branch_id; drives branch scoping for locations, registers, staff and reporting.

Tables owned: inventory_balances (unique (location_id, variant_id); FKs location_id→inventory_locations, variant_id→product_variants), inventory_movements (FKs location_id, variant_id; enum movement_type; polymorphic reference_type/reference_id), inventory_locations (FKs shop_id, branch_id→branches), inventory_transfers (unique (shop_id, transfer_number); from_location_id/to_location_id→inventory_locations; enum transfer_status), inventory_transfer_items (transfer_id→inventory_transfers, variant_id→product_variants), stock_counts (FKs shop_id, location_id; counted_by/approved_by), stock_count_items (stock_count_id→stock_counts, variant_id→product_variants), branches (shop_id→shops), registers (branch_id→branches). Reorder defaults live in shops.settings.inventory, with nullable per-variant overrides on product_variants.


Purchasing & Accounting

Purchase orders

Two admin pages hold most of these forms: Purchasing & suppliers (/admin/purchasing) and Accounting (/admin/accounting). The master-data pages (Suppliers, Chart of accounts, Expense categories, Payment methods) share the generic MasterDataPage component → saveMasterDataFn / setMasterDataActiveFn / deleteMasterDataFn (src/lib/masterdata.functions.ts). Financial writes (invoices, payments, expenses) run through server functions gated by FINANCE_ROLES (owner, admin, manager, accountant).

Supplier — quick add — /admin/purchasing

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes""non-empty—
EmailemailtextNo""——
PhonephonetextNo""——
—payment_termstextautonet_30—set even though no input is shown here

Supplier — full record — /admin/suppliers

UI labelField keyTypeReqDefaultValidation / optionsNotes
Supplier namenametextYes—required—
CodecodetextNo———
EmailemailtextNo———
PhonephonetextNo———
Tax numbertax_numbertextNo———
Payment termspayment_termsselectNo—cod / net_7 / net_15 / net_30 / net_60—
Credit limitcredit_limitnumberNo—step 0.01—
AddressaddresstextareaNo———
Activeis_activebooleanNotrue—archive toggle

Connections — referenced by purchase_orders.supplier_id, supplier_invoices.supplier_id; delete blocked while purchase orders reference it (masterdata.server guard).


Purchase order — /admin/purchasing (New purchase order)

Pick a supplier, then add one row per product, each with its own quantity and unit cost. Use + Add product to add rows and the row's remove control to delete them; a live running total sums every line as you type. The same product can't appear twice on one PO — combine it into a single line (duplicates are blocked).

UI labelField keyTypeReqDefaultValidation / optionsNotes
Suppliersupplier_iduuid (EntitySelect)No"" → nullfrom Suppliersone supplier per PO
Productvariant_idselectYes""active variants (limit 500)one row per product; no duplicate products in a PO
QuantityquantitynumberYes1`Number()1`per line; input selects-on-focus
Unit costunit_costnumberYes""`Number()0`per line; input selects-on-focus
—po_numbertextautoPO-#####document_sequences prefix+padding, increments next_numberauto; preserved when editing
—statusenumautosubmittedpo_statusauto; preserved when editing
—subtotal / totalnumberautoΣ(quantity × unit_cost) across lines—auto; the live running total
— (line)totalnumberautoquantity × unit_cost—auto
The AI assistant can also create multi-line POs via create_purchase_order (ai-actions.server.ts) subject to the AI permission matrix.

Expandable detail, Edit & Cancel. In the purchase-orders list, click a PO row to expand an inline detail table with one row per line — Product · Ordered · Received · Unit cost · Line total. From that expanded detail, and only while nothing has been received on the PO, two actions are available:

Once any goods are received, Edit and Cancel disappear and a short note explains the order can no longer be changed — editing a received PO would require reversing already-posted stock and cost postings, which isn't supported.

Connections — references Supplier (supplier_id) and Variant (variant_id, per line); consumed by Receive goods and Supplier invoice (prefill).


Receive goods — /admin/purchasing (per-PO "Receive goods" button)

FieldTypeReqDefaultNotes
purchase_order_iduuidautothe POfrom button
location_iduuidautoshop default location"No inventory location configured" if none
receipt_numbertextautoGRN-#####document_sequences
per line variant_id, quantity, unit_cost—autofrom PO itemsreceived at full ordered qty

Cross-form effects — each line adds stock (inventory_movements purchase + inventory_balances), updates cost (variant_costs.cost_price/avg_cost), stamps purchase_order_items.received_quantity, and flips the PO to received. A goods receipt updates stock and cost operationally only — it posts no journal entry; the inventory asset and the payable are booked when the matching supplier invoice is posted (invoice-time recognition).


Supplier invoice — /admin/purchasing (New supplier invoice)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Suppliersupplier_iduuid (EntitySelect)Yes""from Suppliers—
Invoice numberinvoice_numbertextYes""1–80 chars—
Invoice dateinvoice_datetext (date)No—optional—
Due datedue_datetext (date)No—nullabledrives AP ageing / due-soon
TotaltotalnumberYes""≥ 0—
Prefill from purchase orderpurchase_order_idselectNo—your POscalls createInvoiceFromSourceFn to draft from a PO/receipt
—goods_receipt_iduuidNonullschema onlyalternate source
—notestextNonull≤2000schema only
—statustextautodraftdraft → posted → partially_paid / paid / overdue / cancelledauto
—paid_totalnumberauto0—auto; updated by payments

Actions: Post (draft → posted, writes the journal entry Dr Inventory / Cr Accounts Payable for stock items — this is when purchased stock becomes a GL asset), Cancel (only when nothing paid). Connections — Supplier (supplier_id), optional PO (purchase_order_id) / goods receipt (goods_receipt_id); feeds AP ageing and "Outstanding by supplier".


Supplier payment — /admin/purchasing (Record a payment)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Invoicesupplier_invoice_idselectYes""invoices in posted / partially_paid / overdueshows outstanding per option
AmountamountnumberYes""> 0—
Methodmethod_codeselectYescashcash / bank / card—
ReferencereferencetextNo""≤200nullable
—paid_attext (date)autonowoptionalauto

Cross-form effects — increments the invoice's paid_total, moves it to partially_paid / paid, and posts a balanced journal entry (Dr AP / Cr Cash or Bank).


Record expense — /admin/accounting (Overview tab)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Categorycategory_idselectNo"" → nullfrom Expense categoriesmaps to a ledger account
AmountamountnumberYes""> 0—
NotenotetextNo""≤500; ""→null—
Payment methodpayment_method_codeselectYescashcash / bankdefault cash
—branch_iduuidautotop-bar Branch filternullabletaken from the page's branch filter

Cross-form effects — writes an expenses row and a balanced journal entry (Dr the category's expense account / Cr Cash or Bank). Also reachable via a cash-drawer expense on /admin/registers (see Cash movement).


Manual voucher (journal / payment / receipt / contra) — /admin/accounting (Vouchers tabs)

Header fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
(tab)voucher_type—Yesjournaljournal / payment / receipt / contra (+ sales / purchase / debit_note / credit_note)picks the number series (JV-/PV-/RV-/CV-…)
Datevoucher_datedateYestodayISO; target period must be openperiod-guarded on post
ReferencereferencetextNo—≤120e.g. cheque #, invoice ref
NarrationnarrationtextNo—≤1000what the voucher is for
NotesnotestextNo—≤2000internal only

Line grid (voucher_lines, min 2 rows)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Accountaccount_idselectYes—searchable Chart of Accounts—
Debitdebitnumber—0≥ 0a line is debit or credit, never both
Creditcreditnumber—0≥ 0—
Departmentdepartment_idselectNonullactive departmentsanalytical dimension
Cost centercost_center_idselectNonullactive cost centersanalytical dimension
NarrationnarrationtextNo—≤300per-line memo

Balanced-entry enforcement — the totals row shows "Out by ₨X" (amber) until debits = credits, then flips to "Balanced"; Save is disabled while unbalanced. validateLines re-checks on the server (and the assert_journal_balanced DB trigger on post): rejects <2 lines ("A voucher needs at least two lines."), a line with both debit and credit, and any imbalance ("Voucher is not balanced…"). Type-specific rules: payment = exactly one Cash/Bank line, credited; receipt = exactly one Cash/Bank line, debited; contra = exactly two Cash/Bank lines (different accounts), one debit + one credit.

Lifecycle — draft → approved → posted → reversed. Save draft creates the voucher with no ledger impact. Open it (View) → Approve (managers) → Post — only posting writes the journal entry and moves the trial balance. A posted voucher shows Reverse, which writes an equal-and-opposite (debits↔credits swapped) contra entry, period-guarded — never by editing the original. Delete is allowed only while draft.

Cross-form effects — posted vouchers appear in General Ledger / Trial Balance / P&L / Balance Sheet identically to automated postings; AUD voucher.* actions logged; department/cost-center dimensions feed the Departments and Cost Centers report tabs.


Chart of accounts — /admin/accounts

UI labelField keyTypeReqDefaultValidation / optionsNotes
Account codecodetextYes—e.g. 1000; locked on system accounts—
Account namenametextYes—required—
TypetypeselectYes—asset / liability / equity / income / expense; locked on system—
Parent accountparent_iduuid (entity)NonullEntitySelect accountstree self-reference
Activeis_activebooleanNotrue—archive toggle

Connections — journal_entry_lines.account_id, expense_categories.account_id, cash_accounts.account_id, self parent_id. System accounts (is_system) are protected from deletion; the default 14-line chart is provisioned at onboarding.


Expense category — /admin/expense-categories

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes—e.g. "Rent"—
Ledger accountaccount_iduuid (entity)NonullEntitySelect accountsexpense account this category posts to
Activeis_activebooleanNotrue—archive toggle

Connections — selected on Record expense (category_id); each category maps to an accounts row via account_id.


Payment method — /admin/payments

UI labelField keyTypeReqDefaultValidation / optionsNotes
Display namenametextYes—e.g. "Cash"—
CodecodetextYes—stable id stored on payments (cash, card…)—
TypetypeselectYes—cash / card / bank / wallet / credit / othercash types post to the drawer + cash ledger
Display orderpositionnumberNo———
Enabledis_enabledbooleanNotrue——

Connections — codes are referenced by POS payments, supplier payments and expenses. (Note: storefront checkout uses a hardcoded cod/bank/card list, not this table.)


Financial reports (read-only) — /admin/accounting

Accounting ledger Tabs computed from the double-entry ledger (journal_entries / journal_entry_lines), not from UI sales totals. Shared filters: From / To date range, Branch, and (Ledger tab) Account; a Print current report button.

TabServer fnFilters
OverviewP&L + Balance sheet + Trial balancerange, branch
P&LgetProfitAndLossrange, branch
Balance SheetgetBalanceSheetas-of (range end), branch
Cash FlowgetCashFlowrange, branch
General LedgergetGeneralLedgerrange, account, limit 500
Trial BalancegetTrialBalancerange, branch
AR AgeinggetArAgeingas-of
AP AgeinggetApAgeingas-of

A ledger-integrity banner (getBalanceValidation) warns if total debits ≠ total credits in the range.

The same /admin/accounting page also hosts operational tabs beyond these read-only reports: Journal / Payment / Receipt / Contra Vouchers (see Manual voucher above), Fiscal Years / Periods (open/close periods — the posting guard that blocks back-dating into a closed month), Departments and Cost Centers (analytical dimensions tagged on voucher lines), and Bank Reconciliation (match statement lines against ledger cash/bank movements until the difference is zero).

Tables owned: suppliers (shop_id→shops), purchase_orders (shop_id→shops, supplier_id→suppliers; po_status; unique (shop_id, po_number)), purchase_order_items (purchase_order_id→purchase_orders, variant_id→product_variants), goods_receipts (purchase_order_id→purchase_orders, location_id→inventory_locations; unique (shop_id, receipt_number)), goods_receipt_items (goods_receipt_id→goods_receipts, variant_id→product_variants), supplier_invoices (supplier_id→suppliers, purchase_order_id→purchase_orders, goods_receipt_id→goods_receipts; payment_status), supplier_payments (supplier_invoice_id→supplier_invoices), accounts (self-FK parent_id; account_type; is_system), journal_entries (shop_id→shops, branch_id→branches), journal_entry_lines (journal_entry_id→journal_entries, account_id→accounts), expenses (category_id→expense_categories, branch_id→branches), expense_categories (account_id→accounts), payment_methods (shop_id→shops), bank_accounts (account_id→accounts).


Budgets — /admin/budgets

Create + line fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Budget namenametextYes—non-emptye.g. "FY2026 Operating Budget"; created as draft, version 1
Account codeaccount_codetextYes—must exist in Chart of Accounts (resolved to account_id server-side)e.g. 6200
AmountamountnumberYes—numeric > 0 (letters/blank → "Enter a numeric amount greater than zero.")per-account budgeted figure

Lines — added on the Budget lines tab while the budget is draft. Enter an account code + amount and press Add / update line: the code is resolved against the Chart of Accounts server-side and the line is upserted (re-entering the same code updates its amount instead of duplicating). Lines become read-only once the budget is approved.

Budget vs Actual — the second tab compares each budgeted account to its posted ledger actual (year-to-date): Budget, Actual, Variance and Variance %, with over-budget variance highlighted amber. (Procurement requisitions can reference a budget for encumbrance context.)

Lifecycle — draft → approved (Approve, owner/admin). Only draft budgets accept line edits.

Connections — budget_lines.account_id→accounts; budgets.fiscal_year_id→fiscal_years (optional); actuals read from posted journal_entry_lines.


Receivables & Payables (ageing statements) — /admin/receivables, /admin/payables


Sales — POS, Registers, Orders & Sync

POS sale (cart) — /admin/pos

POS terminal

Order-level fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
Register (header select)register_iduuidNofirst open/first registermust be a shop registernull allowed
(scan box)scan/SKUtext——matches barcode or SKUEnter adds item; not persisted
Search (F2)searchtextNo—client filter, ≤60 shownnot persisted
Category chipscategorytextNoallderived from catalogclient filter
Guest / Customer (F4)customer_iduuidNonull (Guest)select from customersattach/detach
Order discount (F6)cartDiscountnumberNo0capped at gross − lineDiscountsspread evenly across lines on submit
—local_uuidtext(≤80)autocrypto.randomUUID()idempotency keydedup on (shop_id, local_uuid)
—notestext(≤500)Nonull—in schema; not sent by POS online path
—branch_iduuidNonull—not in POS UI
Subtotalgrossnumberauto—Σ price×qtyauto
Discountsline+order discnumberauto—Σ line.discount + orderDiscountauto
TaxtaxTotalnumberauto—per line: the product's tax rate, else the shop default; inclusive or exclusiveauto; shown when > 0, labelled Incl. tax for tax-inclusive rates
Totaltotalnumberauto—net subtotal + taxauto; equals the authoritative server priceCart total — the client prices each line with the same shared helpers (resolveLineTax / priceLine / sumPricedLines), so the cashier collects exactly what the server posts

Cart lines (UI Line; server posLine)

UI labelField keyTypeReqDefaultValidation / optionsNotes
(product)variant_iduuidYes—valid variantone row per variant
Qty (−/input/+)quantitynumberYes10.001–9999; ≤0 removes linenumeric(14,3)
Disc.discountnumberNo0≥0, max 1,000,000submitted as line.discount + perLineOrderDiscount
line amountline totalnumberauto—price×qty − discountauto

Offline snapshot (sent only on offline queue / sync retry — client_snapshot): device_id (text ≤80, marks orders.is_offline), local_seq (int), offline_created_at (ISO), and immutable client_snapshot.{lines, subtotal, discount_total, tax_total, total} — the "as-charged" record the server reconciles against; variance → pos_sync_conflicts.

Keyboard shortcuts: F2 Search · F4 Customer · F6 Order discount · F8 Hold · F9 Pay · Esc Cancel/close (↑/↓/←/→ move the grid highlight; Enter in search adds the highlighted item). Hold/Resume held sales are client-only state (no backend).

Connections


Payment screen — /admin/pos (Pay modal, F9)

Split-payment rows (UI PaymentRow; server payments[])

UI labelField keyTypeReqDefaultValidation / optionsNotes
Method tiles / Pay N selectmethod_codetext(≤40)Yescashcash, card, bank, credit, othercash→acct 1000, else→bank 1100
Pay N amountamountnumberYessubtotal (first row)≥0; only rows >0 submittednumeric(14,2)
—referencetext(≤120)No——in schema; not in POS UI
—statustextautocaptured—auto
Allocatedallocatednumberauto—Σ amountsauto
Remainingremainingnumberauto—subtotal − allocatedauto
Change duechangeDuenumberauto—max(0, allocated − subtotal)auto; returned as change

≥1 payment required; "Split payment" adds a row prefilled with the remaining. Complete is enabled only when fully allocated (allocated ≥ subtotal, some amount >0, subtotal >0).

Credit (pay later) — udhaar. When Settings → Customers & credit has credit sales on and the signed-in user's role is permitted (credit_roles), the Pay screen shows a Credit sale (udhaar / pay later) toggle. With it ticked the button becomes Complete credit sale and the sale can post with partial or zero tender — the unpaid remainder is booked to the customer's account (Accounts Receivable) and the order is marked partial or unpaid.


Open cash session — /admin/pos header & /admin/registers

UI labelField keyTypeReqDefaultValidation / optionsNotes
(register select)registerIduuidYes—shop register—
Opening floatopeningBalancenumberYes0≥0rejected if register already has an open session
—statustextautoopen—auto
—opened_by / opened_atuuid / tsautocaller / now—auto

Cash movement (in/out/expense) — /admin/registers (session panel)

UI labelField keyTypeReqDefaultValidation / optionsNotes
(session context)sessionIduuidYesselected sessionmust be open—
TypemovementTypeenumYescash_incash_in, cash_out, expense—
AmountamountnumberYes—>0 (min 0.01)numeric
Notenotetext(≤300)Nonull——
—categoryIduuidNonullexpense categoryonly for expense; not in registers UI
—reference_type/reference_idtext/uuidautonullset to expense/expenseIdauto when expense

Close cash session — /admin/pos header & /admin/registers

UI labelField keyTypeReqDefaultValidation / optionsNotes
(session context)sessionIduuidYesselected/open sessionmust be open—
Counted cashcountedBalancenumberYes0≥0numeric(14,2)
Expectedclosing_balancenumberauto—opening + cash_sales − cash_refunds + cash_in − cash_out − expensesauto
Differencediscrepancynumberauto—counted − expectedauto; alert if abs>0 (critical if >5% of expected)
—closed_at / statusts / textautonow / closed—auto

Read-only reconciliation stats: Opening, Cash sales, Refunds, Cash in, Cash out, Expenses, Expected, Counted, Difference.


Register create / edit / assign — /admin/registers

Create/edit

UI labelField keyTypeReqDefaultValidation / optionsNotes
NamenametextYes—1–120 chars—
CodecodetextNonull≤40 chars—
—branch_iduuidNonullshop branchnot in UI
Active/Inactive (toggle)is_activebooleanNotrue—the only field edit UI toggles

Assign users — per-member checkbox writes register_users (registerId + userId, idempotent, unique (register_id,user_id)).

Connections (registers) — references Register (register_id), Branch (branch_id), staff from shop_users, session (cash_session_id). An open register session gates POS selling; movements + sessions feed reconciliation and journal entries; discrepancies raise ai_alerts. One open session per register; refunds land back in the originating open session's drawer.


Return / refund — /admin/orders (order detail)

Header fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
(order)orderIduuidYes—order in shop—
Reasonreasontext(≤300)NonulldropdownUI: Reason dropdown (e.g. "Customer changed mind")
Refund methodmethod_codetext(≤40)Nocashcash refunds re-enter open drawerUI: Refund method selector (Cash …)
Refund totaltotalnumberauto—Σ proportional line totals; must be >0auto
Return numberreturn_numbertextauto—per-shop uniqueauto

Return lines (server items[])

UI labelField keyTypeReqDefaultValidation / optionsNotes
(line)order_item_iduuidYes—belongs to order—
QtyquantitynumberYes0≥0; clamped to remaining (sold − already returned)UI: per-line Return quantity input (partial returns supported)
RestockrestockbooleanNotruetoggles inventory returnUI: Restock returned items toggle (default on); restocked units reverse COGS

The admin.orders.tsx Return items modal surfaces per-line return quantity (partial returns), a Reason dropdown, a Refund method selector, and a Restock returned items toggle; the live Refund ≈ estimate updates as you set quantities, and Confirm return posts the reversing entry. Verified example: a partial return of one line on a ₨16,399 sale created RET-000001 (₨3,499 refund), set the order payment status to partial, restocked the unit, and reduced Revenue −₨3,499 + COGS −₨1,800 in the ledger (still balanced; A = L + E holds).


Sales — list & detail — /admin/sales

List filters

UI labelField keyTypeReqDefaultValidation / optionsNotes
SearchsearchtextNo""matches order_number / contact_email / contact_phone—
ChannelchannelselectNoallall / pos / online / ai—
PaymentpaymentselectNoallall / paid / partial / unpaid (credit) / refunded—
From / TodateFrom / dateTodateNo—filter on created_at—

Each row shows Sale # · Date · Channel · Customer · Payment · Total · Outstanding (Total − Paid). View opens a read-only detail modal: line items (qty / unit / discount / tax / total), the subtotal → discount → tax → shipping → Total breakdown, Paid vs Outstanding, the tenders (payments), plus customer, register, cashier and any returns.

Record payment — shown in the detail modal only while a sale still has an outstanding balance (a credit / partial sale):

UI labelField keyTypeReqDefaultValidation / optionsNotes
AmountamountnumberYes—> 0Full balance button fills the outstanding amount
Methodmethod_codeselectYescashcash / card / bank / other—

Recording a payment increments paid_total, moves the sale to partial / paid, and adds a tender row — the same settlement path used to clear udhaar balances taken at the till.


Offline sync — queue & conflict resolution — /admin/sync

Conflict resolution fields

UI labelField keyTypeReqDefaultValidation / optionsNotes
(row)conflictIduuidYes—conflict in shop—
Retry / Accept / RejectactionenumYes—accept \reject \retryaccept→resolved; reject→rejected; retry→re-post (rejected) or bump attempts
Notenotetext(≤500)Nonulldisabled unless status pending/retryingstored in resolution
Kindkindtextauto—price_variance / tax_variance / rejectedauto
Statusstatustextautopendingpending/retrying/resolved/rejectedauto
Offline vs Server totalsnapshot.total / server_state.totalnumberauto—display onlyauto
Attemptsattemptsintauto1+1 per retryauto

Local device queue ("On this device" / "Rejected on this device", IndexedDB via src/lib/offline, not a DB table): Retry resends the PendingSale payload (incl. device_id, local_seq, offline_created_at, client_snapshot) via syncOfflineSales; Discard (manager-only) drops the local pending sale/conflict.

Connections

Tables owned: orders (FKs shop_id→shops, branch_id→branches, register_id→registers, cash_session_id→cash_sessions, customer_id→customers; unique (shop_id, order_number), partial-unique (shop_id, local_uuid)), order_items (order_id→orders, variant_id→product_variants), payments (order_id→orders), receipts (order_id→orders; unique (shop_id, receipt_number)), returns (order_id→orders; unique (shop_id, return_number)), return_items (return_id→returns, order_item_id→order_items, variant_id→product_variants), refunds (order_id→orders, return_id→returns), registers (branch_id→branches), register_users (register_id→registers; unique (register_id, user_id)), cash_sessions (register_id→registers), cash_movements (cash_session_id→cash_sessions, register_id→registers; type sale/refund/expense/cash_in/cash_out/opening/closing), cash_accounts (account_id→accounts), pos_sync_conflicts (branch_id→branches, register_id→registers; unique (shop_id, local_uuid)).


Storefront Editor & AI (Admin)

All Storefront Editor forms load via getAdminStorefrontFn → getAdminStorefront (src/lib/storefront-admin.server.ts), returning { shop, settings, banners, sections, navigation, policies, pages, themes } for the active shop. All writes require owner / admin / manager (assertManager). The editor is a single route with tabs (theme, banners, sections, navigation, pages, policies, contact, domains) plus an opt-in live-preview iframe of /store/<slug>?preview=1.

Draft vs Publish model:


Theme — /admin/storefront (tab: Theme)

Saved as one draft patch. Colour fields initialise from the theme's own palette when the shop hasn't genuinely customised them (customColor / THEME_DEFAULT_SETTINGS, src/lib/storefront-theme.ts).

UI labelField key (patch)TypeReqDefaultValidation / optionsNotes
Homepage varianthomepage_variantselect—"index"from themes[0].variants[].code, else index/index-2…index-5—
Primaryprimary_colorcolor/hex—#0A69D8free hex + pickerlegacy default #ff6a00 treated as "not customised"
Secondarysecondary_colorcolor/hex—#333333—legacy default #111111
Accentaccent_colorcolor/hex—#ffa500—legacy default #f5a623
Texttext_colorcolor/hex—#333333——
Backgroundbackground_colorcolor/hex—#ffffff——
Font family (optional)font_familytext—""empty → saved as null (keeps theme typography)—
Hero titlehero.titletext—""packed into hero JSON—
Hero subtitlehero.subtitletext—""——
Hero imagehero.image_urlimage URL—""AdminImageUpload folder cms—
Hero CTA labelhero.cta_labeltext—""——
Show announcement barannouncement_bar.enabledcheckbox—false——
Announcement textannouncement_bar.texttext—""——
Facebook / Instagram / TikTok / WhatsAppsocial_links.*text—""packed into social_links JSON—
Meta titleseo.titletext—""packed into seo JSONfeeds storefront <title>
Meta descriptionseo.descriptiontext—""—feeds meta description
Meta keywordsseo.keywordstext—""——

Reset to theme default patches the five colours + font_family to THEME_DEFAULT_SETTINGS, leaving hero/announcement/social/SEO untouched. Still requires Publish. Persisted draft keys (SETTINGS_DRAFT_KEYS): homepage_variant, primary/secondary/accent/text/background_color, font_family, announcement_bar, header, footer, hero, social_links, seo, theme_code (header/footer/theme_code are draftable but not in this UI).


Banners — /admin/storefront (tab: Banners)

UI labelField keyTypeReqDefaultValidation / optionsNotes
—iduuid—null (new)present → update—
PlacementplacementselectYes"hero"hero, promo—
PositionpositionnumberYes(count+1)*10int ≥ 0↑/↓ buttons
Titletitletext—""nullable"(untitled)" if blank
Subtitlesubtitletext—""nullable—
Banner imageimage_urlimage URL—""folder cms wide—
Link URLlink_urltext—""nullable—
Visibleis_visiblecheckboxYestrue——
—is_draftbooleanautotrue on save—cleared on publish

Sections — /admin/storefront (tab: Sections)

UI labelField keyTypeReqDefaultValidation / optionsNotes
—iduuid—null——
Page slugpage_slugtextYes"home"which storefront page—
Section typesection_typeselectYesfeatured_categoriesfeatured_categories, featured_products, flash_deals, product_section, promotional, footer—
Titletitletext—""nullable—
PositionpositionnumberYes(count+1)*10int ≥ 0—
Visibleis_visiblecheckboxYestrue——
Section imageconfig.image_urlimage URL——stored in config JSON—
Config (JSON)configJSON textareaYes{}must parse ("Config must be valid JSON")free-form block config
—is_draftbooleanautotrue on save——

Navigation — /admin/storefront (tab: Navigation)

UI labelField keyTypeReqDefaultValidation / optionsNotes
—iduuid—null——
LocationlocationselectYes"header"header, footer—
LabellabeltextYes""min 1—
URL (relative)urltextYes""empty allowed; col NOT NULLrendered as /<url>
PositionpositionnumberYes(count+1)*10int ≥ 0—
Visibleis_visiblecheckboxYestrue——
—parent_iduuid—nullself-FK for nestingnot in UI
—is_draftbooleanautotrue on save——

Pages — /admin/storefront (tab: Pages)

UI labelField keyTypeReqDefaultValidation / optionsNotes
—iduuid—null——
SlugslugtextYes""min 1unique (shop_id, slug)
TitletitletextYes""min 1—
Publishedis_publishedcheckboxYesfalsecol default true"· unpublished" in list
(content)contenttextareaYes*""empty allowedbody text

Policies — /admin/storefront (tab: Policies)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Pagepolicy_typeselectYes"privacy"privacy, terms, return, shipping, faqselects which row is edited
—titletextautofrom POLICY_TYPES mapmin 1derived, not a visible input
(content)contenttextareaYes*existing / ""——
Publishedis_publishedcheckboxYestrue——

POLICY_TYPES titles: Privacy Policy, Terms & Conditions, Return Policy, Shipping Policy, FAQ.


Branding & Contact — /admin/storefront (tab: Contact)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Store namenametext—shop.nameonly written if non-emptyupdates shops.name
Taglinetaglinetext—shop.taglinenullable—
Store logologo_urlimage URL—shop.logo_urlfolder brandingheader + receipts
Faviconfavicon_urlimage URL—shop.favicon_urlfolder brandingbrowser tab icon
Phonephonetext—shop.phonenullable—
Emailemailtext—shop.emailnullable—
WhatsAppwhatsapptext—shop.whatsappnullable—
Addressaddresstext—shop.addressnullable—

Storefront Connections


Domains (connect your own domain) — /admin/storefront (tab: Domains)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Add a domainhostnametextYes—3–253 chars; one shop per host (case-insensitive)e.g. trendies.pk or www.trendies.pk
—verification_statusenumautopendingpending / verified / failedstatus badge per row
—is_primarybooleanautofalseone primary per shopset via Make primary (verified only); others redirect to it

Merchant flow

  1. Add the domain — Storefront → Domains, type the host, click Add domain (lists as Pending).
  2. Create the DNS records at your registrar: an A record (@ → 185.158.133.1) plus the _lovable TXT verification record — use the exact values shown on screen.
  3. The platform verifies ownership and issues the SSL certificate (the free https:// padlock).
  4. Once the domain resolves to your store, click Mark verified — it switches to Verified and your storefront goes live on it.
  5. (Optional) If you added more than one (e.g. apex + www), click Make primary; secondary domains redirect to the primary.
  6. (Optional) Serves — each domain has a Serves selector (shown as soon as the domain is added, even before DNS verification) choosing WHAT it serves at its root: Online store (default — the storefront, as above) or Restaurant website (the /dine site from Restaurant → Website). A restaurant-assigned domain opens the restaurant site at / and is its own home — it is not redirected to the store's primary domain. Changing it saves immediately and is recorded in the audit log (shop_site.domain_assigned).

Unknown / unverified hosts never resolve to a shop, and one domain maps to exactly one shop. Full registrar-by-registrar steps (including Namecheap) are in the in-app guide at /admin/domain-setup.


AI (Admin) — /admin/ai

AI business assistant

Three tabs: Assistant (chat + right-rail panels: reports, pending actions, action history, permissions, alerts), Knowledge base, Business memory. Most AI writes require owner / admin / manager; chat itself only needs active shop membership.

Assistant chat — /admin/ai (tab: Assistant)

UI labelField keyTypeReqDefaultValidation / optionsNotes
message boxmessagetextYes""1–2000 charsEnter to send
—shopIduuidautocurrent shop——
—conversationIduuidautonull first turn, then returned id—threads the conversation
—localetextautoshop.default_locale≤5—

Reports: Morning briefing (getBriefing) and End-of-day summary (getEndOfDay), input {shopId} only.

The assistant can take actions, not just answer. Ask it to create a purchase order, adjust inventory, record an expense or payment, or draft a message; it resolves the records it needs (supplier, product, amounts) and then either runs immediately or queues for approval depending on that capability's mode in AI permissions (disabled / approval / automatic). Approval-mode actions appear under Pending AI actions; on Approve the action is actually executed and its result is posted back into the chat (see Permission matrix and Approval queue). Actions never exceed the acting user's role limits.


Permission matrix — /admin/ai ("AI permissions" panel)

UI labelField keyTypeReqDefaultValidation / optionsNotes
(agent label)agentenumautoseededai_agent = salesman \assistantdisplay only
(capability label)capabilitytextautoseeded—display only
mode dropdownmodeselectYescol default disableddisabled \approval \automaticthe only editable field
—id, shop_iduuidauto—unique (shop_id, agent, capability)—

Seeded capabilities (one ai_permissions row per shop): salesman → view_products / view_inventory / view_prices / recommend_products / add_to_cart / track_order (automatic), create_order (approval). assistant → view_sales / view_inventory / view_purchases / view_expenses / view_accounting / view_customers / suggest_reorders / generate_reports (automatic), create_purchase_order / adjust_inventory / record_expense / issue_refund (approval).


Approval queue — /admin/ai (Pending AI actions / Action history)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Approve / RejectdecisionbuttonYes—approved \rejected—
—approvalIduuidautorow id——
—shopIduuidautocurrent shop——

Row display fields: action, payload (summarised: supplier, items, total, amount, quantity, variant, reason, note), status (approval_status: pending/approved/rejected/executed, plus app-written failed), created_at, executed_at, result, error, requested_by, decided_by, decided_at, conversation_id.


Alerts — /admin/ai ("Alerts" panel)


Knowledge base — /admin/ai (tab: Knowledge base)

UI labelField keyTypeReqDefaultValidation / optionsNotes
TitletitletextYes""1–200; file upload falls back to file.name—
(content)contenttextareaYes""1–200000markdown/CSV/plain
(file picker)content (from file)file——.txt, .md, .csv read as text—
—sourcetext—"manual"≤100not in UI
—docTypetext—server "text"col doc_type default policynot in UI
—is_activebooleanautotrue——

content is split by chunkText (~800 chars, 120 overlap) into ai_knowledge_chunks (each with a generated search tsvector). Returns {documentId, chunkCount}. List row shows title, chunk_count (auto), source, created_at.


Business memory — /admin/ai (tab: Business memory)

Add memory

UI labelField keyTypeReqDefaultValidation / optionsNotes
ScopescopetextYes""1–50; col default generale.g. policy, supplier, hours
(content)contenttextareaYes""1–4000; sanitised (cards/tokens/emails redacted)—
—sourceenumauto"user"—user-created
—statusenumauto"approved" (user) / pending (AI)——
—confidencenumericauto1 (AI proposals 0.6)——
—is_activebooleanautotrue——

Edit id + scope?/content?. Toggle active id + isActive. Decide AI-proposed id + decision (approved\|rejected) — only affects source='ai' AND status='pending'; approve also sets is_active=true. The UI splits into AI-proposed (pending → Approve/Reject) and Approved (Edit / Activate-Deactivate). AI reads (activeBusinessMemory) only include is_active=true AND status='approved'.

AI Connections

Tables owned:


Storefront — Customer Forms

Storefront home

Auth pages (sign in / up / forgot) call Supabase Auth directly from the browser (supabase.auth.*), not a server fn. Cart, wishlist, and compare are client-only, persisted in localStorage (zenis-store:<shopSlug>) via useStorefrontCart (src/lib/cart-store.ts); the DB carts / cart_items / wishlists tables are not written by any storefront route. A customers row is created lazily on first authenticated account/checkout access (getOrCreateCustomer), or by placeOnlineOrder for guests.


Customer sign in — /store/$shopSlug/sign-in

UI labelField keyTypeReqDefaultValidation / optionsNotes
EmailemailtextYes""——
PasswordpasswordpasswordYes""——
(Continue with Google)—button——OAuth redirect to sign-in—
(Resend confirmation email)—button——shown only when error is "not confirmed"auth.resend({type:"signup"})

Connections — on success navigates to account; session drives getCustomerAccount/checkout prefill; account is scoped per shop (metadata shop_slug).


Customer sign up — /store/$shopSlug/sign-up

Storefront shop

UI labelField keyTypeReqDefaultValidation / optionsNotes
Full namefullNametextNo""→ user_metadata.full_name—
EmailemailtextYes""non-empty (trimmed); Supabase email format—
PasswordpasswordpasswordYes""min length 6 (client)—
(shop scope)shop_slugautoautoshop.slug—→ user_metadata.shop_slug
(Continue with Google)—button——OAuth redirect to origin—

Connections — confirmed session → account; later getOrCreateCustomer inserts customers (shop_id, user_id, email). Duplicate-email surfaced as "already exists".


Forgot password — /store/$shopSlug/forgot-password

UI labelField keyTypeReqDefaultValidation / optionsNotes
EmailemailtextYes""trimmedneutral "if that email exists" message

Product page — add to cart — /store/$shopSlug/product/$slug

Product detail

UI labelField keyTypeReqDefaultValidation / optionsNotes
OptionsvariantIdselect (buttons)Yes if >1 variantdefault variant (is_default) else first activeactive variants onlyhidden when single variant
QuantityquantitynumberYes1min 1; coerced max(1, n)—
Add to cart—button——disabled if no variant or out of stockwrites CartLine to localStorage
Wishlist (heart)—button——toggles wishlist list—
Compare—button——toggles compare list—

CartLine written per add: variant_id, quantity, name (product + variant), sku, price, image (first product image), product_slug.

Connections — reads Product / active Variant (price, compare_at_price, track_inventory, stock); cart/checkout/wishlist/compare read the same localStorage store; stock drives disabled state.


Cart — /store/$shopSlug/cart

Cart

UI labelField keyTypeReqDefaultValidation / optionsNotes
Itemname,sku,pricedisplay———read-only
QuantityquantitynumberYescurrentmin 0; <=0 removes line—
Total—auto—price × quantity—computed
(trash)—button——removes line—

Totals: Subtotal (auto, Σ price×qty client-side); note "Taxes and totals calculated securely at checkout". "Checkout" → checkout, which re-prices server-side.


Checkout — /store/$shopSlug/checkout

Checkout

Contact / Billing details

UI labelField keyTypeReqDefaultValidation / optionsNotes
Full namecustomer.full_nametextYesprefilled from session1–120 charsUI blocks empty name/email
Emailcustomer.emailemailYesprefilled from sessionvalid email—
Phonecustomer.phonetextNo""max 40—
—customer.user_iduuidautosession user idoptionalattaches order to logged-in customer

Shipping address sub-form (shipping_address, all optional, stored as JSON on order): line1 (max 200), city (max 80), postal_code (max 30), country (max 80).

Payment / meta

UI labelField keyTypeReqDefaultValidation / optionsNotes
Payment (radio)payment_method_coderadioYescodcod \bank \cardcod = unpaid/AR (1150); bank/card = prepaid → paid, Bank (1100)
— (notes)notestextNo—max 500not in UI (schema-only)
— (idempotency)client_uuiduuidautocrypto.randomUUID()max 80dedupes retries via orders.local_uuid
Itemsitems[]autoYesfrom cartvariant_id uuid, quantity 0.001–9999, min 1 lineserver re-prices

Order summary (auto, from quoteCart): subtotal, tax_total, total, discount_total, and generated order_number / receipt_number.

Connections


Track order — /store/$shopSlug/track-order

Track order

UI labelField keyTypeReqDefaultValidation / optionsNotes
Order numberorderNumbertextYes""min 1; trimmed—
Email / phonecontacttextYes""min 3; must match contact_email or contact_phonebutton disabled until both filled

Returns status/payment/fulfillment/tracking + items + total (read-only).


Account — Profile — /store/$shopSlug/account (tab: Profile)

UI labelField keyTypeReqDefaultValidation / optionsNotes
Full namefull_nametextNocustomer valuemax 120; ""→null—
EmailemailemailNocustomer valuemax 160; ""→null—
PhonephonetextNocustomer valuemax 40; ""→null—

Account — Addresses — /store/$shopSlug/account (tab: Addresses)

Address sub-form

UI labelField keyTypeReqDefaultValidation / optionsNotes
—iduuidauto—present = update, absent = insert—
LabellabeltextNo""max 60—
Address line 1line1textNo""max 200—
Address line 2line2textNo""max 200—
CitycitytextNo""max 100—
StatestatetextNo""max 100—
Postal codepostal_codetextNo""max 30—
CountrycountrytextNo""max 100—
PhonephonetextNo""max 40—
Set as defaultis_defaultcheckboxNofalse—setting true unsets other defaults first

Connections — saved addresses are the customer's own list (not auto-injected into the checkout form, which has its own inline fields).


Account — Order history / reorder — /store/$shopSlug/account (tab: Orders)

Read-only list: Order # / Date / Status / Payment / Total (from orders); View opens the detail modal; Reorder these items re-adds each order_item (with variant_id) to the cart.


Wishlist / Compare — /store/$shopSlug/wishlist · /compare


Contact — /store/$shopSlug/contact


Storefront AI Salesman — chat widget (/store/$shopSlug)

It can now:

Tables owned: customers (shop_id→shops, user_id→auth.users, unique (shop_id,user_id)), customer_addresses (customer_id→customers), orders (customer_id→customers, unique (shop_id,order_number), partial-unique (shop_id,local_uuid)), order_items (order_id→orders, variant_id→product_variants), payments (order_id→orders), receipts (order_id→orders, unique (shop_id,receipt_number)). Defined but unused by these storefront routes (client-side localStorage instead): carts, cart_items, wishlists. Checkout payment methods are hardcoded (cod/bank/card), not from payment_methods. The storefront product page has a verified-buyer review submission form (the shopper supplies their order number, which must contain the product); submissions land as pending and appear on the storefront only after a manager publishes them from /admin/reviews. The AI Salesman surfaces the published rows from the reviews table.


Reports

Three read-only report suites, each built the same way: a report picker, a From / To date range, and Print + Download CSV actions (ReportShell; default range = month-to-date). Because they expose cost, margin and outstanding balances, each suite is role-gated. All three read from existing sales / purchasing / inventory data (orders, POs, receipts, movements, balances, ledger) — no new tables; each report exports CSV with the columns shown on screen.

Sales Reports — /admin/sales-reports

Purchase Reports — /admin/purchasing-reports

Inventory Reports — /admin/inventory-reports


Promotions, Loyalty & Marketing

Promotion / coupon — /admin/promotions

New promotion

UI labelTypeReqDefaultNotes
NametextYes""shown on the discount line
TypeselectYesPercentagepercentage or fixed-amount
Percent (0–100)numberYes (percentage)0discount rate
Minimum ordernumberNo0only applies at/above this subtotal
Max discount (cap, optional)numberNo—caps the computed discount
Channelsmulti-toggleNoempty = allpos / online / restaurant
ActivetoggleYesoninactive promotions never apply

Coupon — wraps a promotion with a code customers enter plus usage limits; remaining-use and eligibility checks are enforced server-side at checkout.

Free-gift campaign — /admin/promotions (tab: Free gifts)

Product reviews & moderation — /admin/reviews

Loyalty program — /admin/loyalty

Program

UI labelTypeDefaultNotes
Program enabledtoggleoffmaster switch
Program nametext"Loyalty"shown to customers
Tier basisselectLifetime spendhow tiers are earned
Points earned per 1 spentnumber1earn rate
Points to redeem 1 valuenumber100redemption rate
Minimum redemption (points)number100floor to redeem
Max redemption (% of order)number50caps redemption per order
Earn points on taxcheckboxoffinclude tax in the earning base
RoundingselectFloorhow fractional points round

Tiers (optional, each with an earning multiplier), Rewards (redeemable; discount rewards reuse the checkout discount mechanism), Point expiration policy (FIFO, disabled by default; redeemed points are never removed; points before the effective date are grandfathered), Tier lifecycle (Process tier transitions — idempotent; unchanged tiers create no history), and a Reward claims list.

Earning & redeeming (in practice): points are earned automatically when a sale completes with a customer attached — at the POS terminal (tap Customer and pick the shopper before taking payment), in Restaurant, or at online checkout — on the earning base (subtotal, or including tax if Earn points on tax is on); the running balance appears on the customer's 360 profile. Redemption is done at the storefront (online) checkout, where a signed-in customer applies a configured Reward (a points-cost discount that reuses the checkout discount mechanism, within Minimum redemption and Max redemption % of order). The Retail POS terminal earns points but does not itself expose reward redemption.

Marketing campaign — /admin/campaigns

Social calendar — /admin/social


Manufacturing (BOM & costing)

Bill of materials — /admin/manufacturing (tab: BOMs & costing)

New BOM — set BOM name, choose the finished product/variant, Output quantity (units yielded per batch), Version, optional Notes and an Active toggle (the current recipe). Save the BOM first, then add components. Add component lines:

UI labelTypeReqNotes
Componentvariant selectYesthe material/ingredient consumed (multi-select picker with search / category / brand / in-stock filters)
QuantitynumberYesper one finished unit
Unit (UoM)textNodefaults to the component's stock unit; converted via unit-conversion
TypeselectNoMaterial / Packaging (classifies the cost bucket)
Wastage %numberNoscrap allowance folded into the rolled-up cost

An Additional costs row adds Labor / Overhead (label + amount). The Theoretical cost panel then shows, per batch: Materials, Packaging, Additional, Total (batch), Unit cost (rolled up from each component's avg_cost × qty, unit-converted, + wastage), the finished variant's Selling price, Gross margin and Margin %. A note flags when any component has no cost / an unsupported unit (the total is then a floor). A newly created BOM persists as inactive until you toggle Active and save.

Production planning — /admin/manufacturing (tab: Production planning)


Restaurant

Enabled by the three Restaurant modules (Restaurant POS, Restaurant Tables, Restaurant Kitchen). Management screens are gated to MANAGERS (owner / admin / manager); customer-facing ordering surfaces are separate. Backend: restaurant*.functions.ts (setup rendered by RestaurantPanel.tsx).

Overview — /admin/restaurant

Setup — /admin/restaurant/<section> (tabbed)

One page with tabs Floor & Tables, Menu & Modifiers, Kitchen Setup, Recipes, Settings (URL section = tables / menu / kitchen / recipes / settings). A branch filter scopes everything.

Restaurant Website — /admin/restaurant-site

Floor & Tables

FormFields
FloorName (e.g. "Ground Floor"), active, branch
TableCode (e.g. "T01"), Name (optional), Capacity (default 4), Floor, branch, active

Menu & Modifiers

FormFields
Modifier groupName (e.g. "Add-ons"), selection type (single / multiple), required, min / max selections
Modifier optionName (e.g. "Extra Cheese"), price adjustment
Assign to productpick a Product, then attach modifier group(s)

Kitchen Setup — define kitchen stations (Code, e.g. "KITCHEN") and route each product → station with an optional prep time (drives KOT routing on the KDS). Routing a product here (with Available on) is also what publishes it to the customer online-ordering menu — the public /order/$token storefront lists exactly these curated items (restaurant_menu_items), not the whole catalog, so add every online-orderable item here.

Recipes — for a menu product, list ingredient products with quantity + unit of measure; feeds theoretical food cost.

Settings — enable Public (online) ordering, toggle Pickup and/or Delivery fulfilment, and publish the customer ordering link / QR (/order/$token). Save to apply the fulfilment toggles. See Online ordering (customer) below.

Restaurant POS — /admin/restaurant-pos

Kitchen Display (KDS) — /admin/restaurant/kds

Front-of-house & operations sub-pages


CRM, Segments & Analytics

One shared customer identity across POS, Restaurant, Ecommerce, CRM, Loyalty and Marketing — customers are never duplicated. Module crm; reads open to SELLING_ROLES, management to MANAGERS. (Loyalty is documented above under Promotions, Loyalty & Marketing.)

Customer Directory — /admin/crm

UI labelField keyTypeNotes
Namefull_nametext≤ 120 chars
Phonephonetext≤ 40 chars
Emailemailtext≤ 160 chars
Citycitytext≤ 80 chars
Groupgroup_idselectcustomer group → group pricing in POS/checkout
Tagstags[]tags≤ 30 tags, ≤ 40 chars each
Notesnotestextarea≤ 1000 chars

Much of the profile (orders, spend, segment, last-order date, loyalty points) is derived from real orders — the customer record is created automatically at first purchase (name/phone/email) and enriched here.

Segments — /admin/segments

CRM Analytics — /admin/crm-analytics

Growth Dashboard — /admin/growth


HR & Payroll

Module employees for HR (MANAGERS); Payroll is finance-gated (owner / admin / accountant).

Employees — /admin/employees

Departments / Designations — /admin/departments, /admin/designations

Attendance — /admin/attendance

Leave — /admin/leave

Shifts — /admin/shifts

Recruitment — /admin/recruitment

Loans — /admin/loans

Performance — /admin/performance

Employee Lifecycle — /admin/hr/lifecycle

Workforce Requests — /admin/hr/workforce-requests

Payroll — /admin/payroll

1. Settings (one-time, required before posting) — map the ledger accounts payroll uses: Salary / earnings expense account (fallback, e.g. 6100 Salaries), Employee payable account (required to post/pay — a liability, e.g. 2000 Accounts Payable), optional Deduction liability account, plus default working days/month, hours/day and overtime multiplier. Pay components can override the expense account per component.

2. Salary structure (Salary Structures tab) — pick an employee → New structure: Effective from (structures are dated; a new one closes the previous period and preserves history), Base salary, optional Currency, Std days / month (26), Std hours / day (8), and optional allowance / deduction components (drawn from Pay Components). Base-salary-only is valid.

3. Period → run → calculate → approve → post → pay

StepWhat happens
New periode.g. "September 2026" with start / end / payment dates → status open
New runnames a run for the period (optionally scoped by department / branch) → draft
Calculatebuilds each employee's payslip from their structure & attendance; prorates for partial-period employment (a mid-period joiner is paid only for days employed) and raises warnings (e.g. no shift assignment, proration) → calculated
Approveconfirms the run (if any employee has warnings, a "Approve anyway?" prompt appears) → approved
Post to accountingposts one balanced journal (Dr salary/earnings expense · Cr employee payable + deduction liabilities), period-guarded → posted. Editing after calculate requires Reopen → Recalculate so the lines re-snapshot the current account mappings.
Payper employee (Details → Salary payment): record the net against Cash or Bank → payment status paid (Dr employee payable · Cr Cash/Bank). A default Cash Drawer and Bank Account are provisioned automatically (linked to Chart-of-Accounts Cash 1000 / Bank 1100), so cash payouts work out of the box; older shops are back-filled on their first payout

Employee Details shows attendance stats, the component breakdown, any warnings, a View payslip (PDF — never license-gated) button, and an Add manual adjustment (earning/deduction) control.


Business Intelligence

Executive Dashboard — /admin/bi

Reports Center — /admin/bi/reports


Advanced Procurement

A sourcing pipeline in front of Purchasing: internal requisitions → approval → RFQ to suppliers → quotations → comparison → award → optional contract → convert to a purchase order. Module procurement; access via procurement permissions (owner/admin allow-all, otherwise per-role role_permissions with built-in defaults). Hub at /admin/procurement (requisition / sourcing / governance / vendor-portal KPIs). Backend: procurement.server.ts (requisitions), procurement2.server.ts (RFQ + awards), procurement3.server.ts (contracts).

Requisition — /admin/procurement/requisitions

UI labelField keyReqNotes
Branchbranch_idNoscopes the request
Departmentdepartment_idNocost owner
Required daterequired_dateNowhen it's needed
PrioritypriorityNolow / normal / high / urgent
JustificationjustificationNowhy it's needed
Budget referencebudget_referenceNolinks to a budget line
NotesnotesNo—
Itemsitems[]Yeseach: variant/description, quantity, unit, estimated price

RFQ — /admin/procurement/rfqs

UI labelField keyReqNotes
TitletitleYes—
DescriptiondescriptionNo—
Issue date / Response deadlineissue_date / response_deadlineNodeadline drives "awaiting responses"
Expected delivery / locationexpected_delivery_date / delivery_locationNo—
Payment terms / Termspayment_terms / termsNo—
Supplierssupplier_ids[]Nowho to invite (also via the vendor portal)
Itemsitems[]Yeslines to be quoted

Quotations & comparison — /admin/procurement/quotations, /admin/procurement/comparisons

Award — /admin/procurement/awards

UI labelField keyReqNotes
Suppliersupplier_idYeswinning supplier
RFQ / Quoterfq_id / quote_idNosource
Payment / Delivery termspayment_terms / delivery_termsNo—
Freight / Other / Tax / Discountfreight_amount / other_charges / tax_amount / discount_amountNoroll into the award total
Itemsitems[]Yesawarded lines

Contract — /admin/procurement/contracts

UI labelField keyReqNotes
Supplier / Titlesupplier_id / titleYes—
Contract typecontract_typeNo—
Start / End / Renewal datestart_date / end_date / renewal_dateNoend_date drives expiry status
Contract valuecontract_valueNo—
Ownerowner_user_idNoresponsible person
Itemsitems[]Noeach: description, agreed price, unit, quantity ceiling, minimum commitment, discount, valid-until

Approval inbox / Suppliers / Reports / Settings

Vendor portal (supplier-facing) — /store/$shopSlug/vendor + /vendor.invite.$token + /vendor.rfq


Appendix A — Enumerations

Many forms share these fixed choice lists (defined as Postgres enum types). Where a form has a "status" or "type" select, it almost always draws from one of these:

EnumUsed byValues
shop_roleTeam & Roles, invitationsowner · admin · manager · cashier · accountant · inventory · staff
product_statusProductdraft · active · archived
order_statusOrderspending · confirmed · processing · completed · cancelled · refunded
order_channelOrderspos · online · ai
payment_statusOrders, invoicesunpaid · partial · paid · refunded
fulfillment_statusOrdersunfulfilled · processing · shipped · delivered · picked_up · returned
movement_typeInventory movementsopening · purchase · sale · return · adjustment · transfer_in · transfer_out · count
transfer_statusTransfersdraft · requested · approved · shipped · received · cancelled
po_statusPurchase ordersdraft · submitted · approved · partial · received · cancelled
account_typeChart of accountsasset · liability · equity · income · expense
approval_statusAI action approvalspending · approved · rejected · executed
ai_agentAIsalesman (storefront) · assistant (admin)

Appendix B — Tables ↔ forms index

The database has ~75 tables, all scoped by shop_id. Key foreign keys (the wiring behind the "Connections" blocks):

Pointer fieldPoints atMeaning
shop_idshopstenant scope on every table
branch_idbranchesbranch binding
category_idcategories / expense_categoriesproduct category · expense category
parent_idcategories / accounts / storefront_navigationtree self-reference
brand_idbrandsproduct brand
tax_rate_idtax_ratesproduct/line tax
product_idproductsvariant/image parent
variant_idproduct_variantsthe sellable unit on order/PO/cart/count/transfer/return lines
customer_idcustomersorder/cart owner
order_id / order_item_idorders / order_itemssale + returned line
return_idreturnsrefund parent
register_idregistersPOS till
cash_session_idcash_sessionsopen till session
location_id / from_location_id / to_location_idinventory_locationsstock location(s)
transfer_idinventory_transferstransfer lines
stock_count_idstock_countscount lines
supplier_idsuppliersPO / invoice / payment
purchase_order_idpurchase_ordersreceipt/invoice source
goods_receipt_idgoods_receiptsreceipt lines
supplier_invoice_idsupplier_invoicessupplier payment target
account_idaccountsjournal line / expense account
journal_entry_idjournal_entriesledger lines
document_idai_knowledge_documentsknowledge chunks
conversation_idai_conversationsAI messages

Per-domain table ownership is listed at the end of each section above.