Compare commits

...

9 Commits

17 changed files with 1430 additions and 0 deletions

32
pos/app.py Normal file
View File

@@ -0,0 +1,32 @@
from flask import Flask
def create_app():
app = Flask(__name__)
# Register blueprints
from blueprints.auth_bp import auth_bp
app.register_blueprint(auth_bp)
from blueprints.config_bp import config_bp
app.register_blueprint(config_bp)
# Health check
@app.route('/pos/health')
def health():
return {'status': 'ok'}
from flask import render_template, send_from_directory
@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)
return app
if __name__ == '__main__':
app = create_app()
app.run(host='0.0.0.0', port=5001, debug=True)

View File

178
pos/blueprints/auth_bp.py Normal file
View File

@@ -0,0 +1,178 @@
# /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

149
pos/blueprints/config_bp.py Normal file
View File

@@ -0,0 +1,149 @@
# /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',
}
})

21
pos/config.py Normal file
View File

@@ -0,0 +1,21 @@
import os
MASTER_DB_URL = os.environ.get(
"MASTER_DB_URL",
"postgresql://nexus:nexus_autoparts_2026@localhost/nexus_autoparts"
)
TENANT_DB_URL_TEMPLATE = os.environ.get(
"TENANT_DB_URL_TEMPLATE",
"postgresql://nexus:nexus_autoparts_2026@localhost/{db_name}"
)
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_MAX_ATTEMPTS_PER_MINUTE = 5
PIN_LOCKOUT_THRESHOLD = 10
PIN_LOCKOUT_MINUTES = 15
TENANT_TEMPLATE_DB = "tenant_template"

57
pos/middleware.py Normal file
View File

@@ -0,0 +1,57 @@
# /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

98
pos/migrations/runner.py Executable file
View File

@@ -0,0 +1,98 @@
#!/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()

View File

@@ -0,0 +1,353 @@
-- /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);

5
pos/requirements.txt Normal file
View File

@@ -0,0 +1,5 @@
Flask>=2.3
psycopg2-binary>=2.9
PyJWT>=2.8
bcrypt>=4.0
lxml>=4.9

50
pos/seed/sat_accounts.sql Normal file
View File

@@ -0,0 +1,50 @@
-- SAT Chart of Accounts (Catálogo de Cuentas SAT)
-- All accounts are system accounts (is_system = true)
-- Parent IDs resolved via subqueries to avoid hardcoded integer dependencies
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active) VALUES
-- ============================================================
-- ACTIVO (100)
-- ============================================================
('100', 'Activo', NULL, 'activo', '100', true, true),
('110', 'Caja', (SELECT id FROM accounts WHERE code = '100'), 'activo', '110', true, true),
('111', 'Bancos', (SELECT id FROM accounts WHERE code = '100'), 'activo', '111', true, true),
('120', 'Clientes', (SELECT id FROM accounts WHERE code = '100'), 'activo', '120', true, true),
('130', 'Inventarios', (SELECT id FROM accounts WHERE code = '100'), 'activo', '130', true, true),
('140', 'IVA Acreditable', (SELECT id FROM accounts WHERE code = '100'), 'activo', '140', true, true),
-- ============================================================
-- PASIVO (200)
-- ============================================================
('200', 'Pasivo', NULL, 'pasivo', '200', true, true),
('210', 'Proveedores', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '210', true, true),
('220', 'IVA Trasladado', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '220', true, true),
('230', 'ISR por Pagar', (SELECT id FROM accounts WHERE code = '200'), 'pasivo', '230', true, true),
-- ============================================================
-- CAPITAL (300)
-- ============================================================
('300', 'Capital', NULL, 'capital', '300', true, true),
('310', 'Capital Social', (SELECT id FROM accounts WHERE code = '300'), 'capital', '310', true, true),
('320', 'Resultados del Ejercicio', (SELECT id FROM accounts WHERE code = '300'), 'capital', '320', true, true),
-- ============================================================
-- INGRESOS (400)
-- ============================================================
('400', 'Ingresos', NULL, 'ingreso', '400', true, true),
('410', 'Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '410', true, true),
('420', 'Devoluciones sobre Ventas', (SELECT id FROM accounts WHERE code = '400'), 'ingreso', '420', true, true),
-- ============================================================
-- COSTOS (500)
-- ============================================================
('500', 'Costos', NULL, 'costo', '500', true, true),
('510', 'Costo de Mercancia Vendida', (SELECT id FROM accounts WHERE code = '500'), 'costo', '510', true, true),
-- ============================================================
-- GASTOS (600)
-- ============================================================
('600', 'Gastos', NULL, 'gasto', '600', true, true),
('610', 'Gastos Operativos', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '610', true, true),
('620', 'Gastos Financieros', (SELECT id FROM accounts WHERE code = '600'), 'gasto', '620', true, true);

0
pos/services/__init__.py Normal file
View File

46
pos/services/audit.py Normal file
View File

@@ -0,0 +1,46 @@
# /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

View File

@@ -0,0 +1,189 @@
# /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

57
pos/static/css/common.css Normal file
View File

@@ -0,0 +1,57 @@
/* /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;
}

111
pos/static/js/login.js Normal file
View File

@@ -0,0 +1,111 @@
// /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');
}
}
})();

53
pos/templates/login.html Normal file
View File

@@ -0,0 +1,53 @@
<!-- /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-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>

31
pos/tenant_db.py Normal file
View File

@@ -0,0 +1,31 @@
# /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))