Files
MyPOS/CLAUDE.md
T

956 lines
63 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# CLAUDE.md — Nails Salon POS (Multi-Tenant SaaS)
## Project Overview
A multi-tenant SaaS POS web application for nail salons. The platform is split across **two dedicated domains**:
| Domain | Audience | Purpose |
|---|---|---|
| `posadmin.ngodanguyen.tech` | System Admins | Platform management: system users, tenants, plan enforcement, settings override |
| `pos.ngodanguyen.tech` | Tenant users | Salon management: staff, customers, bookings, POS, inventory, marketing, reports |
A **System Admin** manages the entire platform and can override any tenant's settings. Each **Tenant** (salon owner/manager/staff) operates exclusively within their own salon context, accessed via `mydomain.com/login` with tenant resolved from their credentials.
---
## Tech Stack
| Layer | Technology |
|---|---|
| Backend framework | Python 3.11+ / Flask |
| WSGI server | Gunicorn |
| Database | MySQL 8.0+ |
| ORM | SQLAlchemy + Flask-Migrate (Alembic) |
| Auth (UI) | Flask-Login + bcrypt |
| Auth (API) | JWT via Flask-JWT-Extended (httpOnly cookie transport) |
| Reverse proxy | Nginx |
| Process manager | systemd |
| Frontend | Jinja2 + Bootstrap 5 + vanilla JS |
| PDF/receipts | WeasyPrint |
| Scheduler | APScheduler (subscription reminders, reports, campaign sends) |
| Forms & CSRF | Flask-WTF |
| Rate limiting | Flask-Limiter (Redis or in-memory backend) |
| Input validation | WTForms validators + custom sanitisers |
| Environment config | python-dotenv |
---
## Domain Architecture
### Domain 1 — `posadmin.ngodanguyen.tech` (System Admin Portal)
Served by a dedicated Nginx `server` block. Access is restricted to users with the `superadmin` role. An IP allowlist at the Nginx level provides an additional layer of protection.
**Functionalities:**
- **System user management** — create, edit, deactivate superadmin accounts; role assignment; force password reset
- **Tenant management** — create, view, edit, suspend, cancel tenants; assign/change plans; view billing history; manual invoice entry
- **Tenant settings override** — view and override any tenant's salon settings (business name, hours, feature flags, commission rates, etc.) with a visible "override active" indicator; lift overrides individually or in bulk
- **Plan management** — create and edit subscription plans, feature flags, pricing, max staff/locations limits
- **Audit log** — immutable, append-only log of all superadmin actions (actor, action, target, timestamp, before/after values); 1-year retention
- **Platform analytics** — active tenants, MRR, trial conversions, churn rate, revenue trends
### Domain 2 — `pos.ngodanguyen.tech` (Tenant Portal)
Served by a separate Nginx `server` block. All tenants share this single domain (`pos.ngodanguyen.tech`). The active tenant and location are resolved from the authenticated session.
`mydomain.com` exposes **two distinct login flows**:
| URL | Credentials | Who uses it |
|---|---|---|
| `pos.ngodanguyen.tech/login` | Email + password | `tenant_admin`, `tenant_manager` |
| `pos.ngodanguyen.tech/staff-login` | Phone number + passcode (46 digit PIN) | `tenant_staff` — kiosk-style login |
| `pos.ngodanguyen.tech/checkin/{tenant_slug}` | No credentials — public kiosk page | Customer self check-in on iPad |
Staff login is designed for quick front-of-house use on a shared tablet. After authenticating, staff land on their personal **Staff Portal** — a simplified view scoped exclusively to their own data.
The **Customer Check-In Kiosk** (`mydomain.com/checkin/{tenant_slug}`) is a separate public-facing page with no authentication. It is designed to run fullscreen on a dedicated iPad at the front desk. The page auto-resets after each submission for the next customer.
**Functionalities (scoped to the authenticated user's tenant and active location):**
- **Dashboard** — KPIs scoped to active location: daily revenue, appointment count, staff on-shift, low-stock alerts, recent transactions, average review rating (last 30 days), todays tips total
- **Location management** *(tenant_admin only)* — create/edit/deactivate locations; set primary location; per-location settings (hours, timezone, contact info)
- **Location switcher** — persistent in nav bar; accessible to `tenant_admin` and `tenant_manager`; `tenant_staff` is locked to their assigned location(s)
- **Staff management** — profiles, job type (salon manager, full-time, part-time, seasonal, receptionist), system role, location assignments, pay structure (hourly rate / salary / guarantee pay), commission rate, schedules per location, active/inactive toggle; passcode set by `tenant_admin` at staff creation time; passcode reset by `tenant_admin` or `tenant_manager`
- **Staff Portal** *(accessed via `mydomain.com/staff-login` — phone number + passcode only)* — personal self-service view for `tenant_staff`:
- Todays appointment schedule (own bookings only, current location)
- Upcoming appointments (next 7 days)
- Working hours log (clock-in / clock-out per shift at active location)
- Pay summary (current period: base pay calculated from pay type + hours or salary period, commission earned, guarantee top-up if applicable)
- Commission summary (per-transaction breakdown)
- Payment history (transactions they personally served)
- Read-only personal profile (name, phone, assigned locations, schedule)
- No access to other staffs data, full customer PII, reports, inventory, or any settings
- **Customers** — tenant-wide profiles; visit history (location-aware); notes; loyalty points; birthday and preferred-staff tracking; customer search and merge
- **Services & products** — tenant-wide service menu (name, category, duration, price); product catalogue (SKU, category, sale price); enable/disable per location; promotions management (percentage-off discount on any service or product, with defined start/end dates; multiple active promotions supported simultaneously)
- **Customer Check-In Kiosk** — public iPad page at `mydomain.com/checkin/{tenant_slug}`; no login required; customer enters their name and phone number and optionally selects the service they are here for; system looks up the customer profile by phone (new profile created automatically if not found); a walk-in entry is queued and an alert is surfaced on the receptionists dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`); the iPad screen shows a confirmation message and resets automatically after 10 seconds for the next customer; rate-limited and CSRF-exempt (no session); tenant slug validated against allowlist to prevent enumeration
- **Bookings / Appointments** — calendar view per location; walk-in and advance bookings; status workflow (pending → confirmed → in-progress → completed → cancelled); cancellation reason capture; appointment notes
- **Online Customer Booking** — public booking page at `mydomain.com/book/{tenant_slug}` (no login required); customer selects service → preferred staff (optional) → available time slot → submits name + phone; appointment lands in calendar as `pending`; owner can configure auto-confirm; confirmation email sent to customer; available on Growth and Pro plans
- **Waitlist** — when a time slot is full, customers can join a waitlist for that slot or staff member; on cancellation the next waitlist entry is notified by email automatically; waitlist queue visible in the appointment calendar; SMS notification deferred to future phase
- **POS / Checkout** — service + product selection; tip amount field (cash or app); automatic promotion detection at checkout (active promotions applied instantly — no manual coupon entry required); original price, discount percentage, and discounted price shown clearly per line item; gift card redemption as payment method; manual additional discount still available for `tenant_admin` and `tenant_manager`; cash or app-based payment (Zelle, Venmo, CashApp, Other); `payment_reference` field for app transaction IDs; receipt generation (print/PDF) showing promotional savings, tip, and gift card balance; option to email digital receipt to customer on file; void transaction with reason; Card/Stripe deferred to a future phase
- **Next-Visit Scheduling at Checkout** — after payment is confirmed and before the receipt is printed, the receptionist is prompted with an optional “Schedule next visit?” step; they can book the customers next appointment directly from the checkout screen (same service, same staff, new date/time); the new appointment is created as `pending` (or `confirmed` if auto-confirm is enabled); the next visit date is printed on the current receipt and shown in the confirmation email; a 24-hour reminder is automatically scheduled for the rebooked appointment; if the customer declines, the step is skipped and the receipt proceeds normally; `rebook_source` field on `appointments` table records that the booking originated from a checkout rebook
- **Tip Tracking** — tip amount field at POS checkout (cash or app, same payment methods as transaction); tip attributed to the serving staff member; stored on `transactions.tip_amount`; visible in Staff Portal (own tips per transaction); factored into pay period summary and commission reports; owner-level tip report by staff and period
- **Gift 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 15 stars with optional comment; if rating ≥ 4 stars, a follow-up screen/email suggests leaving a public review on Google, Facebook, or Yelp (platform review URLs configured by owner in salon settings); if rating < 4 stars, private thank-you message shown and feedback goes silently to the owners dashboard for internal improvement; one review request per transaction; no-show and voided transactions excluded from review sends
- **Reports** — revenue (daily/weekly/monthly, by staff, by service, by location); tip reports (by staff, by period); commission summary; staff pay period reports (base pay, commission, guarantee top-up, total per staff); no-show and cancellation metrics (by customer, by staff); rebook rate (percentage of checkouts where a next visit was scheduled); check-in kiosk usage (walk-ins per day via kiosk vs manual entry); promotion performance; review analytics (average rating, response rate, rating distribution, platform click-through); end-of-day reconciliation variance history; inventory valuation and low-stock; all exportable as CSV and PDF
- **Salon settings** — business info, operating hours, notification preferences, receipt footer, logo upload; review platform URLs (Google, Facebook, Yelp); appointment reminder timing; review request delay; auto-confirm booking toggle; gift card expiry policy; kiosk check-in settings (enable/disable kiosk page, service list shown on kiosk, auto-reset timer duration)
> **Settings override:** Fields locked by a superadmin override display an "Override by admin" badge. The tenant cannot modify those fields until the override is lifted from the admin portal.
---
## Multi-Tenancy Architecture
### Strategy: Shared Database, Shared Schema (Row-Level Isolation)
Every tenant-owned table carries a `tenant_id` foreign key. All queries are automatically scoped via a Flask `g.tenant` context object set after authentication.
### Tenant & Location Resolution
**Email + password flow** (`mydomain.com/login`) — for `tenant_admin` and `tenant_manager`:
1. User submits email + password.
2. Flask-Login loads the `User` record; `user.tenant_id` stored in session.
3. `@before_request` hook calls `load_tenant_context()` → sets `g.tenant`; checks `tenant.status`.
4. `load_location_context()` resolves `g.location` from session (last selected) or defaults to primary location.
5. All downstream queries filter by `g.tenant.id` and `g.location.id`.
6. If `tenant.status` is `suspended` or `cancelled`, user is redirected to a locked page.
**Phone + passcode flow** (`mydomain.com/staff-login`) — for `tenant_staff`:
1. Staff submits phone number + 46 digit passcode.
2. Flask looks up the `Staff` record by `(phone, tenant_id)`; `tenant_id` is resolved from the phone number's owning tenant (phone is unique platform-wide within a tenant, not globally — lookup is by phone first, then tenant validated via the staff record).
3. Passcode verified against `staff.passcode_hash` (bcrypt).
4. Brute-force lockout: 5 failures → `passcode_locked_until` set for 15 minutes.
5. On success, a Flask-Login session is created scoped to the matched tenant and the staff's primary assigned location.
6. Staff are redirected to `/staff/portal` — the simplified personal dashboard.
7. `tenant_staff` sessions cannot access any owner/manager routes; `@require_role` enforces this.
On `admin.mydomain.com`, there is no `g.tenant` — all queries are unscoped platform-level queries.
### Data Isolation Rules
1. Every tenant-owned model MUST have `tenant_id` (FK → `tenants.id`, non-nullable, indexed).
2. Location-specific models MUST also carry `location_id` (FK → `locations.id`, indexed).
3. All blueprint query helpers MUST filter by `g.tenant.id` — never raw `Model.query.all()`.
4. Location-scoped queries additionally filter by `g.location.id`.
5. `tenant_staff` location access is enforced by checking `staff_locations` membership before `g.location` is set.
6. Superadmin routes bypass tenant scoping entirely.
7. Cross-tenant data access is only permitted from the superadmin context.
8. Settings overrides in `tenant_setting_overrides` take precedence over `tenant_settings` at the application layer.
9. Demo tenant is write-protected at the application layer: any `POST`/`PUT`/`DELETE` request on a demo session returns 403 with a "Demo account is read-only" message.
---
## Database Schema (High-Level)
### Platform-Level Tables (superadmin scope)
```
system_users
id, email, password_hash, name, role, is_active,
failed_login_attempts, locked_until,
password_reset_token, password_reset_expires_at,
last_login_at, created_at
plans
id, name, price_monthly, max_staff, max_locations,
features_json, is_active
tenants
id, slug, name, owner_email, plan_id, status,
trial_ends_at, subscription_expires_at,
is_demo, created_at, updated_at
tenant_billing_history
id, tenant_id, amount, description, paid_at, invoice_ref, recorded_by
tenant_setting_overrides
id, tenant_id, setting_key, setting_value,
overridden_by, overridden_at, lifted_at, note
audit_log
id, actor_id, actor_type, action, target_type, target_id,
before_json, after_json, ip_address, created_at
(append-only; no UPDATE or DELETE ever issued against this table)
(retention: purge records older than 365 days via monthly APScheduler job)
jwt_blocklist
id, jti, created_at
(stores revoked JWT refresh token JTIs; persisted to DB — survives Gunicorn worker restarts;
checked on every /api/auth/refresh call; purged with expired tokens via APScheduler)
```
### Tenant-Level Tables (all carry tenant_id)
> **Soft Delete Convention:** All tenant models include a `deleted_at` timestamp (nullable). Queries always filter `WHERE deleted_at IS NULL`. Hard deletes are never issued from application code — only via a superadmin maintenance tool. Admin and tenant_admin can restore soft-deleted records.
```
users
id, tenant_id, email, password_hash, role, is_active,
failed_login_attempts, locked_until,
password_reset_token, password_reset_expires_at,
last_login_at, created_at
tenant_settings id, tenant_id, setting_key, setting_value
locations
id, tenant_id, name, address, phone, email,
timezone, is_active, is_primary, created_at
location_settings id, tenant_id, location_id, setting_key, setting_value
customers
id, tenant_id, name, phone, email, date_of_birth,
preferred_staff_id, notes, loyalty_points,
is_active, no_show_count, created_at, deleted_at
services id, tenant_id, name, category, duration_min, price, is_active, deleted_at
products id, tenant_id, name, sku, category, sale_price, is_active, deleted_at
promotions id, tenant_id, name, discount_percent,
applies_to, target_ids_json,
starts_at, ends_at, is_active, created_by, created_at
(applies_to: 'service' | 'product' | 'all_services' | 'all_products' | 'all')
(target_ids_json: list of service/product IDs when applies_to is 'service' or 'product';
null when applies_to is 'all_services', 'all_products', or 'all')
(discount_percent: 1100; e.g. 20 = 20% off)
(active window: starts_at <= NOW() <= ends_at AND is_active = true)
staff id, tenant_id, user_id(FK), name, phone, passcode_hash, deleted_at,
staff_type, pay_type, hourly_rate, salary_amount,
guarantee_amount, pay_period,
commission_rate, commission_enabled,
is_active, passcode_failed_attempts, passcode_locked_until
(staff_type: 'salon_manager' | 'full_time' | 'part_time' | 'seasonal' | 'receptionist')
(pay_type: 'hourly' | 'salary' | 'guarantee')
(hourly_rate: applicable when pay_type = 'hourly'; rate × hours from staff_clockings)
(salary_amount: fixed gross per pay_period when pay_type = 'salary')
(guarantee_amount: minimum guaranteed per pay_period; commission tops it up if higher)
(pay_period: 'weekly' | 'biweekly' | 'monthly')
(commission_enabled: boolean; can be disabled for salaried staff)
(phone: unique within tenant; used for staff-login)
(passcode_hash: bcrypt-hashed 4-6 digit PIN set by owner)
staff_locations id, tenant_id, staff_id, location_id
staff_schedules id, tenant_id, staff_id, location_id, day_of_week, start_time, end_time
appointments
id, tenant_id, location_id, customer_id, staff_id, service_id,
start_time, end_time, is_walk_in, status,
notes, cancellation_reason, cancelled_at,
rebook_source, rebooked_from_transaction_id,
created_by, created_at
(status: 'pending' | 'confirmed' | 'in_progress' | 'completed' | 'cancelled' | 'no_show')
(rebook_source: 'checkout' | 'online' | 'manual' | 'kiosk'; nullable — identifies booking origin)
(rebooked_from_transaction_id: FK → transactions.id; nullable — links rebook to its source checkout)
transactions
id, tenant_id, location_id, appointment_id, customer_id, staff_id,
subtotal, discount, tip_amount, gift_card_amount, total,
payment_method, payment_reference, gift_card_id,
review_request_sent_at,
voided_at, voided_by, void_reason, created_at
(payment_method: 'cash' | 'zelle' | 'venmo' | 'cashapp' | 'gift_card' | 'other')
(payment_reference: app transaction ID or descriptive note, nullable)
(gift_card_id: FK → gift_cards.id; nullable)
(review_request_sent_at: timestamp; NULL = not yet sent; prevents duplicate sends)
transaction_items id, transaction_id, service_id, product_id, qty,
unit_price, original_price, discount_percent, promotion_id
(unit_price: final price after promotion; original_price: price before promotion)
(promotion_id: FK → promotions.id; null if no promotion applied)
inventory
id, tenant_id, location_id, name, sku, category,
qty_on_hand, reorder_level, cost_price, sale_price
inventory_log id, tenant_id, location_id, inventory_id, delta, reason, created_at
commission_log id, tenant_id, location_id, staff_id, transaction_id, amount, period
staff_pay_periods id, tenant_id, staff_id, period_start, period_end,
pay_type, base_amount, commission_amount,
guarantee_topup, total_amount, status, notes
(base_amount: salary, or hourly_rate × total hours, for the period)
(guarantee_topup: max(0, guarantee_amount - commission_amount) when pay_type = 'guarantee')
(status: 'draft' | 'approved' | 'paid')
staff_clockings id, tenant_id, location_id, staff_id,
clocked_in_at, clocked_out_at, total_minutes, notes
(one row per shift; clocked_out_at NULL = currently clocked in)
marketing_campaigns
id, tenant_id, name, channel, status, audience_filter_json,
subject, message_body, scheduled_at, sent_at,
sent_count, open_count
(channel: 'email'; 'sms' in future phase)
(status: 'draft' | 'scheduled' | 'sending' | 'sent' | 'cancelled')
gift_cards id, tenant_id, code, original_value, remaining_balance,
issued_by, issued_to_customer_id, expires_at,
is_active, created_at
(code: unique per tenant; generated on issuance)
checkin_queue id, tenant_id, location_id, customer_id,
customer_name, customer_phone, service_requested,
checked_in_at, status, acknowledged_by, acknowledged_at
(status: 'waiting' | 'acknowledged' | 'seated' | 'expired')
(customer_id: FK → customers.id; nullable if new customer not yet profiled)
(service_requested: free-text from kiosk service selector; nullable)
(acknowledged_by: FK → users.id; the receptionist who handled the alert)
waitlist id, tenant_id, location_id, customer_name, customer_phone,
customer_email, staff_id, service_id,
requested_date, status, notified_at, created_at
(status: 'waiting' | 'notified' | 'booked' | 'expired')
checkout_reviews id, tenant_id, location_id, transaction_id, customer_id,
staff_id, rating, comment, is_public_suggested,
google_clicked, facebook_clicked, yelp_clicked,
created_at
(rating: 15 integer)
(is_public_suggested: true if rating ≥ 4 — platform links were shown)
(google/facebook/yelp_clicked: tracked via redirect link for analytics)
daily_reconciliations
id, tenant_id, location_id, date, total_cash, total_app_payments,
total_tips, total_gift_card_redemptions, expected_cash_in_drawer,
actual_cash_counted, variance, closed_by, closed_at, notes
(variance: actual_cash_counted - expected_cash_in_drawer; negative = shortage)
appointment_reminders
id, tenant_id, location_id, appointment_id, reminder_type,
scheduled_for, sent_at, channel, status
(reminder_type: '24h' | '2h')
(channel: 'email'; 'sms' in future phase)
(status: 'pending' | 'sent' | 'failed' | 'cancelled')
```
---
## User Roles & Access Control
### System Admin Domain (`admin.mydomain.com`)
| Role | Capabilities |
|---|---|
| `superadmin` | Full platform access: system users, tenants, plans, billing, settings override, audit log, analytics |
> Future: a `support_agent` role (read-only tenant access) for helpdesk use.
### Tenant Domain (`mydomain.com`)
**System roles** control application access. **Staff job types** describe employment classification. They are independent — the mapping below is the convention to follow:
| System Role | Staff Job Types | Location Access | Capabilities |
|---|---|---|---|
| `tenant_admin` | Salon owner | All locations | Full salon access: all modules, settings, location management, user management; sets/resets staff passcodes |
| `tenant_manager` | `salon_manager` | All locations | Appointments, POS, customers, inventory, reports; no settings, no user management; can reset staff passcodes |
| `tenant_staff` | `full_time`, `part_time`, `seasonal`, `receptionist` | Assigned locations only | Staff Portal only (via phone + passcode login): own schedule, appointments, working hours, pay summary, commission, payment history; no access to manager/admin routes |
**Staff job types** (stored as `staff.staff_type`):
| Job Type | Description |
|---|---|
| `salon_manager` | Manages day-to-day operations; mapped to `tenant_manager` system role |
| `full_time` | Full-time employed staff; typically salary or hourly pay |
| `part_time` | Part-time employed staff; typically hourly pay |
| `seasonal` | Temporary seasonal staff; hourly or guarantee pay |
| `receptionist` | Front-desk staff; handles bookings and walk-ins; hourly or salary pay |
**Staff pay types** (stored as `staff.pay_type`):
| Pay Type | How it works | Commission |
|---|---|---|
| `hourly` | `hourly_rate` × total hours clocked in the pay period | Optional; added on top of base hourly pay |
| `salary` | Fixed `salary_amount` per `pay_period` regardless of hours | Optional; can be disabled via `commission_enabled = false` |
| `guarantee` | Guaranteed minimum `guarantee_amount` per period; if commission exceeds guarantee, staff earns commission only — no double-dipping | Always enabled; guarantee is the floor |
Role enforcement: `@require_role(*roles)` decorator on every blueprint route.
Location enforcement: `load_location_context()` validates `tenant_staff` membership before setting `g.location`.
---
## Subscription Plans
| Plan | Monthly Price | Max Staff | Max Locations | Features |
|---|---|---|---|---|
| **Starter** | $29 | 3 | 1 | POS (cash + app + tips + gift cards), appointments, customers, services, promotions, appointment reminders, customer reviews, end-of-day reconciliation, basic reports |
| **Growth** | $59 | 10 | 3 | + Inventory, commission tracking, full reports, multi-location, online customer booking, waitlist |
| **Pro** | $99 | Unlimited | Unlimited | + Email marketing campaigns |
### Plan Enforcement
- `load_tenant_context()` checks `tenant.status` on every request: `active`, `trial`, `suspended`, `cancelled`.
- Feature flags in `plans.features_json`; enforced via `@tenant_feature_required('marketing')` decorator.
- Staff and location count limits enforced at creation time (checked against `plan.max_staff`, `plan.max_locations`).
- Suspended tenants → locked page with billing instructions.
- Trial: 14 days; APScheduler sends reminder emails at day 7 and day 13.
- `tenant_setting_overrides` can force-enable or force-disable features regardless of plan (superadmin only).
---
## Project Structure
```
salon_pos/
├── app/
│ ├── __init__.py # App factory (create_app)
│ ├── extensions.py # db, login_manager, jwt, migrate, limiter, scheduler
│ ├── context.py # load_tenant_context(), load_location_context()
│ ├── decorators.py # @require_role, @tenant_feature_required, @demo_readonly
│ ├── security.py # HTTP headers middleware, input sanitiser helpers
│ ├── models/
│ │ ├── platform.py # SystemUser, Tenant, Plan, BillingHistory,
│ │ │ # TenantSettingOverride, AuditLog
│ │ └── salon.py # Location, LocationSetting, User, Customer,
│ │ # Service, Product, Staff, StaffLocation,
│ │ # StaffSchedule, Appointment, Transaction,
│ │ # TransactionItem, Inventory, InventoryLog,
│ │ # CommissionLog, MarketingCampaign,
│ │ # GiftCard, Waitlist, CheckoutReview,
│ │ # DailyReconciliation, AppointmentReminder
│ │
│ ├── admin/ # ← admin.mydomain.com
│ │ ├── __init__.py # create_admin_app()
│ │ ├── auth/
│ │ ├── system_users/
│ │ ├── tenants/
│ │ ├── plans/
│ │ ├── billing/
│ │ ├── settings_override/
│ │ ├── audit_log/
│ │ └── analytics/
│ │
│ ├── tenant/ # ← mydomain.com (Jinja2 UI)
│ │ ├── __init__.py # create_tenant_app()
│ │ ├── auth/ # Email+password login, logout, password reset, demo shortcut
│ │ ├── staff_auth/ # Phone + passcode login / logout for tenant_staff
│ │ ├── checkin/ # Customer self check-in kiosk — no auth (/checkin/{slug})
│ │ ├── dashboard/
│ │ ├── locations/ # Location CRUD + switcher
│ │ ├── customers/
│ │ ├── appointments/
│ │ ├── services/ # Services + products menu + promotions
│ │ ├── pos/ # Checkout, void, receipts, rebook-at-checkout
│ │ ├── staff/ # Profiles, schedules, commission, passcode management
│ │ ├── staff_portal/ # Staff Portal: personal schedule, hours, commission, payments
│ │ ├── booking/ # Public customer booking — no auth (/book/{slug})
│ │ ├── waitlist/ # Waitlist management
│ │ ├── gift_cards/ # Gift card issuance and management
│ │ ├── reviews/ # Review dashboard (owner view of ratings)
│ │ ├── reconciliation/ # End-of-day close + variance
│ │ ├── inventory/
│ │ ├── marketing/ # Email campaigns (Pro plan)
│ │ ├── reports/
│ │ └── settings/
│ │
│ ├── api/ # ← REST API (/api/v1/)
│ │ ├── __init__.py # register_api(app)
│ │ ├── auth/ # JWT issue / refresh / revoke
│ │ ├── v1/
│ │ │ ├── locations.py
│ │ │ ├── customers.py
│ │ │ ├── appointments.py
│ │ │ ├── services.py
│ │ ├── promotions.py # Promotion CRUD + active promotion resolver
│ │ │ ├── pos.py
│ │ │ ├── staff.py
│ │ │ ├── staff_portal.py # Staff-facing personal data endpoints
│ │ │ ├── checkin.py # Kiosk check-in submission + queue push
│ │ │ ├── booking.py # Public booking availability + submission
│ │ │ ├── gift_cards.py # Gift card CRUD + redemption
│ │ │ ├── reviews.py # Review submission + owner dashboard
│ │ │ ├── reconciliation.py # End-of-day close + variance
│ │ │ ├── inventory.py
│ │ │ ├── reports.py
│ │ │ └── settings.py
│ │ └── errors.py # Standardised JSON error responses
│ │
│ ├── templates/
│ │ ├── admin/
│ │ └── tenant/
│ └── static/
│ ├── admin/
│ └── tenant/
├── migrations/
├── config.py # Config: Dev, Prod, Test
├── wsgi_admin.py
├── wsgi_tenant.py
├── requirements.txt
├── .env.example
├── deploy/
│ ├── salon_pos_admin.service
│ ├── salon_pos_tenant.service
│ ├── nginx.conf
│ └── backup/
│ ├── db_backup.sh
│ └── backup.cron
└── tests/
├── test_admin_auth.py
├── test_tenant_auth.py
├── test_tenancy_isolation.py
├── test_location_scoping.py
├── test_demo_readonly.py
├── test_settings_override.py
├── test_pos.py
├── test_api_v1.py
├── test_staff_login.py
├── test_promotions.py
├── test_gift_cards.py
├── test_reviews.py
├── test_public_booking.py
├── test_checkin_kiosk.py
├── test_rebook_at_checkout.py
└── test_security_headers.py
```
> Two Flask app factories (`create_admin_app`, `create_tenant_app`) share models and database but have separate blueprint sets, login managers, JWT instances, and session cookies. Two independent Gunicorn processes, two Unix sockets.
---
## Nginx Configuration
```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 | 46 digit PIN; bcrypt-hashed (same cost factor as passwords); set by `tenant_admin` at staff creation; never returned via API or displayed in UI after creation |
| Staff passcode reset | `tenant_admin` or `tenant_manager` can generate a new passcode; old hash immediately invalidated; new passcode shown once then discarded |
| Staff brute-force lockout | 5 failures on `/staff-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.
---
---
## 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 1N salons; active location via session switcher. |
| 3 | API layer | REST API (`/api/v1/`) alongside Jinja2 UI; JWT via httpOnly cookies. Required for future mobile/PWA. |
| 4 | Backup | Daily `mysqldump` + gzip via cron at 02:00. 30-day local retention. Monthly restore drill. |
| 5 | Marketing channel | Email campaigns now (SMTP). SMS (Twilio) deferred to future phase. |
| 6 | Tenant onboarding | Shared demo at `mydomain.com/demo` (read-only, pre-seeded, write-blocked by `@demo_readonly`). Official tenants provisioned by superadmin only. |
| 7 | Staff login | Separate `mydomain.com/staff-login` flow using phone number + 46 digit passcode. Passcode bcrypt-hashed, set by owner at registration. Staff land on a personal Staff Portal (schedule, hours, commission, payments). |
| 8 | Staff roles & pay | Job types: `salon_manager`, `full_time`, `part_time`, `seasonal`, `receptionist`. Pay types: `hourly` (rate × hours), `salary` (fixed per period), `guarantee` (minimum floor topped up by commission). Commission optional for hourly/salary; always active for guarantee. |
| 9 | Promotions | Services and products support percentage-off promotions with a defined start/end date window. The checkout engine resolves and applies active promotions automatically per line item — no manual entry needed. Original price, discount %, and final price are stored on each `transaction_item` for audit and reporting. |
| 10 | Online booking & waitlist | Public booking page at `/book/{slug}` (no auth; Growth + Pro plans). Waitlist joins when slots are full; auto-email notification on cancellation. |
| 11 | Tip tracking | Tip field at POS; attributed to serving staff; visible in Staff Portal and pay reports. Included in end-of-day reconciliation totals. |
| 12 | Gift cards | Issued by tenant_admin with unique code and set value; redeemed at POS as payment method; balance decremented per use; tracked in `gift_cards` table with expiry. |
| 13 | Customer reviews & reputation | Review request email sent X min after checkout (default 60 min). Rating ≥ 4 → public review links (Google, Facebook, Yelp); rating < 4 → private internal feedback only. One send per transaction. Platform URLs in salon settings. Click-through tracked. |
| 14 | Appointment reminders | Automated email 24h before appointment (+ optional 2h). Configurable per tenant. Reduces no-show rate. SMS deferred. |
| 15 | End-of-day reconciliation | Close Day action; cash + app + tip totals vs actual cash counted; variance flagged; stored in `daily_reconciliations`. |
| 16 | Soft delete | All tenant models carry `deleted_at`. Queries filter `WHERE deleted_at IS NULL`. No hard deletes from application code. Restore available to admin and tenant_admin. |
| 17 | Tenant health dashboard | Superadmin portal shows per-tenant signals: last login, appointment volume trend, days to subscription expiry, billing issues. |
| 18 | Customer check-in kiosk | Dedicated iPad page at `/checkin/{slug}` (no auth). Customer enters name + phone, optionally selects service. System looks up or creates the customer profile, queues a walk-in entry in `checkin_queue`, and surfaces an alert on the receptionist dashboard via **5-second polling** (`GET /api/v1/checkin/queue?status=waiting`) — chosen over SSE/WebSockets to minimise resource usage on a constrained VPS. Page auto-resets after 10 seconds. Rate-limited; slug allowlist prevents enumeration. |
| 19 | Next-visit scheduling at checkout | Optional rebook prompt after payment is confirmed. Receptionist picks date, time, and staff for the next appointment from the checkout screen. New appointment created as `pending` with `rebook_source = checkout`. Next visit date printed on receipt and confirmation email. 24-hour reminder auto-scheduled. Customer can decline — step is skipped gracefully. |
| 20 | Real-time alert mechanism | 5-second polling chosen over SSE/WebSockets for receptionist check-in alerts. VPS has 4 GB RAM — SSE needs async Gunicorn workers; WebSockets needs Flask-SocketIO + Redis. Polling adds zero infrastructure overhead. Endpoint: `GET /api/v1/checkin/queue?status=waiting`. |
| 21 | Circular FK resolution | `appointments.rebooked_from_transaction_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. |