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