# /home/Autopartes/pos/blueprints/invoicing_bp.py """Invoicing blueprint: CFDI generation, queue management, cancellation. All CFDI business logic lives in services (cfdi_builder, cfdi_queue). This blueprint is the HTTP layer that validates input and returns JSON. """ import json from datetime import datetime from flask import Blueprint, request, jsonify, g from middleware import require_auth from tenant_db import get_tenant_conn from services.cfdi_facturapi_builder import ( build_ingreso_payload, build_egreso_payload, build_pago_payload, ) from services.cfdi_queue import ( enqueue_cfdi, process_queue, retry_failed, cancel_cfdi, get_queue_status, ) from services import facturapi_service from services.audit import log_action invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') def _get_issuer_config(cur, branch_id=None): """Load CFDI issuer configuration. If branch_id is provided and the branch has fiscal data, use it. Otherwise fall back to tenant-level config. """ # Tenant-level defaults config = {} cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'") for row in cur.fetchall(): config[row[0]] = row[1] result = { 'rfc': config.get('tenant_rfc', ''), 'razon_social': config.get('tenant_razon_social', ''), 'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'), 'cp': config.get('tenant_cp', '00000'), 'serie': config.get('cfdi_serie', 'A'), 'facturapi_key': config.get('cfdi_facturapi_key', ''), 'facturapi_org_id': config.get('cfdi_facturapi_org_id', ''), } # Branch-level override if branch_id: cur.execute(""" SELECT rfc, razon_social, regimen_fiscal, codigo_postal, serie_cfdi FROM branches WHERE id = %s """, (branch_id,)) row = cur.fetchone() if row and row[0]: result['rfc'] = row[0] or result['rfc'] result['razon_social'] = row[1] or result['razon_social'] result['regimen_fiscal'] = row[2] or result['regimen_fiscal'] result['cp'] = row[3] or result['cp'] result['serie'] = row[4] or result['serie'] return result def _get_sale_with_items(cur, sale_id): """Load a sale with its items for CFDI generation.""" cur.execute(""" SELECT id, branch_id, customer_id, employee_id, sale_type, payment_method, subtotal, discount_total, tax_total, total, metodo_pago_sat, forma_pago_sat, status, created_at FROM sales WHERE id = %s """, (sale_id,)) row = cur.fetchone() if not row: return None sale = { 'id': row[0], 'branch_id': row[1], 'customer_id': row[2], 'employee_id': row[3], 'sale_type': row[4], 'payment_method': row[5], 'subtotal': float(row[6]) if row[6] else 0, 'discount_total': float(row[7]) if row[7] else 0, 'tax_total': float(row[8]) if row[8] else 0, 'total': float(row[9]) if row[9] else 0, 'metodo_pago_sat': row[10] or 'PUE', 'forma_pago_sat': row[11] or '01', 'status': row[12], 'created_at': str(row[13]), } cur.execute(""" SELECT id, inventory_id, part_number, name, quantity, unit_price, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal, clave_prod_serv, clave_unidad FROM sale_items WHERE sale_id = %s ORDER BY id """, (sale_id,)) sale['items'] = [] for r in cur.fetchall(): sale['items'].append({ 'id': r[0], 'inventory_id': r[1], 'part_number': r[2], 'name': r[3], 'quantity': r[4], 'unit_price': float(r[5]) if r[5] else 0, 'unit_cost': float(r[6]) if r[6] else 0, 'discount_pct': float(r[7]) if r[7] else 0, 'discount_amount': float(r[8]) if r[8] else 0, 'tax_rate': float(r[9]) if r[9] else 0.16, 'tax_amount': float(r[10]) if r[10] else 0, 'subtotal': float(r[11]) if r[11] else 0, 'clave_prod_serv': r[12] or '25174800', 'clave_unidad': r[13] or 'H87', }) return sale def _get_customer(cur, customer_id): """Load customer data for CFDI receptor.""" if not customer_id: return None cur.execute(""" SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp FROM customers WHERE id = %s """, (customer_id,)) row = cur.fetchone() if not row: return None return { 'id': row[0], 'name': row[1], 'rfc': row[2], 'razon_social': row[3], 'regimen_fiscal': row[4], 'uso_cfdi': row[5] or 'G03', 'cp': row[6], } # ─── Generate CFDI ───────────────────────────────── @invoicing_bp.route('/invoice', methods=['POST']) @require_auth('invoicing.create') def generate_invoice(): """Generate a CFDI for a sale and enqueue for timbrado. Body: { sale_id: int, type: 'ingreso' (default) | 'egreso', original_uuid: str (required for egreso) } """ data = request.get_json() or {} sale_id = data.get('sale_id') cfdi_type = data.get('type', 'ingreso') if not sale_id: return jsonify({'error': 'sale_id is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: sale = _get_sale_with_items(cur, sale_id) if not sale: return jsonify({'error': 'Sale not found'}), 404 tenant_config = _get_issuer_config(cur, sale.get('branch_id')) if not tenant_config['rfc']: return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400 if sale['status'] == 'cancelled': return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400 customer = _get_customer(cur, sale.get('customer_id')) # Check if this sale already has a stamped CFDI cur.execute(""" SELECT id, status FROM cfdi_queue WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed') """, (sale_id, cfdi_type)) existing = cur.fetchone() if existing: return jsonify({ 'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})' }), 409 # Build Facturapi payload if cfdi_type == 'ingreso': payload = build_ingreso_payload(sale, tenant_config, customer) elif cfdi_type == 'egreso': original_uuid = data.get('original_uuid') if not original_uuid: return jsonify({'error': 'original_uuid required for egreso'}), 400 payload = build_egreso_payload(sale, tenant_config, customer, original_uuid) else: return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400 # Enqueue result = enqueue_cfdi(conn, sale_id, cfdi_type, payload) log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'], new_value={'sale_id': sale_id, 'type': cfdi_type, 'folio': result['provisional_folio']}) conn.commit() cur.close() conn.close() return jsonify(result), 201 except ValueError as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 500 # ─── Queue Management ────────────────────────────── @invoicing_bp.route('/queue', methods=['GET']) @require_auth('invoicing.view') def list_queue(): """List CFDI queue items. Query params: status, sale_id, type, page, per_page """ conn = get_tenant_conn(g.tenant_id) filters = { 'status': request.args.get('status'), 'sale_id': request.args.get('sale_id'), 'type': request.args.get('type'), 'page': request.args.get('page', 1), 'per_page': request.args.get('per_page', 50), } result = get_queue_status(conn, filters) conn.close() return jsonify(result) @invoicing_bp.route('/queue/', methods=['GET']) @require_auth('invoicing.view') def get_queue_item(cfdi_id): """Get CFDI queue item detail (includes XML).""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT q.id, q.sale_id, q.type, q.payload_unsigned, q.xml_signed, q.uuid_fiscal, q.status, q.retry_count, q.provisional_folio, q.error_message, q.cancel_motive, q.cancel_replacement_uuid, q.created_at, q.stamped_at, q.external_id FROM cfdi_queue q WHERE q.id = %s """, (cfdi_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'CFDI queue item not found'}), 404 item = { 'id': row[0], 'sale_id': row[1], 'type': row[2], 'payload_unsigned': row[3], 'xml_signed': row[4], 'uuid_fiscal': row[5], 'status': row[6], 'retry_count': row[7], 'provisional_folio': row[8], 'error_message': row[9], 'cancel_motive': row[10], 'cancel_replacement_uuid': row[11], 'created_at': str(row[12]) if row[12] else None, 'stamped_at': str(row[13]) if row[13] else None, 'external_id': row[14], } cur.close() conn.close() return jsonify(item) @invoicing_bp.route('/queue/process', methods=['POST']) @require_auth('invoicing.create') def trigger_process_queue(): """Manually trigger processing of pending CFDI queue items.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: tenant_config = _get_issuer_config(cur) if not tenant_config.get('facturapi_key'): cur.close() conn.close() return jsonify({'error': 'Facturapi key not configured'}), 400 # Reset eligible failed items first reset_count = retry_failed(conn) # Process the queue result = process_queue(conn, tenant_config) result['retries_reset'] = reset_count cur.close() conn.close() return jsonify(result) except Exception as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 500 # ─── Cancel CFDI ──────────────────────────────────── @invoicing_bp.route('/cancel/', methods=['POST']) @require_auth('invoicing.delete') def cancel_invoice(cfdi_id): """Cancel a CFDI with SAT motive code. Body: { motive: '01' | '02' | '03' | '04', replacement_uuid: str (required if motive == '01') } Only owner and admin can cancel CFDIs. """ if g.employee_role not in ('owner', 'admin'): return jsonify({'error': 'Only owner or admin can cancel CFDIs'}), 403 data = request.get_json() or {} motive = data.get('motive') replacement_uuid = data.get('replacement_uuid') if not motive: return jsonify({'error': 'motive is required'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: tenant_config = _get_issuer_config(cur) result = cancel_cfdi( conn, cfdi_id, motive, replacement_uuid, tenant_config=tenant_config, ) log_action(conn, 'CFDI_CANCELLED', 'cfdi_queue', cfdi_id, new_value={'motive': motive, 'replacement_uuid': replacement_uuid}) conn.commit() cur.close() conn.close() return jsonify(result) except ValueError as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 500 # ─── PDF Generation ───────────────────────────────── @invoicing_bp.route('//pdf', methods=['GET']) @require_auth('invoicing.view') def get_sale_pdf(sale_id): """Generate a PDF representation of the sale/CFDI. Returns an HTML page styled for print/PDF generation. For actual PDF file generation, the frontend uses window.print() or a headless browser. This endpoint returns the formatted HTML. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() sale = _get_sale_with_items(cur, sale_id) if not sale: cur.close(); conn.close() return jsonify({'error': 'Sale not found'}), 404 tenant_config = _get_issuer_config(cur, sale.get('branch_id')) customer = _get_customer(cur, sale.get('customer_id')) # Check if there's a stamped CFDI cur.execute(""" SELECT uuid_fiscal, provisional_folio, status, stamped_at FROM cfdi_queue WHERE sale_id = %s AND type = 'ingreso' AND status = 'stamped' ORDER BY stamped_at DESC LIMIT 1 """, (sale_id,)) cfdi_row = cur.fetchone() cfdi_info = None if cfdi_row: cfdi_info = { 'uuid_fiscal': cfdi_row[0], 'provisional_folio': cfdi_row[1], 'status': cfdi_row[2], 'stamped_at': str(cfdi_row[3]) if cfdi_row[3] else None, } cur.close() conn.close() return jsonify({ 'sale': sale, 'tenant': { 'rfc': tenant_config.get('rfc', ''), 'razon_social': tenant_config.get('razon_social', ''), 'regimen_fiscal': tenant_config.get('regimen_fiscal', ''), 'cp': tenant_config.get('cp', ''), }, 'customer': customer, 'cfdi': cfdi_info, }) @invoicing_bp.route('/stats', methods=['GET']) @require_auth('invoicing.read') def api_invoicing_stats(): """Return counts for tab badges: invoices, credit notes, payment complements, cancellations.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT COUNT(*) FILTER (WHERE type = 'ingreso' AND status IN ('pending', 'stamped', 'retry')) as facturas, COUNT(*) FILTER (WHERE type = 'egreso' AND status IN ('pending', 'stamped', 'retry')) as notas_credito, COUNT(*) FILTER (WHERE type = 'pago' AND status IN ('pending', 'stamped', 'retry')) as complementos, COUNT(*) FILTER (WHERE status = 'cancelled') as cancelaciones FROM cfdi_queue """) row = cur.fetchone() cur.close() conn.close() return jsonify({ 'facturas': row[0] or 0, 'notas_credito': row[1] or 0, 'complementos': row[2] or 0, 'cancelaciones': row[3] or 0, }) @invoicing_bp.route('/global-invoice', methods=['POST']) @require_auth('invoicing.create') def generate_global_invoice(): """Generate a monthly global invoice for cash sales. Body: { year: int (default current year), month: int (default current month), branch_id: int (optional) } """ data = request.get_json() or {} now = datetime.now() year = data.get('year', now.year) month = data.get('month', now.month) branch_id = data.get('branch_id') try: year = int(year) month = int(month) if month < 1 or month > 12: return jsonify({'error': 'month must be 1-12'}), 400 except (ValueError, TypeError): return jsonify({'error': 'year and month must be integers'}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() tenant_config = _get_issuer_config(cur, branch_id) if not tenant_config['rfc']: cur.close(); conn.close() return jsonify({'error': 'Tenant RFC not configured'}), 400 from services.global_invoice import generate_global_invoice result = generate_global_invoice( conn, tenant_config, year, month, branch_id=branch_id, employee_id=getattr(g, 'employee_id', None) ) if 'error' in result: cur.close(); conn.close() return jsonify(result), 400 log_action(conn, 'GLOBAL_INVOICE_CREATE', 'cfdi_queue', result['id'], new_value={'year': year, 'month': month, 'sales_count': result['sales_count']}) conn.commit() cur.close() conn.close() return jsonify(result), 201 @invoicing_bp.route('/global-invoice/', methods=['GET']) @require_auth('invoicing.view') def get_global_invoice(cfdi_id): """Get status and linked sales of a global invoice.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() from services.global_invoice import get_global_invoice_status result = get_global_invoice_status(conn, cfdi_id) cur.close() conn.close() if not result: return jsonify({'error': 'Global invoice not found'}), 404 return jsonify(result) @invoicing_bp.route('/global-invoice/eligible-sales', methods=['GET']) @require_auth('invoicing.view') def get_eligible_sales_for_global(): """Preview sales that would be included in a global invoice. Query params: year, month, branch_id """ now = datetime.now() year = request.args.get('year', now.year, type=int) month = request.args.get('month', now.month, type=int) branch_id = request.args.get('branch_id', type=int) conn = get_tenant_conn(g.tenant_id) from services.global_invoice import get_eligible_sales sales = get_eligible_sales(conn, year, month, branch_id) conn.close() return jsonify({ 'year': year, 'month': month, 'count': len(sales), 'total': sum(s['total'] for s in sales), 'sales': [{'id': s['id'], 'total': s['total'], 'created_at': s['created_at']} for s in sales], }) # ─── Facturapi extras ─────────────────────────────── @invoicing_bp.route('/facturapi/status', methods=['GET']) @require_auth('invoicing.view') def facturapi_status(): """Return Facturapi organization status for the tenant.""" conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() tenant_config = _get_issuer_config(cur) cur.close() conn.close() status = facturapi_service.get_org_status(tenant_config) return jsonify(status) @invoicing_bp.route('/facturapi/setup', methods=['POST']) @require_auth('invoicing.create') def facturapi_setup(): """Create or link a Facturapi organization for this tenant. Requires FACTURAPI_USER_KEY environment variable. Stores cfdi_facturapi_org_id and cfdi_facturapi_key in tenant_config. """ conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() try: tenant_config = _get_issuer_config(cur) if not tenant_config.get('rfc'): return jsonify({'error': 'Tenant RFC not configured'}), 400 result = facturapi_service.create_organization(tenant_config) cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_org_id', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (result['org_id'],)) cur.execute(""" INSERT INTO tenant_config (key, value) VALUES ('cfdi_facturapi_key', %s) ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value """, (result['api_key'],)) log_action(conn, 'FACTURAPI_SETUP', 'tenant_config', None, new_value={'org_id': result['org_id']}) conn.commit() cur.close() conn.close() return jsonify({ 'org_id': result['org_id'], 'message': 'Facturapi organization created. Complete pending steps in Facturapi dashboard.', }) except ValueError as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 400 except Exception as e: conn.rollback() cur.close() conn.close() return jsonify({'error': str(e)}), 500 @invoicing_bp.route('/facturapi/download//', methods=['GET']) @require_auth('invoicing.view') def facturapi_download(cfdi_id, doc_type): """Download PDF or XML for a stamped CFDI from Facturapi. doc_type: 'pdf' | 'xml' """ if doc_type not in ('pdf', 'xml'): return jsonify({'error': "doc_type must be 'pdf' or 'xml'"}), 400 conn = get_tenant_conn(g.tenant_id) cur = conn.cursor() cur.execute(""" SELECT external_id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s """, (cfdi_id,)) row = cur.fetchone() if not row: cur.close(); conn.close() return jsonify({'error': 'CFDI not found'}), 404 external_id, uuid_fiscal, status = row if status != 'stamped' or not external_id: cur.close(); conn.close() return jsonify({'error': 'CFDI is not stamped or has no external id'}), 400 tenant_config = _get_issuer_config(cur) cur.close() conn.close() try: if doc_type == 'pdf': content = facturapi_service.download_pdf(tenant_config, external_id) mime = 'application/pdf' filename = f'cfdi_{uuid_fiscal or external_id}.pdf' else: content = facturapi_service.download_xml(tenant_config, external_id) mime = 'application/xml' filename = f'cfdi_{uuid_fiscal or external_id}.xml' except Exception as e: return jsonify({'error': str(e)}), 500 from flask import Response return Response( content, mimetype=mime, headers={'Content-Disposition': f'attachment; filename="{filename}"'}, )