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>
1924 lines
66 KiB
Markdown
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)
|