diff --git a/docs/plans/2026-03-27-pos-plan-1-foundation.md b/docs/plans/2026-03-27-pos-plan-1-foundation.md new file mode 100644 index 0000000..d9dc6c2 --- /dev/null +++ b/docs/plans/2026-03-27-pos-plan-1-foundation.md @@ -0,0 +1,1923 @@ +# 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)