Files
2026-05-08 10:15:40 -04:00

72 KiB
Raw Permalink Blame History

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
posadmin.ngodanguyen.tech System Admins Platform management: system users, tenants, plan enforcement, settings override
pos.ngodanguyen.tech 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 — posadmin.ngodanguyen.tech (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 — pos.ngodanguyen.tech (Tenant Portal)

Served by a separate Nginx server block. All tenants share this single domain (pos.ngodanguyen.tech). The active tenant and location are resolved from the authenticated session.

mydomain.com exposes two distinct login flows:

URL Credentials Who uses it
pos.ngodanguyen.tech/login Email + password tenant_admin, tenant_manager
pos.ngodanguyen.tech/staff-login Phone number + passcode (46 digit PIN) tenant_staff — kiosk-style login
pos.ngodanguyen.tech/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), todays 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:
    • Todays 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 staffs 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 an alert is surfaced on the receptionists dashboard via 5-second polling (GET /api/v1/checkin/queue?status=waiting); 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 customers 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 Cardstenant_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 15 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 owners 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 + 46 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)

jwt_blocklist
  id, jti, created_at
  (stores revoked JWT refresh token JTIs; persisted to DB — survives Gunicorn worker restarts;
   checked on every /api/auth/refresh call; purged with expired tokens via APScheduler)

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: 1100; 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: 15 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

# ── 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

