diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..708d0d0 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,887 @@ +# CLAUDE.md — Nails Salon POS (Multi-Tenant SaaS) + +## Project Overview + +A multi-tenant SaaS POS web application for nail salons. The platform is split across **two dedicated domains**: + +| Domain | Audience | Purpose | +|---|---|---| +| `admin.mydomain.com` | System Admins | Platform management: system users, tenants, plan enforcement, settings override | +| `mydomain.com` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports | + +A **System Admin** manages the entire platform and can override any tenant's settings. Each **Tenant** (salon owner/manager/staff) operates exclusively within their own salon context, accessed via `mydomain.com/login` with tenant resolved from their credentials. + +--- + +## Tech Stack + +| Layer | Technology | +|---|---| +| Backend framework | Python 3.11+ / Flask | +| WSGI server | Gunicorn | +| Database | MySQL 8.0+ | +| ORM | SQLAlchemy + Flask-Migrate (Alembic) | +| Auth (UI) | Flask-Login + bcrypt | +| Auth (API) | JWT via Flask-JWT-Extended (httpOnly cookie transport) | +| Reverse proxy | Nginx | +| Process manager | systemd | +| Frontend | Jinja2 + Bootstrap 5 + vanilla JS | +| PDF/receipts | WeasyPrint | +| Scheduler | APScheduler (subscription reminders, reports, campaign sends) | +| Forms & CSRF | Flask-WTF | +| Rate limiting | Flask-Limiter (Redis or in-memory backend) | +| Input validation | WTForms validators + custom sanitisers | +| Environment config | python-dotenv | + +--- + +## Domain Architecture + +### Domain 1 — `admin.mydomain.com` (System Admin Portal) + +Served by a dedicated Nginx `server` block. Access is restricted to users with the `superadmin` role. An IP allowlist at the Nginx level provides an additional layer of protection. + +**Functionalities:** +- **System user management** — create, edit, deactivate superadmin accounts; role assignment; force password reset +- **Tenant management** — create, view, edit, suspend, cancel tenants; assign/change plans; view billing history; manual invoice entry +- **Tenant settings override** — view and override any tenant's salon settings (business name, hours, feature flags, commission rates, etc.) with a visible "override active" indicator; lift overrides individually or in bulk +- **Plan management** — create and edit subscription plans, feature flags, pricing, max staff/locations limits +- **Audit log** — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention +- **Platform analytics** — active tenants, MRR, trial conversions, churn rate, revenue trends + +### Domain 2 — `mydomain.com` (Tenant Portal) + +Served by a separate Nginx `server` block. All tenants share this single domain. The active tenant and location are resolved from the authenticated session. + +`mydomain.com` exposes **two distinct login flows**: + +| URL | Credentials | Who uses it | +|---|---|---| +| `mydomain.com/login` | Email + password | `tenant_admin`, `tenant_manager` | +| `mydomain.com/staff-login` | Phone number + passcode (4–6 digit PIN) | `tenant_staff` — kiosk-style login | +| `mydomain.com/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad | + +Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal **Staff Portal** — a simplified view scoped exclusively to their own data. + +The **Customer Check-In Kiosk** (`mydomain.com/checkin/{tenant_slug}`) is a separate public-facing page with no authentication. It is designed to run fullscreen on a dedicated iPad at the front desk. The page auto-resets after each submission for the next customer. + +**Functionalities (scoped to the authenticated user's tenant and active location):** + +- **Dashboard** — KPIs scoped to active location: daily revenue, appointment count, staff on-shift, low-stock alerts, recent transactions, average review rating (last 30 days), today’s tips total +- **Location management** *(tenant_admin only)* — create/edit/deactivate locations; set primary location; per-location settings (hours, timezone, contact info) +- **Location switcher** — persistent in nav bar; accessible to `tenant_admin` and `tenant_manager`; `tenant_staff` is locked to their assigned location(s) +- **Staff management** — profiles, job type (salon manager, full-time, part-time, seasonal, receptionist), system role, location assignments, pay structure (hourly rate / salary / guarantee pay), commission rate, schedules per location, active/inactive toggle; passcode set by `tenant_admin` at staff creation time; passcode reset by `tenant_admin` or `tenant_manager` +- **Staff Portal** *(accessed via `mydomain.com/staff-login` — phone number + passcode only)* — personal self-service view for `tenant_staff`: + - Today’s appointment schedule (own bookings only, current location) + - Upcoming appointments (next 7 days) + - Working hours log (clock-in / clock-out per shift at active location) + - Pay summary (current period: base pay calculated from pay type + hours or salary period, commission earned, guarantee top-up if applicable) + - Commission summary (per-transaction breakdown) + - Payment history (transactions they personally served) + - Read-only personal profile (name, phone, assigned locations, schedule) + - No access to other staff’s data, full customer PII, reports, inventory, or any settings +- **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge +- **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously) +- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and a real-time alert is pushed to the receptionist’s dashboard; the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration +- **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes +- **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans +- **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase +- **POS / Checkout** — service + product selection; tip amount field (cash or app); automatic promotion detection at checkout (active promotions applied instantly — no manual coupon entry required); original price, discount percentage, and discounted price shown clearly per line item; gift card redemption as payment method; manual additional discount still available for `tenant_admin` and `tenant_manager`; cash or app-based payment (Zelle, Venmo, CashApp, Other); `payment_reference` field for app transaction IDs; receipt generation (print/PDF) showing promotional savings, tip, and gift card balance; option to email digital receipt to customer on file; void transaction with reason; Card/Stripe deferred to a future phase +- **Next-Visit Scheduling at Checkout** — after payment is confirmed and before the receipt is printed, the receptionist is prompted with an optional “Schedule next visit?” step; they can book the customer’s next appointment directly from the checkout screen (same service, same staff, new date/time); the new appointment is created as `pending` (or `confirmed` if auto-confirm is enabled); the next visit date is printed on the current receipt and shown in the confirmation email; a 24-hour reminder is automatically scheduled for the rebooked appointment; if the customer declines, the step is skipped and the receipt proceeds normally; `rebook_source` field on `appointments` table records that the booking originated from a checkout rebook +- **Tip Tracking** — tip amount field at POS checkout (cash or app, same payment methods as transaction); tip attributed to the serving staff member; stored on `transactions.tip_amount`; visible in Staff Portal (own tips per transaction); factored into pay period summary and commission reports; owner-level tip report by staff and period +- **Gift Cards** — `tenant_admin` issues gift cards with a set monetary value; unique code generated per card; at POS staff redeems code as a payment method (partial or full payment); balance checked and decremented on each use; remaining balance shown on receipt; `gift_cards` table tracks code, original value, remaining balance, issuer, and expiry date +- **Inventory** — per-location stock levels; reorder alerts; manual stock adjustment with reason log; automatic deduction on POS sale +- **End-of-Day Reconciliation** — “Close Day” action summarises total cash received, total app payments, total tips, expected cash in drawer; manager enters actual cash counted; system calculates and flags variance; stored in `daily_reconciliations` table; variance report visible to `tenant_admin`; prevents undetected cash discrepancies +- **Marketing** *(Pro plan)* — email campaigns to customer segments (audience filter by visit date, loyalty tier, service type); subject + body composer; scheduled or immediate send; basic delivery tracking (sent count, open count). SMS deferred to a future phase. +- **Appointment Reminders** — automated email reminder sent 24 hours before appointment (configurable: on/off and lead time per tenant); optional 2-hour reminder; reduces no-show rate; SMS version deferred to future phase +- **Customer Reviews & Reputation** — review request email sent automatically X minutes after checkout (default 60 min, configurable per tenant); customer rates the visit 1–5 stars with optional comment; if rating ≥ 4 stars, a follow-up screen/email suggests leaving a public review on Google, Facebook, or Yelp (platform review URLs configured by owner in salon settings); if rating < 4 stars, private thank-you message shown and feedback goes silently to the owner’s dashboard for internal improvement; one review request per transaction; no-show and voided transactions excluded from review sends +- **Reports** — revenue (daily/weekly/monthly, by staff, by service, by location); tip reports (by staff, by period); commission summary; staff pay period reports (base pay, commission, guarantee top-up, total per staff); no-show and cancellation metrics (by customer, by staff); rebook rate (percentage of checkouts where a next visit was scheduled); check-in kiosk usage (walk-ins per day via kiosk vs manual entry); promotion performance; review analytics (average rating, response rate, rating distribution, platform click-through); end-of-day reconciliation variance history; inventory valuation and low-stock; all exportable as CSV and PDF +- **Salon settings** — business info, operating hours, notification preferences, receipt footer, logo upload; review platform URLs (Google, Facebook, Yelp); appointment reminder timing; review request delay; auto-confirm booking toggle; gift card expiry policy; kiosk check-in settings (enable/disable kiosk page, service list shown on kiosk, auto-reset timer duration) + +> **Settings override:** Fields locked by a superadmin override display an "Override by admin" badge. The tenant cannot modify those fields until the override is lifted from the admin portal. + +--- + +## Multi-Tenancy Architecture + +### Strategy: Shared Database, Shared Schema (Row-Level Isolation) + +Every tenant-owned table carries a `tenant_id` foreign key. All queries are automatically scoped via a Flask `g.tenant` context object set after authentication. + +### Tenant & Location Resolution + +**Email + password flow** (`mydomain.com/login`) — for `tenant_admin` and `tenant_manager`: + +1. User submits email + password. +2. Flask-Login loads the `User` record; `user.tenant_id` stored in session. +3. `@before_request` hook calls `load_tenant_context()` → sets `g.tenant`; checks `tenant.status`. +4. `load_location_context()` resolves `g.location` from session (last selected) or defaults to primary location. +5. All downstream queries filter by `g.tenant.id` and `g.location.id`. +6. If `tenant.status` is `suspended` or `cancelled`, user is redirected to a locked page. + +**Phone + passcode flow** (`mydomain.com/staff-login`) — for `tenant_staff`: + +1. Staff submits phone number + 4–6 digit passcode. +2. Flask looks up the `Staff` record by `(phone, tenant_id)`; `tenant_id` is resolved from the phone number's owning tenant (phone is unique platform-wide within a tenant, not globally — lookup is by phone first, then tenant validated via the staff record). +3. Passcode verified against `staff.passcode_hash` (bcrypt). +4. Brute-force lockout: 5 failures → `passcode_locked_until` set for 15 minutes. +5. On success, a Flask-Login session is created scoped to the matched tenant and the staff's primary assigned location. +6. Staff are redirected to `/staff/portal` — the simplified personal dashboard. +7. `tenant_staff` sessions cannot access any owner/manager routes; `@require_role` enforces this. + +On `admin.mydomain.com`, there is no `g.tenant` — all queries are unscoped platform-level queries. + +### Data Isolation Rules + +1. Every tenant-owned model MUST have `tenant_id` (FK → `tenants.id`, non-nullable, indexed). +2. Location-specific models MUST also carry `location_id` (FK → `locations.id`, indexed). +3. All blueprint query helpers MUST filter by `g.tenant.id` — never raw `Model.query.all()`. +4. Location-scoped queries additionally filter by `g.location.id`. +5. `tenant_staff` location access is enforced by checking `staff_locations` membership before `g.location` is set. +6. Superadmin routes bypass tenant scoping entirely. +7. Cross-tenant data access is only permitted from the superadmin context. +8. Settings overrides in `tenant_setting_overrides` take precedence over `tenant_settings` at the application layer. +9. Demo tenant is write-protected at the application layer: any `POST`/`PUT`/`DELETE` request on a demo session returns 403 with a "Demo account is read-only" message. + +--- + +## Database Schema (High-Level) + +### Platform-Level Tables (superadmin scope) + +``` +system_users + id, email, password_hash, name, role, is_active, + failed_login_attempts, locked_until, + password_reset_token, password_reset_expires_at, + last_login_at, created_at + +plans + id, name, price_monthly, max_staff, max_locations, + features_json, is_active + +tenants + id, slug, name, owner_email, plan_id, status, + trial_ends_at, subscription_expires_at, + is_demo, created_at, updated_at + +tenant_billing_history + id, tenant_id, amount, description, paid_at, invoice_ref, recorded_by + +tenant_setting_overrides + id, tenant_id, setting_key, setting_value, + overridden_by, overridden_at, lifted_at, note + +audit_log + id, actor_id, actor_type, action, target_type, target_id, + before_json, after_json, ip_address, created_at + (append-only; no UPDATE or DELETE ever issued against this table) + (retention: purge records older than 365 days via monthly APScheduler job) +``` + +### Tenant-Level Tables (all carry tenant_id) + +> **Soft Delete Convention:** All tenant models include a `deleted_at` timestamp (nullable). Queries always filter `WHERE deleted_at IS NULL`. Hard deletes are never issued from application code — only via a superadmin maintenance tool. Admin and tenant_admin can restore soft-deleted records. + +``` +users + id, tenant_id, email, password_hash, role, is_active, + failed_login_attempts, locked_until, + password_reset_token, password_reset_expires_at, + last_login_at, created_at + +tenant_settings id, tenant_id, setting_key, setting_value + +locations + id, tenant_id, name, address, phone, email, + timezone, is_active, is_primary, created_at + +location_settings id, tenant_id, location_id, setting_key, setting_value + +customers + id, tenant_id, name, phone, email, date_of_birth, + preferred_staff_id, notes, loyalty_points, + is_active, no_show_count, created_at, deleted_at + +services id, tenant_id, name, category, duration_min, price, is_active, deleted_at +products id, tenant_id, name, sku, category, sale_price, is_active, deleted_at + +promotions id, tenant_id, name, discount_percent, + applies_to, target_ids_json, + starts_at, ends_at, is_active, created_by, created_at + (applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all') + (target_ids_json: list of service/product IDs when applies_to is 'service' or 'product'; + null when applies_to is 'all_services', 'all_products', or 'all') + (discount_percent: 1–100; e.g. 20 = 20% off) + (active window: starts_at <= NOW() <= ends_at AND is_active = true) + +staff id, tenant_id, user_id(FK), name, phone, passcode_hash, deleted_at, + staff_type, pay_type, hourly_rate, salary_amount, + guarantee_amount, pay_period, + commission_rate, commission_enabled, + is_active, passcode_failed_attempts, passcode_locked_until + (staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist') + (pay_type: 'hourly' | 'salary' | 'guarantee') + (hourly_rate: applicable when pay_type = 'hourly'; rate × hours from staff_clockings) + (salary_amount: fixed gross per pay_period when pay_type = 'salary') + (guarantee_amount: minimum guaranteed per pay_period; commission tops it up if higher) + (pay_period: 'weekly' | 'biweekly' | 'monthly') + (commission_enabled: boolean; can be disabled for salaried staff) + (phone: unique within tenant; used for staff-login) + (passcode_hash: bcrypt-hashed 4-6 digit PIN set by owner) +staff_locations id, tenant_id, staff_id, location_id +staff_schedules id, tenant_id, staff_id, location_id, day_of_week, start_time, end_time + +appointments + id, tenant_id, location_id, customer_id, staff_id, service_id, + start_time, end_time, is_walk_in, status, + notes, cancellation_reason, cancelled_at, + rebook_source, rebooked_from_transaction_id, + created_by, created_at + (status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show') + (rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk'; nullable — identifies booking origin) + (rebooked_from_transaction_id: FK → transactions.id; nullable — links rebook to its source checkout) + +transactions + id, tenant_id, location_id, appointment_id, customer_id, staff_id, + subtotal, discount, tip_amount, gift_card_amount, total, + payment_method, payment_reference, gift_card_id, + review_request_sent_at, + voided_at, voided_by, void_reason, created_at + (payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other') + (payment_reference: app transaction ID or descriptive note, nullable) + (gift_card_id: FK → gift_cards.id; nullable) + (review_request_sent_at: timestamp; NULL = not yet sent; prevents duplicate sends) + +transaction_items id, transaction_id, service_id, product_id, qty, + unit_price, original_price, discount_percent, promotion_id + (unit_price: final price after promotion; original_price: price before promotion) + (promotion_id: FK → promotions.id; null if no promotion applied) + +inventory + id, tenant_id, location_id, name, sku, category, + qty_on_hand, reorder_level, cost_price, sale_price + +inventory_log id, tenant_id, location_id, inventory_id, delta, reason, created_at +commission_log id, tenant_id, location_id, staff_id, transaction_id, amount, period + +staff_pay_periods id, tenant_id, staff_id, period_start, period_end, + pay_type, base_amount, commission_amount, + guarantee_topup, total_amount, status, notes + (base_amount: salary, or hourly_rate × total hours, for the period) + (guarantee_topup: max(0, guarantee_amount - commission_amount) when pay_type = 'guarantee') + (status: 'draft' | 'approved' | 'paid') + +staff_clockings id, tenant_id, location_id, staff_id, + clocked_in_at, clocked_out_at, total_minutes, notes + (one row per shift; clocked_out_at NULL = currently clocked in) + +marketing_campaigns + id, tenant_id, name, channel, status, audience_filter_json, + subject, message_body, scheduled_at, sent_at, + sent_count, open_count + (channel: 'email'; 'sms' in future phase) + (status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled') + +gift_cards id, tenant_id, code, original_value, remaining_balance, + issued_by, issued_to_customer_id, expires_at, + is_active, created_at + (code: unique per tenant; generated on issuance) + +checkin_queue id, tenant_id, location_id, customer_id, + customer_name, customer_phone, service_requested, + checked_in_at, status, acknowledged_by, acknowledged_at + (status: 'waiting' | 'acknowledged' | 'seated' | 'expired') + (customer_id: FK → customers.id; nullable if new customer not yet profiled) + (service_requested: free-text from kiosk service selector; nullable) + (acknowledged_by: FK → users.id; the receptionist who handled the alert) + +waitlist id, tenant_id, location_id, customer_name, customer_phone, + customer_email, staff_id, service_id, + requested_date, status, notified_at, created_at + (status: 'waiting' | 'notified' | 'booked' | 'expired') + +checkout_reviews id, tenant_id, location_id, transaction_id, customer_id, + staff_id, rating, comment, is_public_suggested, + google_clicked, facebook_clicked, yelp_clicked, + created_at + (rating: 1–5 integer) + (is_public_suggested: true if rating ≥ 4 — platform links were shown) + (google/facebook/yelp_clicked: tracked via redirect link for analytics) + +daily_reconciliations + id, tenant_id, location_id, date, total_cash, total_app_payments, + total_tips, total_gift_card_redemptions, expected_cash_in_drawer, + actual_cash_counted, variance, closed_by, closed_at, notes + (variance: actual_cash_counted - expected_cash_in_drawer; negative = shortage) + +appointment_reminders + id, tenant_id, location_id, appointment_id, reminder_type, + scheduled_for, sent_at, channel, status + (reminder_type: '24h' | '2h') + (channel: 'email'; 'sms' in future phase) + (status: 'pending' | 'sent' | 'failed' | 'cancelled') +``` + +--- + +## User Roles & Access Control + +### System Admin Domain (`admin.mydomain.com`) + +| Role | Capabilities | +|---|---| +| `superadmin` | Full platform access: system users, tenants, plans, billing, settings override, audit log, analytics | + +> Future: a `support_agent` role (read-only tenant access) for helpdesk use. + +### Tenant Domain (`mydomain.com`) + +**System roles** control application access. **Staff job types** describe employment classification. They are independent — the mapping below is the convention to follow: + +| System Role | Staff Job Types | Location Access | Capabilities | +|---|---|---|---| +| `tenant_admin` | Salon owner | All locations | Full salon access: all modules, settings, location management, user management; sets/resets staff passcodes | +| `tenant_manager` | `salon_manager` | All locations | Appointments, POS, customers, inventory, reports; no settings, no user management; can reset staff passcodes | +| `tenant_staff` | `full_time`, `part_time`, `seasonal`, `receptionist` | Assigned locations only | Staff Portal only (via phone + passcode login): own schedule, appointments, working hours, pay summary, commission, payment history; no access to manager/admin routes | + +**Staff job types** (stored as `staff.staff_type`): + +| Job Type | Description | +|---|---| +| `salon_manager` | Manages day-to-day operations; mapped to `tenant_manager` system role | +| `full_time` | Full-time employed staff; typically salary or hourly pay | +| `part_time` | Part-time employed staff; typically hourly pay | +| `seasonal` | Temporary seasonal staff; hourly or guarantee pay | +| `receptionist` | Front-desk staff; handles bookings and walk-ins; hourly or salary pay | + +**Staff pay types** (stored as `staff.pay_type`): + +| Pay Type | How it works | Commission | +|---|---|---| +| `hourly` | `hourly_rate` × total hours clocked in the pay period | Optional; added on top of base hourly pay | +| `salary` | Fixed `salary_amount` per `pay_period` regardless of hours | Optional; can be disabled via `commission_enabled = false` | +| `guarantee` | Guaranteed minimum `guarantee_amount` per period; if commission exceeds guarantee, staff earns commission only — no double-dipping | Always enabled; guarantee is the floor | + +Role enforcement: `@require_role(*roles)` decorator on every blueprint route. +Location enforcement: `load_location_context()` validates `tenant_staff` membership before setting `g.location`. + +--- + +## Subscription Plans + +| Plan | Monthly Price | Max Staff | Max Locations | Features | +|---|---|---|---|---| +| **Starter** | $29 | 3 | 1 | POS (cash + app + tips + gift cards), appointments, customers, services, promotions, appointment reminders, customer reviews, end-of-day reconciliation, basic reports | +| **Growth** | $59 | 10 | 3 | + Inventory, commission tracking, full reports, multi-location, online customer booking, waitlist | +| **Pro** | $99 | Unlimited | Unlimited | + Email marketing campaigns | + +### Plan Enforcement + +- `load_tenant_context()` checks `tenant.status` on every request: `active`, `trial`, `suspended`, `cancelled`. +- Feature flags in `plans.features_json`; enforced via `@tenant_feature_required('marketing')` decorator. +- Staff and location count limits enforced at creation time (checked against `plan.max_staff`, `plan.max_locations`). +- Suspended tenants → locked page with billing instructions. +- Trial: 14 days; APScheduler sends reminder emails at day 7 and day 13. +- `tenant_setting_overrides` can force-enable or force-disable features regardless of plan (superadmin only). + +--- + +## Project Structure + +``` +salon_pos/ +├── app/ +│ ├── __init__.py # App factory (create_app) +│ ├── extensions.py # db, login_manager, jwt, migrate, limiter, scheduler +│ ├── context.py # load_tenant_context(), load_location_context() +│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly +│ ├── security.py # HTTP headers middleware, input sanitiser helpers +│ ├── models/ +│ │ ├── platform.py # SystemUser, Tenant, Plan, BillingHistory, +│ │ │ # TenantSettingOverride, AuditLog +│ │ └── salon.py # Location, LocationSetting, User, Customer, +│ │ # Service, Product, Staff, StaffLocation, +│ │ # StaffSchedule, Appointment, Transaction, +│ │ # TransactionItem, Inventory, InventoryLog, +│ │ # CommissionLog, MarketingCampaign, +│ │ # GiftCard, Waitlist, CheckoutReview, +│ │ # DailyReconciliation, AppointmentReminder +│ │ +│ ├── admin/ # ← admin.mydomain.com +│ │ ├── __init__.py # create_admin_app() +│ │ ├── auth/ +│ │ ├── system_users/ +│ │ ├── tenants/ +│ │ ├── plans/ +│ │ ├── billing/ +│ │ ├── settings_override/ +│ │ ├── audit_log/ +│ │ └── analytics/ +│ │ +│ ├── tenant/ # ← mydomain.com (Jinja2 UI) +│ │ ├── __init__.py # create_tenant_app() +│ │ ├── auth/ # Email+password login, logout, password reset, demo shortcut +│ │ ├── staff_auth/ # Phone + passcode login / logout for tenant_staff +│ │ ├── checkin/ # Customer self check-in kiosk — no auth (/checkin/{slug}) +│ │ ├── dashboard/ +│ │ ├── locations/ # Location CRUD + switcher +│ │ ├── customers/ +│ │ ├── appointments/ +│ │ ├── services/ # Services + products menu + promotions +│ │ ├── pos/ # Checkout, void, receipts, rebook-at-checkout +│ │ ├── staff/ # Profiles, schedules, commission, passcode management +│ │ ├── staff_portal/ # Staff Portal: personal schedule, hours, commission, payments +│ │ ├── booking/ # Public customer booking — no auth (/book/{slug}) +│ │ ├── waitlist/ # Waitlist management +│ │ ├── gift_cards/ # Gift card issuance and management +│ │ ├── reviews/ # Review dashboard (owner view of ratings) +│ │ ├── reconciliation/ # End-of-day close + variance +│ │ ├── inventory/ +│ │ ├── marketing/ # Email campaigns (Pro plan) +│ │ ├── reports/ +│ │ └── settings/ +│ │ +│ ├── api/ # ← REST API (/api/v1/) +│ │ ├── __init__.py # register_api(app) +│ │ ├── auth/ # JWT issue / refresh / revoke +│ │ ├── v1/ +│ │ │ ├── locations.py +│ │ │ ├── customers.py +│ │ │ ├── appointments.py +│ │ │ ├── services.py +│ │ ├── promotions.py # Promotion CRUD + active promotion resolver +│ │ │ ├── pos.py +│ │ │ ├── staff.py +│ │ │ ├── staff_portal.py # Staff-facing personal data endpoints +│ │ │ ├── checkin.py # Kiosk check-in submission + queue push +│ │ │ ├── booking.py # Public booking availability + submission +│ │ │ ├── gift_cards.py # Gift card CRUD + redemption +│ │ │ ├── reviews.py # Review submission + owner dashboard +│ │ │ ├── reconciliation.py # End-of-day close + variance +│ │ │ ├── inventory.py +│ │ │ ├── reports.py +│ │ │ └── settings.py +│ │ └── errors.py # Standardised JSON error responses +│ │ +│ ├── templates/ +│ │ ├── admin/ +│ │ └── tenant/ +│ └── static/ +│ ├── admin/ +│ └── tenant/ +│ +├── migrations/ +├── config.py # Config: Dev, Prod, Test +├── wsgi_admin.py +├── wsgi_tenant.py +├── requirements.txt +├── .env.example +├── deploy/ +│ ├── salon_pos_admin.service +│ ├── salon_pos_tenant.service +│ ├── nginx.conf +│ └── backup/ +│ ├── db_backup.sh +│ └── backup.cron +└── tests/ + ├── test_admin_auth.py + ├── test_tenant_auth.py + ├── test_tenancy_isolation.py + ├── test_location_scoping.py + ├── test_demo_readonly.py + ├── test_settings_override.py + ├── test_pos.py + ├── test_api_v1.py + ├── test_staff_login.py + ├── test_promotions.py + ├── test_gift_cards.py + ├── test_reviews.py + ├── test_public_booking.py + ├── test_checkin_kiosk.py + ├── test_rebook_at_checkout.py + └── test_security_headers.py +``` + +> Two Flask app factories (`create_admin_app`, `create_tenant_app`) share models and database but have separate blueprint sets, login managers, JWT instances, and session cookies. Two independent Gunicorn processes, two Unix sockets. + +--- + +## Nginx Configuration + +```nginx +# ── Admin portal ──────────────────────────────────────────────── +server { + listen 443 ssl; + server_name admin.mydomain.com; + + ssl_certificate /etc/ssl/certs/mydomain.crt; + ssl_certificate_key /etc/ssl/private/mydomain.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # IP allowlist — office / VPN only + allow 203.0.113.0/24; + deny all; + + # Security headers + add_header X-Frame-Options "DENY" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + + location / { + proxy_pass http://unix:/run/salon_pos_admin.sock; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 5m; + } + + location /static/admin/ { + alias /opt/salon_pos/app/static/admin/; + expires 7d; + } +} + +# ── Tenant portal ──────────────────────────────────────────────── +server { + listen 443 ssl; + server_name mydomain.com; + + ssl_certificate /etc/ssl/certs/mydomain.crt; + ssl_certificate_key /etc/ssl/private/mydomain.key; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_ciphers HIGH:!aNULL:!MD5; + + # Security headers + add_header X-Frame-Options "SAMEORIGIN" always; + add_header X-Content-Type-Options "nosniff" always; + add_header Referrer-Policy "strict-origin-when-cross-origin" always; + add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always; + add_header Content-Security-Policy "default-src 'self'; script-src 'self'; style-src 'self' 'unsafe-inline'; img-src 'self' data:; frame-ancestors 'none';" always; + + location / { + proxy_pass http://unix:/run/salon_pos_tenant.sock; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + client_max_body_size 5m; + } + + location /static/tenant/ { + alias /opt/salon_pos/app/static/tenant/; + expires 7d; + } +} + +# ── HTTP → HTTPS redirect ──────────────────────────────────────── +server { + listen 80; + server_name admin.mydomain.com mydomain.com; + return 301 https://$host$request_uri; +} +``` + +--- + +## systemd Units + +### Admin portal — `salon_pos_admin.service` + +```ini +[Unit] +Description=Nails Salon POS — Admin Portal (Gunicorn) +After=network.target mysql.service + +[Service] +User=salonpos +WorkingDirectory=/opt/salon_pos +EnvironmentFile=/opt/salon_pos/.env +ExecStart=/opt/salon_pos/venv/bin/gunicorn \ + --workers 2 \ + --bind unix:/run/salon_pos_admin.sock \ + --timeout 120 \ + wsgi_admin:app +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +### Tenant portal — `salon_pos_tenant.service` + +```ini +[Unit] +Description=Nails Salon POS — Tenant Portal (Gunicorn) +After=network.target mysql.service + +[Service] +User=salonpos +WorkingDirectory=/opt/salon_pos +EnvironmentFile=/opt/salon_pos/.env +ExecStart=/opt/salon_pos/venv/bin/gunicorn \ + --workers 4 \ + --bind unix:/run/salon_pos_tenant.sock \ + --timeout 120 \ + wsgi_tenant:app +Restart=on-failure +RestartSec=5s + +[Install] +WantedBy=multi-user.target +``` + +--- + +## Development Phases + +### Phase 1 — Foundation +- [ ] Project scaffold: two app factories, shared extensions, config classes +- [ ] Shared models: `SystemUser`, `Tenant`, `Plan`, `TenantSettingOverride`, `AuditLog` +- [ ] Tenant-level models: `Location`, `LocationSetting`, `User` +- [ ] `load_tenant_context()` + `load_location_context()` hooks +- [ ] Admin portal auth (login/logout, brute-force lockout, `@require_role`) +- [ ] Tenant portal auth (login/logout, password reset, brute-force lockout) +- [ ] `@demo_readonly` decorator — blocks all write operations on the demo tenant +- [ ] Demo account: `mydomain.com/demo` shortcut, pre-seeded read-only data +- [ ] Database migrations baseline +- [ ] Two Gunicorn entrypoints + two systemd units + Nginx config (with security headers) + +### Phase 2 — Admin Portal +- [ ] System user management (CRUD, force password reset) +- [ ] Tenant management (create, edit, suspend, cancel, assign plan) +- [ ] Plan management (create/edit, feature flags, limits) +- [ ] Billing history (manual invoice entry, per-tenant view) +- [ ] Tenant settings override (set/lift with before/after audit trail) +- [ ] Audit log viewer (filter by actor, action, date; export) +- [ ] Platform analytics dashboard + +### Phase 3 — Multi-Location & Tenant Core Modules +- [ ] Location management (CRUD, primary flag, per-location settings) +- [ ] Location switcher UI + `tenant_staff` location restriction enforcement +- [ ] Staff ↔ location assignment (many-to-many) +- [ ] Staff passcode management (set at creation, reset by admin/manager, never stored in plaintext) +- [ ] Dashboard (KPIs scoped to active location) +- [ ] Customers (CRUD, visit history, loyalty, birthday, preferred staff, search) +- [ ] Services & products (tenant-wide catalogue) +- [ ] Promotions management (create/edit/deactivate promotions; percentage-off; date range; target specific or all services/products) +- [ ] Promotion engine: `get_active_promotion(item_id, item_type)` helper — called at checkout to resolve applicable promotion for each line item automatically +- [ ] Appointments (calendar, walk-in flag, status workflow, cancellation + no-show capture; no-show count incremented on customer record) +- [ ] Customer check-in kiosk (`/checkin/{slug}`; no auth; phone lookup or new profile; walk-in queued to `checkin_queue`; real-time dashboard alert to receptionist; auto-reset timer; rate limiting; `service_requested` selector) +- [ ] Online customer booking (`/book/{slug}`; no auth; availability check; confirmation email; auto-confirm toggle) +- [ ] Waitlist (join when slot full; auto-notify on cancellation; queue management in calendar) +- [ ] Staff Portal — `/staff/portal` (personal schedule, upcoming appointments, clock-in/clock-out, commission summary, payment history, read-only profile) +- [ ] POS / Checkout (tip field; gift card redemption; cash / Zelle / Venmo / CashApp / Other; auto-apply active promotions per line item; show original price + discount + final price; void with reason; receipt PDF with tip, promotional savings, gift card balance; email digital receipt option) +- [ ] Next-visit scheduling at checkout (optional rebook prompt after payment confirmed; date/time/staff picker; creates new `pending` appointment; `rebook_source = checkout`; next visit shown on receipt; 24h reminder auto-scheduled) +- [ ] Gift cards (issuance with unique code; POS redemption; balance tracking; expiry) +- [ ] End-of-day reconciliation (Close Day flow; cash count entry; variance calculation; `daily_reconciliations` record) + +### Phase 4 — Tenant Operations Modules +- [ ] Staff management (profiles, job type, system role, location assignments, schedules) +- [ ] Pay structure setup per staff member (pay type, rates, pay period, commission toggle) +- [ ] Commission tracking (per transaction, period summary; respects `commission_enabled` flag per staff) +- [ ] Working hours / clockings (clock-in at login or manual; clock-out; total hours computed per period) +- [ ] Pay period calculation engine: hourly (rate × hours), salary (fixed), guarantee (max of guarantee vs commission); results written to `staff_pay_periods` +- [ ] Pay period approval workflow (draft → approved → paid; `tenant_admin` approves) +- [ ] Inventory (per location: stock, reorder alerts, adjustment log) +- [ ] Automatic inventory deduction on POS sale +- [ ] Appointment reminder engine (APScheduler queues 24h + 2h email reminders on booking creation/update; cancels pending reminders on appointment cancellation; records in `appointment_reminders`) +- [ ] Customer review request engine (APScheduler sends review email X min post-checkout; smart routing — rating ≥ 4 shows Google/Facebook/Yelp links; rating < 4 goes to silent internal feedback; click-through tracking via redirect; one send per transaction enforced by `review_request_sent_at`) + +### Phase 5 — REST API +- [ ] JWT auth (issue, refresh, revoke; httpOnly cookie transport) +- [ ] `GET/POST/PUT/DELETE` endpoints for all core resources under `/api/v1/` +- [ ] Location-scoped API context (JWT payload: `tenant_id` + `location_id`) +- [ ] Staff Portal API endpoints (`/api/v1/staff-portal/schedule`, `/clockings`, `/commission`, `/payments`, `/pay-summary`) +- [ ] `@demo_readonly` enforced on API write endpoints +- [ ] Standardised JSON error responses +- [ ] API rate limiting (Flask-Limiter) +- [ ] OpenAPI/Swagger spec generation + +### Phase 6 — Reports, Marketing & Backup +- [ ] Revenue reports (by period, staff, service, location; CSV + PDF export) +- [ ] Promotion performance reports (discount given per promotion, revenue impact, usage count per period) +- [ ] Commission reports (by period, staff; CSV export) +- [ ] Staff pay period reports (base pay + commission + guarantee top-up per staff per period; CSV + PDF; used for payroll reference) +- [ ] Inventory reports (low stock, valuation per location) +- [ ] Tip reports (by staff, by period) +- [ ] Gift card reports (issued, redeemed, outstanding balance) +- [ ] No-show and cancellation reports (by customer, by staff, by period) +- [ ] Rebook rate report (% of checkouts with a next visit scheduled; by period, by staff) +- [ ] Check-in kiosk usage report (kiosk vs manual walk-in entries per day per location) +- [ ] Review analytics report (average rating, response rate, rating distribution, platform click-through) +- [ ] End-of-day reconciliation history report (variance trends by location) +- [ ] Email marketing campaigns (audience filter, composer, scheduled send, delivery tracking) +- [ ] APScheduler: trial reminders (day 7, day 13), monthly report emails, audit log purge job +- [ ] Platform analytics (MRR, churn, active tenants, trial conversions) +- [ ] `db_backup.sh` + `backup.cron` (daily 02:00, 30-day retention) + +### Phase 7 — Hardening & Deployment +- [ ] CSRF protection (Flask-WTF) on both portals +- [ ] Brute-force lockout verified end-to-end for both login flows (email and phone+passcode) +- [ ] Session idle timeout (30 min tenant portal, 60 min admin portal) +- [ ] File upload validation: `secure_filename`, extension allowlist, size cap (5 MB) +- [ ] Admin portal IP allowlist: Nginx + Flask double-check +- [ ] Separate `SESSION_COOKIE_NAME` per app +- [ ] Security headers verified: CSP, HSTS, X-Frame-Options, X-Content-Type-Options +- [ ] Automated tests (pytest): all suites including `test_security_headers.py`, `test_demo_readonly.py` +- [ ] Soft-delete verified across all tenant models (`deleted_at` filtering enforced; restore flow for admin and tenant_admin) +- [ ] No-show counter wired to customer profile and no-show/cancellation reports +- [ ] Tenant health dashboard in admin portal (last login, appointment volume trend, days to expiry, billing status) +- [ ] Backup restore drill: verify `salon_pos_*.sql.gz` restores cleanly to staging +- [ ] Deployment runbook (README): fresh install, migration, service management, backup setup + +--- + +## Security Architecture + +### Authentication + +| Concern | Measure | +|---|---| +| Password hashing | `bcrypt`, cost factor ≥ 12 | +| Password policy | Minimum 10 characters; must include uppercase, lowercase, digit | +| Brute-force lockout | 5 consecutive failures → account locked for 15 minutes; `failed_login_attempts` + `locked_until` columns on both `system_users` and `users` | +| Password reset | Time-limited token (1 hour); single-use; stored as `password_reset_token` (hashed) + `password_reset_expires_at` | +| Session cookies | `Secure`, `HttpOnly`, `SameSite=Lax`; separate `SESSION_COOKIE_NAME` per app | +| Session idle timeout | 30 min (tenant portal), 60 min (admin portal); enforced server-side via `PERMANENT_SESSION_LIFETIME` | +| JWT (API) | Short-lived access token (15 min); long-lived refresh token (7 days); transported in `httpOnly` cookies — never `localStorage` or `sessionStorage` | +| JWT revocation | Refresh token revocation via a `jwt_blocklist` table; checked on every refresh | +| Staff passcode | 4–6 digit PIN; bcrypt-hashed (same cost factor as passwords); set by `tenant_admin` at staff creation; never returned via API or displayed in UI after creation | +| Staff passcode reset | `tenant_admin` or `tenant_manager` can generate a new passcode; old hash immediately invalidated; new passcode shown once then discarded | +| Staff brute-force lockout | 5 failures on `/staff-login` → `passcode_locked_until` set for 15 minutes on the `staff` record; rate-limited at Nginx + Flask-Limiter level | + +### Authorisation + +| Concern | Measure | +|---|---| +| Role enforcement | `@require_role(*roles)` decorator on every blueprint route | +| Tenant isolation | `g.tenant` set from authenticated session; all queries filtered by `tenant_id` | +| Location isolation | `g.location` validated against `staff_locations` for `tenant_staff` role; 403 on violation | +| Feature gating | `@tenant_feature_required('flag')` decorator; flags from `plans.features_json` | +| Staff portal isolation | `tenant_staff` sessions routed exclusively to `/staff/portal/*`; `@require_role` blocks all owner/manager routes; staff cannot access other staff members' data | +| Kiosk endpoint security | `/checkin/{slug}` is CSRF-exempt (no session); tenant slug validated against known slugs (not guessable); rate-limited at 20 req/min per IP (Flask-Limiter); only `name`, `phone`, and `service_requested` accepted — all other fields ignored; phone number sanitised and validated before profile lookup | +| Demo read-only | `@demo_readonly` decorator returns 403 on any write operation for demo tenant | +| Superadmin IP gate | Nginx `allow`/`deny` + Flask checks `ADMIN_IP_ALLOWLIST` for double enforcement | + +### Input & Output Security + +| Concern | Measure | +|---|---| +| SQL injection | SQLAlchemy ORM with parameterized queries only; no raw string interpolation | +| XSS | Jinja2 auto-escaping enabled globally; explicit `|safe` filter never used on user input | +| CSRF | Flask-WTF CSRF tokens on all state-changing HTML forms; API uses JWT (stateless, CSRF-exempt) | +| Input validation | WTForms validators on all forms; custom sanitisers strip control characters; regex validation on slugs, setting keys, SKUs | +| File uploads | `werkzeug.utils.secure_filename`; extension allowlist (`jpg`, `jpeg`, `png`, `gif`); max 5 MB; stored outside web root | +| Output encoding | All user-generated content rendered via Jinja2 templates (auto-escaped); no `innerHTML` assignment in JS | + +### Transport & Infrastructure Security + +| Concern | Measure | +|---|---| +| TLS | TLSv1.2 + TLSv1.3 only; strong cipher suite; HSTS (`max-age=63072000`) | +| HTTP security headers | `X-Frame-Options`, `X-Content-Type-Options`, `Referrer-Policy`, `Strict-Transport-Security`, `Content-Security-Policy` — set in Nginx for both domains | +| Secrets management | All secrets in `.env` (mode 600); never committed to VCS; `.env.example` contains only placeholder values | +| Database credentials | Separate MySQL user per app (`salon_pos_app` for the application, `salon_pos_backup` for backups); principle of least privilege | +| Process isolation | Gunicorn runs as unprivileged `salonpos` user; no root access | +| Rate limiting | Flask-Limiter on all auth endpoints: 10 req/min on `/login`; 10 req/min on `/staff-login`; 5 req/hour on `/password-reset` | + +### Audit & Monitoring + +| Concern | Measure | +|---|---| +| Superadmin audit log | All create/edit/suspend/override actions logged with `before_json`, `after_json`, `ip_address`; append-only table | +| Audit log retention | 365-day retention; monthly purge job via APScheduler | +| Application logs | Gunicorn access + error logs to `/var/log/salon_pos_{admin,tenant}/`; log rotation via `logrotate` | +| Backup integrity | Monthly restore drill to staging environment; documented in deployment runbook | +| Failed login monitoring | `failed_login_attempts` queryable from admin portal; suspicious patterns visible in audit log | + +--- + +## Environment Variables (`.env.example`) + +``` +FLASK_ENV=production +SECRET_KEY=change-me-to-a-random-256-bit-key +ADMIN_SECRET_KEY=separate-key-for-admin-portal-sessions +DATABASE_URL=mysql+pymysql://salon_pos_app:password@localhost/salon_pos +MAIL_SERVER=smtp.example.com +MAIL_PORT=587 +MAIL_USE_TLS=true +MAIL_USERNAME=noreply@mydomain.com +MAIL_PASSWORD= +ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24 +ADMIN_DOMAIN=admin.mydomain.com +TENANT_DOMAIN=mydomain.com +DEMO_TENANT_SLUG=demo +BACKUP_DIR=/var/backups/salon_pos +BACKUP_RETAIN_DAYS=30 +SESSION_TIMEOUT_TENANT=1800 +SESSION_TIMEOUT_ADMIN=3600 +JWT_ACCESS_TOKEN_EXPIRES=900 +JWT_REFRESH_TOKEN_EXPIRES=604800 +MAX_LOGIN_ATTEMPTS=5 +LOGIN_LOCKOUT_MINUTES=15 +STAFF_PASSCODE_MIN_LENGTH=4 +STAFF_PASSCODE_MAX_LENGTH=6 +``` + +--- + +## Backup Strategy + +Daily automated `mysqldump` via cron. No external cloud dependency. + +**`deploy/backup/db_backup.sh`** +```bash +#!/bin/bash +set -euo pipefail +TIMESTAMP=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="${BACKUP_DIR:-/var/backups/salon_pos}" +DB_NAME="salon_pos" +RETAIN_DAYS="${BACKUP_RETAIN_DAYS:-30}" + +mkdir -p "$BACKUP_DIR" +mysqldump --defaults-file=/etc/mysql/backup.cnf "$DB_NAME" \ + | gzip > "$BACKUP_DIR/salon_pos_$TIMESTAMP.sql.gz" + +find "$BACKUP_DIR" -name "*.sql.gz" -mtime +"$RETAIN_DAYS" -delete +echo "[$(date)] Backup completed: salon_pos_$TIMESTAMP.sql.gz" +``` + +**`deploy/backup/backup.cron`** +``` +0 2 * * * salonpos /opt/salon_pos/deploy/backup/db_backup.sh >> /var/log/salon_pos_backup.log 2>&1 +``` + +MySQL backup credentials stored in `/etc/mysql/backup.cnf` (mode 600, owned by `salonpos`). Monthly restore drill to staging is mandatory — document results in the runbook. + +--- + +## Decisions Log + +| # | Decision | Resolution | +|---|---|---| +| 1 | Payment methods | Cash + app payments (Zelle, Venmo, CashApp) now. Stripe/card deferred to future phase. | +| 2 | Multi-location | `locations` table under one tenant. Tenant admin manages 1–N salons; active location via session switcher. | +| 3 | API layer | REST API (`/api/v1/`) alongside Jinja2 UI; JWT via httpOnly cookies. Required for future mobile/PWA. | +| 4 | Backup | Daily `mysqldump` + gzip via cron at 02:00. 30-day local retention. Monthly restore drill. | +| 5 | Marketing channel | Email campaigns now (SMTP). SMS (Twilio) deferred to future phase. | +| 6 | Tenant onboarding | Shared demo at `mydomain.com/demo` (read-only, pre-seeded, write-blocked by `@demo_readonly`). Official tenants provisioned by superadmin only. | +| 7 | Staff login | Separate `mydomain.com/staff-login` flow using phone number + 4–6 digit passcode. Passcode bcrypt-hashed, set by owner at registration. Staff land on a personal Staff Portal (schedule, hours, commission, payments). | +| 8 | Staff roles & pay | Job types: `salon_manager`, `full_time`, `part_time`, `seasonal`, `receptionist`. Pay types: `hourly` (rate × hours), `salary` (fixed per period), `guarantee` (minimum floor topped up by commission). Commission optional for hourly/salary; always active for guarantee. | +| 9 | Promotions | Services and products support percentage-off promotions with a defined start/end date window. The checkout engine resolves and applies active promotions automatically per line item — no manual entry needed. Original price, discount %, and final price are stored on each `transaction_item` for audit and reporting. | +| 10 | Online booking & waitlist | Public booking page at `/book/{slug}` (no auth; Growth + Pro plans). Waitlist joins when slots are full; auto-email notification on cancellation. | +| 11 | Tip tracking | Tip field at POS; attributed to serving staff; visible in Staff Portal and pay reports. Included in end-of-day reconciliation totals. | +| 12 | Gift cards | Issued by tenant_admin with unique code and set value; redeemed at POS as payment method; balance decremented per use; tracked in `gift_cards` table with expiry. | +| 13 | Customer reviews & reputation | Review request email sent X min after checkout (default 60 min). Rating ≥ 4 → public review links (Google, Facebook, Yelp); rating < 4 → private internal feedback only. One send per transaction. Platform URLs in salon settings. Click-through tracked. | +| 14 | Appointment reminders | Automated email 24h before appointment (+ optional 2h). Configurable per tenant. Reduces no-show rate. SMS deferred. | +| 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. | +| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. | +| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. | +| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and pushes a real-time alert to the receptionist dashboard. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. | +| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |