# /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 # Plan limit check from services.billing import check_limit, next_plan, PLANS, get_plan conn_chk = get_tenant_conn(g.tenant_id) cur_chk = conn_chk.cursor() cur_chk.execute("SELECT count(*) FROM branches WHERE is_active = true") current_branches = cur_chk.fetchone()[0] cur_chk.close() conn_chk.close() allowed, limit, current = check_limit(g.tenant_id, 'max_branches', current_branches) if not allowed: plan_key = get_plan(g.tenant_id) nxt = next_plan(plan_key) nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' return jsonify({'error': f'Plan limit reached ({limit} branches). Upgrade to {nxt_name}.'}), 403 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 # Plan limit check from services.billing import check_limit, next_plan, PLANS, get_plan conn_chk = get_tenant_conn(g.tenant_id) cur_chk = conn_chk.cursor() cur_chk.execute("SELECT count(*) FROM employees WHERE is_active = true") current_employees = cur_chk.fetchone()[0] cur_chk.close() conn_chk.close() allowed, limit, current = check_limit(g.tenant_id, 'max_employees', current_employees) if not allowed: plan_key = get_plan(g.tenant_id) nxt = next_plan(plan_key) nxt_name = PLANS[nxt]['name'] if nxt else 'Enterprise' return jsonify({'error': f'Plan limit reached ({limit} employees). Upgrade to {nxt_name}.'}), 403 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('/employees/', methods=['PUT']) @require_auth('config.edit') def update_employee(emp_id): """Update an existing employee's name, email, role, branch, discount, active status. If PIN is provided, it gets re-hashed. Otherwise PIN stays unchanged.""" import bcrypt data = request.get_json() or {} conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() # Check employee exists cur.execute("SELECT id FROM employees WHERE id = %s", (emp_id,)) if not cur.fetchone(): cur.close(); conn.close() return jsonify({'error': 'Employee not found'}), 404 # Build SET clause dynamically — only update provided fields updates = [] params = [] field_map = { 'name': 'name', 'email': 'email', 'phone': 'phone', 'role': 'role', 'branch_id': 'branch_id', 'max_discount_pct': 'max_discount_pct', 'is_active': 'is_active', } for json_key, col in field_map.items(): if json_key in data: updates.append(f"{col} = %s") params.append(data[json_key]) # PIN update (only if provided and non-empty) if data.get('pin') and len(str(data['pin'])) >= 4: pin_hash = bcrypt.hashpw(str(data['pin']).encode(), bcrypt.gensalt()).decode() updates.append("pin = %s") params.append(pin_hash) updates.append("password_hash = %s") params.append(pin_hash) if not updates: cur.close(); conn.close() return jsonify({'error': 'Nothing to update'}), 400 params.append(emp_id) cur.execute(f"UPDATE employees SET {', '.join(updates)} WHERE id = %s", params) from services.audit import log_action log_action(conn, 'EMPLOYEE_UPDATE', 'employee', emp_id, new_value={k: v for k, v in data.items() if k != 'pin'}) conn.commit() cur.close() conn.close() return jsonify({'ok': True, 'message': 'Employee updated'}) @config_bp.route('/currency', methods=['GET']) @require_auth() def get_currency(): """Get currency config for this tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key IN ('currency', 'exchange_rate_usd_mxn')") cfg = {} for row in cur.fetchall(): cfg[row[0]] = row[1] cur.close() conn.close() from config import DEFAULT_CURRENCY, EXCHANGE_RATE_USD_MXN return jsonify({ 'currency': cfg.get('currency', DEFAULT_CURRENCY), 'exchange_rate': float(cfg.get('exchange_rate_usd_mxn', str(EXCHANGE_RATE_USD_MXN))), 'currencies': { 'MXN': {'symbol': '$', 'name': 'Peso Mexicano'}, 'USD': {'symbol': 'US$', 'name': 'US Dollar'}, } }) @config_bp.route('/currency', methods=['PUT']) @require_auth('config.edit') def update_currency(): """Update currency config for this tenant.""" data = request.get_json() or {} currency = data.get('currency', 'MXN') rate = data.get('exchange_rate') if currency not in ('MXN', 'USD'): return jsonify({'error': 'currency must be MXN or USD'}), 400 if rate is not None: try: rate = float(rate) if rate <= 0: raise ValueError except (TypeError, ValueError): return jsonify({'error': 'exchange_rate must be a positive number'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('currency', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (currency,)) if rate is not None: cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('exchange_rate_usd_mxn', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (str(rate),)) conn.commit() cur.close() conn.close() # Invalidate cached exchange rate so next sale picks up the new value from services.currency import invalidate_rate_cache invalidate_rate_cache() return jsonify({'message': 'Currency config updated', 'currency': currency}) @config_bp.route('/business', methods=['GET']) @require_auth() def get_business(): """Read-only tenant business info from tenant_config.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'tenant_%'") cfg = {} for row in cur.fetchall(): cfg[row[0]] = row[1] cur.close() conn.close() return jsonify({ 'razon_social': cfg.get('tenant_razon_social', ''), 'nombre': cfg.get('tenant_nombre', cfg.get('tenant_razon_social', '')), 'rfc': cfg.get('tenant_rfc', ''), 'regimen_fiscal': cfg.get('tenant_regimen_fiscal', ''), 'direccion': cfg.get('tenant_direccion', ''), 'telefono': cfg.get('tenant_telefono', ''), 'email': cfg.get('tenant_email', ''), }) @config_bp.route('/business', methods=['PUT']) @require_auth('config.edit') def update_business(): """Save tenant business info to tenant_config.""" data = request.get_json() or {} field_map = { 'razon_social': 'tenant_razon_social', 'nombre': 'tenant_nombre', 'rfc': 'tenant_rfc', 'regimen_fiscal': 'tenant_regimen_fiscal', 'direccion': 'tenant_direccion', 'telefono': 'tenant_telefono', 'email': 'tenant_email', # Tax params 'tax_iva': 'tax_iva', 'tax_ieps': 'tax_ieps', 'invoice_serie': 'invoice_serie', 'invoice_folio': 'invoice_folio', 'default_currency': 'default_currency', 'default_payment_method': 'default_payment_method', } conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() for field, key in field_map.items(): val = data.get(field) if val is not None: cur.execute(""" INSERT INTO tenant_config (key, value) VALUES (%s, %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (key, str(val).strip())) conn.commit() cur.close() conn.close() return jsonify({'ok': True}) @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', } }) # ─── Billing / Subscription ────────────────────────── @config_bp.route('/billing', methods=['GET']) @require_auth() def get_billing(): """Get current plan, usage stats, and available plans.""" from services.billing import get_plan_details, PLANS, PLAN_ORDER plan = get_plan_details(g.tenant_id) # Get current usage counts conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute("SELECT count(*) FROM inventory WHERE is_active = true") products = cur.fetchone()[0] cur.execute("SELECT count(*) FROM employees WHERE is_active = true") employees = cur.fetchone()[0] cur.execute("SELECT count(*) FROM branches WHERE is_active = true") branches = cur.fetchone()[0] cur.close() conn.close() return jsonify({ 'current_plan': plan, 'usage': { 'products': products, 'employees': employees, 'branches': branches, }, 'plans': {k: {**v, 'key': k} for k, v in PLANS.items()}, 'plan_order': PLAN_ORDER, }) @config_bp.route('/billing/upgrade', methods=['POST']) @require_auth('config.edit') def upgrade_billing(): """Upgrade tenant plan.""" from services.billing import upgrade_plan data = request.get_json() or {} new_plan = data.get('plan') if not new_plan: return jsonify({'error': 'plan required'}), 400 result = upgrade_plan(g.tenant_id, new_plan) if 'error' in result: return jsonify(result), 400 return jsonify(result)