# POS + Cuentas por Cobrar — Implementation Plan > **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** Add a Point of Sale with credit accounts, invoicing with tax data, and payment tracking to the Nexus Autoparts system. **Architecture:** New PostgreSQL tables (customers, invoices, invoice_items, payments) + API endpoints in server.py + two new pages (pos.html, cuentas.html). Prices are cost + configurable margin. Customer balances are maintained via triggers on invoice/payment inserts. **Tech Stack:** Flask, PostgreSQL, SQLAlchemy raw SQL via `text()`, vanilla HTML/CSS/JS (same stack as existing app). --- ### Task 1: Database schema — Create new tables and columns **Files:** - Modify: `/home/Autopartes/dashboard/server.py` (no changes yet, just DB) **Step 1: Add cost_usd columns and create POS tables** Run this SQL via Python script: ```python # /home/Autopartes/setup_pos_tables.py from sqlalchemy import create_engine, text import sys sys.path.insert(0, '.') from config import DB_URL engine = create_engine(DB_URL) with engine.connect() as conn: conn.execute(text(""" -- Add cost columns ALTER TABLE parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2); ALTER TABLE aftermarket_parts ADD COLUMN IF NOT EXISTS cost_usd DECIMAL(12,2); -- Customers CREATE TABLE IF NOT EXISTS customers ( id_customer SERIAL PRIMARY KEY, name VARCHAR(200) NOT NULL, rfc VARCHAR(13), business_name VARCHAR(300), email VARCHAR(200), phone VARCHAR(20), address TEXT, credit_limit DECIMAL(12,2) DEFAULT 0, balance DECIMAL(12,2) DEFAULT 0, payment_terms INTEGER DEFAULT 30, active BOOLEAN DEFAULT TRUE, created_at TIMESTAMP DEFAULT NOW() ); -- Invoices CREATE TABLE IF NOT EXISTS invoices ( id_invoice SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id_customer), folio VARCHAR(20) UNIQUE NOT NULL, date_issued TIMESTAMP DEFAULT NOW(), subtotal DECIMAL(12,2) NOT NULL DEFAULT 0, tax_rate DECIMAL(5,4) DEFAULT 0.16, tax_amount DECIMAL(12,2) NOT NULL DEFAULT 0, total DECIMAL(12,2) NOT NULL DEFAULT 0, amount_paid DECIMAL(12,2) DEFAULT 0, status VARCHAR(20) DEFAULT 'pending', notes TEXT, created_at TIMESTAMP DEFAULT NOW() ); -- Invoice items CREATE TABLE IF NOT EXISTS invoice_items ( id_invoice_item SERIAL PRIMARY KEY, invoice_id INTEGER NOT NULL REFERENCES invoices(id_invoice) ON DELETE CASCADE, part_id INTEGER REFERENCES parts(id_part), aftermarket_id INTEGER REFERENCES aftermarket_parts(id_aftermarket_parts), description VARCHAR(500) NOT NULL, quantity INTEGER DEFAULT 1, unit_cost DECIMAL(12,2) DEFAULT 0, margin_pct DECIMAL(5,2) DEFAULT 30, unit_price DECIMAL(12,2) NOT NULL, line_total DECIMAL(12,2) NOT NULL ); -- Payments CREATE TABLE IF NOT EXISTS payments ( id_payment SERIAL PRIMARY KEY, customer_id INTEGER NOT NULL REFERENCES customers(id_customer), invoice_id INTEGER REFERENCES invoices(id_invoice), amount DECIMAL(12,2) NOT NULL, payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo', reference VARCHAR(100), date_payment TIMESTAMP DEFAULT NOW(), notes TEXT, created_at TIMESTAMP DEFAULT NOW() ); -- Indexes CREATE INDEX IF NOT EXISTS idx_invoices_customer ON invoices(customer_id); CREATE INDEX IF NOT EXISTS idx_invoices_status ON invoices(status); CREATE INDEX IF NOT EXISTS idx_invoices_folio ON invoices(folio); CREATE INDEX IF NOT EXISTS idx_invoice_items_invoice ON invoice_items(invoice_id); CREATE INDEX IF NOT EXISTS idx_payments_customer ON payments(customer_id); CREATE INDEX IF NOT EXISTS idx_payments_invoice ON payments(invoice_id); -- Folio sequence CREATE SEQUENCE IF NOT EXISTS invoice_folio_seq START 1; """)) conn.commit() print("POS tables created successfully") ``` **Step 2: Run the script** ```bash cd /home/Autopartes && python3 setup_pos_tables.py ``` **Step 3: Verify** ```bash python3 -c " from sqlalchemy import create_engine, text from config import DB_URL engine = create_engine(DB_URL) with engine.connect() as conn: for t in ['customers','invoices','invoice_items','payments']: cols = conn.execute(text(f\"SELECT column_name FROM information_schema.columns WHERE table_name='{t}' ORDER BY ordinal_position\")).fetchall() print(f'{t}: {[c[0] for c in cols]}') " ``` **Step 4: Commit** ```bash git add setup_pos_tables.py && git commit -m "feat(pos): add POS database schema" ``` --- ### Task 2: API endpoints — Customers CRUD **Files:** - Modify: `/home/Autopartes/dashboard/server.py` — insert before Main Block (line ~2672) **Step 1: Add customer endpoints** Insert these endpoints before the `# Main Block` comment in server.py: ```python # ============================================================================ # POS (Point of Sale) Endpoints # ============================================================================ @app.route('/pos') def pos_page(): return send_from_directory('.', 'pos.html') @app.route('/pos.js') def pos_js(): return send_from_directory('.', 'pos.js') @app.route('/pos.css') def pos_css(): return send_from_directory('.', 'pos.css') @app.route('/cuentas') def cuentas_page(): return send_from_directory('.', 'cuentas.html') @app.route('/cuentas.js') def cuentas_js(): return send_from_directory('.', 'cuentas.js') @app.route('/cuentas.css') def cuentas_css(): return send_from_directory('.', 'cuentas.css') # ---- Customers ---- @app.route('/api/pos/customers') def api_pos_customers(): session = Session() try: search = request.args.get('search', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["active = TRUE"] params = {'limit': per_page, 'offset': offset} if search: filters.append("(name ILIKE :search OR rfc ILIKE :search OR business_name ILIKE :search)") params['search'] = f'%{search}%' where = ' AND '.join(filters) total = session.execute(text(f"SELECT COUNT(*) FROM customers WHERE {where}"), params).scalar() rows = session.execute(text(f""" SELECT id_customer, name, rfc, business_name, phone, balance, credit_limit, payment_terms FROM customers WHERE {where} ORDER BY name LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/pos/customers/') def api_pos_customer_detail(customer_id): session = Session() try: row = session.execute(text( "SELECT * FROM customers WHERE id_customer = :id" ), {'id': customer_id}).mappings().first() if not row: return jsonify({'error': 'Cliente no encontrado'}), 404 return jsonify(dict(row)) finally: session.close() @app.route('/api/pos/customers', methods=['POST']) def api_pos_create_customer(): session = Session() try: data = request.get_json() result = session.execute(text(""" INSERT INTO customers (name, rfc, business_name, email, phone, address, credit_limit, payment_terms) VALUES (:name, :rfc, :business_name, :email, :phone, :address, :credit_limit, :payment_terms) RETURNING id_customer """), { 'name': data['name'], 'rfc': data.get('rfc'), 'business_name': data.get('business_name'), 'email': data.get('email'), 'phone': data.get('phone'), 'address': data.get('address'), 'credit_limit': data.get('credit_limit', 0), 'payment_terms': data.get('payment_terms', 30) }) new_id = result.scalar() session.commit() return jsonify({'id': new_id, 'message': 'Cliente creado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/customers/', methods=['PUT']) def api_pos_update_customer(customer_id): session = Session() try: data = request.get_json() session.execute(text(""" UPDATE customers SET name = :name, rfc = :rfc, business_name = :business_name, email = :email, phone = :phone, address = :address, credit_limit = :credit_limit, payment_terms = :payment_terms WHERE id_customer = :id """), { 'name': data['name'], 'rfc': data.get('rfc'), 'business_name': data.get('business_name'), 'email': data.get('email'), 'phone': data.get('phone'), 'address': data.get('address'), 'credit_limit': data.get('credit_limit', 0), 'payment_terms': data.get('payment_terms', 30), 'id': customer_id }) session.commit() return jsonify({'message': 'Cliente actualizado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() ``` **Step 2: Verify routes load** ```bash cd /home/Autopartes/dashboard && python3 -c "import server; [print(r.rule) for r in server.app.url_map.iter_rules() if 'pos' in r.rule]" ``` **Step 3: Commit** ```bash git add dashboard/server.py && git commit -m "feat(pos): add customer CRUD endpoints" ``` --- ### Task 3: API endpoints — Invoices and invoice items **Files:** - Modify: `/home/Autopartes/dashboard/server.py` **Step 1: Add invoice endpoints** (insert after customer endpoints, before Main Block) ```python # ---- Invoices ---- @app.route('/api/pos/invoices') def api_pos_invoices(): session = Session() try: customer_id = request.args.get('customer_id', '') status = request.args.get('status', '') page = int(request.args.get('page', 1)) per_page = min(int(request.args.get('per_page', 50)), 100) offset = (page - 1) * per_page filters = ["1=1"] params = {'limit': per_page, 'offset': offset} if customer_id: filters.append("i.customer_id = :customer_id") params['customer_id'] = int(customer_id) if status: filters.append("i.status = :status") params['status'] = status where = ' AND '.join(filters) total = session.execute(text(f""" SELECT COUNT(*) FROM invoices i WHERE {where} """), params).scalar() rows = session.execute(text(f""" SELECT i.id_invoice, i.folio, i.date_issued, i.subtotal, i.tax_amount, i.total, i.amount_paid, i.status, c.name AS customer_name, c.rfc FROM invoices i JOIN customers c ON i.customer_id = c.id_customer WHERE {where} ORDER BY i.date_issued DESC LIMIT :limit OFFSET :offset """), params).mappings().all() return jsonify({'data': [dict(r) for r in rows], 'pagination': { 'page': page, 'per_page': per_page, 'total': total, 'total_pages': (total + per_page - 1) // per_page }}) finally: session.close() @app.route('/api/pos/invoices/') def api_pos_invoice_detail(invoice_id): session = Session() try: inv = session.execute(text(""" SELECT i.*, c.name AS customer_name, c.rfc, c.business_name, c.address FROM invoices i JOIN customers c ON i.customer_id = c.id_customer WHERE i.id_invoice = :id """), {'id': invoice_id}).mappings().first() if not inv: return jsonify({'error': 'Factura no encontrada'}), 404 items = session.execute(text(""" SELECT ii.*, p.oem_part_number, ap.part_number AS aftermarket_number FROM invoice_items ii LEFT JOIN parts p ON ii.part_id = p.id_part LEFT JOIN aftermarket_parts ap ON ii.aftermarket_id = ap.id_aftermarket_parts WHERE ii.invoice_id = :id ORDER BY ii.id_invoice_item """), {'id': invoice_id}).mappings().all() return jsonify({'invoice': dict(inv), 'items': [dict(it) for it in items]}) finally: session.close() @app.route('/api/pos/invoices', methods=['POST']) def api_pos_create_invoice(): session = Session() try: data = request.get_json() customer_id = data['customer_id'] items = data['items'] # [{part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price}] tax_rate = data.get('tax_rate', 0.16) notes = data.get('notes', '') if not items: return jsonify({'error': 'La factura debe tener al menos una línea'}), 400 # Generate folio folio_num = session.execute(text("SELECT nextval('invoice_folio_seq')")).scalar() folio = f"NX-{folio_num:06d}" # Calculate totals subtotal = sum(it['quantity'] * it['unit_price'] for it in items) tax_amount = round(subtotal * tax_rate, 2) total = round(subtotal + tax_amount, 2) # Create invoice result = session.execute(text(""" INSERT INTO invoices (customer_id, folio, subtotal, tax_rate, tax_amount, total, notes) VALUES (:customer_id, :folio, :subtotal, :tax_rate, :tax_amount, :total, :notes) RETURNING id_invoice """), { 'customer_id': customer_id, 'folio': folio, 'subtotal': subtotal, 'tax_rate': tax_rate, 'tax_amount': tax_amount, 'total': total, 'notes': notes }) invoice_id = result.scalar() # Create items for it in items: line_total = it['quantity'] * it['unit_price'] session.execute(text(""" INSERT INTO invoice_items (invoice_id, part_id, aftermarket_id, description, quantity, unit_cost, margin_pct, unit_price, line_total) VALUES (:inv_id, :part_id, :af_id, :desc, :qty, :cost, :margin, :price, :total) """), { 'inv_id': invoice_id, 'part_id': it.get('part_id'), 'af_id': it.get('aftermarket_id'), 'desc': it['description'], 'qty': it['quantity'], 'cost': it.get('unit_cost', 0), 'margin': it.get('margin_pct', 30), 'price': it['unit_price'], 'total': line_total }) # Update customer balance session.execute(text( "UPDATE customers SET balance = balance + :total WHERE id_customer = :id" ), {'total': total, 'id': customer_id}) session.commit() return jsonify({'id': invoice_id, 'folio': folio, 'total': total, 'message': 'Factura creada'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/invoices//cancel', methods=['PUT']) def api_pos_cancel_invoice(invoice_id): session = Session() try: inv = session.execute(text( "SELECT total, customer_id, status FROM invoices WHERE id_invoice = :id" ), {'id': invoice_id}).mappings().first() if not inv: return jsonify({'error': 'Factura no encontrada'}), 404 if inv['status'] == 'cancelled': return jsonify({'error': 'La factura ya está cancelada'}), 400 session.execute(text( "UPDATE invoices SET status = 'cancelled' WHERE id_invoice = :id" ), {'id': invoice_id}) # Reverse the balance session.execute(text( "UPDATE customers SET balance = balance - :total WHERE id_customer = :cid" ), {'total': inv['total'], 'cid': inv['customer_id']}) session.commit() return jsonify({'message': 'Factura cancelada'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() ``` **Step 2: Commit** ```bash git add dashboard/server.py && git commit -m "feat(pos): add invoice endpoints" ``` --- ### Task 4: API endpoints — Payments and statements **Files:** - Modify: `/home/Autopartes/dashboard/server.py` **Step 1: Add payment endpoints** (insert after invoice endpoints) ```python # ---- Payments ---- @app.route('/api/pos/payments', methods=['POST']) def api_pos_create_payment(): session = Session() try: data = request.get_json() customer_id = data['customer_id'] amount = float(data['amount']) payment_method = data.get('payment_method', 'efectivo') reference = data.get('reference') invoice_id = data.get('invoice_id') notes = data.get('notes') if amount <= 0: return jsonify({'error': 'El monto debe ser mayor a 0'}), 400 result = session.execute(text(""" INSERT INTO payments (customer_id, invoice_id, amount, payment_method, reference, notes) VALUES (:cid, :inv_id, :amount, :method, :ref, :notes) RETURNING id_payment """), { 'cid': customer_id, 'inv_id': invoice_id, 'amount': amount, 'method': payment_method, 'ref': reference, 'notes': notes }) payment_id = result.scalar() # Update customer balance session.execute(text( "UPDATE customers SET balance = balance - :amount WHERE id_customer = :id" ), {'amount': amount, 'id': customer_id}) # If applied to specific invoice, update its amount_paid and status if invoice_id: session.execute(text( "UPDATE invoices SET amount_paid = amount_paid + :amount WHERE id_invoice = :id" ), {'amount': amount, 'id': invoice_id}) # Update invoice status session.execute(text(""" UPDATE invoices SET status = CASE WHEN amount_paid >= total THEN 'paid' WHEN amount_paid > 0 THEN 'partial' ELSE 'pending' END WHERE id_invoice = :id """), {'id': invoice_id}) session.commit() return jsonify({'id': payment_id, 'message': 'Pago registrado'}) except Exception as e: session.rollback() return jsonify({'error': str(e)}), 500 finally: session.close() @app.route('/api/pos/customers//statement') def api_pos_customer_statement(customer_id): session = Session() try: customer = session.execute(text( "SELECT * FROM customers WHERE id_customer = :id" ), {'id': customer_id}).mappings().first() if not customer: return jsonify({'error': 'Cliente no encontrado'}), 404 invoices = session.execute(text(""" SELECT id_invoice, folio, date_issued, total, amount_paid, status FROM invoices WHERE customer_id = :id AND status != 'cancelled' ORDER BY date_issued DESC LIMIT 100 """), {'id': customer_id}).mappings().all() payments = session.execute(text(""" SELECT p.id_payment, p.amount, p.payment_method, p.reference, p.date_payment, p.notes, i.folio AS invoice_folio FROM payments p LEFT JOIN invoices i ON p.invoice_id = i.id_invoice WHERE p.customer_id = :id ORDER BY p.date_payment DESC LIMIT 100 """), {'id': customer_id}).mappings().all() return jsonify({ 'customer': dict(customer), 'invoices': [dict(i) for i in invoices], 'payments': [dict(p) for p in payments] }) finally: session.close() @app.route('/api/pos/search-parts') def api_pos_search_parts(): """Search parts for the POS cart — returns OEM and aftermarket with prices.""" session = Session() try: q = request.args.get('q', '') if len(q) < 2: return jsonify([]) results = [] # Search OEM parts oem = session.execute(text(""" SELECT p.id_part, p.oem_part_number, p.name_part, p.name_es, p.cost_usd, pg.name_part_group AS group_name, 'oem' AS part_type FROM parts p JOIN part_groups pg ON p.group_id = pg.id_part_group WHERE p.oem_part_number ILIKE :q OR p.name_part ILIKE :q ORDER BY p.oem_part_number LIMIT 20 """), {'q': f'%{q}%'}).mappings().all() results.extend([dict(r) for r in oem]) # Search aftermarket parts af = session.execute(text(""" SELECT ap.id_aftermarket_parts AS id_part, ap.part_number AS oem_part_number, ap.name_aftermarket_parts AS name_part, ap.name_es, COALESCE(ap.cost_usd, ap.price_usd) AS cost_usd, m.name_manufacture AS group_name, 'aftermarket' AS part_type FROM aftermarket_parts ap JOIN manufacturers m ON ap.manufacturer_id = m.id_manufacture WHERE ap.part_number ILIKE :q OR ap.name_aftermarket_parts ILIKE :q ORDER BY ap.part_number LIMIT 20 """), {'q': f'%{q}%'}).mappings().all() results.extend([dict(r) for r in af]) return jsonify(results) finally: session.close() ``` **Step 2: Commit** ```bash git add dashboard/server.py && git commit -m "feat(pos): add payment and search endpoints" ``` --- ### Task 5: Frontend — POS page (pos.html + pos.css + pos.js) **Files:** - Create: `/home/Autopartes/dashboard/pos.html` - Create: `/home/Autopartes/dashboard/pos.css` - Create: `/home/Autopartes/dashboard/pos.js` **Step 1: Create pos.html** HTML page with: - Customer selector (search + create new) - Part search bar (searches OEM + aftermarket) - Cart table (description, qty, cost, margin%, price, total) - Totals section (subtotal, IVA 16%, total) - "Facturar" button **Step 2: Create pos.css** Styles for the POS layout: 2-column (left=search+cart, right=customer info + totals). **Step 3: Create pos.js** JavaScript logic: - Customer search and selection - Part search → add to cart - Editable margin per line - Auto-calculate prices: `unit_price = cost * (1 + margin/100)` - Totals: subtotal, IVA, total - Facturar → POST /api/pos/invoices **Step 4: Commit** ```bash git add dashboard/pos.html dashboard/pos.css dashboard/pos.js git commit -m "feat(pos): add point of sale frontend" ``` --- ### Task 6: Frontend — Cuentas page (cuentas.html + cuentas.css + cuentas.js) **Files:** - Create: `/home/Autopartes/dashboard/cuentas.html` - Create: `/home/Autopartes/dashboard/cuentas.css` - Create: `/home/Autopartes/dashboard/cuentas.js` **Step 1: Create cuentas.html** HTML page with: - Customer list with balances - Customer detail: info card, pending invoices, payment history - Payment form: amount, method, reference, apply to invoice - Create/edit customer modal **Step 2: Create cuentas.js** JavaScript logic: - Load customers with balances - Customer detail view with statement - Register payment → POST /api/pos/payments - Create/edit customer form **Step 3: Commit** ```bash git add dashboard/cuentas.html dashboard/cuentas.css dashboard/cuentas.js git commit -m "feat(pos): add accounts receivable frontend" ``` --- ### Task 7: Navigation + final integration **Files:** - Modify: `/home/Autopartes/dashboard/nav.js` **Step 1: Add POS and Cuentas links to nav** Add to the `navLinks` array and `isActive` function: ```javascript // isActive: if ((h === '/pos') && (p === '/pos')) return true; if ((h === '/cuentas') && (p === '/cuentas')) return true; // navLinks: { label: 'POS', href: '/pos' }, { label: 'Cuentas', href: '/cuentas' }, ``` **Step 2: Test full flow** ```bash # Start server nohup python3 /home/Autopartes/dashboard/server.py > /tmp/nexus-server.log 2>&1 & sleep 2 # Test customer creation curl -s -X POST http://localhost:5000/api/pos/customers \ -H "Content-Type: application/json" \ -d '{"name":"Taller Prueba","rfc":"TAL123456XX0","credit_limit":50000,"payment_terms":30}' # Test page loads curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/pos curl -s -o /dev/null -w "%{http_code}" http://localhost:5000/cuentas ``` **Step 3: Final commit** ```bash git add -A && git commit -m "feat(pos): complete POS and accounts system" ```