54 KiB
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_adminandtenant_manager;tenant_staffis 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_adminat staff creation time; passcode reset bytenant_adminortenant_manager - Staff Portal (accessed via
mydomain.com/staff-login— phone number + passcode only) — personal self-service view fortenant_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 aspending; 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_adminandtenant_manager; cash or app-based payment (Zelle, Venmo, CashApp, Other);payment_referencefield 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(orconfirmedif 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_sourcefield onappointmentstable 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_adminissues 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_cardstable 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_reconciliationstable; variance report visible totenant_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:
- User submits email + password.
- Flask-Login loads the
Userrecord;user.tenant_idstored in session. @before_requesthook callsload_tenant_context()→ setsg.tenant; checkstenant.status.load_location_context()resolvesg.locationfrom session (last selected) or defaults to primary location.- All downstream queries filter by
g.tenant.idandg.location.id. - If
tenant.statusissuspendedorcancelled, user is redirected to a locked page.
Phone + passcode flow (mydomain.com/staff-login) — for tenant_staff:
- Staff submits phone number + 4–6 digit passcode.
- Flask looks up the
Staffrecord by(phone, tenant_id);tenant_idis 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). - Passcode verified against
staff.passcode_hash(bcrypt). - Brute-force lockout: 5 failures →
passcode_locked_untilset for 15 minutes. - On success, a Flask-Login session is created scoped to the matched tenant and the staff's primary assigned location.
- Staff are redirected to
/staff/portal— the simplified personal dashboard. tenant_staffsessions cannot access any owner/manager routes;@require_roleenforces this.
On admin.mydomain.com, there is no g.tenant — all queries are unscoped platform-level queries.
Data Isolation Rules
- Every tenant-owned model MUST have
tenant_id(FK →tenants.id, non-nullable, indexed). - Location-specific models MUST also carry
location_id(FK →locations.id, indexed). - All blueprint query helpers MUST filter by
g.tenant.id— never rawModel.query.all(). - Location-scoped queries additionally filter by
g.location.id. tenant_stafflocation access is enforced by checkingstaff_locationsmembership beforeg.locationis set.- Superadmin routes bypass tenant scoping entirely.
- Cross-tenant data access is only permitted from the superadmin context.
- Settings overrides in
tenant_setting_overridestake precedence overtenant_settingsat the application layer. - Demo tenant is write-protected at the application layer: any
POST/PUT/DELETErequest 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_attimestamp (nullable). Queries always filterWHERE 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_agentrole (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()checkstenant.statuson 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_overridescan 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
- 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_readonlydecorator — blocks all write operations on the demo tenant- Demo account:
mydomain.com/demoshortcut, 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_stafflocation 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 tocheckin_queue; real-time dashboard alert to receptionist; auto-reset timer; rate limiting;service_requestedselector) - 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
pendingappointment;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_reconciliationsrecord)
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_enabledflag 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_adminapproves) - 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/DELETEendpoints 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_readonlyenforced 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_NAMEper 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_atfiltering 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.gzrestores 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 ` |
| 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
#!/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. |