2026-05-07 11:09:06 -04:00
2026-05-07 11:09:06 -04:00
2026-05-06 16:09:18 -04:00
2026-05-06 16:32:31 -04:00
2026-05-06 14:19:07 -04:00
2026-05-07 11:09:06 -04:00
2026-05-06 14:19:07 -04:00
2026-05-05 12:25:20 -04:00
2026-05-06 14:19:07 -04:00
2026-05-06 17:24:02 -04:00
2026-05-06 14:19:07 -04:00
2026-05-06 14:19:07 -04:00
2026-05-06 14:19:07 -04:00

Salon POS — Multi-Tenant SaaS

Overview

A multi-tenant SaaS POS web application for nail salons, served across two dedicated domains:

Domain Audience Purpose
posadmin.ngodanguyen.tech System Admins Platform management
pos.ngodanguyen.tech Tenant users Salon management

Tech Stack

  • Backend: Python 3.11+ / Flask
  • Database: MySQL 8.0+ with SQLAlchemy ORM
  • Auth: Flask-Login (UI) + Flask-JWT-Extended (API)
  • Frontend: Jinja2 + Bootstrap 5 + Vanilla JS
  • WSGI: Gunicorn (2 independent processes)
  • Reverse Proxy: Nginx
  • Scheduler: APScheduler
  • Process Manager: systemd

Fresh Install (Production)

1. System dependencies

sudo apt update && sudo apt install -y python3.11 python3.11-venv python3-pip \
    mysql-server nginx libmysqlclient-dev build-essential libssl-dev

2. Application user

sudo useradd -m -s /bin/bash salonpos
sudo mkdir -p /opt/salon_pos
sudo chown salonpos:salonpos /opt/salon_pos

3. Clone and set up virtual environment

cd /opt/salon_pos
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt

4. Environment configuration

cp .env.example .env
chmod 600 .env
# Edit .env with your values — SECRET_KEY, ADMIN_SECRET_KEY, DATABASE_URL, MAIL_*, etc.
nano .env

5. MySQL setup

CREATE DATABASE salon_pos CHARACTER SET utf8mb4 COLLATE utf8mb4_unicode_ci;
CREATE USER 'salon_pos_app'@'localhost' IDENTIFIED BY 'strong-password-here';
GRANT SELECT, INSERT, UPDATE, DELETE, CREATE, ALTER, INDEX, DROP
    ON salon_pos.* TO 'salon_pos_app'@'localhost';

-- Backup user (read-only)
CREATE USER 'salon_pos_backup'@'localhost' IDENTIFIED BY 'backup-password-here';
GRANT SELECT, LOCK TABLES ON salon_pos.* TO 'salon_pos_backup'@'localhost';
FLUSH PRIVILEGES;

Store backup credentials in /etc/mysql/backup.cnf (mode 600):

[client]
user=salon_pos_backup
password=backup-password-here
host=localhost

6. Database migrations

cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db init      # First time only
flask --app wsgi_tenant:app db migrate -m "Phase 1 — initial schema"
flask --app wsgi_tenant:app db upgrade

7. Seed initial data (plans + superadmin)

Important: There is no default seeded admin user. You must create one manually using the shell commands below. Choose a strong password — minimum 10 characters, at least one uppercase letter, one lowercase letter, and one digit.

flask --app wsgi_admin:app shell
from app.extensions import db, bcrypt
from app.models.platform import Plan, SystemUser

# Create subscription plans
plans = [
    Plan(name="Starter", price_monthly=29.00, max_staff=3, max_locations=1,
         features_json={"pos": True, "appointments": True, "customers": True,
                        "services": True, "promotions": True,
                        "appointment_reminders": True, "customer_reviews": True,
                        "reconciliation": True, "basic_reports": True,
                        "inventory": False, "commission": False,
                        "full_reports": False, "multi_location": False,
                        "online_booking": False, "waitlist": False,
                        "marketing": False}),
    Plan(name="Growth", price_monthly=59.00, max_staff=10, max_locations=3,
         features_json={"pos": True, "appointments": True, "customers": True,
                        "services": True, "promotions": True,
                        "appointment_reminders": True, "customer_reviews": True,
                        "reconciliation": True, "basic_reports": True,
                        "inventory": True, "commission": True,
                        "full_reports": True, "multi_location": True,
                        "online_booking": True, "waitlist": True,
                        "marketing": False}),
    Plan(name="Pro", price_monthly=99.00, max_staff=None, max_locations=None,
         features_json={"pos": True, "appointments": True, "customers": True,
                        "services": True, "promotions": True,
                        "appointment_reminders": True, "customer_reviews": True,
                        "reconciliation": True, "basic_reports": True,
                        "inventory": True, "commission": True,
                        "full_reports": True, "multi_location": True,
                        "online_booking": True, "waitlist": True,
                        "marketing": True}),
]
db.session.add_all(plans)

# Create first superadmin
admin = SystemUser(
    email="admin@yourdomain.com",
    password_hash=bcrypt.generate_password_hash("ChangeMe1!").decode("utf-8"),
    name="System Admin",
    role="superadmin",
    is_active=True,
)
db.session.add(admin)
db.session.commit()
print("Seeded successfully.")
exit()

8. Log directories

sudo mkdir -p /var/log/salon_pos_admin /var/log/salon_pos_tenant
sudo chown salonpos:salonpos /var/log/salon_pos_admin /var/log/salon_pos_tenant

9. systemd services

sudo cp deploy/salon_pos_admin.service /etc/systemd/system/
sudo cp deploy/salon_pos_tenant.service /etc/systemd/system/
sudo systemctl daemon-reload
sudo systemctl enable salon_pos_admin salon_pos_tenant
sudo systemctl start salon_pos_admin salon_pos_tenant
sudo systemctl status salon_pos_admin salon_pos_tenant

10. Nginx

sudo cp deploy/nginx.conf /etc/nginx/sites-available/salon_pos
sudo ln -s /etc/nginx/sites-available/salon_pos /etc/nginx/sites-enabled/
# Update IP allowlist in nginx.conf before enabling
sudo nginx -t
sudo systemctl reload nginx

11. Backup cron

sudo cp deploy/backup/db_backup.sh /opt/salon_pos/deploy/backup/
sudo chmod +x /opt/salon_pos/deploy/backup/db_backup.sh
sudo mkdir -p /var/backups/salon_pos
sudo chown salonpos:salonpos /var/backups/salon_pos
# Install cron job for salonpos user
sudo crontab -u salonpos deploy/backup/backup.cron

Service Management

# Status
sudo systemctl status salon_pos_admin
sudo systemctl status salon_pos_tenant

# Restart
sudo systemctl restart salon_pos_admin
sudo systemctl restart salon_pos_tenant

# View logs
sudo journalctl -u salon_pos_admin -f
sudo journalctl -u salon_pos_tenant -f
tail -f /var/log/salon_pos_admin/error.log
tail -f /var/log/salon_pos_tenant/error.log

Database Migrations (Ongoing)

cd /opt/salon_pos
source venv/bin/activate
flask --app wsgi_tenant:app db migrate -m "Description of change"
flask --app wsgi_tenant:app db upgrade

# Rollback one step
flask --app wsgi_tenant:app db downgrade

Running Tests

cd /opt/salon_pos
source venv/bin/activate
pip install pytest
FLASK_ENV=testing pytest tests/ -v

Monthly Restore Drill (Mandatory)

# On staging server:
LATEST=$(ls -t /var/backups/salon_pos/*.sql.gz | head -1)
gunzip -c "$LATEST" | mysql -u root salon_pos_staging
echo "Restore drill completed: $LATEST"
# Document the result and date in this runbook.

Last restore drill: _______________ Performed by: _______________ Result: _______________


Development Setup

git clone <repo>
cd salon_pos
python3.11 -m venv venv
source venv/bin/activate
pip install -r requirements.txt
cp .env.example .env
# Set FLASK_ENV=development in .env
flask --app wsgi_tenant:app db upgrade
flask --app wsgi_tenant:app run --port 5001   # Tenant portal → http://localhost:5001/login
flask --app wsgi_admin:app run --port 5002    # Admin portal  → http://localhost:5002/login

Project Structure

Phase 1 Resolutions

The following issues were encountered and resolved during Phase 1 — documented here to inform future contributors.

Circular Foreign Keys (MySQL InnoDB)

appointments.rebooked_from_transaction_id and transactions.appointment_id form a circular FK dependency. MySQL InnoDB enforces FK constraints at CREATE TABLE time so neither table could be created first. Fix: both columns use use_alter=True, name="fk_..." so SQLAlchemy defers them as ALTER TABLE statements after all tables are created.

If you re-generate migrations from scratch, always verify with:

flask --app wsgi_tenant:app db upgrade

Any (1824, "Failed to open the referenced table ...") error means a circular FK is missing use_alter=True.

Static File Double-Prefix

Flask apps with static_folder="../static" and static_url_path="/static/admin" produce URLs like /static/admin/css/admin.css. If templates then call url_for('static', filename='admin/css/admin.css') the URL becomes /static/admin/admin/css/admin.css (doubled prefix). Fix: each app points static_folder directly at its subdirectory (static/admin/ or static/tenant/) with static_url_path="/static". Templates use url_for('static', filename='css/admin.css') with no portal prefix in the filename.

Content Security Policy and Bootstrap CDN

The CSP must explicitly allow https://cdn.jsdelivr.net in script-src, style-src, and font-src. Both app/security.py (Flask after-request hook) and deploy/nginx.conf must be consistent — Nginx headers override Flask headers in production.

Admin Login URL

The admin portal login page is at posadmin.ngodanguyen.tech/login (not /admin/login). The admin auth blueprint uses url_prefix="". Visiting / redirects to /login.


salon_pos/
├── app/
│   ├── __init__.py
│   ├── extensions.py          # Shared Flask extensions
│   ├── context.py             # load_tenant_context, load_location_context
│   ├── decorators.py          # @require_role, @tenant_feature_required, @demo_readonly
│   ├── security.py            # Security headers + input sanitisers
│   ├── forms.py               # Password validation helper
│   ├── models/
│   │   ├── platform.py        # SystemUser, Tenant, Plan, AuditLog, ...
│   │   └── salon.py           # All tenant-scoped models
│   ├── admin/                 # posadmin.ngodanguyen.tech blueprints
│   └── tenant/                # pos.ngodanguyen.tech blueprints
├── config.py                  # Dev / Prod / Test config classes
├── wsgi_admin.py              # Gunicorn entrypoint — admin
├── wsgi_tenant.py             # Gunicorn entrypoint — tenant
├── requirements.txt
├── .env.example
├── deploy/                    # systemd units, Nginx config, backup scripts
└── tests/                    # pytest test suites
S
Description
MyPOS application
Readme
236 KiB
Languages
HTML 51.2%
Python 48.6%