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

66 KiB

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

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
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
# /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
# /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
cd /home/Autopartes/pos && python3 app.py &
sleep 2
curl -s http://localhost:5001/pos/health
# Expected: {"status":"ok"}
kill %1
  • Step 5: Commit
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:

-- /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
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:

-- /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
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)

# /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
# /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
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
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

# /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
# /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:

# /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
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
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

# /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
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

# /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():

    from blueprints.config_bp import config_bp
    app.register_blueprint(config_bp)
  • Step 3: Commit
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

/* /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
<!-- /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
// /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:

    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
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

#!/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
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
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
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)