# POS Foundation Implementation Plan (1 of 5) > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build the multi-tenant foundation: Flask app with blueprints, tenant DB provisioning, employee auth (JWT + PIN), permissions system, and audit logging. **Architecture:** Standalone Flask app at `/home/Autopartes/pos/` with Blueprint-based routing. Each tenant gets its own PostgreSQL database created from a template. Auth uses JWT tokens with tenant_id claim; POS login uses 4-digit PIN. Tenant routing resolved from JWT at middleware level. **Tech Stack:** Python 3, Flask, psycopg2, PyJWT, bcrypt, lxml, PostgreSQL 16 **Spec:** `/home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md` **Sub-plans:** 1. **Foundation** (this plan) — multi-tenant, auth, permissions, audit 2. **Inventory + Catalog** — inventory CRUD, operations engine, catalog UI + cart 3. **POS + Cash Register** — sales, payments, quotations, layaways, F-keys 4. **CFDI + Accounting** — invoice queue, Horux API, journal entries, reports 5. **PWA + Sync** — Service Worker, IndexedDB, offline-first, sync engine --- ## File Structure ``` /home/Autopartes/pos/ ├── app.py # Flask app factory, blueprint registration, tenant middleware ├── config.py # DB URLs, JWT secret, app settings ├── requirements.txt # Python dependencies ├── tenant_db.py # Tenant DB connection manager (get connection by tenant_id) ├── blueprints/ │ ├── __init__.py │ ├── auth_bp.py # PIN login, JWT issue/refresh, session management │ └── config_bp.py # Tenant configuration, branches, theming ├── services/ │ ├── __init__.py │ ├── tenant_manager.py # Create/migrate tenant DBs from template │ └── audit.py # Audit log helper (insert-only) ├── middleware.py # require_tenant_auth decorator, permission checks ├── migrations/ │ ├── v1.0_initial.sql # Complete tenant DB schema (21 tables) │ └── runner.py # Apply migrations to all tenants ├── seed/ │ └── sat_accounts.sql # Default SAT chart of accounts ├── templates/ │ └── login.html # PIN pad login page └── static/ ├── css/ │ └── common.css # Base styles with CSS custom properties for theming └── js/ └── login.js # PIN pad logic ``` --- ### Task 1: Project scaffold and config **Files:** - Create: `pos/requirements.txt` - Create: `pos/config.py` - Create: `pos/app.py` - Create: `pos/blueprints/__init__.py` - Create: `pos/services/__init__.py` - [ ] **Step 0: Create requirements.txt and install dependencies** ```bash cat > /home/Autopartes/pos/requirements.txt << 'REQS' Flask>=3.0 psycopg2-binary>=2.9 PyJWT>=2.8 bcrypt>=4.1 lxml>=5.1 REQS cd /home/Autopartes/pos pip install -r requirements.txt ``` - [ ] **Step 1: Create directory structure** ```bash mkdir -p /home/Autopartes/pos/{blueprints,services,migrations,seed,templates,static/{css,js}} touch /home/Autopartes/pos/blueprints/__init__.py touch /home/Autopartes/pos/services/__init__.py ``` - [ ] **Step 2: Create config.py** ```python # /home/Autopartes/pos/config.py import os # Master DB (tenants, subscriptions, catalog) MASTER_DB_URL = os.environ.get( "MASTER_DB_URL", "postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts" ) # Tenant DB URL template — {db_name} gets replaced per tenant TENANT_DB_URL_TEMPLATE = os.environ.get( "TENANT_DB_URL_TEMPLATE", "postgresql://nexus:nexus_autoparts_2026@localhost/{db_name}" ) # JWT JWT_SECRET = os.environ.get("POS_JWT_SECRET", "nexus-pos-secret-change-in-prod-2026") JWT_ACCESS_EXPIRES = 28800 # 8 hours (full shift) JWT_REFRESH_EXPIRES = 2592000 # 30 days # PIN security PIN_MAX_ATTEMPTS_PER_MINUTE = 5 PIN_LOCKOUT_THRESHOLD = 10 PIN_LOCKOUT_MINUTES = 15 # Template DB name TENANT_TEMPLATE_DB = "tenant_template" ``` - [ ] **Step 3: Create minimal app.py** ```python # /home/Autopartes/pos/app.py from flask import Flask def create_app(): app = Flask(__name__) # Health check @app.route('/pos/health') def health(): return {'status': 'ok'} return app if __name__ == '__main__': app = create_app() app.run(host='0.0.0.0', port=5001, debug=True) ``` - [ ] **Step 4: Verify app starts** ```bash cd /home/Autopartes/pos && python3 app.py & sleep 2 curl -s http://localhost:5001/pos/health # Expected: {"status":"ok"} kill %1 ``` - [ ] **Step 5: Commit** ```bash cd /home/Autopartes git add pos/ git commit -m "feat(pos): scaffold project structure and Flask app" ``` --- ### Task 2: Tenant DB schema (v1.0_initial.sql) **Files:** - Create: `pos/migrations/v1.0_initial.sql` - [ ] **Step 1: Create the complete tenant schema SQL** Create `pos/migrations/v1.0_initial.sql` with the full 21 tables from the spec (section 10, "tenant_{id} DB") and all indexes: ```sql -- /home/Autopartes/pos/migrations/v1.0_initial.sql -- Tenant DB schema v1.0 — 21 tables -- Source: design spec section 10 (tenant_{id} DB) -- ===================== -- SUCURSALES -- ===================== CREATE TABLE branches ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, address TEXT, phone VARCHAR(20), is_active BOOLEAN DEFAULT TRUE ); -- ===================== -- EMPLEADOS Y PERMISOS -- ===================== CREATE TABLE employees ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, email VARCHAR(200), phone VARCHAR(20), pin VARCHAR(100), -- hashed, 4 digitos password_hash VARCHAR(200), role VARCHAR(20) NOT NULL, -- owner, admin, cashier, warehouse, accountant branch_id INTEGER REFERENCES branches(id), max_discount_pct NUMERIC(5,2) DEFAULT 0, is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE employee_permissions ( employee_id INTEGER REFERENCES employees(id), permission VARCHAR(100) NOT NULL, -- 'pos.sell', 'inventory.adjust', etc. PRIMARY KEY (employee_id, permission) ); CREATE TABLE employee_sessions ( id SERIAL PRIMARY KEY, employee_id INTEGER REFERENCES employees(id), device_id VARCHAR(200), token VARCHAR(500) NOT NULL, expires_at TIMESTAMPTZ NOT NULL ); -- ===================== -- CLIENTES -- ===================== CREATE TABLE customers ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), name VARCHAR(300) NOT NULL, rfc VARCHAR(13), razon_social VARCHAR(300), regimen_fiscal VARCHAR(10), -- codigo SAT regimen uso_cfdi VARCHAR(10) DEFAULT 'G03', -- codigo SAT uso CFDI cp VARCHAR(5), email VARCHAR(200), phone VARCHAR(20), address TEXT, price_tier SMALLINT DEFAULT 1 CHECK (price_tier IN (1,2,3)), -- 1=mostrador, 2=taller, 3=mayoreo credit_limit NUMERIC(12,2) DEFAULT 0, credit_balance NUMERIC(12,2) DEFAULT 0, -- saldo actual de credito is_active BOOLEAN DEFAULT TRUE, vehicle_info JSONB, -- [{make, model, year, vin, plates}] created_at TIMESTAMPTZ DEFAULT NOW() ); -- ===================== -- INVENTARIO -- ===================== CREATE TABLE inventory ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), part_number VARCHAR(100) NOT NULL, barcode VARCHAR(100), name VARCHAR(300) NOT NULL, description TEXT, category_id INTEGER, brand VARCHAR(100), vehicle_compatibility JSONB, unit VARCHAR(20) DEFAULT 'PZA', cost NUMERIC(12,2) DEFAULT 0, price_1 NUMERIC(12,2) DEFAULT 0, -- mostrador price_2 NUMERIC(12,2) DEFAULT 0, -- taller price_3 NUMERIC(12,2) DEFAULT 0, -- mayoreo tax_rate NUMERIC(5,4) DEFAULT 0.16, min_stock INTEGER DEFAULT 0, max_stock INTEGER DEFAULT 0, location VARCHAR(50), -- ubicacion en almacen image_url VARCHAR(500), is_active BOOLEAN DEFAULT TRUE, catalog_part_id INTEGER, -- referencia a catalogo Nexus (via API, no FK) created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE inventory_operations ( id SERIAL PRIMARY KEY, inventory_id INTEGER REFERENCES inventory(id), branch_id INTEGER REFERENCES branches(id), operation_type VARCHAR(20) NOT NULL, -- SALE, PURCHASE, RETURN, ADJUST, TRANSFER, INITIAL quantity INTEGER NOT NULL, -- positivo o negativo reference_id INTEGER, reference_type VARCHAR(50), -- 'sale', 'purchase', 'return', etc. cost_at_time NUMERIC(12,2), employee_id INTEGER REFERENCES employees(id), device_id VARCHAR(200), notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); -- Stock actual = SUM(inventory_operations.quantity) WHERE inventory_id=X AND branch_id=Y -- ===================== -- VENTAS -- ===================== CREATE TABLE sales ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), customer_id INTEGER REFERENCES customers(id), -- NULL = publico general employee_id INTEGER REFERENCES employees(id), register_id INTEGER, -- FK cash_registers (deferred, table below) sale_type VARCHAR(20) NOT NULL, -- cash, credit, mixed payment_method VARCHAR(20), -- efectivo, transferencia, tarjeta, mixto subtotal NUMERIC(12,2) NOT NULL, discount_total NUMERIC(12,2) DEFAULT 0, tax_total NUMERIC(12,2) NOT NULL, total NUMERIC(12,2) NOT NULL, amount_paid NUMERIC(12,2) DEFAULT 0, change_given NUMERIC(12,2) DEFAULT 0, metodo_pago_sat VARCHAR(3), -- PUE o PPD forma_pago_sat VARCHAR(2), -- 01, 03, 04, 99 status VARCHAR(20) DEFAULT 'completed', -- completed, cancelled, returned device_id VARCHAR(200), notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE sale_items ( id SERIAL PRIMARY KEY, sale_id INTEGER REFERENCES sales(id), inventory_id INTEGER REFERENCES inventory(id), part_number VARCHAR(100), name VARCHAR(300), quantity INTEGER NOT NULL, unit_price NUMERIC(12,2) NOT NULL, -- precio al momento de la venta unit_cost NUMERIC(12,2), -- costo al momento de la venta discount_pct NUMERIC(5,2) DEFAULT 0, discount_amount NUMERIC(12,2) DEFAULT 0, tax_rate NUMERIC(5,4) DEFAULT 0.16, tax_amount NUMERIC(12,2) DEFAULT 0, subtotal NUMERIC(12,2) NOT NULL, clave_prod_serv VARCHAR(10), -- clave SAT clave_unidad VARCHAR(10) -- clave unidad SAT ); -- ===================== -- COTIZACIONES -- ===================== CREATE TABLE quotations ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), customer_id INTEGER REFERENCES customers(id), employee_id INTEGER REFERENCES employees(id), subtotal NUMERIC(12,2) NOT NULL, tax_total NUMERIC(12,2) NOT NULL, total NUMERIC(12,2) NOT NULL, status VARCHAR(20) DEFAULT 'active', -- active, converted, expired, cancelled valid_until DATE, converted_sale_id INTEGER REFERENCES sales(id), notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE quotation_items ( id SERIAL PRIMARY KEY, quotation_id INTEGER REFERENCES quotations(id), inventory_id INTEGER REFERENCES inventory(id), part_number VARCHAR(100), name VARCHAR(300), quantity INTEGER NOT NULL, unit_price NUMERIC(12,2) NOT NULL, discount_pct NUMERIC(5,2) DEFAULT 0, tax_rate NUMERIC(5,4) DEFAULT 0.16, subtotal NUMERIC(12,2) NOT NULL ); -- ===================== -- APARTADOS (LAYAWAYS) -- ===================== CREATE TABLE layaways ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), customer_id INTEGER REFERENCES customers(id) NOT NULL, employee_id INTEGER REFERENCES employees(id), total NUMERIC(12,2) NOT NULL, amount_paid NUMERIC(12,2) DEFAULT 0, status VARCHAR(20) DEFAULT 'active', -- active, completed, cancelled expires_at DATE, converted_sale_id INTEGER REFERENCES sales(id), notes TEXT, created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE layaway_payments ( id SERIAL PRIMARY KEY, layaway_id INTEGER REFERENCES layaways(id), amount NUMERIC(12,2) NOT NULL, payment_method VARCHAR(20), reference VARCHAR(100), employee_id INTEGER REFERENCES employees(id), created_at TIMESTAMPTZ DEFAULT NOW() ); -- ===================== -- CAJA REGISTRADORA -- ===================== CREATE TABLE cash_registers ( id SERIAL PRIMARY KEY, branch_id INTEGER REFERENCES branches(id), employee_id INTEGER REFERENCES employees(id), register_number SMALLINT NOT NULL, -- numero de caja (1, 2, 3...) opening_amount NUMERIC(12,2) NOT NULL, -- fondo inicial closing_amount NUMERIC(12,2), -- monto contado al cerrar expected_amount NUMERIC(12,2), -- monto esperado calculado difference NUMERIC(12,2), -- closing - expected status VARCHAR(10) DEFAULT 'open', -- open, closed opened_at TIMESTAMPTZ DEFAULT NOW(), closed_at TIMESTAMPTZ ); CREATE TABLE cash_movements ( id SERIAL PRIMARY KEY, register_id INTEGER REFERENCES cash_registers(id), type VARCHAR(5) NOT NULL, -- 'in' o 'out' amount NUMERIC(12,2) NOT NULL, reason VARCHAR(300) NOT NULL, employee_id INTEGER REFERENCES employees(id), created_at TIMESTAMPTZ DEFAULT NOW() ); -- ===================== -- FACTURACION (CFDI QUEUE) -- ===================== CREATE TABLE cfdi_queue ( id SERIAL PRIMARY KEY, sale_id INTEGER REFERENCES sales(id), type VARCHAR(10) NOT NULL, -- ingreso, egreso, pago xml_unsigned TEXT, -- XML generado por POS backend xml_signed TEXT, -- XML firmado+timbrado por Horux uuid_fiscal VARCHAR(36), -- UUID del SAT status VARCHAR(20) DEFAULT 'pending', -- pending, sending, stamped, failed, cancelled retry_count SMALLINT DEFAULT 0, provisional_folio VARCHAR(20), -- PRE-XXXXX error_message TEXT, cancel_motive VARCHAR(2), -- 01, 02, 03, 04 cancel_replacement_uuid VARCHAR(36), -- UUID del CFDI sustituto (motivo 01) created_at TIMESTAMPTZ DEFAULT NOW(), stamped_at TIMESTAMPTZ ); -- ===================== -- CONTABILIDAD -- ===================== CREATE TABLE accounts ( id SERIAL PRIMARY KEY, code VARCHAR(20) NOT NULL UNIQUE, name VARCHAR(200) NOT NULL, parent_id INTEGER REFERENCES accounts(id), type VARCHAR(20) NOT NULL, -- activo, pasivo, capital, ingreso, costo, gasto sat_code VARCHAR(20), is_system BOOLEAN DEFAULT FALSE, -- cuentas predeterminadas no editables is_active BOOLEAN DEFAULT TRUE ); CREATE TABLE journal_entries ( id SERIAL PRIMARY KEY, entry_number INTEGER NOT NULL, date DATE NOT NULL, type VARCHAR(20), -- ingreso, egreso, diario, poliza description TEXT, reference_type VARCHAR(50), -- sale, purchase, cash_register, etc. reference_id INTEGER, status VARCHAR(20) DEFAULT 'posted', -- draft, posted, cancelled created_by INTEGER REFERENCES employees(id), is_auto BOOLEAN DEFAULT TRUE, -- generada automaticamente created_at TIMESTAMPTZ DEFAULT NOW() ); CREATE TABLE journal_entry_lines ( id SERIAL PRIMARY KEY, journal_entry_id INTEGER REFERENCES journal_entries(id), account_id INTEGER REFERENCES accounts(id), debit NUMERIC(14,2) DEFAULT 0, credit NUMERIC(14,2) DEFAULT 0, description TEXT ); CREATE TABLE fiscal_periods ( id SERIAL PRIMARY KEY, year SMALLINT NOT NULL, month SMALLINT NOT NULL, status VARCHAR(10) DEFAULT 'open', -- open, closed closed_by INTEGER REFERENCES employees(id), closed_at TIMESTAMPTZ, UNIQUE (year, month) ); -- ===================== -- AUDITORIA -- ===================== CREATE TABLE audit_log ( id SERIAL PRIMARY KEY, employee_id INTEGER REFERENCES employees(id), action VARCHAR(50) NOT NULL, entity_type VARCHAR(50), entity_id INTEGER, old_value JSONB, new_value JSONB, device_id VARCHAR(200), ip_address VARCHAR(45), branch_id INTEGER REFERENCES branches(id), created_at TIMESTAMPTZ DEFAULT NOW() ); -- INSERT-only: nunca UPDATE, nunca DELETE -- ===================== -- INDEXES -- ===================== CREATE INDEX idx_inv_ops_inventory ON inventory_operations(inventory_id); CREATE INDEX idx_inv_ops_branch ON inventory_operations(branch_id); CREATE INDEX idx_inv_ops_type ON inventory_operations(operation_type); CREATE INDEX idx_inv_ops_created ON inventory_operations(created_at); CREATE INDEX idx_sales_branch ON sales(branch_id); CREATE INDEX idx_sales_customer ON sales(customer_id); CREATE INDEX idx_sales_created ON sales(created_at); CREATE INDEX idx_sales_status ON sales(status); CREATE INDEX idx_sale_items_sale ON sale_items(sale_id); CREATE INDEX idx_inventory_part ON inventory(part_number); CREATE INDEX idx_inventory_barcode ON inventory(barcode); CREATE INDEX idx_inventory_branch ON inventory(branch_id); CREATE INDEX idx_customers_rfc ON customers(rfc); CREATE INDEX idx_customers_name ON customers(name); CREATE INDEX idx_cfdi_queue_status ON cfdi_queue(status); CREATE INDEX idx_cfdi_queue_sale ON cfdi_queue(sale_id); CREATE INDEX idx_journal_entries_date ON journal_entries(date); CREATE INDEX idx_journal_lines_entry ON journal_entry_lines(journal_entry_id); CREATE INDEX idx_audit_log_action ON audit_log(action); CREATE INDEX idx_audit_log_entity ON audit_log(entity_type, entity_id); CREATE INDEX idx_audit_log_employee ON audit_log(employee_id); CREATE INDEX idx_audit_log_created ON audit_log(created_at); CREATE UNIQUE INDEX idx_inventory_branch_part ON inventory(branch_id, part_number); CREATE INDEX idx_employee_sessions_token ON employee_sessions(token); ``` - [ ] **Step 2: Commit** ```bash git add pos/migrations/v1.0_initial.sql git commit -m "feat(pos): add tenant DB schema v1.0 with 21 tables and indexes" ``` --- ### Task 3: SAT chart of accounts seed data **Files:** - Create: `pos/seed/sat_accounts.sql` - [ ] **Step 1: Create SAT accounts seed** Uses subqueries for parent_id lookups instead of hardcoded auto-increment IDs, making the seed resilient to ID changes: ```sql -- /home/Autopartes/pos/seed/sat_accounts.sql -- Catalogo de cuentas predeterminado basado en estructura SAT -- Uses code-based lookups for parent_id to avoid fragile hardcoded IDs. -- Top-level accounts (no parent) INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('100', 'Activo', NULL, 'activo', '100', true), ('200', 'Pasivo', NULL, 'pasivo', '200', true), ('300', 'Capital', NULL, 'capital', '301', true), ('400', 'Ingresos', NULL, 'ingreso', '401', true), ('500', 'Costos', NULL, 'costo', '501', true), ('600', 'Gastos', NULL, 'gasto', '601', true); -- Activo children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('110', 'Caja', (SELECT id FROM accounts WHERE code = '100'), 'activo', '101.01', true), ('111', 'Bancos', (SELECT id FROM accounts WHERE code = '100'), 'activo', '102', true), ('120', 'Clientes', (SELECT id FROM accounts WHERE code = '100'), 'activo', '105', true), ('130', 'Inventarios', (SELECT id FROM accounts WHERE code = '100'), 'activo', '115', true), ('140', 'IVA Acreditable', (SELECT id FROM accounts WHERE code = '100'), 'activo', '119', true); -- Pasivo children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('210', 'Proveedores', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '201', true), ('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '216', true), ('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '216.10', true); -- Capital children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('310', 'Capital Social', (SELECT id FROM accounts WHERE code = '300'), 'capital', '301', true), ('320', 'Resultados del Ejercicio', (SELECT id FROM accounts WHERE code = '300'), 'capital', '304', true); -- Ingresos children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('410', 'Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '401', true), ('420', 'Devoluciones sobre Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '401.01', true); -- Costos children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('510', 'Costo de Mercancia Vendida', (SELECT id FROM accounts WHERE code = '500'), 'costo', '501', true); -- Gastos children INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system) VALUES ('610', 'Gastos Operativos', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '601', true), ('620', 'Gastos Financieros', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '602', true); ``` - [ ] **Step 2: Commit** ```bash git add pos/seed/sat_accounts.sql git commit -m "feat(pos): add SAT chart of accounts seed data" ``` --- ### Task 4: Tenant manager service **Files:** - Create: `pos/services/tenant_manager.py` - Create: `pos/tenant_db.py` - [ ] **Step 1: Create tenant_db.py (connection manager)** ```python # /home/Autopartes/pos/tenant_db.py """Tenant DB connection manager. Gets a psycopg2 connection for a specific tenant.""" import psycopg2 from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE def get_master_conn(): """Get connection to nexus_master DB.""" return psycopg2.connect(MASTER_DB_URL) def get_tenant_conn(tenant_id): """Get connection to a tenant's DB by looking up db_name in nexus_master.""" master = get_master_conn() cur = master.cursor() cur.execute("SELECT db_name FROM tenants WHERE id = %s AND is_active = true", (tenant_id,)) row = cur.fetchone() cur.close() master.close() if not row: raise ValueError(f"Tenant {tenant_id} not found or inactive") db_name = row[0] return psycopg2.connect(TENANT_DB_URL_TEMPLATE.format(db_name=db_name)) def get_tenant_conn_by_dbname(db_name): """Get connection to a tenant DB directly by name.""" return psycopg2.connect(TENANT_DB_URL_TEMPLATE.format(db_name=db_name)) ``` - [ ] **Step 2: Create tenant_manager.py** ```python # /home/Autopartes/pos/services/tenant_manager.py """Create and manage tenant databases.""" import os import psycopg2 from psycopg2 import sql from psycopg2.extensions import ISOLATION_LEVEL_AUTOCOMMIT from config import MASTER_DB_URL, TENANT_DB_URL_TEMPLATE, TENANT_TEMPLATE_DB from tenant_db import get_master_conn, get_tenant_conn_by_dbname MIGRATIONS_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'migrations') SEED_DIR = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'seed') def ensure_master_tables(): """Create tenants/subscriptions/schema_version tables in nexus_master if missing.""" conn = get_master_conn() cur = conn.cursor() cur.execute(""" CREATE TABLE IF NOT EXISTS tenants ( id SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, db_name VARCHAR(100) UNIQUE NOT NULL, rfc VARCHAR(13), plan VARCHAR(50) DEFAULT 'basic', is_active BOOLEAN DEFAULT TRUE, created_at TIMESTAMPTZ DEFAULT NOW() ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS subscriptions ( id SERIAL PRIMARY KEY, tenant_id INTEGER REFERENCES tenants(id), plan VARCHAR(50) NOT NULL, status VARCHAR(20) DEFAULT 'active', started_at TIMESTAMPTZ DEFAULT NOW(), expires_at TIMESTAMPTZ, stripe_id VARCHAR(100) ) """) cur.execute(""" CREATE TABLE IF NOT EXISTS tenant_schema_version ( tenant_id INTEGER PRIMARY KEY REFERENCES tenants(id), version VARCHAR(20) NOT NULL DEFAULT 'v1.0', updated_at TIMESTAMPTZ DEFAULT NOW() ) """) conn.commit() cur.close() conn.close() def create_template_db(): """Create tenant_template DB with full schema if it doesn't exist.""" conn = psycopg2.connect(MASTER_DB_URL) conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) cur = conn.cursor() # Check if template already exists (idempotent — safe to call from multiple tasks) cur.execute("SELECT 1 FROM pg_database WHERE datname = %s", (TENANT_TEMPLATE_DB,)) if cur.fetchone(): cur.close() conn.close() return False # Already exists # Use psycopg2.sql.Identifier for safe dynamic DB name cur.execute( sql.SQL('CREATE DATABASE {}').format(sql.Identifier(TENANT_TEMPLATE_DB)) ) cur.close() conn.close() # Apply schema to template tpl_conn = get_tenant_conn_by_dbname(TENANT_TEMPLATE_DB) tpl_cur = tpl_conn.cursor() schema_path = os.path.join(MIGRATIONS_DIR, 'v1.0_initial.sql') with open(schema_path) as f: tpl_cur.execute(f.read()) seed_path = os.path.join(SEED_DIR, 'sat_accounts.sql') with open(seed_path) as f: tpl_cur.execute(f.read()) tpl_conn.commit() tpl_cur.close() tpl_conn.close() return True # Created def provision_tenant(name, rfc=None, owner_name="Admin", owner_email=None, owner_pin="0000"): """Create a new tenant: register in master, create DB from template, create owner employee.""" import bcrypt ensure_master_tables() create_template_db() # Generate db_name conn = get_master_conn() cur = conn.cursor() # Insert tenant cur.execute(""" INSERT INTO tenants (name, db_name, rfc) VALUES (%s, %s, %s) RETURNING id, db_name """, (name, f"tenant_{name.lower().replace(' ', '_')[:30]}", rfc)) tenant_id, db_name = cur.fetchone() # Track schema version cur.execute(""" INSERT INTO tenant_schema_version (tenant_id, version) VALUES (%s, 'v1.0') """, (tenant_id,)) conn.commit() cur.close() conn.close() # Create DB from template — use psycopg2.sql.Identifier for safe dynamic names master_conn = psycopg2.connect(MASTER_DB_URL) master_conn.set_isolation_level(ISOLATION_LEVEL_AUTOCOMMIT) master_cur = master_conn.cursor() master_cur.execute( sql.SQL('CREATE DATABASE {} TEMPLATE {}').format( sql.Identifier(db_name), sql.Identifier(TENANT_TEMPLATE_DB) ) ) master_cur.close() master_conn.close() # Create default branch and owner employee tenant_conn = get_tenant_conn_by_dbname(db_name) tenant_cur = tenant_conn.cursor() tenant_cur.execute(""" INSERT INTO branches (name) VALUES ('Principal') RETURNING id """) branch_id = tenant_cur.fetchone()[0] pin_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode() pwd_hash = bcrypt.hashpw(owner_pin.encode(), bcrypt.gensalt()).decode() tenant_cur.execute(""" INSERT INTO employees (name, email, pin, password_hash, role, branch_id, max_discount_pct, is_active) VALUES (%s, %s, %s, %s, 'owner', %s, 100, true) RETURNING id """, (owner_name, owner_email, pin_hash, pwd_hash, branch_id)) owner_id = tenant_cur.fetchone()[0] # Grant all permissions to owner permissions = [ 'pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost', 'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer', 'catalog.view', 'catalog.edit', 'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit', 'customers.delete', 'accounting.view', 'accounting.create', 'accounting.close', 'invoicing.view', 'invoicing.create', 'invoicing.cancel', 'reports.view', 'reports.financial', 'config.view', 'config.edit', 'config.edit_prices' ] for perm in permissions: tenant_cur.execute( "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s)", (owner_id, perm) ) tenant_conn.commit() tenant_cur.close() tenant_conn.close() return {'tenant_id': tenant_id, 'db_name': db_name, 'owner_id': owner_id} def list_tenants(): """List all tenants.""" conn = get_master_conn() cur = conn.cursor() cur.execute("SELECT id, name, db_name, rfc, plan, is_active, created_at FROM tenants ORDER BY id") tenants = [] for row in cur.fetchall(): tenants.append({ 'id': row[0], 'name': row[1], 'db_name': row[2], 'rfc': row[3], 'plan': row[4], 'is_active': row[5], 'created_at': str(row[6]) }) cur.close() conn.close() return tenants ``` - [ ] **Step 3: Verify tenant provisioning** ```bash cd /home/Autopartes/pos python3 -c " from services.tenant_manager import provision_tenant result = provision_tenant('Test Refaccionaria', rfc='TEST010101AAA', owner_name='Admin Test', owner_pin='1234') print(result) " # Expected: {'tenant_id': N, 'db_name': 'tenant_test_refaccionaria', 'owner_id': 1} # Verify DB was created PGPASSWORD=nexus_autoparts_2026 psql -U nexus -h localhost -c "\l" | grep tenant_test # Expected: tenant_test_refaccionaria # Verify tables exist PGPASSWORD=nexus_autoparts_2026 psql -U nexus -d tenant_test_refaccionaria -h localhost -c "\dt" # Expected: 21 tables listed # Clean up test tenant DB and template DB PGPASSWORD=nexus_autoparts_2026 psql -U nexus -h localhost -c "DROP DATABASE IF EXISTS tenant_test_refaccionaria" PGPASSWORD=nexus_autoparts_2026 psql -U nexus -h localhost -c "DROP DATABASE IF EXISTS tenant_template" PGPASSWORD=nexus_autoparts_2026 psql -U nexus -d nexus_autoparts -h localhost -c "DELETE FROM tenant_schema_version WHERE tenant_id = (SELECT id FROM tenants WHERE db_name='tenant_test_refaccionaria')" PGPASSWORD=nexus_autoparts_2026 psql -U nexus -d nexus_autoparts -h localhost -c "DELETE FROM tenants WHERE db_name='tenant_test_refaccionaria'" ``` > **Note:** The template DB is also cleaned up here so that subsequent tasks (Task 5 etc.) will recreate it fresh. In production, `create_template_db()` is idempotent and checks `pg_database` before creating. - [ ] **Step 4: Commit** ```bash git add pos/tenant_db.py pos/services/tenant_manager.py git commit -m "feat(pos): add tenant manager — provision DBs from template" ``` --- ### Task 5: Auth middleware and PIN login **Files:** - Create: `pos/middleware.py` - Create: `pos/blueprints/auth_bp.py` - [ ] **Step 1: Create middleware.py** ```python # /home/Autopartes/pos/middleware.py """Auth middleware for POS: JWT validation + tenant resolution + permission checks.""" import jwt from functools import wraps from flask import request, jsonify, g from config import JWT_SECRET def require_auth(*required_permissions): """Decorator: validate JWT, resolve tenant, optionally check permissions. Usage: @require_auth() # any authenticated employee @require_auth('pos.sell') # needs specific permission @require_auth('pos.sell', 'pos.discount') # needs ALL listed permissions """ def decorator(f): @wraps(f) def decorated(*args, **kwargs): auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): return jsonify({'error': 'Token required'}), 401 try: payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256']) except jwt.ExpiredSignatureError: return jsonify({'error': 'Token expired'}), 401 except jwt.InvalidTokenError: return jsonify({'error': 'Invalid token'}), 401 if payload.get('type') != 'pos_access': return jsonify({'error': 'Invalid token type'}), 401 g.tenant_id = payload['tenant_id'] g.employee_id = payload['employee_id'] g.employee_role = payload['role'] g.employee_name = payload['name'] g.branch_id = payload.get('branch_id') g.permissions = set(payload.get('permissions', [])) g.device_id = request.headers.get('X-Device-Id', 'unknown') # Check permissions if required_permissions: missing = set(required_permissions) - g.permissions # owner role bypasses all permission checks if g.employee_role != 'owner' and missing: return jsonify({'error': f'Missing permissions: {", ".join(missing)}'}), 403 return f(*args, **kwargs) return decorated return decorator def has_permission(permission): """Check if current user has a specific permission. Use inside a route.""" return g.employee_role == 'owner' or permission in g.permissions ``` - [ ] **Step 2: Create auth_bp.py** ```python # /home/Autopartes/pos/blueprints/auth_bp.py """Auth blueprint: PIN login, JWT tokens, session management.""" import jwt import bcrypt import time from datetime import datetime, timezone, timedelta from flask import Blueprint, request, jsonify from config import JWT_SECRET, JWT_ACCESS_EXPIRES, PIN_MAX_ATTEMPTS_PER_MINUTE, PIN_LOCKOUT_THRESHOLD, PIN_LOCKOUT_MINUTES from tenant_db import get_tenant_conn, get_master_conn auth_bp = Blueprint('auth', __name__, url_prefix='/pos/api/auth') # In-memory rate limiting (per device) _pin_attempts = {} # device_id -> [(timestamp, success)] def _check_rate_limit(device_id): """Check PIN rate limit. Returns (allowed, message).""" now = time.time() attempts = _pin_attempts.get(device_id, []) # Clean old attempts (older than lockout period) cutoff = now - (PIN_LOCKOUT_MINUTES * 60) attempts = [a for a in attempts if a[0] > cutoff] _pin_attempts[device_id] = attempts # Check lockout failed_count = sum(1 for a in attempts if not a[1]) if failed_count >= PIN_LOCKOUT_THRESHOLD: return False, f'Dispositivo bloqueado. Intente en {PIN_LOCKOUT_MINUTES} minutos.' # Check per-minute rate one_min_ago = now - 60 recent = sum(1 for a in attempts if a[0] > one_min_ago and not a[1]) if recent >= PIN_MAX_ATTEMPTS_PER_MINUTE: return False, 'Demasiados intentos. Espere un momento.' return True, '' def _record_attempt(device_id, success): """Record a PIN attempt.""" if device_id not in _pin_attempts: _pin_attempts[device_id] = [] _pin_attempts[device_id].append((time.time(), success)) @auth_bp.route('/login', methods=['POST']) def login_pin(): """Login with tenant_id + PIN + device_id.""" data = request.get_json() or {} tenant_id = data.get('tenant_id') pin = data.get('pin', '') device_id = data.get('device_id', request.headers.get('X-Device-Id', 'unknown')) # Optional: branch_id from the device for PIN search optimization device_branch_id = data.get('branch_id') if not tenant_id or not pin: return jsonify({'error': 'tenant_id and pin required'}), 400 # Rate limit check allowed, msg = _check_rate_limit(device_id) if not allowed: return jsonify({'error': msg}), 429 try: conn = get_tenant_conn(tenant_id) except ValueError: return jsonify({'error': 'Tenant not found'}), 404 cur = conn.cursor() # PERFORMANCE NOTE: This PIN check is O(n) over active employees because PINs are # hashed and cannot be looked up directly. For most tenants (<100 employees) this is # fine. If a tenant has hundreds of employees, consider: # 1. Adding a PIN prefix index (first 2 digits stored as a non-secret hint column) # 2. Caching active employee count and alerting if >200 # # Short-circuit optimization: if the device is bound to a branch (branch_id known), # query that branch's employees first to reduce the bcrypt comparison space. matched_employee = None if device_branch_id: # Try branch employees first (fast path for known devices) cur.execute(""" SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct FROM employees e WHERE e.is_active = true AND e.pin IS NOT NULL AND e.branch_id = %s """, (device_branch_id,)) for emp in cur.fetchall(): emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()): matched_employee = { 'id': emp_id, 'name': emp_name, 'role': emp_role, 'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0 } break if not matched_employee: # Fallback: check ALL active employees (covers owners, admins, roaming staff) cur.execute(""" SELECT e.id, e.name, e.pin, e.role, e.branch_id, e.max_discount_pct FROM employees e WHERE e.is_active = true AND e.pin IS NOT NULL """) employees = cur.fetchall() for emp in employees: emp_id, emp_name, emp_pin_hash, emp_role, emp_branch, emp_discount = emp if emp_pin_hash and bcrypt.checkpw(pin.encode(), emp_pin_hash.encode()): matched_employee = { 'id': emp_id, 'name': emp_name, 'role': emp_role, 'branch_id': emp_branch, 'max_discount_pct': float(emp_discount) if emp_discount else 0 } break if not matched_employee: _record_attempt(device_id, False) cur.close() conn.close() return jsonify({'error': 'PIN incorrecto'}), 401 _record_attempt(device_id, True) # Get permissions cur.execute( "SELECT permission FROM employee_permissions WHERE employee_id = %s", (matched_employee['id'],) ) permissions = [r[0] for r in cur.fetchall()] cur.close() conn.close() # Generate JWT payload = { 'tenant_id': tenant_id, 'employee_id': matched_employee['id'], 'name': matched_employee['name'], 'role': matched_employee['role'], 'branch_id': matched_employee['branch_id'], 'max_discount_pct': matched_employee['max_discount_pct'], 'permissions': permissions, 'device_id': device_id, 'type': 'pos_access', 'exp': datetime.now(timezone.utc) + timedelta(seconds=JWT_ACCESS_EXPIRES), 'iat': datetime.now(timezone.utc), } token = jwt.encode(payload, JWT_SECRET, algorithm='HS256') return jsonify({ 'token': token, 'employee': matched_employee, 'permissions': permissions }) @auth_bp.route('/me', methods=['GET']) def auth_me(): """Get current employee info from token.""" auth_header = request.headers.get('Authorization', '') if not auth_header.startswith('Bearer '): return jsonify({'error': 'Token required'}), 401 try: payload = jwt.decode(auth_header[7:], JWT_SECRET, algorithms=['HS256']) return jsonify({ 'employee_id': payload['employee_id'], 'name': payload['name'], 'role': payload['role'], 'tenant_id': payload['tenant_id'], 'branch_id': payload.get('branch_id'), 'permissions': payload.get('permissions', []) }) except jwt.InvalidTokenError: return jsonify({'error': 'Invalid token'}), 401 ``` - [ ] **Step 3: Register auth blueprint in app.py** Update `pos/app.py`: ```python # /home/Autopartes/pos/app.py from flask import Flask def create_app(): app = Flask(__name__) # Register blueprints from blueprints.auth_bp import auth_bp app.register_blueprint(auth_bp) # Health check @app.route('/pos/health') def health(): return {'status': 'ok'} return app if __name__ == '__main__': app = create_app() app.run(host='0.0.0.0', port=5001, debug=True) ``` - [ ] **Step 4: Test auth flow** ```bash cd /home/Autopartes/pos # First create a test tenant python3 -c " from services.tenant_manager import provision_tenant result = provision_tenant('Test Auth', owner_name='Owner', owner_pin='1234') print(result) # Capture tenant_id for subsequent commands import json print('TENANT_ID=' + str(result['tenant_id'])) " # Start server python3 app.py & sleep 2 # Use the tenant_id from provisioning output (capture dynamically) TENANT_ID=$(python3 -c " from tenant_db import get_master_conn conn = get_master_conn() cur = conn.cursor() cur.execute(\"SELECT id FROM tenants WHERE db_name='tenant_test_auth'\") print(cur.fetchone()[0]) cur.close(); conn.close() ") # Test login curl -s -X POST http://localhost:5001/pos/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"tenant_id\": $TENANT_ID, \"pin\": \"1234\", \"device_id\": \"test-device\"}" # Expected: {"token": "eyJ...", "employee": {...}, "permissions": [...]} kill %1 ``` - [ ] **Step 5: Commit** ```bash git add pos/middleware.py pos/blueprints/auth_bp.py pos/app.py git commit -m "feat(pos): add PIN auth with JWT, rate limiting, and permission middleware" ``` --- ### Task 6: Audit service **Files:** - Create: `pos/services/audit.py` - [ ] **Step 1: Create audit service** ```python # /home/Autopartes/pos/services/audit.py """Audit logging service. INSERT-only, never update or delete.""" from flask import g def log_action(conn, action, entity_type=None, entity_id=None, old_value=None, new_value=None): """Insert an audit log entry using the current request context. Args: conn: psycopg2 connection to the tenant DB action: SALE, CANCEL, PRICE_CHANGE, STOCK_ADJUST, LOGIN, DISCOUNT, etc. entity_type: 'sale', 'inventory', 'customer', 'employee', etc. entity_id: ID of the affected entity old_value: dict of previous values (or None) new_value: dict of new values (or None) """ import json cur = conn.cursor() cur.execute(""" INSERT INTO audit_log (employee_id, action, entity_type, entity_id, old_value, new_value, device_id, ip_address, branch_id) VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s) """, ( getattr(g, 'employee_id', None), action, entity_type, entity_id, json.dumps(old_value) if old_value else None, json.dumps(new_value) if new_value else None, getattr(g, 'device_id', None), _get_client_ip(), getattr(g, 'branch_id', None), )) # Don't commit here — let the caller control the transaction def _get_client_ip(): """Get client IP, handling proxies.""" from flask import request if request.headers.get('X-Forwarded-For'): return request.headers['X-Forwarded-For'].split(',')[0].strip() return request.remote_addr ``` - [ ] **Step 2: Commit** ```bash git add pos/services/audit.py git commit -m "feat(pos): add insert-only audit logging service" ``` --- ### Task 7: Config blueprint and theming **Files:** - Create: `pos/blueprints/config_bp.py` - [ ] **Step 1: Create config blueprint** ```python # /home/Autopartes/pos/blueprints/config_bp.py """Config blueprint: tenant configuration, branches, theming.""" from flask import Blueprint, request, jsonify, g from middleware import require_auth, has_permission from tenant_db import get_tenant_conn config_bp = Blueprint('config', __name__, url_prefix='/pos/api/config') @config_bp.route('/branches', methods=['GET']) @require_auth() def list_branches(): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT id, name, address, phone, is_active FROM branches ORDER BY id") branches = [] for r in cur.fetchall(): branches.append({'id': r[0], 'name': r[1], 'address': r[2], 'phone': r[3], 'is_active': r[4]}) cur.close() conn.close() return jsonify({'data': branches}) @config_bp.route('/branches', methods=['POST']) @require_auth('config.edit') def create_branch(): data = request.get_json() or {} if not data.get('name'): return jsonify({'error': 'name required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO branches (name, address, phone) VALUES (%s, %s, %s) RETURNING id """, (data['name'], data.get('address'), data.get('phone'))) branch_id = cur.fetchone()[0] conn.commit() cur.close() conn.close() return jsonify({'id': branch_id, 'message': 'Branch created'}), 201 @config_bp.route('/employees', methods=['GET']) @require_auth('config.view') def list_employees(): conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT e.id, e.name, e.email, e.phone, e.role, e.branch_id, b.name as branch_name, e.max_discount_pct, e.is_active FROM employees e LEFT JOIN branches b ON e.branch_id = b.id ORDER BY e.id """) employees = [] for r in cur.fetchall(): employees.append({ 'id': r[0], 'name': r[1], 'email': r[2], 'phone': r[3], 'role': r[4], 'branch_id': r[5], 'branch_name': r[6], 'max_discount_pct': float(r[7]) if r[7] else 0, 'is_active': r[8] }) cur.close() conn.close() return jsonify({'data': employees}) @config_bp.route('/employees', methods=['POST']) @require_auth('config.edit') def create_employee(): import bcrypt data = request.get_json() or {} required = ['name', 'role', 'pin'] for f in required: if not data.get(f): return jsonify({'error': f'{f} required'}), 400 valid_roles = ['admin', 'cashier', 'warehouse', 'accountant'] if data['role'] not in valid_roles: return jsonify({'error': f'role must be one of: {", ".join(valid_roles)}'}), 400 pin_hash = bcrypt.hashpw(data['pin'].encode(), bcrypt.gensalt()).decode() conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO employees (name, email, phone, pin, role, branch_id, max_discount_pct, is_active) VALUES (%s, %s, %s, %s, %s, %s, %s, true) RETURNING id """, (data['name'], data.get('email'), data.get('phone'), pin_hash, data['role'], data.get('branch_id'), data.get('max_discount_pct', 0))) emp_id = cur.fetchone()[0] # Set default permissions by role role_permissions = { 'admin': ['pos.sell', 'pos.discount', 'pos.cancel', 'pos.view_cost', 'inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer', 'catalog.view', 'catalog.edit', 'customers.view', 'customers.create', 'customers.edit', 'customers.edit_credit', 'invoicing.view', 'invoicing.create', 'reports.view', 'reports.financial', 'config.view', 'config.edit', 'config.edit_prices'], 'cashier': ['pos.sell', 'pos.discount', 'pos.cancel', 'catalog.view', 'customers.view', 'customers.create'], 'warehouse': ['inventory.view', 'inventory.create', 'inventory.edit', 'inventory.adjust', 'inventory.transfer', 'catalog.view'], 'accountant': ['accounting.view', 'accounting.create', 'invoicing.view', 'invoicing.create', 'invoicing.cancel', 'reports.view', 'reports.financial', 'customers.view'], } for perm in role_permissions.get(data['role'], []): cur.execute( "INSERT INTO employee_permissions (employee_id, permission) VALUES (%s, %s) ON CONFLICT DO NOTHING", (emp_id, perm) ) from services.audit import log_action log_action(conn, 'EMPLOYEE_CREATE', 'employee', emp_id, new_value={'name': data['name'], 'role': data['role']}) conn.commit() cur.close() conn.close() return jsonify({'id': emp_id, 'message': 'Employee created'}), 201 @config_bp.route('/theme', methods=['GET']) @require_auth() def get_theme(): """Get current theme for this tenant. Returns CSS variables.""" # For v1, return a default theme. The design team will add more. return jsonify({ 'theme': 'default', 'variables': { '--color-primary': '#1a73e8', '--color-secondary': '#5f6368', '--color-accent': '#ff6b35', '--color-bg': '#ffffff', '--color-surface': '#f8f9fa', '--color-text': '#202124', '--color-border': '#dadce0', '--font-display': "'Sora', sans-serif", '--font-body': "'Plus Jakarta Sans', sans-serif", '--font-mono': "'JetBrains Mono', monospace", '--radius': '8px', } }) ``` - [ ] **Step 2: Register config blueprint in app.py** Add to `pos/app.py` in `create_app()`: ```python from blueprints.config_bp import config_bp app.register_blueprint(config_bp) ``` - [ ] **Step 3: Commit** ```bash git add pos/blueprints/config_bp.py pos/app.py git commit -m "feat(pos): add config blueprint — branches, employees, theming" ``` --- ### Task 8: Login page (PIN pad) **Files:** - Create: `pos/templates/login.html` - Create: `pos/static/js/login.js` - Create: `pos/static/css/common.css` - [ ] **Step 1: Create common.css with theme infrastructure** ```css /* /home/Autopartes/pos/static/css/common.css */ /* Theme variables — overridden by tenant theme */ :root { --color-primary: #1a73e8; --color-secondary: #5f6368; --color-accent: #ff6b35; --color-bg: #ffffff; --color-surface: #f8f9fa; --color-text: #202124; --color-text-secondary: #5f6368; --color-border: #dadce0; --color-success: #34a853; --color-warning: #f9ab00; --color-error: #ea4335; --font-display: 'Sora', sans-serif; --font-body: 'Plus Jakarta Sans', sans-serif; --font-mono: 'JetBrains Mono', monospace; --radius: 8px; --shadow: 0 1px 3px rgba(0,0,0,0.12); } *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; } body { font-family: var(--font-body); background: var(--color-bg); color: var(--color-text); line-height: 1.6; } .btn { display: inline-flex; align-items: center; justify-content: center; padding: 10px 20px; border: 1px solid var(--color-border); border-radius: var(--radius); font-family: var(--font-body); font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: all 0.2s; background: var(--color-surface); color: var(--color-text); } .btn:hover { background: var(--color-border); } .btn--primary { background: var(--color-primary); color: white; border-color: var(--color-primary); } .btn--primary:hover { opacity: 0.9; } .btn--accent { background: var(--color-accent); color: white; border-color: var(--color-accent); } .card { background: var(--color-surface); border: 1px solid var(--color-border); border-radius: var(--radius); padding: 24px; } ``` - [ ] **Step 2: Create login.html** ```html Nexus POS — Login
Nexus POS
Ingresa tu PIN
``` - [ ] **Step 3: Create login.js** ```javascript // /home/Autopartes/pos/static/js/login.js (function() { 'use strict'; var pin = ''; var dots = document.querySelectorAll('#pinDots .pin-dot'); var errorEl = document.getElementById('loginError'); // Get tenant_id from URL param or localStorage var tenantId = new URLSearchParams(window.location.search).get('tenant') || localStorage.getItem('pos_tenant_id'); // Device ID (persistent) var deviceId = localStorage.getItem('pos_device_id'); if (!deviceId) { deviceId = 'dev-' + Date.now() + '-' + Math.random().toString(36).substr(2, 9); localStorage.setItem('pos_device_id', deviceId); } /** * Check if a JWT token is expired by decoding its payload. * Returns true if the token is valid (not expired), false otherwise. */ function isTokenValid(token) { try { var parts = token.split('.'); if (parts.length !== 3) return false; // Base64url decode the payload (index 1) var payload = parts[1].replace(/-/g, '+').replace(/_/g, '/'); var decoded = JSON.parse(atob(payload)); // exp is in seconds, Date.now() is in milliseconds if (!decoded.exp) return false; // Add 30-second buffer to avoid edge cases return (decoded.exp * 1000) > (Date.now() + 30000); } catch (e) { return false; } } function updateDots() { dots.forEach(function(dot, i) { dot.classList.toggle('filled', i < pin.length); }); } window.addDigit = function(d) { if (pin.length >= 4) return; pin += d; updateDots(); errorEl.textContent = ''; if (pin.length === 4) { submitPin(); } }; window.clearPin = function() { pin = ''; updateDots(); errorEl.textContent = ''; }; window.submitPin = function() { if (pin.length !== 4) return; errorEl.textContent = ''; fetch('/pos/api/auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ tenant_id: parseInt(tenantId), pin: pin, device_id: deviceId }) }) .then(function(res) { return res.json().then(function(d) { return { ok: res.ok, data: d }; }); }) .then(function(result) { if (!result.ok) { errorEl.textContent = result.data.error || 'Error de autenticacion'; clearPin(); return; } localStorage.setItem('pos_token', result.data.token); localStorage.setItem('pos_employee', JSON.stringify(result.data.employee)); localStorage.setItem('pos_tenant_id', tenantId); window.location.href = '/pos/catalog'; }) .catch(function() { errorEl.textContent = 'Error de conexion'; clearPin(); }); }; // Keyboard support document.addEventListener('keydown', function(e) { if (e.key >= '0' && e.key <= '9') addDigit(e.key); else if (e.key === 'Backspace') clearPin(); else if (e.key === 'Enter') submitPin(); }); // Auto-redirect if already logged in AND token is not expired var token = localStorage.getItem('pos_token'); if (token && tenantId) { if (isTokenValid(token)) { window.location.href = '/pos/catalog'; } else { // Token expired — clean up and stay on login page localStorage.removeItem('pos_token'); localStorage.removeItem('pos_employee'); } } })(); ``` - [ ] **Step 4: Add login page route to app.py** Add to `create_app()` in app.py: ```python from flask import send_from_directory, render_template @app.route('/pos/login') def pos_login(): return render_template('login.html') @app.route('/pos/static/') def pos_static(filename): return send_from_directory('static', filename) ``` - [ ] **Step 5: Commit** ```bash git add pos/templates/login.html pos/static/js/login.js pos/static/css/common.css pos/app.py git commit -m "feat(pos): add PIN pad login page with keyboard support and theming" ``` --- ### Task 9: Migration runner **Files:** - Create: `pos/migrations/runner.py` - [ ] **Step 1: Create migration runner** ```python #!/usr/bin/env python3 # /home/Autopartes/pos/migrations/runner.py """Apply schema migrations to all tenant databases.""" import os import sys sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__)))) from tenant_db import get_master_conn, get_tenant_conn_by_dbname MIGRATIONS_DIR = os.path.dirname(os.path.abspath(__file__)) # Migration registry: version -> filename MIGRATIONS = { 'v1.0': 'v1.0_initial.sql', # Future: 'v1.1': 'v1.1_add_xyz.sql', } def get_all_tenants(): """Get all tenants with their current schema version.""" conn = get_master_conn() cur = conn.cursor() cur.execute(""" SELECT t.id, t.db_name, t.name, COALESCE(v.version, 'v0.0') as version FROM tenants t LEFT JOIN tenant_schema_version v ON t.id = v.tenant_id WHERE t.is_active = true """) tenants = cur.fetchall() cur.close() conn.close() return tenants def apply_migration(db_name, version): """Apply a single migration to a tenant DB.""" filename = MIGRATIONS[version] filepath = os.path.join(MIGRATIONS_DIR, filename) if not os.path.exists(filepath): print(f" ERROR: Migration file not found: {filepath}") return False conn = get_tenant_conn_by_dbname(db_name) cur = conn.cursor() try: with open(filepath) as f: cur.execute(f.read()) conn.commit() return True except Exception as e: conn.rollback() print(f" ERROR: {e}") return False finally: cur.close() conn.close() def run_migrations(): """Apply pending migrations to all tenants.""" tenants = get_all_tenants() sorted_versions = sorted(MIGRATIONS.keys()) print(f"Found {len(tenants)} active tenants") print(f"Available migrations: {sorted_versions}") for tenant_id, db_name, name, current_version in tenants: print(f"\n[{name}] (db={db_name}, current={current_version})") for version in sorted_versions: if version <= current_version: continue print(f" Applying {version}...", end=' ') if apply_migration(db_name, version): # Update version in master master_conn = get_master_conn() master_cur = master_conn.cursor() master_cur.execute(""" INSERT INTO tenant_schema_version (tenant_id, version) VALUES (%s, %s) ON CONFLICT (tenant_id) DO UPDATE SET version = %s, updated_at = NOW() """, (tenant_id, version, version)) master_conn.commit() master_cur.close() master_conn.close() print("OK") else: print("FAILED — stopping migrations for this tenant") break print("\nDone.") if __name__ == '__main__': run_migrations() ``` - [ ] **Step 2: Commit** ```bash git add pos/migrations/runner.py git commit -m "feat(pos): add tenant migration runner — applies schema updates to all tenants" ``` --- ### Task 10: Integration test and final wiring - [ ] **Step 1: Full integration test** ```bash cd /home/Autopartes/pos # 1. Provision a tenant and capture its ID dynamically PROVISION_OUTPUT=$(python3 -c " from services.tenant_manager import provision_tenant, list_tenants result = provision_tenant('Refac Demo', rfc='DEMO010101AAA', owner_name='Demo Owner', owner_pin='9999') print(result) ") echo "Provisioning output: $PROVISION_OUTPUT" # Extract tenant_id dynamically from the provisioning output TENANT_ID=$(python3 -c " from tenant_db import get_master_conn conn = get_master_conn() cur = conn.cursor() cur.execute(\"SELECT id FROM tenants WHERE db_name='tenant_refac_demo'\") row = cur.fetchone() print(row[0]) cur.close(); conn.close() ") echo "Using tenant_id=$TENANT_ID" # 2. Start the POS server python3 app.py & POS_PID=$! sleep 3 # 3. Test health curl -s http://localhost:5001/pos/health echo "" # 4. Test login (using dynamic TENANT_ID) curl -s -X POST http://localhost:5001/pos/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"tenant_id\":$TENANT_ID,\"pin\":\"9999\",\"device_id\":\"test\"}" | python3 -m json.tool # 5. Get token and test authenticated endpoint TOKEN=$(curl -s -X POST http://localhost:5001/pos/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"tenant_id\":$TENANT_ID,\"pin\":\"9999\",\"device_id\":\"test\"}" | python3 -c "import sys,json; print(json.load(sys.stdin)['token'])") # 6. Test /me curl -s http://localhost:5001/pos/api/auth/me \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool # 7. Test branches curl -s http://localhost:5001/pos/api/config/branches \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool # 8. Test employees curl -s http://localhost:5001/pos/api/config/employees \ -H "Authorization: Bearer $TOKEN" | python3 -m json.tool # 9. Create a cashier (using dynamic TENANT_ID for branch_id=1 which is always the first branch) curl -s -X POST http://localhost:5001/pos/api/config/employees \ -H "Authorization: Bearer $TOKEN" \ -H 'Content-Type: application/json' \ -d '{"name":"Maria Cajera","role":"cashier","pin":"1111","branch_id":1}' | python3 -m json.tool # 10. Login as cashier (using dynamic TENANT_ID) curl -s -X POST http://localhost:5001/pos/api/auth/login \ -H 'Content-Type: application/json' \ -d "{\"tenant_id\":$TENANT_ID,\"pin\":\"1111\",\"device_id\":\"caja1\"}" | python3 -m json.tool # 11. Test login page renders curl -s -o /dev/null -w "%{http_code}" http://localhost:5001/pos/login echo " (should be 200)" kill $POS_PID ``` - [ ] **Step 2: Final commit** ```bash git add -A pos/ git commit -m "feat(pos): foundation complete — multi-tenant, auth, permissions, audit, config" git push origin main ``` --- ## Summary This foundation plan creates: - **Multi-tenant infrastructure**: DB-per-client with template provisioning - **PIN authentication**: 4-digit login with JWT, rate limiting, lockout, branch-aware PIN optimization - **Permission system**: Role-based (owner/admin/cashier/warehouse/accountant) with granular permissions - **Audit logging**: Insert-only, tracks all actions - **Config management**: Branches, employees, theming infrastructure - **Migration runner**: Versioned schema updates across all tenants - **21 tables** per tenant DB, matching the design spec exactly - **SQL injection prevention**: `psycopg2.sql.Identifier` for all dynamic DB names - **JWT expiration check** on login page auto-redirect **Next plan**: POS Plan 2 — Inventory + Catalog (inventory CRUD, operations engine, catalog UI with cart, barcode support)