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>/…, demotrendies-store).
How to read this manual
Each form is documented as:
- Purpose / Access / Backend — what it's for, which roles may use it, and the server function + database table(s) behind it (so developers can trace it).
- A Fields table with these columns:
| Column | Meaning |
|---|---|
| UI label | What the user sees on screen. |
| Field key | The underlying form/database field name. |
| Type | text, number, money, integer, select, multi-select, toggle, date, file, textarea, color, computed. |
| Req | Required (shown as ● or "Yes") · optional (○ or "No") · auto = set by the system, not typed by the user. |
| Default | Pre-filled value, if any. |
| Validation / options | Constraints, or the choice list for selects. |
| Notes | Anything else worth knowing. |
- A Connections block:
- Selects / references — other forms this one reads from (foreign keys). Example: a Product selects a Category.
- Feeds / used by — forms and screens that consume this record downstream.
- Cross-form effects — what submitting does elsewhere (stock movements, ledger postings, sequence numbers).
Convention used everywhere: a field ending in_idis 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.
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
- Product → looks up Category (
category_id, a self-nesting tree viaparent_id), Brand (brand_id), Tax rate (tax_rate_id). - Product → has many Variants (
product_variants, the sellable unit). Each Variant fans out to Barcodes, Prices, Variant costs (private, never public), Product images, and Inventory balances (one per stock Location).
Selling spine
- Order (
order_channel= pos / online / ai) → Order items (→ Variant) + Payments (→ Payment method) + Receipts; optionally → Customer (customer_id). - Return (→ Order) → Return items (→ Order item) → Refunds.
- POS: Register → Cash session → Cash movements; Register users grant till access.
Inventory spine
- Inventory movement (
movement_type) is the ledger of stock; it updates Inventory balances (Variant × Location). - Transfer → Transfer items, moving stock between Locations (
from_location_id→to_location_id) on receipt. - Stock count → Stock count items post adjustments.
Purchasing spine
- Supplier → Purchase order → PO items (→ Variant); Goods receipt (→ PO) → Receipt items; Supplier invoice (→ PO); Supplier payment (→ Supplier / Invoice).
Accounting spine
- Every posting writes a balanced Journal entry → Journal entry lines (→ Account; Accounts form a tree via
parent_id, typed byaccount_type). Expenses (→ Expense category, Account). Payment methods, Bank accounts, Cash accounts.
Storefront + AI
- Cart → Cart items (→ Variant). Customer → Customer addresses, Wishlists. Store look/behaviour: Storefront settings / themes / pages / navigation / banners / sections / policies.
- AI permissions gate assistant actions; Knowledge documents → Knowledge chunks; Conversations → Messages, Tool calls, Action approvals, Alerts.
A full table-by-table foreign-key index is in Appendix B.
Contents
- Accounts, Team & Shop Settings
- Catalog
- Inventory
- Purchasing & Accounting
- Sales — POS, Registers, Orders & Sync
- Storefront Editor & AI (Admin)
- Storefront — Customer Forms
Accounts, Team & Shop Settings
Sign in / Sign up — /auth (?mode=signup)
- Purpose: Staff authentication and new-account registration for the admin portal.
- Access: Public (unauthenticated). Already-signed-in users are redirected to
/admin. - Backend: Supabase Auth client SDK (no server fn / zod) —
supabase.auth.signUp/supabase.auth.signInWithPassword→auth.users(a DB trigger mirrors intoprofiles).mode=signupsearch param preselects the sign-up view.
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Full name | fullName | text | ● (signup only) | "" | — | Passed as options.data.full_name → user metadata → profiles.full_name. Hidden in sign-in mode. |
| ● | "" | trimmed | Used for both signup and signin | |||
| Password | password | password | ● | "" | Supabase policy (min 6) | — |
| — | mode | search param | — | (signin) | "signup" toggles view | Also toggled by "Create a new account" link |
Connections
- Feeds / used by: Session drives all
_authenticated/routes;full_name/emailsurface in Team → Members*. New signup withemailRedirectTo=/admin. - Cross-form effects: Successful auth navigates to
/admin(which routes to Onboarding if the user has no shop). Signup without a session shows "check your email to confirm."
Forgot password — /auth (inline action)
- Purpose: Trigger a password-reset email.
- Access: Public (sign-in view only).
- Backend:
supabase.auth.resetPasswordForEmail(email, { redirectTo: /reset-password?next=%2Fadmin })(client SDK).
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| ● | "" | non-empty (reuses the sign-in Email input) | Always shows generic "if that email has an account…" message |
Connections
- Cross-form effects: Email link lands on Reset password with
next=/admin.
Reset password — /reset-password (?next=<relative path>)
- Purpose: Set a new password from a recovery link (used by both staff and storefront customers).
- Access: Public, but the Update button is enabled only once a recovery/
SIGNED_INsession is present. - Backend:
supabase.auth.updateUser({ password })(client SDK) →auth.users.
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| New password | password | password | ● | "" | min 6 chars | — |
| Confirm new password | confirm | password | ● | "" | must equal password | — |
| — | next | search param | — | /admin | must start with / and not // (same-origin only) | Redirect target after success |
Connections
- Cross-form effects: On success redirects to
next(default/admin) after ~1.2s.
Onboarding — create shop — /onboarding (auth-gated)
- Purpose: Provision a new tenant (shop) for the signed-in user, who becomes owner.
- Access: Any authenticated user (creator is inserted as
owner). - Backend:
createShop(src/lib/shop.functions.ts) →provisionShop(src/lib/shop.server.ts) → tablesshops,shop_users, and many child tables (see cross-form effects).
Fields (authoritative = createShop zod schema)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Shop name | name | text | ● | "" | 2–80 chars (button disabled < 2) | — |
| Store address (URL slug) | slug | text | ● | auto from name | 2–60 chars; server re-slugifies ([^a-z0-9-]→-) & checks uniqueness | Client defaults to name lowercased-hyphenated |
| Currency | currency_code | select | ● | "USD" | 2–5 chars; USD/PKR/QAR/AED/SAR/INR/EUR/GBP | — |
| — | currency_symbol | text | ● (auto) | "$" | 1–5 chars | auto — derived client-side from selected currency |
| Timezone | timezone | select | ● | "UTC" | UTC/Asia/Karachi/Riyadh/Qatar/Dubai/Kolkata/Europe/London/America/New_York | — |
| Default language | default_locale | select | ● | "en" | enum en/ar/ur/hi | From SUPPORTED_LOCALES |
| Storefront languages | supported_locales | checkbox[] | ● | ["en"] | array of en/ar/ur/hi, min 1 | Falls back to ["en"] if none checked |
| Phone | phone | text | ○ | "" | ≤ 40 chars | — |
| text | ○ | "" | ≤ 120 chars (not validated as email) | — | ||
| Address | address | text | ○ | "" | ≤ 240 chars | — |
| — | created_by | uuid | auto | context.userId | — | Server-set |
| — | settings | jsonb | auto | DEFAULT_SHOP_SETTINGS | — | Large default object (tax/receipt/inventory/pricing/etc.) |
Connections
- Feeds / used by: Creates the tenant consumed by every admin/POS/storefront route; owner membership drives
getMyShopsand the role matrix. - Cross-form effects (all in
provisionShop, one batch of inserts): shops(withsettings = DEFAULT_SHOP_SETTINGS),shop_users(creator →owner)branches: "Main Branch" (MAIN,is_default, default business_hours)registers: "Register 1" (REG1);inventory_locations: "Main Store" (is_default)payment_methods: Cash / Card / Bank Transfer / Store Credit (disabled) / Cash on Deliverytax_rates: "Standard VAT" rate 0 (is_default)accounts: 14-line chart of accounts (is_system)document_sequences: 8 doc types (order/receipt/invoice/purchase_order/return/credit_note/debit_note/goods_receipt)ai_permissions: 19 capability rows;storefront_settings(1 row);storefront_navigation(5);storefront_policies(4);expense_categories(5)
Accept invitation — /invite/$token
- Purpose: Let an invited user join a shop's team.
- Access: Preview is public; accepting requires being signed in with the invited email.
- Backend:
getInvitationPreview(unauth) andacceptStaffInvitation(auth) (src/lib/team.functions.ts) →team.server.ts→ tablesstaff_invitations(read + update) andshop_users(upsert).
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | token | URL param | ● | — | ≥ 10 chars | Route 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
- Selects / references: Resolves shop name via
staff_invitations.shop_id → shops(name). - Cross-form effects on accept: upsert into
shop_users(shop_id,user_id,role,branch_id,is_active=true, onConflictshop_id,user_id); setstaff_invitations.status='accepted',accepted_at,accepted_by; audit log; navigates to/admin.
Invite a staff member — /admin/team
- Purpose: Send a staff invitation and generate a shareable invite link.
- Access:
owner,admin— page requirescan("settings"); server enforcesrequireRole(..., ADMINS). - Backend:
inviteStaffMember(src/lib/team.functions.ts) →inviteStaff(team.server.ts) → tablestaff_invitations.
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| ● | "" | z.string().email(); lowercased on insert | — | |||
| Role | role | select | ● | "staff" | enum owner/admin/manager/cashier/accountant/inventory/staff | — |
| Branch | branchId | select | ○ | "" → null | uuid nullable; "Any branch" = null | Options from Branches (shop-scoped) |
| — | shopId | uuid | auto | active shop | — | From useAdminShop |
| — | origin | url | auto | window.location.origin | — | Used to build invite link |
| — | token | text | auto | gen_random_bytes(24) hex | — | DB-generated |
| — | status | text | auto | "pending" | pending/accepted/revoked/expired | — |
| — | expires_at | timestamptz | auto | now + 14 days | — | — |
| — | invited_by | uuid | auto | context.userId | — | — |
Connections
- Selects / references: Branch (from Branches) via
branch_id. - Feeds / used by: Row appears in Invitations list; link consumed by Accept invitation. Returns
{ link: <origin>/invite/<token> }shown with a Copy button. - Cross-form effects: Insert into
staff_invitations; auditstaff.invite.
Revoke invitation — /admin/team (Invitations list)
- Purpose: Cancel a pending invitation.
- Access:
owner,admin. - Backend:
revokeStaffInvitation→revokeInvitation→staff_invitations(setstatus='revoked').
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (Revoke button) | invitationId | uuid | ● | — | uuid | Only shown when status='pending' |
| — | shopId | uuid | auto | active shop | — | — |
Edit / deactivate member — /admin/team (Members list)
- Purpose: Change a member's role/branch or (de)activate them.
- Access:
owner,admin. - Backend:
updateTeamMember(src/lib/team.functions.ts) →updateMember(team.server.ts) → tableshop_users.
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Role (inline select) | role | select | ○ | member's current | enum owner/admin/manager/cashier/accountant/inventory/staff | Saves on change |
| Branch (inline select) | branchId | select | ○ | member's current | uuid nullable; "Any branch"=null | Options from Branches |
| Deactivate / Reactivate | isActive | boolean | ○ | member's current | toggles is_active | Button label flips on state |
| — | memberId | uuid | ● | — | the shop_users.id | — |
| — | shopId | uuid | auto | active shop | — | — |
Connections
- Cross-form effects: Updates
shop_users; auditstaff.member.update. Guard: cannot demote or deactivate the last activeowner("must always have at least one active owner").
Shop Settings — /admin/settings
- Backend (all sections except Tax rates & Branches):
updateShopSection(src/lib/shop.functions.ts) writesshopscolumns (theshoppatch) and/or merges intoshops.settings[<section>]jsonb (thesettingsPatch). Section role gate enforced server-side viahas_shop_role; the write uses the service-role client after the check. - Access per section: Business, Localization, Tax, Document numbering →
owner,admin. Receipts, POS, Inventory, Pricing, Purchasing, Business hours, Notifications →owner,admin,manager. Lower roles see the section read-only.
Business profile — settings section business
Writes shops columns + settings.business.tax_id.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Shop name | name | text (shop col) | ● | shops.name | trimmed, non-empty | — |
| Store address (slug) | slug | text (shop col) | — | shops.slug | read-only | "Contact support to change" |
| Legal / registered name | legal_name | text (shop col) | ○ | "" → null | — | — |
| Tax ID / registration number | tax_id | text (settings.business) | ○ | "" | — | Only settings-jsonb field here |
| email (shop col) | ○ | "" → null | — | — | ||
| Phone | phone | text (shop col) | ○ | "" → null | — | — |
| Business logo | logo_url | image upload (shop col) | ○ | "" → null | via AdminImageUpload (folder branding) | Used on receipts/invoices/storefront |
| Address | address | textarea (shop col) | ○ | "" → null | — | — |
| Timezone | timezone | text (shop col) | ● | shops.timezone | free-text | — |
Localization — settings section localization (writes shops columns)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Default language | default_locale | select | ● | shops.default_locale | en/ar/ur/hi | Drives RTL |
| Currency code | currency_code | text | ● | shops.currency_code | uppercased | — |
| Currency symbol | currency_symbol | text | ● | shops.currency_symbol | — | — |
| Symbol position | currency_position | select | ● | before | before / after | — |
| Decimal places | decimal_places | number | ● | 2 | 0–4 | — |
| Decimal separator | decimal_separator | text | ● | "." | — | — |
| Thousand separator | thousand_separator | text | ● | "," | — | — |
| Date format | date_format | text | ● | "dd/MM/yyyy" | — | — |
| Time format | time_format | select | ● | "HH:mm" | 24h / 12h | — |
Tax — settings section tax (+ inline tax_rates editing)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Tax registered | enabled | toggle | ○ | true | — | — |
| Prices include tax | inclusive | toggle | ○ | false | — | — |
| Tax registration number | registration_number | text | ○ | "" | — | — |
| Rounding rule | rounding | select | ○ | "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 label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Header text | header | textarea | ○ | "" | — | — |
| Footer text | footer | textarea | ○ | "" | — | — |
| Show tax breakdown | show_tax_number | toggle | ○ | true | — | — |
| Show logo on receipt | show_logo | toggle | ○ | true | — | — |
| Roll width | roll_width | select | ○ | "80mm" | 58mm / 80mm | — |
| Reprint policy | reprint_policy | select | ○ | "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 label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Default branch | default_branch_id | select | ○ | "" | from Branches | Clears register on change |
| Default register | default_register_id | select | ○ | "" | from Registers filtered by branch | — |
| Allow line-item discounts | allow_line_discounts | toggle | ○ | true | — | — |
| Maximum discount % | max_discount_percent | number | ○ | 20 | 0–100 | Cashier ceiling |
| Require customer on sale | require_customer | toggle | ○ | false | — | — |
| Cash rounding | cash_rounding | select | ○ | "none" | none / nearest_0.05 / nearest_0.10 | — |
| Offline mode | offline_mode_enabled | toggle | ○ | true | — | — |
Inventory — settings section inventory
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Allow negative stock | allow_negative_stock | toggle | ○ | false | — | — |
| Default location | default_location_id | select | ○ | default location | from Inventory locations | — |
| Low-stock threshold (default) | low_stock_threshold | number | ○ | 5 | ≥ 0 | — |
| Reorder point | reorder_point | number | ○ | 10 | ≥ 0 | — |
| Reorder quantity | reorder_quantity | number | ○ | 20 | ≥ 0 | — |
Pricing & discounts — settings section pricing
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Default markup % | default_markup_percent | number | ○ | 30 | — | — |
| Price rounding | price_rounding | select | ○ | "none" | none / nearest_0_05 / nearest_0_50 / nearest_1 / charm_99 | — |
| Max line discount % (cashier) | max_line_discount_percent | number | ○ | 10 | — | — |
| Max order discount % (cashier) | max_order_discount_percent | number | ○ | 15 | — | — |
| Require manager approval above limits | require_manager_override | toggle | ○ | true | — | — |
| Allow selling below cost | allow_below_cost | toggle | ○ | false | — | — |
Purchasing — settings section purchasing
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Default supplier payment terms (days) | default_payment_terms_days | number | ○ | 30 | — | — |
| Require approval before sending a PO | require_po_approval | toggle | ○ | true | — | — |
| Approval required above amount | po_approval_threshold | number | ○ | 0 | 0 = every PO needs approval | — |
| Allow receiving more than ordered | allow_over_receipt | toggle | ○ | false | — | — |
| Update item cost on goods receipt | auto_update_cost_on_receipt | toggle | ○ | true | — | — |
| Costing method | costing_method | select | ○ | "weighted_average" | weighted_average / last_cost | — |
Document numbering — settings section documents
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Sales order prefix | order_prefix | text | ○ | "SO-" | — | — |
| Receipt prefix | receipt_prefix | text | ○ | "RCP-" | — | — |
| Purchase order prefix | purchase_order_prefix | text | ○ | "PO-" | — | — |
| Stock transfer prefix | transfer_prefix | text | ○ | "TR-" | — | — |
| Number padding | number_padding | number | ○ | 5 | 5 → SO-00001 | — |
| Reset sequences each year | reset_yearly | toggle | ○ | false | — | — |
This section writes only toshops.settings.documents. The livedocument_sequencesrows (their ownprefix/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)
- Selects / references: POS section → Branches (
default_branch_id) and Registers (default_register_id); Inventory → Inventory locations (default_location_id); Tax → Tax rates. - Feeds / used by:
shops.settingsdrives POS guardrails, pricing/discount limits, purchasing approvals, receipt rendering, storefront hours, and notifications. Business/localization columns render on receipts, invoices, storefront and admin/POS formatting. - Cross-form effects: Business writes
shopscolumns +settings.business.tax_id; other sections merge intoshops.settings[<section>]; Tax-rate edits writetax_ratesdirectly. Writes bypass the owner/admin-onlyshopsRLS via the service-role client after the section role check (so managers can save their permitted sections).
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).
Catalog
New / Edit Product — /admin/products
- Purpose: Create or edit a catalog product with its media, merchandising flags, tax default and full variant matrix.
- Access: owner, admin, manager (server
requireRole(..., MANAGERS)). Page requires auth; save is role-gated. - Backend:
saveProductFn(src/lib/catalog.functions.ts) →saveProduct(src/lib/catalog.server.ts) →products(+ cascades toproduct_variants,variant_costs,prices,barcodes,inventory_movements,inventory_balances). Images viasaveProductImagesFn→product_images.
Fields (product level)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | shopId | uuid | Yes | active shop | — | auto |
| — | productId | uuid | No | null | — | auto; edit only |
| Product name | name | text | Yes | — | 1–200 chars | drives slug + SKU seed |
| Category | category_id | uuid (select) | No | null | EntitySelect categories | FK → categories |
| Brand | brand_id | uuid (select) | No | null | EntitySelect brands | FK → brands |
| Selling unit | unit | text | No | pcs | max 24 | server falls back to pcs |
| Status | status | enum | Yes | active (UI) | active / draft | DB column default draft |
| Short description | short_description | text | No | null | max 500 | — |
| Full description | description | textarea | No | null | max 20000 | — |
| Tags | tags | string[] | No | [] | ≤30 tags, each 1–40 | comma-separated input |
| Tax rate | tax_rate_id | uuid (select) | No | null | EntitySelect tax_rates | resolves variant→product→category→shop default |
| Featured | is_featured | boolean | No | false | — | merchandising flag |
| New arrival | is_new_arrival | boolean | No | false | — | — |
| Best seller | is_best_seller | boolean | No | false | — | — |
| — | slug | text | — | slugify(name) | unique per shop | auto on create only |
| — | has_variants | boolean | — | variants.length>1 | — | auto computed |
| Media | (images) | sub-form | No | — | ≤30 images | see Product Images |
| Variants | variants | sub-form | Yes | — | min 1 | see Variant Builder |
Variant Builder sub-form (src/components/admin/VariantBuilder.tsx)
| UI label | Field key | Type | Req | Default | Validation / options | Notes / target |
|---|---|---|---|---|---|---|
| — | id | uuid | No | null | — | auto; edit only |
| Variant | name | text | No | null | max 120 | auto = attribute combo label ("Black / M") |
| SKU | sku | text | Yes | <BASE>-<suffix> | 1–80; unique per shop; no in-product dupes | auto-seeded → product_variants.sku |
| Barcode | barcode | text | No | null | max 64; unique per shop | → barcodes (is_primary); "Gen" button generates |
| Cost | cost | number | No | 0 | 0–10,000,000 | → variant_costs.cost_price & avg_cost (never public) |
| Price | price | number | No | 0 | 0–10,000,000 | → product_variants.price |
| Sale price | sale_price | number | No | null | 0–10,000,000 | >0 → prices (type='sale'); ≤0/empty deactivates |
| Weight | weight | number | No | null | 0–1,000,000 | → product_variants.weight |
| Tax | tax_rate_id | uuid (select) | No | null | tax rate list | collected + bulk-appliable but not persisted per-variant |
| Opening stock | opening_stock | number | No | 0 | 0–10,000,000 | create-only → inventory_movements (opening) + inventory_balances |
| Active | is_active | boolean | Yes | true | — | → product_variants.is_active |
| — | attributes | record<string,string> | — | {} | from attribute matrix | → product_variants.attributes (jsonb) |
| — | is_default | boolean | — | index===0 | — | auto; first variant is default |
Attribute matrix: pick an existing attribute (EntitySelect attributes) or create inline (ensureAttributeFn → product_attributes + product_attribute_values); tick 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.
Product Images sub-form (saveProductImagesFn)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (upload) | url | text | Yes | — | 1–1000 chars | public URL (bucket product-images) |
| — | storage_path | text | No | null | max 400 | storage key |
| Alt | alt | text | No | null | max 200 | — |
| — | id | uuid | No | null | — | auto on existing rows |
| — | position | int | — | array index | — | auto; index 0 = cover |
Connections
- Selects / references: Category (
category_id), Brand (brand_id), Tax rate (tax_rate_id), Attributes (product_attributes/product_attribute_values). - Feeds / used by: POS (barcode lookup, sale lines), Storefront (status=active only), Inventory (balances/movements), Purchasing, Labels.
- Cross-form effects: opening stock →
inventory_movements(opening)+inventory_balances; cost →variant_costs(private); price →product_variants.price(firespricing_versiontrigger); sale price →prices; barcode →barcodes.
Barcode generate / attach — /admin/products (variant panel + builder "Gen")
- Purpose: Generate or manually attach EAN-13 / CODE128 barcodes to a variant; deactivate old ones.
- Access: panel (generate/attach/deactivate) = owner, admin, manager, inventory; builder "Gen" & bulk-generate = owner, admin, manager.
- Backend: panel →
generateBarcodeFn/attachBarcodeFn/deactivateBarcodeFn/listVariantBarcodesFn(src/lib/barcode.functions.ts); builder/bulk →generateBarcodeFn/bulkGenerateBarcodesFn(src/lib/catalog.functions.ts). Both →barcodes.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | shopId | uuid | Yes | active shop | — | auto |
| — | variantId | uuid | Yes | — | — | target variant |
| (Generate EAN-13 / CODE128) | type | enum | Yes | ean13 | ean13 / code128 | — |
| Attach manual barcode | code | text | Yes (attach) | — | 1–64; EAN-13 needs valid GS1 check digit; CODE128 ≤48 | duplicate active code rejected |
| — | (check digit) | — | auto | — | GS1 mod-10 (ean13CheckDigit) | auto |
| — | source | text | auto | generated / manual | — | auto per path |
| — | is_primary | boolean | auto | true if first active | — | deactivate promotes next barcode |
| — | is_active | boolean | auto | true | — | 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
- Purpose: Manage hierarchical catalog categories (menu, category pages, product grouping).
- Access: UI
permission="products"; server write INVENTORY_ROLES. - Backend:
saveMasterDataFn(entitycategories) →categories.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | — | required | — |
| Parent category | parent_id | uuid (entity) | No | null | EntitySelect categories; not self | FK → categories |
| Slug | slug | text | No | slugify(name) | unique per shop | auto when blank; dedup-suffixed |
| Display order | position | number | No | 0 | int | sort order |
| Default tax rate | tax_rate_id | uuid (entity) | No | null | EntitySelect tax_rates | category-level tax fallback |
| Category image | image_url | image | No | null | upload folder categories | — |
| Description | description | textarea | No | null | — | — |
| Visible on storefront | is_active | boolean | No | true | — | 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
- Access: UI
permission="products"; server write INVENTORY_ROLES. Backend:saveMasterDataFn(entitybrands) →brands.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | — | required | — |
| Slug | slug | text | No | slugify(name) | unique per shop | auto when blank |
| Brand logo | logo_url | image | No | null | folder brands | — |
| Description | description | textarea | No | null | — | — |
| Visible on storefront | is_active | boolean | No | true | — | archive toggle |
Connections — used by products (brand_id), storefront brand filters/pages. Delete blocked while products reference it.
Attribute + values — /admin/attributes
- Purpose: Define variant attributes (Size, Colour…) and allowed values; feed the variant matrix.
- Access: UI
permission="products"; server write INVENTORY_ROLES. - Backend: attributes →
saveMasterDataFn(entityattributes) →product_attributes; values →saveMasterDataFn(entityattribute_values) →product_attribute_values. Inline creation from builder usesensureAttributeFn.
Attribute
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Attribute name | name | text | Yes | — | required | — |
| Code | code | text | No | slugify(name) | unique per shop | auto when blank |
Attribute value sub-form
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Value | value | text | Yes | — | required | — |
| — | attribute_id | uuid | Yes | selected 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
- Access: UI
permission="settings"(owner, admin); server write MANAGERS (manager can write via API though UI hides it). - Backend:
saveMasterDataFn(entitytax_rates) →tax_rates.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | — | required | placeholder "VAT 15%" |
| Rate (%) | rate | number | Yes | 0 | numeric(6,3); step 0.001 | — |
| Prices include this tax | is_inclusive | boolean | No | false | — | — |
| Use as shop default | is_default | boolean | No | false | exclusive | auto unsets other defaults |
| Active | is_active | boolean | No | true | — | 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
- Purpose: Select variants, pick a size/fields, print a barcode label sheet (no DB writes).
- Access: any authenticated shop member (reads only).
- Backend:
labelCatalogFn(src/lib/catalog.functions.ts) readsproduct_variants(+products,barcodes); render viaBarcodeLabel(JsBarcode).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (variant select) | selected | checkbox set | — | none | per variant | drives label queue |
| Copies | copies[id] | number | No | 1 | min 1 | per selected variant |
| Label size | sizeId | enum | Yes | 40x30 | 40x30, 50x25, 58roll, a4-3x8, a4-4x10 | mm dims from LABEL_SIZES |
| Show → shop name/logo | fields.shop | boolean | No | true | — | — |
| Show → product name | fields.productName | boolean | No | true | — | — |
| Show → variant | fields.variantName | boolean | No | true | — | — |
| Show → SKU | fields.sku | boolean | No | true | — | — |
| Show → price | fields.price | boolean | No | true | — | — |
| Show → barcode | fields.barcode | boolean | No | true | — | 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
- Purpose: Import products / suppliers / inventory from CSV or XLSX: upload → map columns → validate → commit.
- Access: owner, admin, manager (validate & commit).
- Backend:
validateImportFn/commitImportFn(src/lib/import.functions.ts). Products commit →products,product_variants,variant_costs,prices,barcodes,inventory_movements,inventory_balances.
Wizard controls
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Type | kind | enum | Yes | products | products / suppliers / inventory | step 1 |
| File | (upload) | file | Yes | — | .csv, .xlsx, .xls; ≤5000 rows | parsed client-side |
| Column mapping | mapping | record | — | auto-guessed | file header → target field | per-field select |
Products import target fields
| UI label | Field key | Req | Validation / options | Notes / target |
|---|---|---|---|---|
| Product name | product_name | Yes | non-empty | groups rows into one product; slug-matched for upsert |
| SKU | sku | Yes | non-empty; dedup in file; existing → update | → product_variants.sku |
| Variant label | variant | No | — | → product_variants.name |
| Attributes | attributes | No | Color:Red;Size:M | parsed → jsonb attributes |
| Category | category | No | matched by name (warns if not found) | → category_id |
| Brand | brand | No | matched by name (warns if not found) | → brand_id |
| Cost | cost | No | numeric, default 0 | → variant_costs |
| Price | price | Yes | numeric | → product_variants.price |
| Sale price | sale_price | No | numeric | → prices (sale) |
| Opening stock | opening_stock | No | numeric, default 0 | new variants only → movements+balances |
| Barcode | barcode | No | dedup 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
Manual stock adjustment — /admin/inventory
- Purpose: Post a signed (+/-) quantity correction against a variant at the shop's default location, with a reason.
- Access: Route is admin-authenticated; the write is a client-side Supabase insert/update governed by
inventory_movements/inventory_balancesRLS (inventory roles). See role matrix. - Backend: inline
applyAdjustmentin the route → direct writes toinventory_movements+inventory_balances.
Fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Product | variant_id | select (uuid) | Yes | "" | variants that already have a balance row | → movement + balance variant_id |
| Quantity (+/-) | quantity | text→number | Yes | "" | Number(quantity) must be non-zero; signed | positive adds, negative removes |
| Reason | note | text | No | "" | — | → inventory_movements.note |
| Location | location_id | — | auto | shop's is_default location | limit 1 | "No inventory location configured" if none |
| Movement type | movement_type | — | auto | 'adjustment' | enum | fixed |
| On-hand after | balance quantity | — | auto | existing + delta | running balance | upserts inventory_balances |
Connections
- Selects / references: Variant via
variant_id; Location implicit via defaultlocation_id. - Feeds / used by: updated balance read by POS / Products / Dashboard low-stock; movement shown in "Recent movements".
- Cross-form effects: each post writes one
inventory_movementsrow and updates/creates theinventory_balancesrow.
Reorder / stock defaults — /admin/settings (Inventory section)
- Purpose: Shop-wide stock defaults (low-stock threshold, reorder point/quantity, default location, negative-stock rule).
- Access: see role matrix (settings-section gate).
- Backend:
updateShopSection(src/lib/shop.functions.ts) →shops.settings.inventory.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Allow negative stock | allow_negative_stock | toggle | No | false | — | lets sales pass below zero |
| Default location | default_location_id | select | No | default location | active inventory_locations | "No default" allowed |
| Low-stock threshold (default) | low_stock_threshold | number | No | 5 | ≥ 0 | fallback when variant has none |
| Reorder point | reorder_point | number | No | 10 | ≥ 0 | used by AI reorder insights |
| Reorder quantity | reorder_quantity | number | No | 20 | ≥ 0 | suggested 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
- Purpose: Move stock between two locations through draft → requested → approved → shipped → received.
- Access: create/edit/ship/receive = INVENTORY_ROLES (owner, admin, manager, inventory); approve = MANAGERS. Cancel disabled once shipped.
- Backend:
createTransferFn,updateTransferItemsFn,transitionTransferFn(src/lib/inventory-ops.functions.ts) →inventory_transfers,inventory_transfer_items; on ship/receive →postInventory(inventory_movements+inventory_balances).
Header fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| From location | from_location_id | select | Yes | "" | active locations; must differ from To | — |
| To location | to_location_id | select | Yes | "" | active locations, excludes From | — |
| Notes | notes | text | No | — | max 500 | — |
Line items (transferItemSchema)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Product | variant_id | select | Yes | "" | active variants (limit 500) | — |
| Qty | quantity | number | Yes | "" | 0.001–999999; rows ≤0 dropped; ≥1 line required | — |
| Avail | (computed) | — | auto | — | balance.quantity − reserved at From | display-only guide |
| Unit cost | unit_cost | number | No | 0 | 0–999999 | in 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
- Selects / references: From/To via
from_location_id/to_location_id; lines viavariant_id. - Feeds / used by: Ship posts
transfer_out(−) at source; Receive poststransfer_in(+) at destination — both writeinventory_movements+inventory_balances; audittransfer.<status>. - Cross-form effects: stock only leaves source on ship and only lands at destination on receive; draft/requested/approved move no stock.
Stock count (start → count → review → post) — /admin/stock-counts
- Purpose: Physical count vs expected, then post variance adjustments to balances.
- Access: create/save = INVENTORY_ROLES; approve = MANAGERS.
- Backend:
createStockCountFn,saveStockCountProgressFn,approveStockCountFn(src/lib/inventory-ops.functions.ts) →stock_counts,stock_count_items; approve →postInventory.
Create fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Location | location_id | select | Yes | "" | active locations | sets expected snapshot source |
| Scope | mode (UI only) | select | Yes | "all" | all / category | maps to fields below |
| — (all) | all_products | boolean | — | true when scope=all | — | seeds every variant |
| Category checkboxes | category_ids | uuid[] | cond. | [] | shown when scope=category | seeds variants of those categories |
| Note | note | text | No | — | max 500 | — |
| — | variant_ids | uuid[] | No | — | in schema, not in UI | explicit variant seed |
Count-sheet line items (stock_count_items)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Product | variant_id | — | auto | seeded at create | — | — |
| Expected | expected_qty | numeric(14,3) | auto | balance at creation | — | snapshot, not editable |
| Counted | counted_qty | number | Yes | 0 | ≥ 0 | editable until completed |
| Difference | (computed) | — | auto | counted − 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.
Connections
- Selects / references: Location via
location_id; items viavariant_id; scope via categories. - Feeds / used by: approved variances update
inventory_balances; auditstock_count.approved. - Cross-form effects: posting a count writes
inventory_movements(typecount) + updatesinventory_balancesfor each non-zero variance.
Location create / edit — /admin/locations
- Purpose: Manage stock locations that hold balances and receive transfers/counts.
- Access: UI permission
inventory; server write = INVENTORY_ROLES. - Backend:
saveMasterDataFn/setMasterDataActiveFn/deleteMasterDataFn(src/lib/masterdata.functions.ts, entityinventory_locations) →inventory_locations.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Location name | name | text | Yes | — | placeholder "Main store" | — |
| Branch | branch_id | entity select | No | null | references branches | FK, nullable |
| Default location for sales | is_default | boolean | No | false | — | — |
| Active | is_active | boolean | No | true | — | 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
- Purpose: Physical trading locations scoping staff, registers, stock locations and reporting.
- Access: UI permission
settings; server write = ADMINS (owner, admin). - Backend:
saveMasterDataFn/setMasterDataActiveFn/deleteMasterDataFn(entitybranches) →branches.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Branch name | name | text | Yes | — | — | — |
| Code | code | text | No | null | — | — |
| Phone | phone | text | No | null | — | — |
| Address | address | textarea | No | null | — | — |
| Default branch | is_default | boolean | No | false | — | — |
| Active | is_active | boolean | No | true | — | — |
| — | business_hours | jsonb | — | [] | — | 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
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
- Purpose: Fast supplier capture inline on the purchasing page.
- Access: purchasing roles (client insert governed by
suppliersRLS). - Backend: direct
supabase.from("suppliers").insert(...).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | "" | non-empty | — |
email | text | No | "" | — | — | |
| Phone | phone | text | No | "" | — | — |
| — | payment_terms | text | auto | net_30 | — | set even though no input is shown here |
Supplier — full record — /admin/suppliers
- Purpose: Maintain the supplier master used by POs, receipts and invoices.
- Access: UI
permission="purchasing". Backend:saveMasterDataFn(entitysuppliers) →suppliers.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Supplier name | name | text | Yes | — | required | — |
| Code | code | text | No | — | — | — |
email | text | No | — | — | — | |
| Phone | phone | text | No | — | — | — |
| Tax number | tax_number | text | No | — | — | — |
| Payment terms | payment_terms | select | No | — | cod / net_7 / net_15 / net_30 / net_60 | — |
| Credit limit | credit_limit | number | No | — | step 0.01 | — |
| Address | address | textarea | No | — | — | — |
| Active | is_active | boolean | No | true | — | 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)
- Purpose: Raise a PO for one product line against a supplier.
- Access: purchasing roles (client writes governed by RLS).
- Backend: direct client writes →
purchase_orders+purchase_order_items; PO number fromdocument_sequences(doc_typepurchase_order).
| UI label | Field key | Type | Req | Default | Validation / options | Notes | ||
|---|---|---|---|---|---|---|---|---|
| Supplier | supplier_id | uuid (EntitySelect) | No | "" → null | from Suppliers | — | ||
| Product | variant_id | select | Yes | "" | active variants (limit 500) | one line per PO in this UI | ||
| Quantity | quantity | number | Yes | 1 | `Number() | 1` | — | |
| Unit cost | unit_cost | number | Yes | "" | `Number() | 0` | — | |
| — | po_number | text | auto | PO-##### | document_sequences prefix+padding, increments next_number | auto | ||
| — | status | enum | auto | submitted | po_status | auto | ||
| — | subtotal / total | number | auto | quantity × unit_cost | — | auto | ||
| — (line) | total | number | auto | quantity × unit_cost | — | auto |
The AI assistant can also create multi-line POs viacreate_purchase_order(ai-actions.server.ts) subject to the AI permission matrix.
Connections — references Supplier (supplier_id) and Variant (variant_id); consumed by Receive goods and Supplier invoice (prefill).
Receive goods — /admin/purchasing (per-PO "Receive goods" button)
- Purpose: Post a goods receipt for an open PO — full ordered quantity, no form fields.
- Access: purchasing roles. Backend: direct writes →
goods_receipts+goods_receipt_items,inventory_movements(typepurchase),inventory_balances,variant_costs(upsert), POstatus='received'; GRN number fromdocument_sequences(doc_typegoods_receipt).
| Field | Type | Req | Default | Notes |
|---|---|---|---|---|
purchase_order_id | uuid | auto | the PO | from button |
location_id | uuid | auto | shop default location | "No inventory location configured" if none |
receipt_number | text | auto | GRN-##### | document_sequences |
per line variant_id, quantity, unit_cost | — | auto | from PO items | received 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.
Supplier invoice — /admin/purchasing (New supplier invoice)
- Purpose: Record a supplier bill (draft), then Post / Cancel it.
- Access: FINANCE_ROLES. Backend:
saveSupplierInvoice/postSupplierInvoiceFn/cancelSupplierInvoiceFn/createInvoiceFromSourceFn(src/lib/purchasing.functions.ts) →supplier_invoices.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Supplier | supplier_id | uuid (EntitySelect) | Yes | "" | from Suppliers | — |
| Invoice number | invoice_number | text | Yes | "" | 1–80 chars | — |
| Invoice date | invoice_date | text (date) | No | — | optional | — |
| Due date | due_date | text (date) | No | — | nullable | drives AP ageing / due-soon |
| Total | total | number | Yes | "" | ≥ 0 | — |
| Prefill from purchase order | purchase_order_id | select | No | — | your POs | calls createInvoiceFromSourceFn to draft from a PO/receipt |
| — | goods_receipt_id | uuid | No | null | schema only | alternate source |
| — | notes | text | No | null | ≤2000 | schema only |
| — | status | text | auto | draft | draft → posted → partially_paid / paid / overdue / cancelled | auto |
| — | paid_total | number | auto | 0 | — | auto; updated by payments |
Actions: Post (draft → posted, writes the AP journal entry), 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)
- Access: FINANCE_ROLES. Backend:
recordSupplierPaymentFn→supplier_payments(+ journal, updates invoicepaid_total/status).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Invoice | supplier_invoice_id | select | Yes | "" | invoices in posted / partially_paid / overdue | shows outstanding per option |
| Amount | amount | number | Yes | "" | > 0 | — |
| Method | method_code | select | Yes | cash | cash / bank / card | — |
| Reference | reference | text | No | "" | ≤200 | nullable |
| — | paid_at | text (date) | auto | now | optional | auto |
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)
- Purpose: Record an operating expense and post it to the ledger.
- Access: FINANCE_ROLES. Backend:
recordExpense(src/lib/accounting.functions.ts) →recordExpenseAndPost→expenses+journal_entries.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Category | category_id | select | No | "" → null | from Expense categories | maps to a ledger account |
| Amount | amount | number | Yes | "" | > 0 | — |
| Note | note | text | No | "" | ≤500; ""→null | — |
| Payment method | payment_method_code | select | Yes | cash | cash / bank | default cash |
| — | branch_id | uuid | auto | top-bar Branch filter | nullable | taken 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).
Chart of accounts — /admin/accounts
- Access: UI
permission="accounting". Backend:saveMasterDataFn(entityaccounts) →accounts.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Account code | code | text | Yes | — | e.g. 1000; locked on system accounts | — |
| Account name | name | text | Yes | — | required | — |
| Type | type | select | Yes | — | asset / liability / equity / income / expense; locked on system | — |
| Parent account | parent_id | uuid (entity) | No | null | EntitySelect accounts | tree self-reference |
| Active | is_active | boolean | No | true | — | 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
- Access: UI
permission="accounting". Backend:saveMasterDataFn(entityexpense_categories) →expense_categories.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | — | e.g. "Rent" | — |
| Ledger account | account_id | uuid (entity) | No | null | EntitySelect accounts | expense account this category posts to |
| Active | is_active | boolean | No | true | — | archive toggle |
Connections — selected on Record expense (category_id); each category maps to an accounts row via account_id.
Payment method — /admin/payments
- Access: UI
permission="settings". Backend:saveMasterDataFn(entitypayment_methods) →payment_methods.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Display name | name | text | Yes | — | e.g. "Cash" | — |
| Code | code | text | Yes | — | stable id stored on payments (cash, card…) | — |
| Type | type | select | Yes | — | cash / card / bank / wallet / credit / other | cash types post to the drawer + cash ledger |
| Display order | position | number | No | — | — | — |
| Enabled | is_enabled | boolean | No | true | — | — |
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
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.
| Tab | Server fn | Filters |
|---|---|---|
| Overview | P&L + Balance sheet + Trial balance | range, branch |
| P&L | getProfitAndLoss | range, branch |
| Balance Sheet | getBalanceSheet | as-of (range end), branch |
| Cash Flow | getCashFlow | range, branch |
| General Ledger | getGeneralLedger | range, account, limit 500 |
| Trial Balance | getTrialBalance | range, branch |
| AR Ageing | getArAgeing | as-of |
| AP Ageing | getApAgeing | as-of |
A ledger-integrity banner (getBalanceValidation) warns if total debits ≠ total credits in the range.
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).
Sales — POS, Registers, Orders & Sync
POS sale (cart) — /admin/pos
- Purpose: Build a retail cart (scan/tap products, per-line and order discounts, attach customer, hold/resume) and start checkout.
- Access: SELLING_ROLES = owner, admin, manager, cashier (enforced by
posCheckout). - Backend:
posCheckout(src/lib/pos.functions.ts) →recordPosSale(src/lib/pos.server.ts) →record_pos_saleRPC →orders,order_items,payments,receipts,inventory_movements/inventory_balances,journal_entries,cash_movements.
Order-level fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Register (header select) | register_id | uuid | No | first open/first register | must be a shop register | null allowed |
| (scan box) | scan/SKU | text | — | — | matches barcode or SKU | Enter adds item; not persisted |
| Search (F2) | search | text | No | — | client filter, ≤60 shown | not persisted |
| Category chips | category | text | No | all | derived from catalog | client filter |
| Guest / Customer (F4) | customer_id | uuid | No | null (Guest) | select from customers | attach/detach |
| Order discount (F6) | cartDiscount | number | No | 0 | capped at gross − lineDiscounts | spread evenly across lines on submit |
| — | local_uuid | text(≤80) | auto | crypto.randomUUID() | idempotency key | dedup on (shop_id, local_uuid) |
| — | notes | text(≤500) | No | null | — | in schema; not sent by POS online path |
| — | branch_id | uuid | No | null | — | not in POS UI |
| Subtotal | gross | number | auto | — | Σ price×qty | auto |
| Discounts | line+order disc | number | auto | — | Σ line.discount + orderDiscount | auto |
| Total | subtotal | number | auto | — | gross − discounts | auto; tax added authoritatively server-side |
Cart lines (UI Line; server posLine)
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (product) | variant_id | uuid | Yes | — | valid variant | one row per variant |
| Qty (−/input/+) | quantity | number | Yes | 1 | 0.001–9999; ≤0 removes line | numeric(14,3) |
| Disc. | discount | number | No | 0 | ≥0, max 1,000,000 | submitted as line.discount + perLineOrderDiscount |
| line amount | line total | number | auto | — | price×qty − discount | auto |
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
- Selects / references: Variant/Product (catalog +
inventory_balances), Customer (customer_id), Register (register_id); opencash_sessionsresolved server-side. - Feeds / used by: completed sale →
order_items,payments,inventory_movements,receipts,journal_entries(Dr Cash 1000/Bank 1100; Cr Revenue 4000 + Tax 2100; Dr COGS 5000 / Cr Inventory 1200),cash_movements; low-stock →ai_alerts. - Cross-form effects: server is the pricing authority (recomputes via
priceCart); payment total must equal order total within currency tolerance; duplicatelocal_uuidreturns the existing order; offline/failed sales queue to/admin/sync.
Payment screen — /admin/pos (Pay modal, F9)
- Purpose: Allocate split payments across methods and compute change, then complete the sale.
- Access: SELLING_ROLES. Backend: same as POS Sale →
payments(+cash_movementsfor cash).
Split-payment rows (UI PaymentRow; server payments[])
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Method tiles / Pay N select | method_code | text(≤40) | Yes | cash | cash, card, bank, credit, other | cash→acct 1000, else→bank 1100 |
| Pay N amount | amount | number | Yes | subtotal (first row) | ≥0; only rows >0 submitted | numeric(14,2) |
| — | reference | text(≤120) | No | — | — | in schema; not in POS UI |
| — | status | text | auto | captured | — | auto |
| Allocated | allocated | number | auto | — | Σ amounts | auto |
| Remaining | remaining | number | auto | — | subtotal − allocated | auto |
| Change due | changeDue | number | auto | — | 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).
Open cash session — /admin/pos header & /admin/registers
- Purpose: Open a register drawer with an opening float.
- Access: any active member; non-managers must be assigned to the register.
- Backend:
openCashSession(src/lib/cash.functions.ts) →cash_sessions+cash_movements(opening).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (register select) | registerId | uuid | Yes | — | shop register | — |
| Opening float | openingBalance | number | Yes | 0 | ≥0 | rejected if register already has an open session |
| — | status | text | auto | open | — | auto |
| — | opened_by / opened_at | uuid / ts | auto | caller / now | — | auto |
Cash movement (in/out/expense) — /admin/registers (session panel)
- Purpose: Record cash added to / removed from an open drawer, or a cash expense.
- Access: any active member; UI enables only for managers or the assigned opener.
- Backend:
recordCashMovement(cash.functions.ts) →cash_movements(+recordExpenseAndPost→ expense +journal_entrieswhen type=expense).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (session context) | sessionId | uuid | Yes | selected session | must be open | — |
| Type | movementType | enum | Yes | cash_in | cash_in, cash_out, expense | — |
| Amount | amount | number | Yes | — | >0 (min 0.01) | numeric |
| Note | note | text(≤300) | No | null | — | — |
| — | categoryId | uuid | No | null | expense category | only for expense; not in registers UI |
| — | reference_type/reference_id | text/uuid | auto | null | set to expense/expenseId | auto when expense |
Close cash session — /admin/pos header & /admin/registers
- Purpose: Count the drawer and close the session, computing variance vs expected.
- Access: any member; only the opener or a manager may close.
- Backend:
closeCashSession(cash.functions.ts) →cash_sessionsupdate +cash_movements(closing) +ai_alerts(on discrepancy).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (session context) | sessionId | uuid | Yes | selected/open session | must be open | — |
| Counted cash | countedBalance | number | Yes | 0 | ≥0 | numeric(14,2) |
| Expected | closing_balance | number | auto | — | opening + cash_sales − cash_refunds + cash_in − cash_out − expenses | auto |
| Difference | discrepancy | number | auto | — | counted − expected | auto; alert if abs>0 (critical if >5% of expected) |
| — | closed_at / status | ts / text | auto | now / closed | — | auto |
Read-only reconciliation stats: Opening, Cash sales, Refunds, Cash in, Cash out, Expenses, Expected, Counted, Difference.
Register create / edit / assign — /admin/registers
- Access: MANAGERS = owner, admin, manager. Backend:
createRegister/updateRegister/assignRegisterUser/unassignRegisterUser(cash.functions.ts) →registers,register_users.
Create/edit
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Name | name | text | Yes | — | 1–120 chars | — |
| Code | code | text | No | null | ≤40 chars | — |
| — | branch_id | uuid | No | null | shop branch | not in UI |
| Active/Inactive (toggle) | is_active | boolean | No | true | — | 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)
- Purpose: Return order lines, optionally restock, and issue a refund.
- Access: MANAGERS (refunds move money out).
- Backend:
posRefund(pos.functions.ts) →recordReturn→record_pos_returnRPC →returns,return_items,refunds,inventory_movements(restock),journal_entries(reversing),cash_movements,ordersstatus.
Header fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (order) | orderId | uuid | Yes | — | order in shop | — |
| Reason | reason | text(≤300) | No | null | — | UI sends "Customer return" |
| (refund method) | method_code | text(≤40) | No | cash | cash refunds re-enter open drawer | UI sends cash |
| Refund total | total | number | auto | — | Σ proportional line totals; must be >0 | auto |
| Return number | return_number | text | auto | — | per-shop unique | auto |
Return lines (server items[])
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| (line) | order_item_id | uuid | Yes | — | belongs to order | — |
| Qty | quantity | number | Yes | full line qty | ≥0.001; clamped to remaining (sold − already returned) | UI does full-line only |
| Restock | restock | boolean | No | true | toggles inventory return | UI forces true; restocked units reverse COGS |
The current admin.orders.tsx UI only offers "Full return & refund" (all lines, full qty, restock=true, cash). Partial qty / restock toggle / method exist in the schema but aren't surfaced.
Offline sync — queue & conflict resolution — /admin/sync
- Purpose: Retry queued offline sales and resolve server-side price/tax/rejected conflicts (accept / reject / retry).
- Access: viewing = any member; resolve/discard/retry = owner, admin, manager (DESTRUCTIVE_ROLES).
- Backend:
syncOfflineSales(pos.functions.ts, batch ≤50);listSyncConflicts/resolveSyncConflict(sync.functions.ts) →pos_sync_conflicts(+recordPosSaleon retry ofrejected).
Conflict resolution fields
| UI label | Field key | Type | Req | Default | Validation / options | Notes | ||
|---|---|---|---|---|---|---|---|---|
| (row) | conflictId | uuid | Yes | — | conflict in shop | — | ||
| Retry / Accept / Reject | action | enum | Yes | — | accept \ | reject \ | retry | accept→resolved; reject→rejected; retry→re-post (rejected) or bump attempts |
| Note | note | text(≤500) | No | null | disabled unless status pending/retrying | stored in resolution | ||
| Kind | kind | text | auto | — | price_variance / tax_variance / rejected | auto | ||
| Status | status | text | auto | pending | pending/retrying/resolved/rejected | auto | ||
| Offline vs Server total | snapshot.total / server_state.total | number | auto | — | display only | auto | ||
| Attempts | attempts | int | auto | 1 | +1 per retry | auto |
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
- Selects / references: conflicts reference
register_id,branch_id,device_id,local_uuid,cashier_id; payload holds the original sale + snapshot,server_stateholds the server re-price. - Feeds / used by: retrying a
rejectedconflict/queued sale callsrecord_pos_sale(idempotent onlocal_uuid); variance conflicts already have a posted order and only log the variance. - Cross-form effects: every offline sale failing validation is recorded as a
rejectedconflict rather than lost; the server never overwrites a posted order — it honours the as-charged snapshot and records the difference here.
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) plus an opt-in live-preview iframe of /store/<slug>?preview=1.
Draft vs Publish model:
- Theme tab writes go into
storefront_settings.draft(a JSON blob) viasaveSettingsDraftFnand never touch live columns until publish. - Banners / Sections / Navigation rows are written with
is_draft = trueon every save. - Publish (
publishStorefrontFn): copies each key inSETTINGS_DRAFT_KEYSfromdraftinto live columns, setsis_published = true, clearsdraft = {}, and flipsis_draft = falseon all draft banner/section/nav rows. - Discard draft (
discardStorefrontDraftFn): clearsdraft = {}and deletes banner/section/nav rows stillis_draft = true. - Pages, Policies, Contact/Branding write straight to live (no draft staging); Pages/Policies have their own
is_publishedflag.
Theme — /admin/storefront (tab: Theme)
- Purpose: Colours, layout variant, font, hero, announcement bar, social links, SEO meta.
- Backend:
saveSettingsDraftFn(src/lib/storefront-admin.functions.ts) →storefront_settings.draft(JSON merge). "Reset to theme default" calls the same fn with only colour/font keys.
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 label | Field key (patch) | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Homepage variant | homepage_variant | select | — | "index" | from themes[0].variants[].code, else index/index-2…index-5 | — |
| Primary | primary_color | color/hex | — | #0A69D8 | free hex + picker | legacy default #ff6a00 treated as "not customised" |
| Secondary | secondary_color | color/hex | — | #333333 | — | legacy default #111111 |
| Accent | accent_color | color/hex | — | #ffa500 | — | legacy default #f5a623 |
| Text | text_color | color/hex | — | #333333 | — | — |
| Background | background_color | color/hex | — | #ffffff | — | — |
| Font family (optional) | font_family | text | — | "" | empty → saved as null (keeps theme typography) | — |
| Hero title | hero.title | text | — | "" | packed into hero JSON | — |
| Hero subtitle | hero.subtitle | text | — | "" | — | — |
| Hero image | hero.image_url | image URL | — | "" | AdminImageUpload folder cms | — |
| Hero CTA label | hero.cta_label | text | — | "" | — | — |
| Show announcement bar | announcement_bar.enabled | checkbox | — | false | — | — |
| Announcement text | announcement_bar.text | text | — | "" | — | — |
| Facebook / Instagram / TikTok / WhatsApp | social_links.* | text | — | "" | packed into social_links JSON | — |
| Meta title | seo.title | text | — | "" | packed into seo JSON | feeds storefront <title> |
| Meta description | seo.description | text | — | "" | — | feeds meta description |
| Meta keywords | seo.keywords | text | — | "" | — | — |
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)
- Backend:
saveBannerFn/deleteBannerFn→storefront_banners. Every save setsis_draft = true.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | id | uuid | — | null (new) | present → update | — |
| Placement | placement | select | Yes | "hero" | hero, promo | — |
| Position | position | number | Yes | (count+1)*10 | int ≥ 0 | ↑/↓ buttons |
| Title | title | text | — | "" | nullable | "(untitled)" if blank |
| Subtitle | subtitle | text | — | "" | nullable | — |
| Banner image | image_url | image URL | — | "" | folder cms wide | — |
| Link URL | link_url | text | — | "" | nullable | — |
| Visible | is_visible | checkbox | Yes | true | — | — |
| — | is_draft | boolean | auto | true on save | — | cleared on publish |
Sections — /admin/storefront (tab: Sections)
- Backend:
saveSectionFn/deleteSectionFn→storefront_sections. Save setsis_draft = true.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | id | uuid | — | null | — | — |
| Page slug | page_slug | text | Yes | "home" | which storefront page | — |
| Section type | section_type | select | Yes | featured_categories | featured_categories, featured_products, flash_deals, product_section, promotional, footer | — |
| Title | title | text | — | "" | nullable | — |
| Position | position | number | Yes | (count+1)*10 | int ≥ 0 | — |
| Visible | is_visible | checkbox | Yes | true | — | — |
| Section image | config.image_url | image URL | — | — | stored in config JSON | — |
| Config (JSON) | config | JSON textarea | Yes | {} | must parse ("Config must be valid JSON") | free-form block config |
| — | is_draft | boolean | auto | true on save | — | — |
Navigation — /admin/storefront (tab: Navigation)
- Backend:
saveNavItemFn/deleteNavItemFn→storefront_navigation. Save setsis_draft = true.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | id | uuid | — | null | — | — |
| Location | location | select | Yes | "header" | header, footer | — |
| Label | label | text | Yes | "" | min 1 | — |
| URL (relative) | url | text | Yes | "" | empty allowed; col NOT NULL | rendered as /<url> |
| Position | position | number | Yes | (count+1)*10 | int ≥ 0 | — |
| Visible | is_visible | checkbox | Yes | true | — | — |
| — | parent_id | uuid | — | null | self-FK for nesting | not in UI |
| — | is_draft | boolean | auto | true on save | — | — |
Pages — /admin/storefront (tab: Pages)
- Backend:
savePageFn/deletePageFn→storefront_pages(live, no draft staging).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | id | uuid | — | null | — | — |
| Slug | slug | text | Yes | "" | min 1 | unique (shop_id, slug) |
| Title | title | text | Yes | "" | min 1 | — |
| Published | is_published | checkbox | Yes | false | col default true | "· unpublished" in list |
| (content) | content | textarea | Yes* | "" | empty allowed | body text |
Policies — /admin/storefront (tab: Policies)
- Backend:
savePolicyFn→storefront_policies(upsert onshop_id, policy_type, live).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Page | policy_type | select | Yes | "privacy" | privacy, terms, return, shipping, faq | selects which row is edited |
| — | title | text | auto | from POLICY_TYPES map | min 1 | derived, not a visible input |
| (content) | content | textarea | Yes* | existing / "" | — | — |
| Published | is_published | checkbox | Yes | true | — | — |
POLICY_TYPES titles: Privacy Policy, Terms & Conditions, Return Policy, Shipping Policy, FAQ.
Branding & Contact — /admin/storefront (tab: Contact)
- Backend:
saveContactInfoFn→shopstable (not a storefront_* table). Written live.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Store name | name | text | — | shop.name | only written if non-empty | updates shops.name |
| Tagline | tagline | text | — | shop.tagline | nullable | — |
| Store logo | logo_url | image URL | — | shop.logo_url | folder branding | header + receipts |
| Favicon | favicon_url | image URL | — | shop.favicon_url | folder branding | browser tab icon |
| Phone | phone | text | — | shop.phone | nullable | — |
email | text | — | shop.email | nullable | — | |
whatsapp | text | — | shop.whatsapp | nullable | — | |
| Address | address | text | — | shop.address | nullable | — |
Storefront Connections
- Selects / references: Sections/banners reference categories & products (via
configJSON / links); Navigation URLs point at storefront customer pages; Pages & Policies render as customer info pages. - Feeds / used by: all published rows drive the public storefront at
/store/<shop.slug>;seofeeds meta tags; theme colours/font inject storefront CSS; branding feeds header + receipts. Public reads are RLS-gated tois_visible AND NOT is_draft. - Cross-form effects: Theme + banner/section/nav changes stay hidden until Publish; currency/language/timezone are managed in
/admin/settings, not here.
AI (Admin) — /admin/ai
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)
- Backend:
askAssistant(src/lib/ai.functions.ts) →aiChat(agent"assistant") →ai_conversations,ai_messages,ai_tool_calls, may insertai_action_approvals.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| message box | message | text | Yes | "" | 1–2000 chars | Enter to send |
| — | shopId | uuid | auto | current shop | — | — |
| — | conversationId | uuid | auto | null first turn, then returned id | — | threads the conversation |
| — | locale | text | auto | shop.default_locale | ≤5 | — |
Reports: Morning briefing (getBriefing) and End-of-day summary (getEndOfDay), input {shopId} only.
Permission matrix — /admin/ai ("AI permissions" panel)
- Purpose: Per-capability mode governing whether the AI may act.
- Backend: direct Supabase update on
ai_permissions.mode; server-side action execution re-checkshas_shop_role.
| UI label | Field key | Type | Req | Default | Validation / options | Notes | ||
|---|---|---|---|---|---|---|---|---|
| (agent label) | agent | enum | auto | seeded | ai_agent = salesman \ | assistant | display only | |
| (capability label) | capability | text | auto | seeded | — | display only | ||
| mode dropdown | mode | select | Yes | col default disabled | disabled \ | approval \ | automatic | the only editable field |
| — | id, shop_id | uuid | auto | — | 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)
- Backend: read
ai_action_approvals(limit 30, newest first); decide viadecideAiApproval→ on approve runsexecuteAiAction, writes result back, posts anai_messagessummary.
| UI label | Field key | Type | Req | Default | Validation / options | Notes | |
|---|---|---|---|---|---|---|---|
| Approve / Reject | decision | button | Yes | — | approved \ | rejected | — |
| — | approvalId | uuid | auto | row id | — | — | |
| — | shopId | uuid | auto | current 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)
- Backend:
listAlerts/refreshAlerts(src/lib/ai-insights.functions.ts) →ai_alerts. Dismiss = direct updateis_read=true. Columns:alert_type,severity(info/warning/critical),title,message,data(JSON),is_read,created_at.
Knowledge base — /admin/ai (tab: Knowledge base)
- Backend:
addKnowledgeDocumentFn/listKnowledgeDocumentsFn/deleteKnowledgeDocumentFn→ai_knowledge_documents(+ chunks intoai_knowledge_chunks).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Title | title | text | Yes | "" | 1–200; file upload falls back to file.name | — |
| (content) | content | textarea | Yes | "" | 1–200000 | markdown/CSV/plain |
| (file picker) | content (from file) | file | — | — | .txt, .md, .csv read as text | — |
| — | source | text | — | "manual" | ≤100 | not in UI |
| — | docType | text | — | server "text" | col doc_type default policy | not in UI |
| — | is_active | boolean | auto | true | — | — |
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)
- Backend:
listBusinessMemoryFn/createBusinessMemoryFn/updateBusinessMemoryFn/setBusinessMemoryActiveFn/decideBusinessMemoryFn(src/lib/ai-memory.functions.ts) →ai_business_memory.
Add memory
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Scope | scope | text | Yes | "" | 1–50; col default general | e.g. policy, supplier, hours |
| (content) | content | textarea | Yes | "" | 1–4000; sanitised (cards/tokens/emails redacted) | — |
| — | source | enum | auto | "user" | — | user-created |
| — | status | enum | auto | "approved" (user) / pending (AI) | — | — |
| — | confidence | numeric | auto | 1 (AI proposals 0.6) | — | — |
| — | is_active | boolean | auto | true | — | — |
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
- Permissions gate actions: a capability's
modedecides disabled (blocked) / approval (queued intoai_action_approvals) / automatic (executed). Approve re-verifies role + mode server-side beforeexecuteAiAction. - Knowledge → chunks: documents are chunked into
ai_knowledge_chunks, retrieved by shop-scoped full-text to ground chat answers. - Memory needs approval: AI-proposed memory stays pending/inactive until a manager approves.
- Conversations/messages/tool-calls record the chat; approval decisions post follow-up
ai_messages.
Tables owned:
- Storefront:
storefront_settings(uniqueshop_id; live cols +draftjsonb;theme_codedefaultzenis),storefront_themes(global,variantsjsonb, no shop FK),storefront_pages(unique(shop_id, slug)),storefront_sections(is_draft),storefront_navigation(self-FKparent_id;is_draft),storefront_banners(is_draft),storefront_policies(unique(shop_id, policy_type)). Branding/Contact writes targetshops. - AI:
ai_permissions(unique(shop_id, agent, capability);agent=ai_agent),ai_conversations(customer_id→customers),ai_messages(conversation_id→ai_conversations),ai_tool_calls(conversation_id→ai_conversations),ai_action_approvals(conversation_id→ai_conversations;status=approval_status),ai_knowledge_documents,ai_knowledge_chunks(document_id→ai_knowledge_documents; generatedsearchtsvector),ai_alerts,ai_business_memory(scope/source/status/confidence/is_active; RLS owner/admin/manager).
Storefront — Customer Forms
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
- Purpose: Authenticate a returning shop-scoped customer (password or Google OAuth).
- Access: guest (redirects to
accountif already signed in). - Backend:
supabase.auth.signInWithPassword/ OAuthgoogle/supabase.auth.resend→auth.users.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
email | text | Yes | "" | — | — | |
| Password | password | password | Yes | "" | — | — |
| (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
- Access: guest. Backend:
supabase.auth.signUp/ OAuthgoogle→auth.users; appcustomersrow created later, lazily.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Full name | fullName | text | No | "" | → user_metadata.full_name | — |
email | text | Yes | "" | non-empty (trimmed); Supabase email format | — | |
| Password | password | password | Yes | "" | min length 6 (client) | — |
| (shop scope) | shop_slug | auto | auto | shop.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
- Access: guest. Backend:
supabase.auth.resetPasswordForEmail→ email; redirects to shared/reset-password?next=<account>.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
email | text | Yes | "" | trimmed | neutral "if that email exists" message |
Product page — add to cart — /store/$shopSlug/product/$slug
- Purpose: Choose a variant + quantity and add to cart (or wishlist/compare).
- Access: guest or signed-in. Backend: none on add (localStorage
addToCart/toggleList); page data viagetStorefrontProduct. No review form exists.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Options | variantId | select (buttons) | Yes if >1 variant | default variant (is_default) else first active | active variants only | hidden when single variant |
| Quantity | quantity | number | Yes | 1 | min 1; coerced max(1, n) | — |
| Add to cart | — | button | — | — | disabled if no variant or out of stock | writes 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
- Access: guest or signed-in (client-side). Backend: none — localStorage. No coupon field.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Item | name,sku,price | display | — | — | — | read-only |
| Quantity | quantity | number | Yes | current | min 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
- Purpose: Capture contact + address + payment method and place an online order.
- Access: guest or signed-in (prefills name/email from session).
- Backend:
quoteCart(prices, strips cost) andplaceOnlineOrder(src/lib/checkout.functions.ts) → RPCrecord_pos_sale→orders,order_items,payments,receipts, inventory + journal; alsocustomers,ai_alerts.
Contact / Billing details
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Full name | customer.full_name | text | Yes | prefilled from session | 1–120 chars | UI blocks empty name/email |
customer.email | Yes | prefilled from session | valid email | — | ||
| Phone | customer.phone | text | No | "" | max 40 | — |
| — | customer.user_id | uuid | auto | session user id | optional | attaches 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 label | Field key | Type | Req | Default | Validation / options | Notes | ||
|---|---|---|---|---|---|---|---|---|
| Payment (radio) | payment_method_code | radio | Yes | cod | cod \ | bank \ | card | cod = unpaid/AR (1150); bank/card = prepaid → paid, Bank (1100) |
| — (notes) | notes | text | No | — | max 500 | not in UI (schema-only) | ||
| — (idempotency) | client_uuid | uuid | auto | crypto.randomUUID() | max 80 | dedupes retries via orders.local_uuid | ||
| Items | items[] | auto | Yes | from cart | variant_id uuid, quantity 0.001–9999, min 1 line | server re-prices |
Order summary (auto, from quoteCart): subtotal, tax_total, total, discount_total, and generated order_number / receipt_number.
Connections
- Selects / references: cart line items → Variants; payment method (hardcoded list, not from
payment_methodstable). - Feeds / used by: creates
orders(channelonline, statusconfirmed),order_items,payments(non-COD),receipts, decrements inventory, posts journal,ai_alerts(new_order) — the sameorders/order_itemsthe admin Orders form reads;customersreused or inserted. - Cross-form effects: on success clears cart, navigates to
payment/success?order=<number>; per-IP rate limit (10/min); idempotent onclient_uuid.
Track order — /store/$shopSlug/track-order
- Access: guest (public). Backend:
trackOrder(src/lib/storefront.functions.ts) → readsorders+order_items(contact fields stripped from response).
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Order number | orderNumber | text | Yes | "" | min 1; trimmed | — |
| Email / phone | contact | text | Yes | "" | min 3; must match contact_email or contact_phone | button disabled until both filled |
Returns status/payment/fulfillment/tracking + items + total (read-only).
Account — Profile — /store/$shopSlug/account (tab: Profile)
- Access: signed-in customer. Backend:
updateCustomerProfile(src/lib/customer.functions.ts) →customers.
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| Full name | full_name | text | No | customer value | max 120; ""→null | — |
email | No | customer value | max 160; ""→null | — | ||
| Phone | phone | text | No | customer value | max 40; ""→null | — |
Account — Addresses — /store/$shopSlug/account (tab: Addresses)
- Access: signed-in customer. Backend:
upsertCustomerAddress/deleteCustomerAddress/setDefaultCustomerAddress→customer_addresses.
Address sub-form
| UI label | Field key | Type | Req | Default | Validation / options | Notes |
|---|---|---|---|---|---|---|
| — | id | uuid | auto | — | present = update, absent = insert | — |
| Label | label | text | No | "" | max 60 | — |
| Address line 1 | line1 | text | No | "" | max 200 | — |
| Address line 2 | line2 | text | No | "" | max 200 | — |
| City | city | text | No | "" | max 100 | — |
| State | state | text | No | "" | max 100 | — |
| Postal code | postal_code | text | No | "" | max 30 | — |
| Country | country | text | No | "" | max 100 | — |
| Phone | phone | text | No | "" | max 40 | — |
| Set as default | is_default | checkbox | No | false | — | 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)
- Access: signed-in customer. Backend:
getCustomerAccount+getOrderDetail→orders,order_items(scoped to owncustomer_id). Reorder is client-side (addToCart).
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
- Access: guest or signed-in (client-side). Backend: none — localStorage
wishlist/comparelists (toggleList,addToCart). DBwishliststable is not used by the storefront. Each item shows storedname/sku/price/imagewith Add-to-cart and Remove buttons; Compare shows a Product / SKU / Price table.
Contact — /store/$shopSlug/contact
- Access: guest. No submit form — display-only contact links (address/phone/email/WhatsApp), directing users to the AI chat button. (
vendor.tsxis likewise a display-only store profile.)
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. No product-review table/form exists.
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:
| Enum | Used by | Values |
|---|---|---|
shop_role | Team & Roles, invitations | owner · admin · manager · cashier · accountant · inventory · staff |
product_status | Product | draft · active · archived |
order_status | Orders | pending · confirmed · processing · completed · cancelled · refunded |
order_channel | Orders | pos · online · ai |
payment_status | Orders, invoices | unpaid · partial · paid · refunded |
fulfillment_status | Orders | unfulfilled · processing · shipped · delivered · picked_up · returned |
movement_type | Inventory movements | opening · purchase · sale · return · adjustment · transfer_in · transfer_out · count |
transfer_status | Transfers | draft · requested · approved · shipped · received · cancelled |
po_status | Purchase orders | draft · submitted · approved · partial · received · cancelled |
account_type | Chart of accounts | asset · liability · equity · income · expense |
approval_status | AI action approvals | pending · approved · rejected · executed |
ai_agent | AI | salesman (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 field | Points at | Meaning |
|---|---|---|
shop_id | shops | tenant scope on every table |
branch_id | branches | branch binding |
category_id | categories / expense_categories | product category · expense category |
parent_id | categories / accounts / storefront_navigation | tree self-reference |
brand_id | brands | product brand |
tax_rate_id | tax_rates | product/line tax |
product_id | products | variant/image parent |
variant_id | product_variants | the sellable unit on order/PO/cart/count/transfer/return lines |
customer_id | customers | order/cart owner |
order_id / order_item_id | orders / order_items | sale + returned line |
return_id | returns | refund parent |
register_id | registers | POS till |
cash_session_id | cash_sessions | open till session |
location_id / from_location_id / to_location_id | inventory_locations | stock location(s) |
transfer_id | inventory_transfers | transfer lines |
stock_count_id | stock_counts | count lines |
supplier_id | suppliers | PO / invoice / payment |
purchase_order_id | purchase_orders | receipt/invoice source |
goods_receipt_id | goods_receipts | receipt lines |
supplier_invoice_id | supplier_invoices | supplier payment target |
account_id | accounts | journal line / expense account |
journal_entry_id | journal_entries | ledger lines |
document_id | ai_knowledge_documents | knowledge chunks |
conversation_id | ai_conversations | AI messages |
Per-domain table ownership is listed at the end of each section above.