Files
Autoparts-DB/docs/plans/2026-03-27-pos-plan-1-foundation.md
consultoria-as 1b9b12d08e docs: add POS Foundation implementation plan (1 of 5)
10-task plan covering multi-tenant infrastructure:
- Tenant DB provisioning from template (21 tables)
- PIN auth with JWT, rate limiting, lockout
- Permission system (owner/admin/cashier/warehouse/accountant)
- Audit logging (insert-only)
- Config: branches, employees, theming
- Migration runner for versioned schema updates

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-31 01:16:59 +00:00

1924 lines
66 KiB
Markdown

# 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
<!-- /home/Autopartes/pos/templates/login.html -->
<!DOCTYPE html>
<html lang="es">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Nexus POS — Login</title>
<link rel="stylesheet" href="/pos/static/css/common.css">
<style>
body { display: flex; align-items: center; justify-content: center; min-height: 100vh; background: var(--color-bg); }
.login-card { text-align: center; max-width: 340px; width: 100%; padding: 40px; }
.login-title { font-family: var(--font-display); font-size: 1.5rem; font-weight: 700; margin-bottom: 8px; }
.login-subtitle { color: var(--color-text-secondary); font-size: 0.9rem; margin-bottom: 24px; }
.pin-display { font-family: var(--font-mono); font-size: 2rem; letter-spacing: 12px; margin-bottom: 20px; height: 48px; display: flex; align-items: center; justify-content: center; }
.pin-dots { display: flex; gap: 12px; justify-content: center; margin-bottom: 24px; }
.pin-dot { width: 16px; height: 16px; border-radius: 50%; border: 2px solid var(--color-border); transition: all 0.2s; }
.pin-dot.filled { background: var(--color-primary); border-color: var(--color-primary); }
.pin-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 10px; max-width: 260px; margin: 0 auto; }
.pin-btn { width: 72px; height: 72px; border-radius: 50%; border: 2px solid var(--color-border); background: var(--color-surface); font-family: var(--font-mono); font-size: 1.5rem; font-weight: 700; cursor: pointer; transition: all 0.15s; color: var(--color-text); }
.pin-btn:hover { background: var(--color-primary); color: white; border-color: var(--color-primary); }
.pin-btn:active { transform: scale(0.95); }
.pin-btn--clear { font-size: 0.8rem; font-family: var(--font-body); }
.login-error { color: var(--color-error); font-size: 0.85rem; margin-top: 16px; min-height: 20px; }
</style>
</head>
<body>
<div class="card login-card">
<div class="login-title">Nexus POS</div>
<div class="login-subtitle">Ingresa tu PIN</div>
<div class="pin-dots" id="pinDots">
<div class="pin-dot"></div>
<div class="pin-dot"></div>
<div class="pin-dot"></div>
<div class="pin-dot"></div>
</div>
<div class="pin-grid">
<button class="pin-btn" onclick="addDigit('1')">1</button>
<button class="pin-btn" onclick="addDigit('2')">2</button>
<button class="pin-btn" onclick="addDigit('3')">3</button>
<button class="pin-btn" onclick="addDigit('4')">4</button>
<button class="pin-btn" onclick="addDigit('5')">5</button>
<button class="pin-btn" onclick="addDigit('6')">6</button>
<button class="pin-btn" onclick="addDigit('7')">7</button>
<button class="pin-btn" onclick="addDigit('8')">8</button>
<button class="pin-btn" onclick="addDigit('9')">9</button>
<button class="pin-btn pin-btn--clear" onclick="clearPin()">Borrar</button>
<button class="pin-btn" onclick="addDigit('0')">0</button>
<button class="pin-btn pin-btn--clear" onclick="submitPin()">OK</button>
</div>
<div class="login-error" id="loginError"></div>
</div>
<script src="/pos/static/js/login.js"></script>
</body>
</html>
```
- [ ] **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/<path:filename>')
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)