[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

[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 COMPLETE

  • Project scaffold: two app factories (create_admin_app, create_tenant_app), shared extensions.py, config.py (Dev/Prod/Test)
  • Platform models: SystemUser, Tenant, Plan, TenantBillingHistory, TenantSettingOverride, AuditLog, JWTBlocklist
  • Tenant-level models: all 26 models including Location, User, Staff, Appointment, Transaction, CheckinQueue, JWTBlocklist, etc.
  • load_tenant_context() + load_location_context() before-request hooks
  • Admin portal auth — login/logout, brute-force lockout (5 attempts → 15 min), password reset with time-limited token
  • Tenant portal auth — login/logout, password reset, brute-force lockout, demo login shortcut
  • Staff portal auth — phone + passcode login, per-staff brute-force lockout, logout
  • @require_role decorator — role enforcement on all routes
  • @tenant_feature_required decorator — plan feature flag gating
  • @demo_readonly decorator — blocks all write operations on demo tenant
  • app/security.py — security headers middleware (CSP with CDN allowance), IP allowlist, input sanitisers
  • app/decorators.py, app/context.py, app/forms.py — cross-cutting concerns
  • Base templates — admin portal (Bootstrap 5, sidebar nav) + tenant portal (Bootstrap 5, sidebar nav, location switcher)
  • Auth templates — login, password reset (request + confirm), account locked, staff PIN login, feature unavailable
  • Database migrations — circular FK resolved (appointmentstransactions via use_alter=True); db upgrade verified
  • wsgi_admin.py + wsgi_tenant.py Gunicorn entrypoints
  • deploy/salon_pos_admin.service + deploy/salon_pos_tenant.service systemd units
  • deploy/nginx.conf — both domains (posadmin.ngodanguyen.tech, pos.ngodanguyen.tech), TLS, CSP updated for Bootstrap CDN
  • deploy/backup/db_backup.sh + backup.cron — daily 02:00, 30-day retention
  • tests/conftest.py, test_admin_auth.py, test_tenant_auth.py, test_staff_login.py, test_tenancy_isolation.py, test_security_headers.py
  • README.md — full deployment runbook (fresh install, migrations, seeding, service management, backup)
  • Demo account pre-seeded data (deferred — requires Phase 2 tenant creation flow)

Phase 2 — Admin Portal COMPLETE

  • System user management (CRUD, force password reset, activate/deactivate)
  • Tenant management (create with owner account + primary location, edit, suspend/cancel/activate, assign plan)
  • Plan management (create/edit, feature flags via checkboxes, max staff/locations, activate/deactivate)
  • Billing history (manual invoice entry per tenant, global list view, per-tenant view)
  • Tenant settings override (set with note, lift individually, history view, before/after audit trail)
  • Audit log viewer (filter by actor/action/target/date, paginated, CSV export)
  • Platform analytics dashboard (MRR, active/trial/churn counts, revenue 30d, trials expiring soon, plan distribution)
  • app/admin/utils.py@superadmin_required, log_admin_action, model_to_dict shared helpers
  • All Phase 2 blueprints registered in create_admin_app() factory
  • Admin base template nav links wired to all Phase 2 routes
  • tests/test_admin_phase2.py — full test suite for all 7 modules
  • Demo account creation deferred — available via POST /tenants/new with is_demo=1

Phase 3 — Multi-Location & Tenant Core Modules COMPLETE

  • Location management (CRUD, primary flag, switch, plan limit check)
  • Location switcher UI + tenant_staff location restriction enforcement
  • Staff management (profiles, job type, location assignment, passcode set/reset)
  • Staff passcode management (validate_passcode helper in security.py)
  • Dashboard (Phase 3 KPIs: revenue, appointments, staff on shift, queue count, upcoming)
  • Customers (search, CRUD, visit history, no-show count, soft-delete)
  • Services & products (tenant-wide catalogue, soft-delete)
  • Promotions management (create, activate/deactivate, applies_to, target_ids_json)
  • Promotion engine: get_active_promotion() + apply_promotion_to_price() in app/tenant/utils.py
  • Appointments (calendar by day, create, edit, status workflow, no-show counter, cancellation)
  • Customer check-in kiosk (/checkin/; auto-profile creation; queue entry; rate-limited; CSRF-exempt)
  • Queue polling API: GET /api/v1/checkin/queue + POST /api/v1/checkin/queue//acknowledge
  • Online booking (/book/; no auth; availability check; confirmation email; tenant_feature_required)
  • Waitlist (add, notify via email, set booked/expired; tenant_feature_required)
  • Staff Portal (clock in/out, today's appointments, schedule, commission, profile)
  • POS/Checkout (services + products, auto-apply promotions, tip, gift card redemption, payment method)
  • Rebook at checkout (creates pending appointment + 24h reminder; linked to transaction)
  • Gift cards (unique code gen, issuance, balance lookup, POS redemption, deactivate)
  • Transaction void (reason required; gift card balance reversed)
  • End-of-day reconciliation (close day, cash count, variance, history)
  • Reviews dashboard (avg rating, distribution chart, list)
  • Settings (managed key/value pairs for booking, reviews, receipt)
  • Phase 4 stubs (inventory, marketing, reports) registered with feature_unavailable page
  • app/tenant/utils.py — log_tenant_action, plan_limit_check, get_active_promotion, apply_promotion_to_price
  • All 17 Phase 3 blueprints registered in create_tenant_app()
  • All nav links in tenant/layouts/base.html wired to Phase 3 routes
  • Both template trees (templates/ and app/templates/) in sync: 67 files each
  • All 46 render_template references verified against disk

Phase 3 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; receptionist alert via 5-second polling (GET /api/v1/checkin/queue?status=waiting); 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 COMPLETE

  • Pay structure per staff (hourly_rate, salary_amount, guarantee_amount, commission_rate, commission_enabled, pay_period — editable via staff form)
  • Pay period calculation engine (app/tenant/pay_periods/routes.py): hourly × hours, salary fixed, guarantee = max(guarantee, commission); results written to staff_pay_periods
  • Pay period approval workflow (draft → approved → paid; tenant_admin only)
  • Inventory (CRUD, reorder alerts, manual adjustment log; tenant_feature_required("inventory"))
  • Automatic inventory deduction on POS product sale (matched by SKU or name; InventoryLog entry created)
  • Appointment reminder engine (APScheduler job every 5 min; sends 24h + 2h email reminders; status=pending|sent|failed|cancelled)
  • Appointment reminders scheduled on appointment create (24h + 2h ahead)
  • Customer review request engine (APScheduler job every 10 min; delay per tenant setting; smart routing ≥4 stars shows platform links; one send enforced by review_request_sent_at)
  • app/scheduler_jobs.py — send_appointment_reminders(), send_review_requests()
  • Scheduler init in create_tenant_app() with SCHEDULER_API_ENABLED=False
  • pay_periods_bp + inventory_bp registered in tenant factory
  • Nav links wired: inventory → inventory.index, pay_periods → pay_periods.index
  • Both template trees in sync: 73 files each
  • Full validation passed: 6 imports OK, 0 missing templates, 0 illegal Jinja2, 0 broken extends

Phase 4 — Staff management (profiles, job type, system role, location assignments, schedules) (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 46 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-loginpasscode_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; receptionist dashboard alert delivered via 5-second JS polling (GET /api/v1/checkin/queue?status=waiting) — no WebSockets or SSE required
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 `
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@ngodanguyen.tech
MAIL_PASSWORD=
ADMIN_IP_ALLOWLIST=192.168.1.0/24,203.0.113.0/24
ADMIN_DOMAIN=posadmin.ngodanguyen.tech
TENANT_DOMAIN=pos.ngodanguyen.tech
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

#!/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.



Development Rules

The following rules are mandatory for all development on this codebase. Violations have caused production 500 errors.

Rule 1 — Never assume, always read first. Before fixing any error or touching any file, read the actual file content on disk. Do not assume the file matches what was previously written — the server may have a different version. Use cat, grep, or sed -n to read the exact content before making any changes.

Rule 2 — No temporary fixes. All fixes must address the root cause. Workarounds that mask a problem without solving it are not permitted. If the root cause is unclear, investigate further before writing any code.

Rule 3 — Never remove or change existing functionality unless explicitly instructed. All changes must be additive or corrective. Existing routes, function names, variable names, and model fields must be preserved unless a change is explicitly requested.

Rule 4 — Always audit the full extends chain in every template you create or modify. Every {% extends "..." %} path must resolve relative to the Flask app's template_folder root. Before delivering any template, verify the parent template exists at that exact path. Correct convention for this project: admin/layouts/base.html for admin templates, tenant/layouts/base.html for tenant templates.

Rule 5 — Never leave url_for() calls pointing at unregistered or stub-only endpoints. If a blueprint is registered as a stub (no routes yet), all url_for() references to its endpoints in templates must be replaced with '#' or guarded with {% if %} until the route is implemented. A BuildError from an unregistered endpoint is a 500 error in production.

Rule 6 — Always add logging for create, edit, and delete actions. Every route that creates, edits, deletes, or changes the status of a record must call logger.info() or logger.warning() with the action, relevant IDs, and actor context.

Rule 7 — All audit log values must be JSON-serialisable. When passing data to AuditLog.log() or any JSON column, always convert datetime/date to ISO-8601 strings via .isoformat() and Decimal to float. Use model_to_dict() from app/admin/utils.py — never pass raw ORM objects.

Rule 8 — Use absolute paths for template_folder and static_folder in app factories. Never use relative paths ("../templates") in Flask app factories. Flask resolves relative paths against the package directory, not the project root, which differs depending on where Gunicorn is started. Always use os.path.dirname(os.path.abspath(__file__)) as the anchor.

Rule 9 — Test all fixes before delivering them. Every fix must be validated by a programmatic test (import test, render test, or unit test) before being packaged for delivery. Do not ship code that has not been executed in the container.

Rule 10 — Pack files when more than 5 files are changed. When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.

Rule 11 — Never call Python builtins or stdlib objects inside Jinja2 {{ }} expressions. Jinja2 does not have access to Python's standard library or builtins such as set(), dict(), list(), int(), float(), str(), len(), sorted(), enumerate(), zip(), timedelta, datetime, date. Any computation involving these must be done in the route and passed as a named template variable. Violations produce UndefinedError in production. Examples of what NOT to do: (assigned_ids or set()), (view_date - timedelta(days=1)). Correct approach: compute prev_date = view_date - timedelta(days=1) in the route and pass prev_date=prev_date to render_template.

Rule 12 — Always pass every variable a template references, on every code path. Every render_template call for a given template must supply the same set of variables — including early-return error paths. If the template uses assigned_ids, every render_template("...form.html", ...) call in that view must include assigned_ids=.... Missing variables on error-return paths produce UndefinedError only when that path is hit, making them hard to catch in testing. When a fix touches more than 5 files, compress them into a ZIP for delivery. Always include only the changed files — not the entire project.


Phase 1 — Implementation Notes

The following decisions and resolutions were made during Phase 1 scaffold implementation.

Real-Time Alert Mechanism

Polling (5-second setInterval hitting GET /api/v1/checkin/queue?status=waiting) was chosen over SSE and WebSockets for the receptionist dashboard check-in alert. The rationale: the VPS has 4 GB RAM running two Gunicorn processes, MySQL, and Nginx. SSE requires async workers (gevent/eventlet) to avoid blocking Gunicorn worker slots; WebSockets requires Flask-SocketIO plus a message broker (Redis). Polling adds zero infrastructure overhead and the 5-second latency is acceptable for a front-desk use case.

Circular Foreign Key Resolution

appointments.rebooked_from_transaction_idtransactions and transactions.appointment_idappointments form a circular FK dependency that prevents MySQL InnoDB from creating either table. Both FKs are declared with use_alter=True, name="fk_..." so SQLAlchemy emits them as deferred ALTER TABLE statements after all tables are created. This is the correct permanent fix — not a workaround.

Dual App Factory — Static File Isolation

Each Flask app factory (create_admin_app, create_tenant_app) is configured with:

  • static_folder="../static/<portal>" — Flask serves directly from static/admin/ or static/tenant/
  • static_url_path="/static" — both portals use /static/css/... URLs with no portal-name prefix in the path

This eliminates the double-prefix bug (/static/admin/admin/css/...) that occurs when static_folder points at the parent static/ directory.

Content Security Policy — CDN Allowance

Bootstrap 5 CSS/JS and Bootstrap Icons are loaded from cdn.jsdelivr.net. The CSP must explicitly allow this CDN in script-src, style-src, and font-src directives. This is set in both app/security.py (Flask layer) and deploy/nginx.conf (Nginx layer, which takes precedence in production). Both must be consistent or Nginx will override Flask's permissive header with the restrictive one.

Admin Portal Login URL

The admin auth blueprint uses url_prefix="" (not /admin). The login page is at posadmin.ngodanguyen.tech/login. Visiting / redirects to /login for unauthenticated users and to /dashboard for authenticated ones.

JWT Blocklist Table Location

jwt_blocklist is defined in app/models/platform.py (not salon.py) because it is a platform-level concern — shared across all tenants, not scoped to any one salon. Refresh token revocation must survive Gunicorn worker restarts, so in-memory storage is not used.

Template Path Resolution

Both portal apps set template_folder to the project-root templates/ directory. All render_template() calls and {% extends %} directives must use the full path from that root:

  • Admin templates: "admin/auth/login.html", {% extends "admin/layouts/base.html" %}
  • Tenant templates: "tenant/auth/login.html", {% extends "tenant/layouts/base.html" %}

Template Authoring Checklist

Every new template must satisfy all of the following before delivery:

  1. File lives in templates/ (project root) — not app/templates/
  2. First line is {% extends "admin/layouts/base.html" %} (admin) or {% extends "tenant/layouts/base.html" %} (tenant)
  3. Every url_for('blueprint.endpoint') call references a route that is actually registered (not a stub). Stubs use '#'
  4. No bare {{ expression }} outside an HTML tag
  5. No {{ csrf_token() }} rendered as standalone text — only inside value="..." of a hidden input

Phase 2 Blueprint Stubs

All Phase 2+ blueprints are registered as stubs (blueprint object only, no routes) so the app boots cleanly. The admin portal base template references to url_for('tenants.index'), url_for('system_users.index') etc. are replaced with # until Phase 2 routes are implemented.


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 1N 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 + 46 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 surfaces an alert on the receptionist dashboard via 5-second polling (GET /api/v1/checkin/queue?status=waiting) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. 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.
20 Real-time alert mechanism 5-second polling chosen over SSE/WebSockets for receptionist check-in alerts. VPS has 4 GB RAM — SSE needs async Gunicorn workers; WebSockets needs Flask-SocketIO + Redis. Polling adds zero infrastructure overhead. Endpoint: GET /api/v1/checkin/queue?status=waiting.
21 Circular FK resolution appointments.rebooked_from_transaction_idtransactions and transactions.appointment_idappointments form a cycle. Both FKs use use_alter=True so SQLAlchemy defers them as ALTER TABLE statements post-creation.
22 Static file serving Each app factory points static_folder to static/admin/ or static/tenant/ directly, with static_url_path="/static". Eliminates double-prefix URL bug (/static/admin/admin/css/...).
23 Content Security Policy CSP allows cdn.jsdelivr.net in script-src, style-src, and font-src for Bootstrap 5 and Bootstrap Icons. Must be set consistently in both app/security.py and deploy/nginx.conf — Nginx overrides Flask headers in production.
24 Admin login URL Admin auth blueprint uses url_prefix="". Login page is at posadmin.ngodanguyen.tech/login. Root / redirects to /login (unauthenticated) or /dashboard (authenticated).
25 JWT blocklist table location jwt_blocklist defined in platform.py (not salon.py) — it is a platform-level concern shared across all tenants. DB-persisted (not in-memory) to survive Gunicorn worker restarts.
26 Template path convention template_folder points to project-root templates/. All render_template() calls and {% extends %} use full paths: "admin/auth/login.html", "admin/layouts/base.html", "tenant/auth/login.html", etc.
27 Production domains Admin portal: posadmin.ngodanguyen.tech. Tenant portal: pos.ngodanguyen.tech. Updated in .env, nginx.conf, and all documentation.
28 Authoritative template tree templates/ (project root) is the only authoritative template tree. The app factories use os.path.abspath(__file__) to resolve this path absolutely. app/templates/ exists as a mirror for legacy compatibility but templates/ is the source of truth. All new templates go in templates/ only.
29 Template extends convention All admin templates extend "admin/layouts/base.html". All tenant templates extend "tenant/layouts/base.html". These paths are relative to the templates/ root. Any other extends path ("layouts/base.html", "tenant/base.html", "admin/base.html") is wrong and will produce a 500.
30 Phase 3+ nav links in base template All Phase 3+ url_for() calls in templates/tenant/layouts/base.html are replaced with '#' until those blueprints are implemented. Leaving live url_for() calls pointing at stub-only blueprints causes BuildError 500s on every authenticated page load.