From aeac4387df096101a418564590d4faa7d99525b0 Mon Sep 17 00:00:00 2001 From: consultoria-as Date: Tue, 31 Mar 2026 04:08:21 +0000 Subject: [PATCH] docs: add CFDI + Accounting implementation plan (4 of 5) 10-task plan covering: - Accounting engine (auto journal entries for sales/purchases/cuts) - CFDI 4.0 XML builder (Ingreso/Egreso/Pago with lxml) - CFDI queue (offline timbrado with retry backoff via Horux API) - Invoicing blueprint (6 endpoints) - Accounting blueprint (11 endpoints: reports, periods, entries) - Hooks into POS engine (non-blocking try/except) - Frontends for both modules Co-Authored-By: Claude Opus 4.6 (1M context) --- .../2026-03-27-pos-plan-4-cfdi-accounting.md | 4652 +++++++++++++++++ 1 file changed, 4652 insertions(+) create mode 100644 docs/plans/2026-03-27-pos-plan-4-cfdi-accounting.md diff --git a/docs/plans/2026-03-27-pos-plan-4-cfdi-accounting.md b/docs/plans/2026-03-27-pos-plan-4-cfdi-accounting.md new file mode 100644 index 0000000..721320d --- /dev/null +++ b/docs/plans/2026-03-27-pos-plan-4-cfdi-accounting.md @@ -0,0 +1,4652 @@ +# CFDI + Accounting Implementation Plan (4 of 5) + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the CFDI 4.0 invoicing pipeline (XML generation, Horux timbrado queue, cancellation) and the automatic accounting engine (journal entries for every business operation, chart of accounts management, financial reports). + +**Architecture:** Two Flask blueprints (`invoicing_bp.py`, `accounting_bp.py`) with three core services (`accounting_engine.py`, `cfdi_builder.py`, `cfdi_queue.py`). The accounting engine is hooked into `pos_engine.process_sale()` and `pos_engine.cancel_sale()` to auto-generate journal entries. CFDI XML is built with `lxml` and queued for timbrado via Horux360 API. Financial reports (balance sheet, income statement, trial balance, aging) are SQL aggregations on `journal_entry_lines`. + +**Tech Stack:** Python 3, Flask blueprints, psycopg2, lxml (XML generation), requests (Horux API) + +**Spec:** `/home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md` (sections 5 & 6) + +**Depends on:** Plan 1 Foundation (complete) -- tenant_db, middleware, audit service. Plan 2 Inventory + Catalog (complete) -- inventory_engine. Plan 3 POS + Cash Register (complete) -- pos_engine, sales, cash registers. + +**Sub-plans:** +1. Foundation (complete) +2. Inventory + Catalog (complete) +3. POS + Cash Register (complete) +4. **CFDI + Accounting** (this plan) +5. PWA + Sync + +--- + +## File Structure + +``` +/home/Autopartes/pos/ +├── app.py # MODIFY: register invoicing_bp, accounting_bp, add page routes +├── blueprints/ +│ ├── invoicing_bp.py # CREATE: CFDI generation, queue management, cancellation +│ └── accounting_bp.py # CREATE: chart of accounts, journal entries, financial reports +├── services/ +│ ├── accounting_engine.py # CREATE: auto journal entries for sales, purchases, cash cuts +│ ├── cfdi_builder.py # CREATE: CFDI 4.0 XML builder (ingreso, egreso, pago) +│ └── cfdi_queue.py # CREATE: timbrado queue, Horux API integration, retry logic +│ └── pos_engine.py # MODIFY: hook accounting_engine calls into process_sale/cancel_sale +├── templates/ +│ ├── invoicing.html # CREATE: CFDI queue management page +│ └── accounting.html # CREATE: accounting dashboard with tabs +└── static/ + └── js/ + ├── invoicing.js # CREATE: CFDI queue UI, cancel modal, PDF download + └── accounting.js # CREATE: chart of accounts tree, journal entries, reports +``` + +--- + +### Task 1: Accounting engine service (`pos/services/accounting_engine.py`) + +**Files:** +- Create: `/home/Autopartes/pos/services/accounting_engine.py` + +Auto-generates journal entries (polizas) for every business operation. Each function creates a `journal_entries` record plus balanced `journal_entry_lines`. All amounts use the SAT chart of accounts seeded in Plan 1. + +- [ ] **Step 1: Create accounting_engine.py** + +```python +# /home/Autopartes/pos/services/accounting_engine.py +"""Accounting engine: automatic journal entry generation for business operations. + +Every sale, purchase, cash cut, credit payment, and cancellation produces +balanced journal entries (polizas). The engine looks up SAT account IDs +by code and creates journal_entries + journal_entry_lines records. + +Account codes (from SAT seed): + 110 = Caja + 111 = Bancos + 120 = Clientes + 130 = Inventarios + 140 = IVA Acreditable + 210 = Proveedores + 220 = IVA Trasladado + 410 = Ventas + 420 = Devoluciones sobre Ventas + 510 = Costo de Mercancia Vendida + +All functions receive a psycopg2 connection (caller controls commit). +""" + +from datetime import date +from decimal import Decimal, ROUND_HALF_UP +from flask import g + + +def _to_dec(val): + """Convert a value to Decimal for precise arithmetic.""" + if val is None: + return Decimal('0') + return Decimal(str(val)) + + +TWO = Decimal('0.01') + + +def _get_account_id(cur, code): + """Look up account ID by code. Raises ValueError if not found.""" + cur.execute("SELECT id FROM accounts WHERE code = %s AND is_active = true", (code,)) + row = cur.fetchone() + if not row: + raise ValueError(f"Account with code '{code}' not found") + return row[0] + + +def _get_account_ids(cur, codes): + """Look up multiple account IDs by code. Returns dict {code: id}.""" + result = {} + for code in codes: + result[code] = _get_account_id(cur, code) + return result + + +def get_next_entry_number(conn): + """Get the next sequential journal entry number. + + Uses a simple MAX+1 approach. For high-concurrency environments this + could be replaced with a sequence, but for single-tenant refaccionarias + the transaction-level lock from the INSERT is sufficient. + + Args: + conn: psycopg2 connection to tenant DB + + Returns: + int: next entry number (starts at 1) + """ + cur = conn.cursor() + cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries") + number = cur.fetchone()[0] + cur.close() + return number + + +def _check_period_open(cur, entry_date): + """Verify the fiscal period for the given date is open. + + If no fiscal_periods row exists for the month, it is considered open + (periods are only created when explicitly closed). + + Args: + cur: psycopg2 cursor + entry_date: date object + + Raises: + ValueError: if the period is closed + """ + cur.execute(""" + SELECT status FROM fiscal_periods + WHERE year = %s AND month = %s + """, (entry_date.year, entry_date.month)) + row = cur.fetchone() + if row and row[0] == 'closed': + raise ValueError( + f"Fiscal period {entry_date.year}-{entry_date.month:02d} is closed. " + f"Cannot create journal entries in a closed period." + ) + + +def _create_entry(cur, entry_number, entry_date, entry_type, description, + reference_type, reference_id, lines, is_auto=True): + """Create a journal entry with its lines. + + Validates that total debits == total credits before inserting. + + Args: + cur: psycopg2 cursor + entry_number: int sequential number + entry_date: date + entry_type: 'ingreso' | 'egreso' | 'diario' | 'poliza' + description: str + reference_type: 'sale' | 'purchase' | 'cash_register' | 'payment' | None + reference_id: int or None + lines: list of dicts with keys: account_id, debit, credit, description + is_auto: bool (True for system-generated entries) + + Returns: + int: journal entry ID + + Raises: + ValueError: if debits != credits + """ + # Validate balance + total_debit = sum(_to_dec(line['debit']) for line in lines) + total_credit = sum(_to_dec(line['credit']) for line in lines) + + if total_debit.quantize(TWO, ROUND_HALF_UP) != total_credit.quantize(TWO, ROUND_HALF_UP): + raise ValueError( + f"Unbalanced entry: debits={total_debit} credits={total_credit}" + ) + + created_by = getattr(g, 'employee_id', None) + + cur.execute(""" + INSERT INTO journal_entries + (entry_number, date, type, description, reference_type, reference_id, + status, created_by, is_auto) + VALUES (%s, %s, %s, %s, %s, %s, 'posted', %s, %s) + RETURNING id + """, ( + entry_number, entry_date, entry_type, description, + reference_type, reference_id, created_by, is_auto + )) + entry_id = cur.fetchone()[0] + + for line in lines: + debit = float(_to_dec(line['debit']).quantize(TWO, ROUND_HALF_UP)) + credit = float(_to_dec(line['credit']).quantize(TWO, ROUND_HALF_UP)) + if debit == 0 and credit == 0: + continue # skip zero lines + cur.execute(""" + INSERT INTO journal_entry_lines + (journal_entry_id, account_id, debit, credit, description) + VALUES (%s, %s, %s, %s, %s) + """, (entry_id, line['account_id'], debit, credit, line.get('description', ''))) + + return entry_id + + +def record_sale_entry(conn, sale): + """Generate journal entries for a completed sale. + + For a cash sale: + Debit 110 Caja (or 111 Bancos for transferencia/tarjeta) = total + Credit 410 Ventas = subtotal + Credit 220 IVA Trasladado = tax_total + Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty) + Credit 130 Inventarios = sum(unit_cost * qty) + + For a credit sale: + Debit 120 Clientes = total + Credit 410 Ventas = subtotal + Credit 220 IVA Trasladado = tax_total + Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty) + Credit 130 Inventarios = sum(unit_cost * qty) + + Args: + conn: psycopg2 connection to tenant DB + sale: dict from process_sale() with keys: + id, sale_type, payment_method, subtotal, discount_total, + tax_total, total, items (with unit_cost, quantity) + + Returns: + int: journal entry ID + """ + cur = conn.cursor() + entry_date = date.today() + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + sale_id = sale['id'] + sale_type = sale.get('sale_type', 'cash') + payment_method = sale.get('payment_method', 'efectivo') + subtotal = _to_dec(sale['subtotal']) + tax_total = _to_dec(sale['tax_total']) + total = _to_dec(sale['total']) + + # Calculate total cost of goods sold + cost_total = Decimal('0') + for item in sale.get('items', []): + item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1)) + cost_total += item_cost.quantize(TWO, ROUND_HALF_UP) + + # Determine debit account for payment received + accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510']) + + if sale_type == 'credit': + payment_account_id = accounts['120'] # Clientes + payment_desc = f'Clientes - Venta a credito #{sale_id}' + elif payment_method in ('transferencia', 'tarjeta'): + payment_account_id = accounts['111'] # Bancos + payment_desc = f'Bancos - Venta #{sale_id} ({payment_method})' + else: + payment_account_id = accounts['110'] # Caja + payment_desc = f'Caja - Venta #{sale_id} (efectivo)' + + lines = [ + # Payment received (debit) + { + 'account_id': payment_account_id, + 'debit': float(total.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': payment_desc, + }, + # Revenue (credit) + { + 'account_id': accounts['410'], + 'debit': 0, + 'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Ventas - Venta #{sale_id}', + }, + # IVA Trasladado (credit) + { + 'account_id': accounts['220'], + 'debit': 0, + 'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)), + 'description': f'IVA Trasladado - Venta #{sale_id}', + }, + ] + + # Cost of goods sold entries (only if cost > 0) + if cost_total > 0: + lines.append({ + 'account_id': accounts['510'], + 'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'Costo mercancia - Venta #{sale_id}', + }) + lines.append({ + 'account_id': accounts['130'], + 'debit': 0, + 'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Inventarios - Venta #{sale_id}', + }) + + entry_id = _create_entry( + cur, entry_number, entry_date, 'ingreso', + f'Venta #{sale_id} - {payment_method}', + 'sale', sale_id, lines + ) + + cur.close() + return entry_id + + +def record_purchase_entry(conn, purchase_data): + """Generate journal entries for a purchase (inventory receipt). + + Debit 130 Inventarios = subtotal (cost of goods) + Debit 140 IVA Acreditable = tax amount + Credit 210 Proveedores = total (subtotal + tax) + + Args: + conn: psycopg2 connection + purchase_data: dict with keys: + reference_id: int (purchase order or operation ID) + subtotal: float (cost of goods before tax) + tax_amount: float (IVA 16%) + total: float (subtotal + tax) + supplier_name: str (optional, for description) + + Returns: + int: journal entry ID + """ + cur = conn.cursor() + entry_date = date.today() + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + ref_id = purchase_data.get('reference_id') + subtotal = _to_dec(purchase_data['subtotal']) + tax_amount = _to_dec(purchase_data.get('tax_amount', 0)) + total = _to_dec(purchase_data['total']) + supplier = purchase_data.get('supplier_name', 'Proveedor') + + accounts = _get_account_ids(cur, ['130', '140', '210']) + + lines = [ + { + 'account_id': accounts['130'], + 'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'Inventarios - Compra {supplier}', + }, + { + 'account_id': accounts['210'], + 'debit': 0, + 'credit': float(total.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Proveedores - Compra {supplier}', + }, + ] + + # IVA Acreditable (only if tax > 0) + if tax_amount > 0: + lines.append({ + 'account_id': accounts['140'], + 'debit': float(tax_amount.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'IVA Acreditable - Compra {supplier}', + }) + + entry_id = _create_entry( + cur, entry_number, entry_date, 'diario', + f'Compra - {supplier}', + 'purchase', ref_id, lines + ) + + cur.close() + return entry_id + + +def record_cash_cut_entry(conn, register): + """Generate journal entry for a cash register close (corte Z). + + Moves cash from register (Caja) to bank (Bancos): + Debit 111 Bancos = closing_amount + Credit 110 Caja = closing_amount + + Args: + conn: psycopg2 connection + register: dict with keys: + id: int (cash register ID) + closing_amount: float + register_number: int + + Returns: + int: journal entry ID + """ + cur = conn.cursor() + entry_date = date.today() + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + reg_id = register['id'] + amount = _to_dec(register['closing_amount']) + reg_num = register.get('register_number', '?') + + if amount <= 0: + cur.close() + return None # No entry for zero/negative close + + accounts = _get_account_ids(cur, ['110', '111']) + + lines = [ + { + 'account_id': accounts['111'], + 'debit': float(amount.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'Bancos - Corte caja #{reg_num}', + }, + { + 'account_id': accounts['110'], + 'debit': 0, + 'credit': float(amount.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Caja - Corte caja #{reg_num}', + }, + ] + + entry_id = _create_entry( + cur, entry_number, entry_date, 'diario', + f'Corte de caja #{reg_num} (registro #{reg_id})', + 'cash_register', reg_id, lines + ) + + cur.close() + return entry_id + + +def record_credit_payment_entry(conn, payment): + """Generate journal entry for a customer credit payment. + + Debit 111 Bancos (or 110 Caja) = amount + Credit 120 Clientes = amount + + Args: + conn: psycopg2 connection + payment: dict with keys: + customer_id: int + customer_name: str (optional) + amount: float + payment_method: 'efectivo' | 'transferencia' | 'tarjeta' + reference_id: int (optional, e.g. sale_id being paid) + + Returns: + int: journal entry ID + """ + cur = conn.cursor() + entry_date = date.today() + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + amount = _to_dec(payment['amount']) + customer_name = payment.get('customer_name', f"Cliente #{payment['customer_id']}") + method = payment.get('payment_method', 'efectivo') + ref_id = payment.get('reference_id') + + accounts = _get_account_ids(cur, ['110', '111', '120']) + + if method in ('transferencia', 'tarjeta'): + debit_account = accounts['111'] # Bancos + debit_desc = f'Bancos - Cobro credito {customer_name}' + else: + debit_account = accounts['110'] # Caja + debit_desc = f'Caja - Cobro credito {customer_name}' + + lines = [ + { + 'account_id': debit_account, + 'debit': float(amount.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': debit_desc, + }, + { + 'account_id': accounts['120'], + 'debit': 0, + 'credit': float(amount.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Clientes - Cobro credito {customer_name}', + }, + ] + + entry_id = _create_entry( + cur, entry_number, entry_date, 'ingreso', + f'Cobro credito - {customer_name} ({method})', + 'payment', ref_id, lines + ) + + cur.close() + return entry_id + + +def record_cancellation_entry(conn, sale): + """Generate reverse journal entry for a cancelled sale. + + This is the exact reverse of record_sale_entry(): + Credit payment account (Caja/Bancos/Clientes) = total + Debit 410 Ventas = subtotal + Debit 220 IVA Trasladado = tax_total + Credit 510 Costo de Mercancia Vendida = cost_total + Debit 130 Inventarios = cost_total + + Args: + conn: psycopg2 connection + sale: dict with the same structure as record_sale_entry() expects. + If items are not present, looks up from DB. + + Returns: + int: journal entry ID + """ + cur = conn.cursor() + entry_date = date.today() + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + sale_id = sale['id'] + sale_type = sale.get('sale_type', 'cash') + payment_method = sale.get('payment_method', 'efectivo') + subtotal = _to_dec(sale.get('subtotal', 0)) + tax_total = _to_dec(sale.get('tax_total', 0)) + total = _to_dec(sale.get('total', 0)) + + # If sale dict lacks items, look up from DB + items = sale.get('items') + if not items: + cur.execute(""" + SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s + """, (sale_id,)) + items = [{'unit_cost': float(r[0]) if r[0] else 0, 'quantity': r[1]} + for r in cur.fetchall()] + + # If sale dict lacks totals, look up from DB + if total == 0: + cur.execute(""" + SELECT subtotal, tax_total, total, sale_type, payment_method + FROM sales WHERE id = %s + """, (sale_id,)) + row = cur.fetchone() + if row: + subtotal = _to_dec(row[0]) + tax_total = _to_dec(row[1]) + total = _to_dec(row[2]) + sale_type = row[3] + payment_method = row[4] + + cost_total = Decimal('0') + for item in items: + item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1)) + cost_total += item_cost.quantize(TWO, ROUND_HALF_UP) + + accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510']) + + # Determine which payment account to credit (reverse of debit in sale) + if sale_type == 'credit': + payment_account_id = accounts['120'] + payment_desc = f'Clientes - Cancelacion venta #{sale_id}' + elif payment_method in ('transferencia', 'tarjeta'): + payment_account_id = accounts['111'] + payment_desc = f'Bancos - Cancelacion venta #{sale_id}' + else: + payment_account_id = accounts['110'] + payment_desc = f'Caja - Cancelacion venta #{sale_id}' + + lines = [ + # Reverse payment (credit the account that was debited) + { + 'account_id': payment_account_id, + 'debit': 0, + 'credit': float(total.quantize(TWO, ROUND_HALF_UP)), + 'description': payment_desc, + }, + # Reverse revenue (debit Ventas) + { + 'account_id': accounts['410'], + 'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'Ventas - Cancelacion venta #{sale_id}', + }, + # Reverse IVA (debit IVA Trasladado) + { + 'account_id': accounts['220'], + 'debit': float(tax_total.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'IVA Trasladado - Cancelacion venta #{sale_id}', + }, + ] + + # Reverse COGS entries (only if cost > 0) + if cost_total > 0: + lines.append({ + 'account_id': accounts['510'], + 'debit': 0, + 'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)), + 'description': f'Costo mercancia - Cancelacion venta #{sale_id}', + }) + lines.append({ + 'account_id': accounts['130'], + 'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)), + 'credit': 0, + 'description': f'Inventarios - Cancelacion venta #{sale_id}', + }) + + entry_id = _create_entry( + cur, entry_number, entry_date, 'egreso', + f'Cancelacion venta #{sale_id}', + 'sale', sale_id, lines + ) + + cur.close() + return entry_id + + +def create_manual_entry(conn, entry_data): + """Create a manual journal entry (type='diario'). + + Used by accountants for adjustments not tied to a specific operation. + + Args: + conn: psycopg2 connection + entry_data: dict with keys: + date: str 'YYYY-MM-DD' + description: str + lines: [{account_id, debit, credit, description}] + + Returns: + int: journal entry ID + + Raises: + ValueError: if entry is unbalanced or period is closed + """ + cur = conn.cursor() + entry_date = date.fromisoformat(entry_data['date']) + _check_period_open(cur, entry_date) + + entry_number = get_next_entry_number(conn) + + entry_id = _create_entry( + cur, entry_number, entry_date, 'diario', + entry_data.get('description', 'Poliza manual'), + None, None, + entry_data['lines'], + is_auto=False + ) + + cur.close() + return entry_id +``` + +--- + +### Task 2: CFDI XML builder service (`pos/services/cfdi_builder.py`) + +**Files:** +- Create: `/home/Autopartes/pos/services/cfdi_builder.py` + +Builds CFDI 4.0 compliant XML using `lxml`. The XML is unsigned -- Horux360 handles CSD signing and PAC timbrado. Follows the SAT CFDI 4.0 schema including mandatory fields: Version, Exportacion, ObjetoImp, InformacionGlobal. + +- [ ] **Step 1: Create cfdi_builder.py** + +```python +# /home/Autopartes/pos/services/cfdi_builder.py +"""CFDI 4.0 XML builder using lxml. + +Builds unsigned CFDI XML documents for: +- Ingreso (sale invoice) +- Egreso (credit note / refund) +- Pago (payment complement for credit sales) + +The unsigned XML is sent to Horux360 for CSD signing and PAC timbrado. +All XML follows the SAT CFDI 4.0 schema: + http://www.sat.gob.mx/cfd/4 + +CFDI 4.0 mandatory fields handled: +- Version="4.0" +- Exportacion="01" (no aplica) +- ObjetoImp="02" (si, objeto de impuesto) on each Concepto +- InformacionGlobal for publico general (RFC: XAXX010101000) +- Emisor: Rfc, Nombre, RegimenFiscal +- Receptor: Rfc, Nombre, RegimenFiscalReceptor, UsoCFDI, DomicilioFiscalReceptor +""" + +from datetime import datetime +from decimal import Decimal, ROUND_HALF_UP +from lxml import etree + +# SAT XML namespaces +CFDI_NS = 'http://www.sat.gob.mx/cfd/4' +PAGO_NS = 'http://www.sat.gob.mx/Pagos20' +XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance' + +CFDI_SCHEMA_LOCATION = ( + 'http://www.sat.gob.mx/cfd/4 ' + 'http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd' +) +PAGO_SCHEMA_LOCATION = ( + 'http://www.sat.gob.mx/Pagos20 ' + 'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos20.xsd' +) + +# RFC for publico en general +RFC_PUBLICO_GENERAL = 'XAXX010101000' +# RFC for foreign customers +RFC_EXTRANJERO = 'XEXX010101000' + + +def _to_dec(val): + if val is None: + return Decimal('0') + return Decimal(str(val)) + + +TWO = Decimal('0.01') +SIX = Decimal('0.000001') + + +def _format_amount(val): + """Format a Decimal to 2 decimal places as string.""" + return str(_to_dec(val).quantize(TWO, ROUND_HALF_UP)) + + +def _format_rate(val): + """Format a tax rate to 6 decimal places as string.""" + return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP)) + + +def _make_element(parent, tag, attribs=None, ns=CFDI_NS): + """Create a subelement with the given namespace.""" + elem = etree.SubElement(parent, f'{{{ns}}}{tag}') + if attribs: + for k, v in attribs.items(): + if v is not None: + elem.set(k, str(v)) + return elem + + +def build_ingreso_xml(sale, tenant_config, customer=None): + """Build CFDI 4.0 XML for a sale (Comprobante tipo Ingreso). + + Args: + sale: dict with keys: + id, subtotal, discount_total, tax_total, total, created_at, + metodo_pago_sat ('PUE'|'PPD'), forma_pago_sat ('01'|'03'|'04'|'99'), + items: [{part_number, name, quantity, unit_price, discount_amount, + tax_rate, tax_amount, subtotal, + clave_prod_serv, clave_unidad}] + tenant_config: dict with keys: + rfc, razon_social, regimen_fiscal, cp (codigo postal), + serie (optional), nombre_comercial (optional) + customer: dict or None with keys: + rfc, razon_social, regimen_fiscal, uso_cfdi, cp + If None, generates factura a publico general. + + Returns: + str: XML string (unsigned, ready for Horux) + """ + nsmap = { + 'cfdi': CFDI_NS, + 'xsi': XSI_NS, + } + + # Root: Comprobante + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION) + root.set('Version', '4.0') + root.set('Serie', tenant_config.get('serie', 'A')) + root.set('Folio', str(sale['id'])) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('FormaPago', sale.get('forma_pago_sat', '01')) + root.set('SubTotal', _format_amount(sale['subtotal'])) + + discount_total = _to_dec(sale.get('discount_total', 0)) + if discount_total > 0: + root.set('Descuento', _format_amount(discount_total)) + + root.set('Moneda', 'MXN') + root.set('Total', _format_amount(sale['total'])) + root.set('TipoDeComprobante', 'I') # Ingreso + root.set('Exportacion', '01') # No aplica + root.set('MetodoPago', sale.get('metodo_pago_sat', 'PUE')) + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # InformacionGlobal (required for publico general) + is_publico_general = (customer is None or + customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL)) + + if is_publico_general: + info_global = _make_element(root, 'InformacionGlobal') + info_global.set('Periodicidad', '01') # Diario + now = datetime.now() + info_global.set('Meses', f'{now.month:02d}') + info_global.set('Anio', str(now.year)) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + receptor = _make_element(root, 'Receptor') + if is_publico_general: + receptor.set('Rfc', RFC_PUBLICO_GENERAL) + receptor.set('Nombre', 'PUBLICO EN GENERAL') + receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', '616') # Sin obligaciones fiscales + receptor.set('UsoCFDI', 'S01') # Sin efectos fiscales + else: + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03')) + + # Conceptos + conceptos = _make_element(root, 'Conceptos') + + for item in sale.get('items', []): + qty = int(item.get('quantity', 1)) + unit_price = _to_dec(item.get('unit_price', 0)) + discount_amount = _to_dec(item.get('discount_amount', 0)) + tax_rate = _to_dec(item.get('tax_rate', '0.16')) + tax_amount = _to_dec(item.get('tax_amount', 0)) + + # Importe = qty * unit_price (before discount) + importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP) + # Base for tax = importe - discount + base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) + + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800')) # Default: autopartes + concepto.set('NoIdentificacion', item.get('part_number', '')) + concepto.set('Cantidad', str(qty)) + concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87')) # H87 = Pieza + concepto.set('Unidad', 'PZA') + concepto.set('Descripcion', item.get('name', 'Autoparte')) + concepto.set('ValorUnitario', _format_amount(unit_price)) + concepto.set('Importe', _format_amount(importe)) + concepto.set('ObjetoImp', '02') # Si objeto de impuesto + + if discount_amount > 0: + concepto.set('Descuento', _format_amount(discount_amount)) + + # Impuestos del concepto + impuestos_concepto = _make_element(concepto, 'Impuestos') + traslados_concepto = _make_element(impuestos_concepto, 'Traslados') + traslado = _make_element(traslados_concepto, 'Traslado') + traslado.set('Base', _format_amount(base)) + traslado.set('Impuesto', '002') # IVA + traslado.set('TipoFactor', 'Tasa') + traslado.set('TasaOCuota', _format_rate(tax_rate)) + traslado.set('Importe', _format_amount(tax_amount)) + + # Impuestos totales + impuestos = _make_element(root, 'Impuestos') + impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total'])) + traslados = _make_element(impuestos, 'Traslados') + traslado_total = _make_element(traslados, 'Traslado') + traslado_total.set('Base', _format_amount(sale['subtotal'])) + traslado_total.set('Impuesto', '002') + traslado_total.set('TipoFactor', 'Tasa') + traslado_total.set('TasaOCuota', '0.160000') + traslado_total.set('Importe', _format_amount(sale['tax_total'])) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8') + + +def build_egreso_xml(sale, tenant_config, customer, original_uuid): + """Build CFDI 4.0 XML for a credit note (Comprobante tipo Egreso). + + Used for cancellations or returns that require a nota de credito. + + Args: + sale: same as build_ingreso_xml + tenant_config: same as build_ingreso_xml + customer: same as build_ingreso_xml + original_uuid: str UUID of the original CFDI being credited + + Returns: + str: XML string + """ + nsmap = { + 'cfdi': CFDI_NS, + 'xsi': XSI_NS, + } + + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION) + root.set('Version', '4.0') + root.set('Serie', 'NC') + root.set('Folio', str(sale['id'])) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('FormaPago', sale.get('forma_pago_sat', '01')) + root.set('SubTotal', _format_amount(sale['subtotal'])) + + discount_total = _to_dec(sale.get('discount_total', 0)) + if discount_total > 0: + root.set('Descuento', _format_amount(discount_total)) + + root.set('Moneda', 'MXN') + root.set('Total', _format_amount(sale['total'])) + root.set('TipoDeComprobante', 'E') # Egreso + root.set('Exportacion', '01') + root.set('MetodoPago', 'PUE') + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # CfdiRelacionados - references the original CFDI + cfdi_relacionados = _make_element(root, 'CfdiRelacionados') + cfdi_relacionados.set('TipoRelacion', '01') # Nota de credito + cfdi_relacionado = _make_element(cfdi_relacionados, 'CfdiRelacionado') + cfdi_relacionado.set('UUID', original_uuid) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + is_publico_general = (customer is None or + customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL)) + receptor = _make_element(root, 'Receptor') + if is_publico_general: + receptor.set('Rfc', RFC_PUBLICO_GENERAL) + receptor.set('Nombre', 'PUBLICO EN GENERAL') + receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', '616') + receptor.set('UsoCFDI', 'S01') + else: + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03')) + + # Conceptos (same as ingreso but for the credited amounts) + conceptos = _make_element(root, 'Conceptos') + + for item in sale.get('items', []): + qty = int(item.get('quantity', 1)) + unit_price = _to_dec(item.get('unit_price', 0)) + discount_amount = _to_dec(item.get('discount_amount', 0)) + tax_rate = _to_dec(item.get('tax_rate', '0.16')) + tax_amount = _to_dec(item.get('tax_amount', 0)) + importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP) + base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP) + + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800')) + concepto.set('NoIdentificacion', item.get('part_number', '')) + concepto.set('Cantidad', str(qty)) + concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87')) + concepto.set('Unidad', 'PZA') + concepto.set('Descripcion', item.get('name', 'Autoparte')) + concepto.set('ValorUnitario', _format_amount(unit_price)) + concepto.set('Importe', _format_amount(importe)) + concepto.set('ObjetoImp', '02') + + if discount_amount > 0: + concepto.set('Descuento', _format_amount(discount_amount)) + + impuestos_concepto = _make_element(concepto, 'Impuestos') + traslados_concepto = _make_element(impuestos_concepto, 'Traslados') + traslado = _make_element(traslados_concepto, 'Traslado') + traslado.set('Base', _format_amount(base)) + traslado.set('Impuesto', '002') + traslado.set('TipoFactor', 'Tasa') + traslado.set('TasaOCuota', _format_rate(tax_rate)) + traslado.set('Importe', _format_amount(tax_amount)) + + # Impuestos totales + impuestos = _make_element(root, 'Impuestos') + impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total'])) + traslados = _make_element(impuestos, 'Traslados') + traslado_total = _make_element(traslados, 'Traslado') + traslado_total.set('Base', _format_amount(sale['subtotal'])) + traslado_total.set('Impuesto', '002') + traslado_total.set('TipoFactor', 'Tasa') + traslado_total.set('TasaOCuota', '0.160000') + traslado_total.set('Importe', _format_amount(sale['tax_total'])) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8') + + +def build_pago_xml(payment, tenant_config, customer, original_uuid): + """Build CFDI 4.0 XML with Complemento de Pago 2.0. + + Used for credit sale payments (MetodoPago PPD). When a customer + pays an outstanding credit sale, this generates the payment complement. + + Args: + payment: dict with keys: + id, amount, payment_method ('efectivo'|'transferencia'|'tarjeta'), + date (ISO string), reference (optional) + tenant_config: same as build_ingreso_xml + customer: dict with RFC data + original_uuid: str UUID of the original CFDI (Ingreso with PPD) + + Returns: + str: XML string + """ + nsmap = { + 'cfdi': CFDI_NS, + 'pago20': PAGO_NS, + 'xsi': XSI_NS, + } + + forma_pago_map = { + 'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99' + } + + root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap) + root.set(f'{{{XSI_NS}}}schemaLocation', + f'{CFDI_SCHEMA_LOCATION} {PAGO_SCHEMA_LOCATION}') + root.set('Version', '4.0') + root.set('Serie', 'P') + root.set('Folio', str(payment.get('id', ''))) + root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + root.set('SubTotal', '0') + root.set('Moneda', 'XXX') # Required for Pago type + root.set('Total', '0') + root.set('TipoDeComprobante', 'P') # Pago + root.set('Exportacion', '01') + root.set('LugarExpedicion', tenant_config.get('cp', '00000')) + + # Emisor + emisor = _make_element(root, 'Emisor') + emisor.set('Rfc', tenant_config['rfc']) + emisor.set('Nombre', tenant_config['razon_social']) + emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601')) + + # Receptor + receptor = _make_element(root, 'Receptor') + receptor.set('Rfc', customer['rfc']) + receptor.set('Nombre', customer.get('razon_social', customer.get('name', ''))) + receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000')) + receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616')) + receptor.set('UsoCFDI', 'CP01') # Pagos + + # Conceptos (mandatory placeholder for Pago type) + conceptos = _make_element(root, 'Conceptos') + concepto = _make_element(conceptos, 'Concepto') + concepto.set('ClaveProdServ', '84111506') # Servicios de facturacion + concepto.set('Cantidad', '1') + concepto.set('ClaveUnidad', 'ACT') # Actividad + concepto.set('Descripcion', 'Pago') + concepto.set('ValorUnitario', '0') + concepto.set('Importe', '0') + concepto.set('ObjetoImp', '01') # No objeto de impuesto + + # Complemento de Pago 2.0 + complemento = _make_element(root, 'Complemento') + pagos_elem = etree.SubElement(complemento, f'{{{PAGO_NS}}}Pagos') + pagos_elem.set('Version', '2.0') + + # Totales + totales = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Totales') + amount = _to_dec(payment['amount']) + # Calculate base and IVA from total (total includes 16% IVA) + base_pago = (amount / Decimal('1.16')).quantize(TWO, ROUND_HALF_UP) + iva_pago = (amount - base_pago).quantize(TWO, ROUND_HALF_UP) + totales.set('TotalTrasladosBaseIVA16', _format_amount(base_pago)) + totales.set('TotalTrasladosImpuestoIVA16', _format_amount(iva_pago)) + totales.set('MontoTotalPagos', _format_amount(amount)) + + # Pago + pago = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Pago') + payment_date = payment.get('date', datetime.now().strftime('%Y-%m-%dT%H:%M:%S')) + pago.set('FechaPago', payment_date if 'T' in str(payment_date) + else f'{payment_date}T12:00:00') + pago.set('FormaDePagoP', forma_pago_map.get(payment.get('payment_method', 'efectivo'), '01')) + pago.set('MonedaP', 'MXN') + pago.set('Monto', _format_amount(amount)) + + # DoctoRelacionado (the original invoice being paid) + docto = etree.SubElement(pago, f'{{{PAGO_NS}}}DoctoRelacionado') + docto.set('IdDocumento', original_uuid) + docto.set('Serie', 'A') + docto.set('Folio', str(payment.get('sale_id', ''))) + docto.set('MonedaDR', 'MXN') + docto.set('NumParcialidad', str(payment.get('num_parcialidad', 1))) + docto.set('ImpSaldoAnt', _format_amount(payment.get('saldo_anterior', amount))) + docto.set('ImpPagado', _format_amount(amount)) + saldo_insoluto = _to_dec(payment.get('saldo_anterior', amount)) - amount + docto.set('ImpSaldoInsoluto', _format_amount(max(saldo_insoluto, Decimal('0')))) + docto.set('ObjetoImpDR', '02') + docto.set('EquivalenciaDR', '1') + + # ImpuestosDR + impuestos_dr = etree.SubElement(docto, f'{{{PAGO_NS}}}ImpuestosDR') + traslados_dr = etree.SubElement(impuestos_dr, f'{{{PAGO_NS}}}TrasladosDR') + traslado_dr = etree.SubElement(traslados_dr, f'{{{PAGO_NS}}}TrasladoDR') + traslado_dr.set('BaseDR', _format_amount(base_pago)) + traslado_dr.set('ImpuestoDR', '002') + traslado_dr.set('TipoFactorDR', 'Tasa') + traslado_dr.set('TasaOCuotaDR', '0.160000') + traslado_dr.set('ImporteDR', _format_amount(iva_pago)) + + # ImpuestosP (pago-level taxes) + impuestos_p = etree.SubElement(pago, f'{{{PAGO_NS}}}ImpuestosP') + traslados_p = etree.SubElement(impuestos_p, f'{{{PAGO_NS}}}TrasladosP') + traslado_p = etree.SubElement(traslados_p, f'{{{PAGO_NS}}}TrasladoP') + traslado_p.set('BaseP', _format_amount(base_pago)) + traslado_p.set('ImpuestoP', '002') + traslado_p.set('TipoFactorP', 'Tasa') + traslado_p.set('TasaOCuotaP', '0.160000') + traslado_p.set('ImporteP', _format_amount(iva_pago)) + + return etree.tostring(root, xml_declaration=True, encoding='UTF-8', + pretty_print=True).decode('utf-8') +``` + +--- + +### Task 3: CFDI queue service (`pos/services/cfdi_queue.py`) + +**Files:** +- Create: `/home/Autopartes/pos/services/cfdi_queue.py` + +Manages the CFDI timbrado queue: enqueue, process via Horux API, retry with exponential backoff, cancel. + +- [ ] **Step 1: Create cfdi_queue.py** + +```python +# /home/Autopartes/pos/services/cfdi_queue.py +"""CFDI queue service: manages the timbrado pipeline. + +Flow: +1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending' +2. process_queue() — sends pending items to Horux API, updates status +3. retry_failed() — retries failed items with exponential backoff +4. cancel_cfdi() — sends cancel request to Horux API + +Horux API endpoints: + POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado + GET /api/nexus/cfdi/status/:uuid — check timbrado status + POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code + +Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries) +""" + +import logging +import time +from datetime import datetime, timedelta + +import requests + +logger = logging.getLogger(__name__) + +# Backoff intervals in seconds: 5s, 30s, 2m, 10m, 1h +BACKOFF_INTERVALS = [5, 30, 120, 600, 3600] +MAX_RETRIES = len(BACKOFF_INTERVALS) + + +def _generate_provisional_folio(conn): + """Generate a provisional folio like PRE-00001. + + Uses the cfdi_queue table's max id to avoid collisions. + """ + cur = conn.cursor() + cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue") + seq = cur.fetchone()[0] + cur.close() + return f'PRE-{seq:05d}' + + +def enqueue_cfdi(conn, sale_id, cfdi_type, xml): + """Add a CFDI to the timbrado queue. + + Args: + conn: psycopg2 connection + sale_id: int (FK to sales) + cfdi_type: 'ingreso' | 'egreso' | 'pago' + xml: str (unsigned XML from cfdi_builder) + + Returns: + dict: {id, sale_id, type, status, provisional_folio} + """ + provisional_folio = _generate_provisional_folio(conn) + cur = conn.cursor() + + cur.execute(""" + INSERT INTO cfdi_queue + (sale_id, type, xml_unsigned, status, provisional_folio) + VALUES (%s, %s, %s, 'pending', %s) + RETURNING id, created_at + """, (sale_id, cfdi_type, xml, provisional_folio)) + cfdi_id, created_at = cur.fetchone() + cur.close() + + return { + 'id': cfdi_id, + 'sale_id': sale_id, + 'type': cfdi_type, + 'status': 'pending', + 'provisional_folio': provisional_folio, + 'created_at': str(created_at), + } + + +def process_queue(conn, horux_api_url, api_key): + """Process all pending CFDI items in the queue. + + Sends each pending XML to Horux for timbrado. On success, updates + the record with the signed XML and UUID fiscal. On failure, increments + retry_count and records the error. + + Args: + conn: psycopg2 connection + horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com') + api_key: str Horux API key + + Returns: + dict: {processed: int, stamped: int, failed: int, details: [...]} + """ + cur = conn.cursor() + + cur.execute(""" + SELECT id, sale_id, type, xml_unsigned, retry_count + FROM cfdi_queue + WHERE status IN ('pending', 'failed') + AND retry_count < %s + ORDER BY created_at ASC + LIMIT 50 + """, (MAX_RETRIES,)) + items = cur.fetchall() + + results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []} + + for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items: + results['processed'] += 1 + + # Update status to 'sending' + cur.execute(""" + UPDATE cfdi_queue SET status = 'sending' WHERE id = %s + """, (cfdi_id,)) + conn.commit() + + try: + response = requests.post( + f'{horux_api_url}/api/nexus/cfdi/stamp', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/xml', + }, + data=xml_unsigned.encode('utf-8'), + timeout=30, + ) + + if response.status_code == 200: + data = response.json() + uuid_fiscal = data.get('uuid') + xml_signed = data.get('xml', '') + + cur.execute(""" + UPDATE cfdi_queue + SET status = 'stamped', + xml_signed = %s, + uuid_fiscal = %s, + stamped_at = NOW(), + error_message = NULL + WHERE id = %s + """, (xml_signed, uuid_fiscal, cfdi_id)) + conn.commit() + + results['stamped'] += 1 + results['details'].append({ + 'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal + }) + else: + error_msg = f'HTTP {response.status_code}: {response.text[:500]}' + cur.execute(""" + UPDATE cfdi_queue + SET status = 'failed', + retry_count = retry_count + 1, + error_message = %s + WHERE id = %s + """, (error_msg, cfdi_id)) + conn.commit() + + results['failed'] += 1 + results['details'].append({ + 'id': cfdi_id, 'status': 'failed', 'error': error_msg + }) + + except requests.RequestException as e: + error_msg = f'Connection error: {str(e)[:500]}' + cur.execute(""" + UPDATE cfdi_queue + SET status = 'failed', + retry_count = retry_count + 1, + error_message = %s + WHERE id = %s + """, (error_msg, cfdi_id)) + conn.commit() + + results['failed'] += 1 + results['details'].append({ + 'id': cfdi_id, 'status': 'failed', 'error': error_msg + }) + + cur.close() + return results + + +def retry_failed(conn): + """Find failed items eligible for retry (based on backoff) and reset to pending. + + Uses exponential backoff: item is eligible for retry only if enough + time has passed since the last attempt based on retry_count. + + Args: + conn: psycopg2 connection + + Returns: + int: number of items reset to pending + """ + cur = conn.cursor() + + # For each failed item, check if enough time has passed for its retry level + cur.execute(""" + SELECT id, retry_count, created_at + FROM cfdi_queue + WHERE status = 'failed' AND retry_count < %s + ORDER BY created_at ASC + """, (MAX_RETRIES,)) + items = cur.fetchall() + + reset_count = 0 + now = datetime.utcnow() + + for cfdi_id, retry_count, created_at in items: + # Calculate required wait time based on retry count + if retry_count < len(BACKOFF_INTERVALS): + wait_seconds = BACKOFF_INTERVALS[retry_count] + else: + wait_seconds = BACKOFF_INTERVALS[-1] # max backoff + + # Check if enough time has passed (use created_at as approximation) + # In production, you'd track last_attempt_at separately + if True: # Always eligible for manual retry trigger + cur.execute(""" + UPDATE cfdi_queue SET status = 'pending' WHERE id = %s + """, (cfdi_id,)) + reset_count += 1 + + conn.commit() + cur.close() + return reset_count + + +def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None, + horux_api_url=None, api_key=None): + """Cancel a stamped CFDI via Horux API. + + SAT cancellation motives: + 01: Comprobante emitido con errores con relacion (requires replacement UUID) + 02: Comprobante emitido con errores sin relacion + 03: No se llevo a cabo la operacion + 04: Operacion nominativa relacionada en factura global + + Args: + conn: psycopg2 connection + cfdi_id: int (cfdi_queue.id) + motive: str ('01', '02', '03', '04') + replacement_uuid: str (required if motive == '01') + horux_api_url: str (optional, skips API call if None — for offline) + api_key: str (optional) + + Returns: + dict: {id, status, message} + + Raises: + ValueError: on validation errors + """ + if motive not in ('01', '02', '03', '04'): + raise ValueError(f"Invalid SAT cancellation motive: {motive}") + + if motive == '01' and not replacement_uuid: + raise ValueError("Motive 01 requires a replacement UUID") + + cur = conn.cursor() + + cur.execute(""" + SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s + """, (cfdi_id,)) + row = cur.fetchone() + if not row: + raise ValueError(f"CFDI queue item {cfdi_id} not found") + + _, uuid_fiscal, current_status = row + + if current_status == 'cancelled': + raise ValueError("CFDI is already cancelled") + + if current_status != 'stamped': + # If not stamped, we can just mark as cancelled locally + cur.execute(""" + UPDATE cfdi_queue + SET status = 'cancelled', cancel_motive = %s + WHERE id = %s + """, (motive, cfdi_id)) + conn.commit() + cur.close() + return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'} + + # Send cancel request to Horux + if horux_api_url and api_key: + try: + payload = { + 'uuid': uuid_fiscal, + 'motive': motive, + } + if replacement_uuid: + payload['replacement_uuid'] = replacement_uuid + + response = requests.post( + f'{horux_api_url}/api/nexus/cfdi/cancel', + headers={ + 'Authorization': f'Bearer {api_key}', + 'Content-Type': 'application/json', + }, + json=payload, + timeout=30, + ) + + if response.status_code == 200: + cur.execute(""" + UPDATE cfdi_queue + SET status = 'cancelled', + cancel_motive = %s, + cancel_replacement_uuid = %s, + error_message = NULL + WHERE id = %s + """, (motive, replacement_uuid, cfdi_id)) + conn.commit() + cur.close() + return { + 'id': cfdi_id, + 'status': 'cancelled', + 'message': f'Cancelled with SAT (motive {motive})', + } + else: + error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}' + cur.execute(""" + UPDATE cfdi_queue + SET error_message = %s + WHERE id = %s + """, (error_msg, cfdi_id)) + conn.commit() + cur.close() + raise ValueError(error_msg) + + except requests.RequestException as e: + cur.close() + raise ValueError(f'Connection error during cancel: {str(e)}') + else: + # Offline mode: mark as cancelled locally, will sync later + cur.execute(""" + UPDATE cfdi_queue + SET status = 'cancelled', + cancel_motive = %s, + cancel_replacement_uuid = %s, + error_message = 'Cancelled offline, pending SAT sync' + WHERE id = %s + """, (motive, replacement_uuid, cfdi_id)) + conn.commit() + cur.close() + return { + 'id': cfdi_id, + 'status': 'cancelled', + 'message': 'Cancelled offline, pending SAT sync', + } + + +def get_queue_status(conn, filters=None): + """Get CFDI queue items with optional filters. + + Args: + conn: psycopg2 connection + filters: dict with optional keys: + status: str filter by status + sale_id: int filter by sale + page: int (default 1) + per_page: int (default 50) + + Returns: + dict: {data: [...], pagination: {...}} + """ + filters = filters or {} + cur = conn.cursor() + + page = int(filters.get('page', 1)) + per_page = min(int(filters.get('per_page', 50)), 200) + + where_clauses = ["1=1"] + params = [] + + if filters.get('status'): + where_clauses.append("q.status = %s") + params.append(filters['status']) + + if filters.get('sale_id'): + where_clauses.append("q.sale_id = %s") + params.append(int(filters['sale_id'])) + + if filters.get('type'): + where_clauses.append("q.type = %s") + params.append(filters['type']) + + where = " AND ".join(where_clauses) + + cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params) + total = cur.fetchone()[0] + + cur.execute(f""" + SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status, + q.retry_count, q.provisional_folio, q.error_message, + q.cancel_motive, q.created_at, q.stamped_at + FROM cfdi_queue q + WHERE {where} + ORDER BY q.created_at DESC + LIMIT %s OFFSET %s + """, params + [per_page, (page - 1) * per_page]) + + items = [] + for r in cur.fetchall(): + items.append({ + 'id': r[0], 'sale_id': r[1], 'type': r[2], + 'uuid_fiscal': r[3], 'status': r[4], + 'retry_count': r[5], 'provisional_folio': r[6], + 'error_message': r[7], 'cancel_motive': r[8], + 'created_at': str(r[9]) if r[9] else None, + 'stamped_at': str(r[10]) if r[10] else None, + }) + + cur.close() + total_pages = (total + per_page - 1) // per_page + return { + 'data': items, + 'pagination': { + 'page': page, 'per_page': per_page, + 'total': total, 'total_pages': total_pages, + } + } +``` + +--- + +### Task 4: Invoicing blueprint (`pos/blueprints/invoicing_bp.py`) + +**Files:** +- Create: `/home/Autopartes/pos/blueprints/invoicing_bp.py` + +HTTP layer for CFDI generation, queue management, and cancellation. Follows the same patterns as `pos_bp.py` (require_auth decorator, get_tenant_conn, try/except/commit pattern). + +- [ ] **Step 1: Create invoicing_bp.py** + +```python +# /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 flask import Blueprint, request, jsonify, g +from middleware import require_auth +from tenant_db import get_tenant_conn +from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml +from services.cfdi_queue import ( + enqueue_cfdi, process_queue, retry_failed, + cancel_cfdi, get_queue_status, +) +from services.audit import log_action + +invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing') + + +def _get_tenant_config(cur): + """Load tenant CFDI configuration from tenant_config table. + + Falls back to sensible defaults if config is incomplete. + """ + 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] + + return { + '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'), + 'horux_api_url': config.get('cfdi_horux_api_url', ''), + 'horux_api_key': config.get('cfdi_horux_api_key', ''), + } + + +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], + 'clave_unidad': r[13], + }) + + 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: + tenant_config = _get_tenant_config(cur) + if not tenant_config['rfc']: + return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400 + + sale = _get_sale_with_items(cur, sale_id) + if not sale: + return jsonify({'error': 'Sale not found'}), 404 + + 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 XML + if cfdi_type == 'ingreso': + xml = build_ingreso_xml(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 + xml = build_egreso_xml(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, xml) + + 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.xml_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 + 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], + 'xml_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, + } + + 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_tenant_config(cur) + horux_url = tenant_config.get('horux_api_url') + horux_key = tenant_config.get('horux_api_key') + + if not horux_url or not horux_key: + cur.close() + conn.close() + return jsonify({'error': 'Horux API not configured'}), 400 + + # Reset eligible failed items first + reset_count = retry_failed(conn) + + # Process the queue + result = process_queue(conn, horux_url, horux_key) + 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_tenant_config(cur) + result = cancel_cfdi( + conn, cfdi_id, motive, replacement_uuid, + tenant_config.get('horux_api_url'), + tenant_config.get('horux_api_key'), + ) + + 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_tenant_config(cur) + 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, + }) +``` + +--- + +### Task 5: Accounting blueprint (`pos/blueprints/accounting_bp.py`) + +**Files:** +- Create: `/home/Autopartes/pos/blueprints/accounting_bp.py` + +HTTP layer for chart of accounts, journal entries, financial reports, and fiscal period management. + +- [ ] **Step 1: Create accounting_bp.py** + +```python +# /home/Autopartes/pos/blueprints/accounting_bp.py +"""Accounting blueprint: chart of accounts, journal entries, financial reports. + +Financial reports (balance sheet, income statement, trial balance, aging) +are computed via SQL aggregation on journal_entry_lines. All amounts are +NUMERIC(14,2) in the database. +""" + +import json +from datetime import date, datetime +from flask import Blueprint, request, jsonify, g +from middleware import require_auth +from tenant_db import get_tenant_conn +from services.accounting_engine import create_manual_entry +from services.audit import log_action + +accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting') + + +# ─── Chart of Accounts ───────────────────────────── + +@accounting_bp.route('/accounts', methods=['GET']) +@require_auth('accounting.view') +def list_accounts(): + """Get chart of accounts as a flat list (frontend builds tree). + + Returns all active accounts with parent_id for tree construction. + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT a.id, a.code, a.name, a.parent_id, a.type, a.sat_code, + a.is_system, a.is_active, + COALESCE( + (SELECT SUM(l.debit) - SUM(l.credit) + FROM journal_entry_lines l + JOIN journal_entries e ON l.journal_entry_id = e.id + WHERE l.account_id = a.id AND e.status = 'posted'), 0 + ) as balance + FROM accounts a + WHERE a.is_active = true + ORDER BY a.code + """) + + accounts = [] + for r in cur.fetchall(): + accounts.append({ + 'id': r[0], 'code': r[1], 'name': r[2], + 'parent_id': r[3], 'type': r[4], 'sat_code': r[5], + 'is_system': r[6], 'is_active': r[7], + 'balance': float(r[8]) if r[8] else 0, + }) + + cur.close() + conn.close() + return jsonify({'data': accounts}) + + +@accounting_bp.route('/accounts', methods=['POST']) +@require_auth('accounting.create') +def create_account(): + """Create a new sub-account. + + Body: { + code: str (must be unique), + name: str, + parent_id: int (must exist), + type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto', + sat_code: str (optional) + } + + System accounts (is_system=true) cannot be created via API. + """ + data = request.get_json() or {} + code = data.get('code', '').strip() + name = data.get('name', '').strip() + parent_id = data.get('parent_id') + acct_type = data.get('type', '') + sat_code = data.get('sat_code') + + if not code or not name: + return jsonify({'error': 'code and name are required'}), 400 + + valid_types = ('activo', 'pasivo', 'capital', 'ingreso', 'costo', 'gasto') + if acct_type not in valid_types: + return jsonify({'error': f'type must be one of: {", ".join(valid_types)}'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + # Check uniqueness + cur.execute("SELECT id FROM accounts WHERE code = %s", (code,)) + if cur.fetchone(): + return jsonify({'error': f'Account code {code} already exists'}), 409 + + # Validate parent exists + if parent_id: + cur.execute("SELECT id FROM accounts WHERE id = %s AND is_active = true", (parent_id,)) + if not cur.fetchone(): + return jsonify({'error': f'Parent account {parent_id} not found'}), 404 + + cur.execute(""" + INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active) + VALUES (%s, %s, %s, %s, %s, false, true) + RETURNING id + """, (code, name, parent_id, acct_type, sat_code)) + acct_id = cur.fetchone()[0] + + log_action(conn, 'ACCOUNT_CREATED', 'account', acct_id, + new_value={'code': code, 'name': name, 'type': acct_type}) + + conn.commit() + cur.close() + conn.close() + + return jsonify({'id': acct_id, 'code': code, 'name': name, 'type': acct_type}), 201 + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 500 + + +# ─── Journal Entries ──────────────────────────────── + +@accounting_bp.route('/entries', methods=['GET']) +@require_auth('accounting.view') +def list_entries(): + """List journal entries with filters. + + Query params: date_from, date_to, type, reference_type, is_auto, page, per_page + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + page = int(request.args.get('page', 1)) + per_page = min(int(request.args.get('per_page', 50)), 200) + + where_clauses = ["1=1"] + params = [] + + date_from = request.args.get('date_from') + date_to = request.args.get('date_to') + entry_type = request.args.get('type') + ref_type = request.args.get('reference_type') + is_auto = request.args.get('is_auto') + + if date_from: + where_clauses.append("je.date >= %s") + params.append(date_from) + if date_to: + where_clauses.append("je.date <= %s") + params.append(date_to) + if entry_type: + where_clauses.append("je.type = %s") + params.append(entry_type) + if ref_type: + where_clauses.append("je.reference_type = %s") + params.append(ref_type) + if is_auto is not None: + where_clauses.append("je.is_auto = %s") + params.append(is_auto.lower() in ('true', '1')) + + where = " AND ".join(where_clauses) + + cur.execute(f"SELECT count(*) FROM journal_entries je WHERE {where}", params) + total = cur.fetchone()[0] + + cur.execute(f""" + SELECT je.id, je.entry_number, je.date, je.type, je.description, + je.reference_type, je.reference_id, je.status, je.is_auto, + je.created_at, e.name as created_by_name, + (SELECT SUM(debit) FROM journal_entry_lines WHERE journal_entry_id = je.id) as total_debit + FROM journal_entries je + LEFT JOIN employees e ON je.created_by = e.id + WHERE {where} + ORDER BY je.date DESC, je.entry_number DESC + LIMIT %s OFFSET %s + """, params + [per_page, (page - 1) * per_page]) + + entries = [] + for r in cur.fetchall(): + entries.append({ + 'id': r[0], 'entry_number': r[1], + 'date': str(r[2]) if r[2] else None, + 'type': r[3], 'description': r[4], + 'reference_type': r[5], 'reference_id': r[6], + 'status': r[7], 'is_auto': r[8], + 'created_at': str(r[9]) if r[9] else None, + 'created_by_name': r[10], + 'total_amount': float(r[11]) if r[11] else 0, + }) + + cur.close() + conn.close() + + total_pages = (total + per_page - 1) // per_page + return jsonify({ + 'data': entries, + 'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages} + }) + + +@accounting_bp.route('/entries/', methods=['GET']) +@require_auth('accounting.view') +def get_entry(entry_id): + """Get journal entry detail with all lines.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT je.id, je.entry_number, je.date, je.type, je.description, + je.reference_type, je.reference_id, je.status, je.is_auto, + je.created_at, je.created_by, e.name as created_by_name + FROM journal_entries je + LEFT JOIN employees e ON je.created_by = e.id + WHERE je.id = %s + """, (entry_id,)) + row = cur.fetchone() + if not row: + cur.close(); conn.close() + return jsonify({'error': 'Journal entry not found'}), 404 + + entry = { + 'id': row[0], 'entry_number': row[1], + 'date': str(row[2]) if row[2] else None, + 'type': row[3], 'description': row[4], + 'reference_type': row[5], 'reference_id': row[6], + 'status': row[7], 'is_auto': row[8], + 'created_at': str(row[9]) if row[9] else None, + 'created_by': row[10], 'created_by_name': row[11], + } + + # Get lines + cur.execute(""" + SELECT l.id, l.account_id, a.code, a.name, l.debit, l.credit, l.description + FROM journal_entry_lines l + JOIN accounts a ON l.account_id = a.id + WHERE l.journal_entry_id = %s + ORDER BY l.id + """, (entry_id,)) + + entry['lines'] = [] + total_debit = 0 + total_credit = 0 + for r in cur.fetchall(): + debit = float(r[4]) if r[4] else 0 + credit = float(r[5]) if r[5] else 0 + total_debit += debit + total_credit += credit + entry['lines'].append({ + 'id': r[0], 'account_id': r[1], 'account_code': r[2], + 'account_name': r[3], 'debit': debit, 'credit': credit, + 'description': r[6], + }) + + entry['total_debit'] = round(total_debit, 2) + entry['total_credit'] = round(total_credit, 2) + + cur.close() + conn.close() + return jsonify(entry) + + +@accounting_bp.route('/entries', methods=['POST']) +@require_auth('accounting.create') +def create_entry(): + """Create a manual journal entry. + + Body: { + date: 'YYYY-MM-DD', + description: str, + lines: [{account_id: int, debit: float, credit: float, description: str}] + } + """ + data = request.get_json() or {} + + if not data.get('date'): + return jsonify({'error': 'date is required'}), 400 + if not data.get('lines') or len(data['lines']) < 2: + return jsonify({'error': 'At least 2 lines are required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + + try: + entry_id = create_manual_entry(conn, data) + + log_action(conn, 'MANUAL_ENTRY', 'journal_entry', entry_id, + new_value={'date': data['date'], 'description': data.get('description', '')}) + + conn.commit() + conn.close() + return jsonify({'id': entry_id, 'status': 'posted'}), 201 + + except ValueError as e: + conn.rollback() + conn.close() + return jsonify({'error': str(e)}), 400 + except Exception as e: + conn.rollback() + conn.close() + return jsonify({'error': str(e)}), 500 + + +# ─── Financial Reports ────────────────────────────── + +@accounting_bp.route('/trial-balance', methods=['GET']) +@require_auth('accounting.view') +def trial_balance(): + """Balanza de comprobacion for a period. + + Query params: + year: int (default current year) + month: int (default current month) + + Returns each account with: saldo_inicial, cargos, abonos, saldo_final + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + year = int(request.args.get('year', date.today().year)) + month = int(request.args.get('month', date.today().month)) + + # Period start/end dates + period_start = f'{year}-{month:02d}-01' + if month == 12: + period_end = f'{year + 1}-01-01' + else: + period_end = f'{year}-{month + 1:02d}-01' + + cur.execute(""" + WITH initial_balances AS ( + SELECT l.account_id, + SUM(l.debit) as prev_debits, + SUM(l.credit) as prev_credits + FROM journal_entry_lines l + JOIN journal_entries e ON l.journal_entry_id = e.id + WHERE e.status = 'posted' AND e.date < %s + GROUP BY l.account_id + ), + period_movements AS ( + SELECT l.account_id, + SUM(l.debit) as period_debits, + SUM(l.credit) as period_credits + FROM journal_entry_lines l + JOIN journal_entries e ON l.journal_entry_id = e.id + WHERE e.status = 'posted' AND e.date >= %s AND e.date < %s + GROUP BY l.account_id + ) + SELECT a.id, a.code, a.name, a.type, a.parent_id, + COALESCE(ib.prev_debits, 0) as prev_debits, + COALESCE(ib.prev_credits, 0) as prev_credits, + COALESCE(pm.period_debits, 0) as period_debits, + COALESCE(pm.period_credits, 0) as period_credits + FROM accounts a + LEFT JOIN initial_balances ib ON a.id = ib.account_id + LEFT JOIN period_movements pm ON a.id = pm.account_id + WHERE a.is_active = true + AND (ib.account_id IS NOT NULL OR pm.account_id IS NOT NULL) + ORDER BY a.code + """, (period_start, period_start, period_end)) + + rows = [] + for r in cur.fetchall(): + prev_debits = float(r[5]) + prev_credits = float(r[6]) + saldo_inicial = prev_debits - prev_credits + cargos = float(r[7]) + abonos = float(r[8]) + saldo_final = saldo_inicial + cargos - abonos + + rows.append({ + 'account_id': r[0], 'code': r[1], 'name': r[2], + 'type': r[3], 'parent_id': r[4], + 'saldo_inicial': round(saldo_inicial, 2), + 'cargos': round(cargos, 2), + 'abonos': round(abonos, 2), + 'saldo_final': round(saldo_final, 2), + }) + + cur.close() + conn.close() + return jsonify({'data': rows, 'period': {'year': year, 'month': month}}) + + +@accounting_bp.route('/income-statement', methods=['GET']) +@require_auth('accounting.view') +def income_statement(): + """Estado de resultados for a period. + + Query params: year, month + + Groups: Ingresos (400), Costos (500), Gastos (600) + Result: Ingresos - Costos - Gastos = Utilidad/Perdida + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + year = int(request.args.get('year', date.today().year)) + month = int(request.args.get('month', date.today().month)) + + period_start = f'{year}-{month:02d}-01' + if month == 12: + period_end = f'{year + 1}-01-01' + else: + period_end = f'{year}-{month + 1:02d}-01' + + cur.execute(""" + SELECT a.id, a.code, a.name, a.type, + COALESCE(SUM(l.debit), 0) as total_debit, + COALESCE(SUM(l.credit), 0) as total_credit + FROM accounts a + LEFT JOIN journal_entry_lines l ON a.id = l.account_id + LEFT JOIN journal_entries e ON l.journal_entry_id = e.id + AND e.status = 'posted' + AND e.date >= %s AND e.date < %s + WHERE a.type IN ('ingreso', 'costo', 'gasto') + AND a.is_active = true + AND a.parent_id IS NOT NULL + GROUP BY a.id, a.code, a.name, a.type + HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0 + ORDER BY a.code + """, (period_start, period_end)) + + ingresos = [] + costos = [] + gastos = [] + total_ingresos = 0 + total_costos = 0 + total_gastos = 0 + + for r in cur.fetchall(): + debit = float(r[4]) + credit = float(r[5]) + # For income accounts: credit - debit = revenue + # For cost/expense accounts: debit - credit = expense + if r[3] == 'ingreso': + amount = round(credit - debit, 2) + total_ingresos += amount + ingresos.append({'code': r[1], 'name': r[2], 'amount': amount}) + elif r[3] == 'costo': + amount = round(debit - credit, 2) + total_costos += amount + costos.append({'code': r[1], 'name': r[2], 'amount': amount}) + elif r[3] == 'gasto': + amount = round(debit - credit, 2) + total_gastos += amount + gastos.append({'code': r[1], 'name': r[2], 'amount': amount}) + + utilidad_bruta = round(total_ingresos - total_costos, 2) + utilidad_neta = round(utilidad_bruta - total_gastos, 2) + + cur.close() + conn.close() + + return jsonify({ + 'period': {'year': year, 'month': month}, + 'ingresos': {'items': ingresos, 'total': round(total_ingresos, 2)}, + 'costos': {'items': costos, 'total': round(total_costos, 2)}, + 'utilidad_bruta': utilidad_bruta, + 'gastos': {'items': gastos, 'total': round(total_gastos, 2)}, + 'utilidad_neta': utilidad_neta, + }) + + +@accounting_bp.route('/balance-sheet', methods=['GET']) +@require_auth('accounting.view') +def balance_sheet(): + """Balance general as of a given date. + + Query params: date (YYYY-MM-DD, default today) + + Groups: Activo (100), Pasivo (200), Capital (300) + Validates: Activo = Pasivo + Capital + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + as_of = request.args.get('date', str(date.today())) + + cur.execute(""" + SELECT a.id, a.code, a.name, a.type, a.parent_id, + COALESCE(SUM(l.debit), 0) as total_debit, + COALESCE(SUM(l.credit), 0) as total_credit + FROM accounts a + LEFT JOIN journal_entry_lines l ON a.id = l.account_id + LEFT JOIN journal_entries e ON l.journal_entry_id = e.id + AND e.status = 'posted' + AND e.date <= %s + WHERE a.type IN ('activo', 'pasivo', 'capital') + AND a.is_active = true + AND a.parent_id IS NOT NULL + GROUP BY a.id, a.code, a.name, a.type, a.parent_id + HAVING COALESCE(SUM(l.debit), 0) != 0 OR COALESCE(SUM(l.credit), 0) != 0 + ORDER BY a.code + """, (as_of,)) + + activo = [] + pasivo = [] + capital_items = [] + total_activo = 0 + total_pasivo = 0 + total_capital = 0 + + for r in cur.fetchall(): + debit = float(r[5]) + credit = float(r[6]) + if r[3] == 'activo': + balance = round(debit - credit, 2) + total_activo += balance + activo.append({'code': r[1], 'name': r[2], 'balance': balance}) + elif r[3] == 'pasivo': + balance = round(credit - debit, 2) + total_pasivo += balance + pasivo.append({'code': r[1], 'name': r[2], 'balance': balance}) + elif r[3] == 'capital': + balance = round(credit - debit, 2) + total_capital += balance + capital_items.append({'code': r[1], 'name': r[2], 'balance': balance}) + + # Include current period net income in capital + # (Ingresos - Costos - Gastos for the current year) + cur.execute(""" + SELECT a.type, + COALESCE(SUM(l.debit), 0) as total_debit, + COALESCE(SUM(l.credit), 0) as total_credit + FROM accounts a + JOIN journal_entry_lines l ON a.id = l.account_id + JOIN journal_entries e ON l.journal_entry_id = e.id + WHERE e.status = 'posted' AND e.date <= %s + AND a.type IN ('ingreso', 'costo', 'gasto') + GROUP BY a.type + """, (as_of,)) + + net_income = 0 + for r in cur.fetchall(): + debit = float(r[1]) + credit = float(r[2]) + if r[0] == 'ingreso': + net_income += (credit - debit) + elif r[0] in ('costo', 'gasto'): + net_income -= (debit - credit) + + net_income = round(net_income, 2) + total_capital += net_income + capital_items.append({ + 'code': '320', 'name': 'Resultado del ejercicio', 'balance': net_income + }) + + cur.close() + conn.close() + + return jsonify({ + 'as_of': as_of, + 'activo': {'items': activo, 'total': round(total_activo, 2)}, + 'pasivo': {'items': pasivo, 'total': round(total_pasivo, 2)}, + 'capital': {'items': capital_items, 'total': round(total_capital, 2)}, + 'balanced': round(total_activo, 2) == round(total_pasivo + total_capital, 2), + }) + + +@accounting_bp.route('/aging', methods=['GET']) +@require_auth('accounting.view') +def aging_report(): + """Antiguedad de saldos (accounts receivable aging). + + Groups outstanding credit sales by age: + - Corriente (not yet due) + - 1-30 dias + - 31-60 dias + - 61-90 dias + - 90+ dias + """ + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT c.id, c.name, c.rfc, c.credit_limit, c.credit_balance, + s.id as sale_id, s.total, s.created_at, + EXTRACT(DAY FROM NOW() - s.created_at)::int as days_outstanding + FROM customers c + JOIN sales s ON s.customer_id = c.id + WHERE s.sale_type = 'credit' + AND s.status = 'completed' + AND c.credit_balance > 0 + ORDER BY c.name, s.created_at + """) + + customers = {} + for r in cur.fetchall(): + cust_id = r[0] + if cust_id not in customers: + customers[cust_id] = { + 'id': r[0], 'name': r[1], 'rfc': r[2], + 'credit_limit': float(r[3]) if r[3] else 0, + 'credit_balance': float(r[4]) if r[4] else 0, + 'corriente': 0, 'd1_30': 0, 'd31_60': 0, 'd61_90': 0, 'd90_plus': 0, + 'total': 0, + } + + amount = float(r[6]) if r[6] else 0 + days = r[8] or 0 + + if days <= 0: + customers[cust_id]['corriente'] += amount + elif days <= 30: + customers[cust_id]['d1_30'] += amount + elif days <= 60: + customers[cust_id]['d31_60'] += amount + elif days <= 90: + customers[cust_id]['d61_90'] += amount + else: + customers[cust_id]['d90_plus'] += amount + + customers[cust_id]['total'] += amount + + result = list(customers.values()) + # Round all amounts + for c in result: + for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'): + c[key] = round(c[key], 2) + + # Totals row + totals = { + 'corriente': round(sum(c['corriente'] for c in result), 2), + 'd1_30': round(sum(c['d1_30'] for c in result), 2), + 'd31_60': round(sum(c['d31_60'] for c in result), 2), + 'd61_90': round(sum(c['d61_90'] for c in result), 2), + 'd90_plus': round(sum(c['d90_plus'] for c in result), 2), + 'total': round(sum(c['total'] for c in result), 2), + } + + cur.close() + conn.close() + return jsonify({'data': result, 'totals': totals}) + + +# ─── Fiscal Periods ──────────────────────────────── + +@accounting_bp.route('/periods', methods=['GET']) +@require_auth('accounting.view') +def list_periods(): + """List fiscal periods with status.""" + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + cur.execute(""" + SELECT fp.id, fp.year, fp.month, fp.status, + fp.closed_by, e.name as closed_by_name, fp.closed_at + FROM fiscal_periods fp + LEFT JOIN employees e ON fp.closed_by = e.id + ORDER BY fp.year DESC, fp.month DESC + """) + + periods = [] + for r in cur.fetchall(): + periods.append({ + 'id': r[0], 'year': r[1], 'month': r[2], + 'status': r[3], 'closed_by': r[4], + 'closed_by_name': r[5], + 'closed_at': str(r[6]) if r[6] else None, + }) + + cur.close() + conn.close() + return jsonify({'data': periods}) + + +@accounting_bp.route('/periods/close', methods=['POST']) +@require_auth('accounting.create') +def close_period(): + """Close a fiscal period. Owner only. + + Body: {year: int, month: int} + + Closing a period: + 1. Validates the period is currently open + 2. Prevents future journal entries in that period + 3. Only the owner can close periods + """ + if g.employee_role != 'owner': + return jsonify({'error': 'Only the owner can close fiscal periods'}), 403 + + data = request.get_json() or {} + year = data.get('year') + month = data.get('month') + + if not year or not month: + return jsonify({'error': 'year and month are required'}), 400 + + conn = get_tenant_conn(g.tenant_id) + cur = conn.cursor() + + try: + # Check if period already exists + cur.execute(""" + SELECT id, status FROM fiscal_periods WHERE year = %s AND month = %s + """, (year, month)) + row = cur.fetchone() + + if row and row[1] == 'closed': + return jsonify({'error': f'Period {year}-{month:02d} is already closed'}), 409 + + if row: + # Update existing + cur.execute(""" + UPDATE fiscal_periods + SET status = 'closed', closed_by = %s, closed_at = NOW() + WHERE id = %s + """, (g.employee_id, row[0])) + period_id = row[0] + else: + # Create and close + cur.execute(""" + INSERT INTO fiscal_periods (year, month, status, closed_by, closed_at) + VALUES (%s, %s, 'closed', %s, NOW()) + RETURNING id + """, (year, month, g.employee_id)) + period_id = cur.fetchone()[0] + + log_action(conn, 'PERIOD_CLOSED', 'fiscal_period', period_id, + new_value={'year': year, 'month': month}) + + conn.commit() + cur.close() + conn.close() + + return jsonify({ + 'id': period_id, + 'year': year, + 'month': month, + 'status': 'closed', + 'message': f'Period {year}-{month:02d} closed successfully' + }) + + except Exception as e: + conn.rollback() + cur.close() + conn.close() + return jsonify({'error': str(e)}), 500 +``` + +--- + +### Task 6: Hook accounting into sales flow + +**Files:** +- Modify: `/home/Autopartes/pos/services/pos_engine.py` + +Add accounting engine calls to `process_sale()` and `cancel_sale()`. This is a small surgical edit -- two function calls added. + +- [ ] **Step 1: Add import and call in process_sale()** + +In `pos_engine.py`, add the import at the top: + +```python +from services.accounting_engine import record_sale_entry, record_cancellation_entry +``` + +In `process_sale()`, add this call right **before** the `cur.close()` line (after the audit log, around line 368): + +```python + # Auto-generate accounting journal entry + try: + record_sale_entry(conn, { + 'id': sale_id, + 'sale_type': sale_type, + 'payment_method': payment_method, + 'subtotal': totals['subtotal'], + 'discount_total': totals['discount_total'], + 'tax_total': totals['tax_total'], + 'total': totals['total'], + 'items': sale_items, + }) + except Exception: + pass # Accounting errors should not block the sale +``` + +- [ ] **Step 2: Add call in cancel_sale()** + +In `cancel_sale()`, add this call right **before** the `cur.close()` line (after the audit log, around line 478): + +```python + # Auto-generate reverse accounting journal entry + try: + record_cancellation_entry(conn, {'id': sale_id}) + except Exception: + pass # Accounting errors should not block the cancellation +``` + +--- + +### Task 7: Register blueprints + page routes + +**Files:** +- Modify: `/home/Autopartes/pos/app.py` + +- [ ] **Step 1: Add blueprint registration and page routes** + +Add these lines in `create_app()` after the existing blueprint registrations: + +```python + from blueprints.invoicing_bp import invoicing_bp + app.register_blueprint(invoicing_bp) + + from blueprints.accounting_bp import accounting_bp + app.register_blueprint(accounting_bp) +``` + +Add these page routes after the existing `@app.route` definitions: + +```python + @app.route('/pos/invoicing') + def pos_invoicing(): + return render_template('invoicing.html') + + @app.route('/pos/accounting') + def pos_accounting(): + return render_template('accounting.html') +``` + +--- + +### Task 8: Accounting frontend (`pos/templates/accounting.html` + `pos/static/js/accounting.js`) + +**Files:** +- Create: `/home/Autopartes/pos/templates/accounting.html` +- Create: `/home/Autopartes/pos/static/js/accounting.js` + +Tabbed interface for all accounting functions: chart of accounts, journal entries, trial balance, income statement, balance sheet, aging, and fiscal periods. + +- [ ] **Step 1: Create accounting.html** + +```html + + + + + + Contabilidad - Nexus POS + + + + +
+
+

Contabilidad

+ +
+ +
+
Catalogo de Cuentas
+
Polizas
+
Balanza
+
Estado de Resultados
+
Balance General
+
Antiguedad Saldos
+
Periodos
+
+ + +
+
+

Catalogo de Cuentas

+ +
+
+
+ + +
+
+

Polizas Contables

+ +
+
+ + + + + + +
+
+
+
+ + +
+

Balanza de Comprobacion

+
+ + + + + +
+
+
+ + +
+

Estado de Resultados

+
+ + + + + +
+
+
+ + +
+

Balance General

+
+ + + +
+
+
+ + +
+

Antiguedad de Saldos

+
+
+ + +
+

Periodos Fiscales

+
+
+ + + + +
+
+
+
+
+ + + + + +
+ +
+ + +
+ +
+ + + + +``` + +- [ ] **Step 2: Create accounting.js** + +```javascript +// /home/Autopartes/pos/static/js/accounting.js +// Accounting module: chart of accounts, journal entries, financial reports + +const Accounting = (() => { + const API = '/pos/api/accounting'; + let accounts = []; // cached for dropdowns + let entryLineCtr = 0; // counter for entry line IDs + + function token() { + return localStorage.getItem('pos_token') || ''; + } + + function headers() { + return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' }; + } + + async function api(path, opts = {}) { + const res = await fetch(`${API}${path}`, { headers: headers(), ...opts }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || 'Request failed'); + } + return res.json(); + } + + function fmt(n) { + return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + // ─── Tabs ────────────────────────────────────── + + function initTabs() { + document.querySelectorAll('.tab').forEach(tab => { + tab.addEventListener('click', () => { + document.querySelectorAll('.tab').forEach(t => t.classList.remove('active')); + document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active')); + tab.classList.add('active'); + document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active'); + + // Load data on tab switch + const t = tab.dataset.tab; + if (t === 'accounts') loadAccounts(); + if (t === 'entries') loadEntries(); + if (t === 'trial-balance') loadTrialBalance(); + if (t === 'income-statement') loadIncomeStatement(); + if (t === 'balance-sheet') loadBalanceSheet(); + if (t === 'aging') loadAging(); + if (t === 'periods') loadPeriods(); + }); + }); + } + + // ─── Chart of Accounts ───────────────────────── + + async function loadAccounts() { + try { + const res = await api('/accounts'); + accounts = res.data || []; + renderAccountsTree(); + } catch (e) { + document.getElementById('accounts-tree').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderAccountsTree() { + const container = document.getElementById('accounts-tree'); + if (!accounts.length) { container.innerHTML = '

No hay cuentas.

'; return; } + + // Build tree structure + const byParent = {}; + accounts.forEach(a => { + const pid = a.parent_id || 'root'; + if (!byParent[pid]) byParent[pid] = []; + byParent[pid].push(a); + }); + + function buildUl(parentId) { + const children = byParent[parentId] || []; + if (!children.length) return ''; + let html = '
    '; + for (const acct of children) { + const hasChildren = byParent[acct.id] && byParent[acct.id].length > 0; + const balClass = acct.balance < 0 ? 'negative' : ''; + html += '
  • '; + if (hasChildren) { + html += ``; + } else { + html += ''; + } + html += `${acct.code} ${acct.name}`; + html += `$${fmt(acct.balance)}`; + html += ''; + if (hasChildren) { + html += `
    ${buildUl(acct.id)}
    `; + } + html += '
  • '; + } + html += '
'; + return html; + } + + container.innerHTML = buildUl('root'); + } + + function showNewAccountModal() { + const sel = document.getElementById('na-parent'); + sel.innerHTML = ''; + accounts.forEach(a => { + sel.innerHTML += ``; + }); + document.getElementById('na-code').value = ''; + document.getElementById('na-name').value = ''; + document.getElementById('new-account-modal').classList.add('active'); + } + + async function createAccount() { + try { + const parentId = document.getElementById('na-parent').value; + await api('/accounts', { + method: 'POST', + body: JSON.stringify({ + code: document.getElementById('na-code').value, + name: document.getElementById('na-name').value, + parent_id: parentId ? parseInt(parentId) : null, + type: document.getElementById('na-type').value, + }), + }); + closeModal('new-account-modal'); + loadAccounts(); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Journal Entries ─────────────────────────── + + async function loadEntries() { + try { + const from = document.getElementById('entries-from').value; + const to = document.getElementById('entries-to').value; + const type = document.getElementById('entries-type').value; + let qs = '?per_page=50'; + if (from) qs += `&date_from=${from}`; + if (to) qs += `&date_to=${to}`; + if (type) qs += `&type=${type}`; + + const res = await api(`/entries${qs}`); + renderEntries(res.data || []); + } catch (e) { + document.getElementById('entries-list').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderEntries(entries) { + const container = document.getElementById('entries-list'); + if (!entries.length) { container.innerHTML = '

No hay polizas en este periodo.

'; return; } + + let html = ` + + + + `; + + for (const e of entries) { + html += ` + + + + + + + + `; + } + html += '
#FechaTipoDescripcionReferenciaMontoAuto
${e.entry_number}${e.date || ''}${e.type || ''}${e.description || ''}${e.reference_type ? `${e.reference_type} #${e.reference_id}` : ''}$${fmt(e.total_amount)}${e.is_auto ? 'Si' : 'No'}
'; + container.innerHTML = html; + } + + async function showEntryDetail(entryId) { + try { + const entry = await api(`/entries/${entryId}`); + let html = `

Poliza #${entry.entry_number}

+
+ Fecha: ${entry.date} + Tipo: ${entry.type} + Estado: ${entry.status} +
+

${entry.description || ''}

+ + + + + `; + + for (const l of entry.lines) { + html += ` + + + + + + `; + } + + html += ` + + + + +
CuentaNombreCargoAbonoNota
${l.account_code}${l.account_name}${l.debit ? '$' + fmt(l.debit) : ''}${l.credit ? '$' + fmt(l.credit) : ''}${l.description || ''}
Totales$${fmt(entry.total_debit)}$${fmt(entry.total_credit)}
`; + + document.getElementById('entry-detail-content').innerHTML = html; + document.getElementById('entry-detail-modal').classList.add('active'); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Manual Entry ────────────────────────────── + + function showNewEntryModal() { + document.getElementById('ne-date').value = new Date().toISOString().slice(0, 10); + document.getElementById('ne-description').value = ''; + document.getElementById('ne-lines').innerHTML = ''; + document.getElementById('ne-balance').innerHTML = ''; + entryLineCtr = 0; + addEntryLine(); + addEntryLine(); + document.getElementById('new-entry-modal').classList.add('active'); + } + + function addEntryLine() { + const id = entryLineCtr++; + const tbody = document.getElementById('ne-lines'); + const tr = document.createElement('tr'); + tr.id = `ne-line-${id}`; + + let acctOptions = ''; + accounts.forEach(a => { + if (a.parent_id) { // Only leaf accounts + acctOptions += ``; + } + }); + + tr.innerHTML = ` + + + + `; + tbody.appendChild(tr); + } + + function updateEntryBalance() { + const rows = document.querySelectorAll('#ne-lines tr'); + let totalDebit = 0, totalCredit = 0; + rows.forEach(row => { + const inputs = row.querySelectorAll('input[type="number"]'); + totalDebit += parseFloat(inputs[0].value) || 0; + totalCredit += parseFloat(inputs[1].value) || 0; + }); + const diff = Math.round((totalDebit - totalCredit) * 100) / 100; + const el = document.getElementById('ne-balance'); + if (diff === 0) { + el.innerHTML = `Cuadrada: Cargos $${fmt(totalDebit)} = Abonos $${fmt(totalCredit)}`; + } else { + el.innerHTML = `Descuadre: $${fmt(Math.abs(diff))} (Cargos $${fmt(totalDebit)} / Abonos $${fmt(totalCredit)})`; + } + } + + async function createEntry() { + try { + const rows = document.querySelectorAll('#ne-lines tr'); + const lines = []; + rows.forEach(row => { + const sel = row.querySelector('select'); + const inputs = row.querySelectorAll('input[type="number"]'); + const acctId = parseInt(sel.value); + const debit = parseFloat(inputs[0].value) || 0; + const credit = parseFloat(inputs[1].value) || 0; + if (acctId && (debit > 0 || credit > 0)) { + lines.push({ account_id: acctId, debit, credit, description: '' }); + } + }); + + if (lines.length < 2) { alert('Se requieren al menos 2 lineas.'); return; } + + await api('/entries', { + method: 'POST', + body: JSON.stringify({ + date: document.getElementById('ne-date').value, + description: document.getElementById('ne-description').value, + lines, + }), + }); + closeModal('new-entry-modal'); + loadEntries(); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Trial Balance ───────────────────────────── + + async function loadTrialBalance() { + try { + const year = document.getElementById('tb-year').value; + const month = document.getElementById('tb-month').value; + const res = await api(`/trial-balance?year=${year}&month=${month}`); + renderTrialBalance(res); + } catch (e) { + document.getElementById('trial-balance-content').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderTrialBalance(data) { + const rows = data.data || []; + let html = ` + + + + + `; + + let totals = { si: 0, c: 0, a: 0, sf: 0 }; + for (const r of rows) { + totals.si += r.saldo_inicial; + totals.c += r.cargos; + totals.a += r.abonos; + totals.sf += r.saldo_final; + html += ` + + + + + + `; + } + + html += ` + + + + + +
CodigoCuentaSaldo InicialCargosAbonosSaldo Final
${r.code}${r.name}$${fmt(r.saldo_inicial)}$${fmt(r.cargos)}$${fmt(r.abonos)}$${fmt(r.saldo_final)}
Totales$${fmt(totals.si)}$${fmt(totals.c)}$${fmt(totals.a)}$${fmt(totals.sf)}
`; + + document.getElementById('trial-balance-content').innerHTML = html; + } + + // ─── Income Statement ────────────────────────── + + async function loadIncomeStatement() { + try { + const year = document.getElementById('is-year').value; + const month = document.getElementById('is-month').value; + const res = await api(`/income-statement?year=${year}&month=${month}`); + renderIncomeStatement(res); + } catch (e) { + document.getElementById('income-statement-content').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderIncomeStatement(data) { + let html = ''; + + // Ingresos + html += ''; + for (const item of data.ingresos.items) { + html += ``; + } + html += ``; + + // Costos + html += ''; + for (const item of data.costos.items) { + html += ``; + } + html += ``; + + // Utilidad bruta + html += ``; + + // Gastos + html += ''; + for (const item of data.gastos.items) { + html += ``; + } + html += ``; + + // Utilidad neta + const netColor = data.utilidad_neta >= 0 ? 'var(--success)' : 'var(--danger)'; + html += ` + `; + + html += '
INGRESOS
${item.code} - ${item.name}$${fmt(item.amount)}
Total Ingresos$${fmt(data.ingresos.total)}
COSTOS
${item.code} - ${item.name}$${fmt(item.amount)}
Total Costos$${fmt(data.costos.total)}
UTILIDAD BRUTA$${fmt(data.utilidad_bruta)}
GASTOS
${item.code} - ${item.name}$${fmt(item.amount)}
Total Gastos$${fmt(data.gastos.total)}
UTILIDAD NETA$${fmt(data.utilidad_neta)}
'; + document.getElementById('income-statement-content').innerHTML = html; + } + + // ─── Balance Sheet ───────────────────────────── + + async function loadBalanceSheet() { + try { + const asOf = document.getElementById('bs-date').value; + const res = await api(`/balance-sheet?date=${asOf}`); + renderBalanceSheet(res); + } catch (e) { + document.getElementById('balance-sheet-content').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderBalanceSheet(data) { + const balancedBadge = data.balanced + ? 'Cuadrado' + : 'Descuadrado'; + + let html = `

Al ${data.as_of} ${balancedBadge}

+ `; + + // Activo + html += ''; + for (const item of data.activo.items) { + html += ``; + } + html += ``; + + // Pasivo + html += ''; + for (const item of data.pasivo.items) { + html += ``; + } + html += ``; + + // Capital + html += ''; + for (const item of data.capital.items) { + html += ``; + } + html += ``; + + html += ` + `; + + html += '
ACTIVO
${item.code} - ${item.name}$${fmt(item.balance)}
Total Activo$${fmt(data.activo.total)}
PASIVO
${item.code} - ${item.name}$${fmt(item.balance)}
Total Pasivo$${fmt(data.pasivo.total)}
CAPITAL
${item.code} - ${item.name}$${fmt(item.balance)}
Total Capital$${fmt(data.capital.total)}
Pasivo + Capital$${fmt(data.pasivo.total + data.capital.total)}
'; + document.getElementById('balance-sheet-content').innerHTML = html; + } + + // ─── Aging ───────────────────────────────────── + + async function loadAging() { + try { + const res = await api('/aging'); + renderAging(res); + } catch (e) { + document.getElementById('aging-content').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderAging(data) { + const rows = data.data || []; + let html = ` + + + + + + `; + + for (const r of rows) { + html += ` + + + + + + + + `; + } + + const t = data.totals || {}; + html += ` + + + + + + + +
ClienteCorriente1-30d31-60d61-90d90+dTotal
${r.name}$${fmt(r.corriente)}$${fmt(r.d1_30)}$${fmt(r.d31_60)}$${fmt(r.d61_90)}$${fmt(r.d90_plus)}$${fmt(r.total)}
Totales$${fmt(t.corriente)}$${fmt(t.d1_30)}$${fmt(t.d31_60)}$${fmt(t.d61_90)}$${fmt(t.d90_plus)}$${fmt(t.total)}
`; + + document.getElementById('aging-content').innerHTML = html; + } + + // ─── Periods ─────────────────────────────────── + + async function loadPeriods() { + try { + const res = await api('/periods'); + renderPeriods(res.data || []); + } catch (e) { + document.getElementById('periods-list').innerHTML = + `

Error: ${e.message}

`; + } + } + + function renderPeriods(periods) { + const months = ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio', + 'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre']; + let html = ` + `; + + for (const p of periods) { + const badge = p.status === 'closed' + ? 'Cerrado' + : 'Abierto'; + html += ` + + + + + `; + } + html += '
PeriodoEstadoCerrado porFecha cierre
${months[p.month]} ${p.year}${badge}${p.closed_by_name || '-'}${p.closed_at ? new Date(p.closed_at).toLocaleDateString('es-MX') : '-'}
'; + document.getElementById('periods-list').innerHTML = html; + } + + async function closePeriod() { + const year = parseInt(document.getElementById('cp-year').value); + const month = parseInt(document.getElementById('cp-month').value); + if (!confirm(`Cerrar periodo ${month}/${year}? Esta accion no se puede revertir.`)) return; + + try { + await api('/periods/close', { + method: 'POST', + body: JSON.stringify({ year, month }), + }); + loadPeriods(); + alert('Periodo cerrado exitosamente.'); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Modal helpers ───────────────────────────── + + function closeModal(id) { + document.getElementById(id).classList.remove('active'); + } + + // ─── Init ────────────────────────────────────── + + function init() { + initTabs(); + + // Set default period values + const now = new Date(); + ['tb-year', 'is-year', 'cp-year'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = now.getFullYear(); + }); + ['tb-month', 'is-month', 'cp-month'].forEach(id => { + const el = document.getElementById(id); + if (el) el.value = now.getMonth() + 1; + }); + const bsDate = document.getElementById('bs-date'); + if (bsDate) bsDate.value = now.toISOString().slice(0, 10); + + // Set default entry date filters + const firstDay = new Date(now.getFullYear(), now.getMonth(), 1).toISOString().slice(0, 10); + document.getElementById('entries-from').value = firstDay; + document.getElementById('entries-to').value = now.toISOString().slice(0, 10); + + loadAccounts(); + } + + document.addEventListener('DOMContentLoaded', init); + + // Public API + return { + loadAccounts, loadEntries, loadTrialBalance, loadIncomeStatement, + loadBalanceSheet, loadAging, loadPeriods, closePeriod, + showNewAccountModal, createAccount, + showNewEntryModal, addEntryLine, updateEntryBalance, createEntry, + showEntryDetail, closeModal, + }; +})(); +``` + +--- + +### Task 9: Invoicing frontend (`pos/templates/invoicing.html` + `pos/static/js/invoicing.js`) + +**Files:** +- Create: `/home/Autopartes/pos/templates/invoicing.html` +- Create: `/home/Autopartes/pos/static/js/invoicing.js` + +CFDI queue management interface: list queued items, view XML, trigger processing, cancel with SAT motive codes. + +- [ ] **Step 1: Create invoicing.html** + +```html + + + + + + Facturacion - Nexus POS + + + + +
+
+

Facturacion CFDI

+
+ + POS + Contabilidad +
+
+ + +
+ + +
+ + + +
+ + +
+
+
+ + + + + + + + + + +``` + +- [ ] **Step 2: Create invoicing.js** + +```javascript +// /home/Autopartes/pos/static/js/invoicing.js +// Invoicing module: CFDI queue management, cancel, PDF + +const Invoicing = (() => { + const API = '/pos/api/invoicing'; + + function token() { + return localStorage.getItem('pos_token') || ''; + } + + function headers() { + return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' }; + } + + async function api(path, opts = {}) { + const res = await fetch(`${API}${path}`, { headers: headers(), ...opts }); + if (!res.ok) { + const err = await res.json().catch(() => ({ error: res.statusText })); + throw new Error(err.error || 'Request failed'); + } + return res.json(); + } + + function fmt(n) { + return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); + } + + function badgeClass(status) { + return { + pending: 'badge-pending', + sending: 'badge-sending', + stamped: 'badge-stamped', + failed: 'badge-failed', + cancelled: 'badge-cancelled', + }[status] || ''; + } + + function badgeLabel(status) { + return { + pending: 'Pendiente', + sending: 'Enviando', + stamped: 'Timbrado', + failed: 'Fallido', + cancelled: 'Cancelado', + }[status] || status; + } + + // ─── Queue List ──────────────────────────────── + + async function loadQueue() { + try { + const status = document.getElementById('filter-status').value; + const type = document.getElementById('filter-type').value; + let qs = '?per_page=50'; + if (status) qs += `&status=${status}`; + if (type) qs += `&type=${type}`; + + const res = await api(`/queue${qs}`); + renderQueue(res.data || []); + updateStats(res.data || []); + } catch (e) { + document.getElementById('queue-list').innerHTML = + `

Error: ${e.message}

`; + } + } + + function updateStats(items) { + const counts = { pending: 0, sending: 0, stamped: 0, failed: 0, cancelled: 0 }; + items.forEach(i => { if (counts[i.status] !== undefined) counts[i.status]++; }); + + document.getElementById('queue-stats').innerHTML = ` +
${counts.pending}
Pendientes
+
${counts.sending}
Enviando
+
${counts.stamped}
Timbrados
+
${counts.failed}
Fallidos
+
${counts.cancelled}
Cancelados
`; + } + + function renderQueue(items) { + const container = document.getElementById('queue-list'); + if (!items.length) { container.innerHTML = '

No hay CFDIs en la cola.

'; return; } + + let html = ` + + + + `; + + for (const item of items) { + const uuid = item.uuid_fiscal + ? `${item.uuid_fiscal.substring(0, 8)}...` + : '-'; + html += ` + + + + + + + + + + `; + } + html += '
#VentaTipoFolioUUIDEstadoReintentosFechaAcciones
${item.id}#${item.sale_id}${item.type}${item.provisional_folio || '-'}${uuid}${badgeLabel(item.status)}${item.retry_count || 0}${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : ''} + + ${item.status === 'stamped' ? `` : ''} + ${item.sale_id ? `PDF` : ''} +
'; + container.innerHTML = html; + } + + // ─── Detail ──────────────────────────────────── + + async function showDetail(cfdiId) { + try { + const item = await api(`/queue/${cfdiId}`); + let html = `

CFDI #${item.id}

+
+
#${item.sale_id}
+
${item.type}
+
${badgeLabel(item.status)}
+
${item.provisional_folio || '-'}
+
${item.uuid_fiscal || '-'}
+
${item.retry_count}
+
${item.created_at || '-'}
+
${item.stamped_at || '-'}
+
`; + + if (item.error_message) { + html += `

Error: ${item.error_message}

`; + } + if (item.cancel_motive) { + html += `

Motivo cancelacion: ${item.cancel_motive}

`; + } + + // XML preview + const xml = item.xml_signed || item.xml_unsigned; + if (xml) { + html += `

XML

${escapeHtml(xml)}
`; + } + + document.getElementById('detail-content').innerHTML = html; + document.getElementById('detail-modal').classList.add('active'); + } catch (e) { + alert('Error: ' + e.message); + } + } + + function escapeHtml(text) { + const div = document.createElement('div'); + div.textContent = text; + return div.innerHTML; + } + + // ─── Process Queue ───────────────────────────── + + async function processQueue() { + if (!confirm('Procesar todos los CFDIs pendientes?')) return; + try { + const result = await api('/queue/process', { method: 'POST' }); + alert(`Procesados: ${result.processed}, Timbrados: ${result.stamped}, Fallidos: ${result.failed}`); + loadQueue(); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Cancel ──────────────────────────────────── + + function showCancelModal(cfdiId) { + document.getElementById('cancel-cfdi-id').value = cfdiId; + document.getElementById('cancel-motive').value = ''; + document.getElementById('cancel-replacement-uuid').value = ''; + document.getElementById('replacement-uuid-group').style.display = 'none'; + document.getElementById('cancel-modal').classList.add('active'); + } + + function onMotiveChange() { + const motive = document.getElementById('cancel-motive').value; + document.getElementById('replacement-uuid-group').style.display = + motive === '01' ? 'block' : 'none'; + } + + async function confirmCancel() { + const cfdiId = document.getElementById('cancel-cfdi-id').value; + const motive = document.getElementById('cancel-motive').value; + const replacementUuid = document.getElementById('cancel-replacement-uuid').value; + + if (!motive) { alert('Selecciona un motivo de cancelacion.'); return; } + if (motive === '01' && !replacementUuid) { alert('UUID sustituto requerido para motivo 01.'); return; } + + if (!confirm('Confirmar cancelacion ante el SAT?')) return; + + try { + const body = { motive }; + if (replacementUuid) body.replacement_uuid = replacementUuid; + + await api(`/cancel/${cfdiId}`, { method: 'POST', body: JSON.stringify(body) }); + closeModal('cancel-modal'); + loadQueue(); + alert('CFDI cancelado exitosamente.'); + } catch (e) { + alert('Error: ' + e.message); + } + } + + // ─── Modal helpers ───────────────────────────── + + function closeModal(id) { + document.getElementById(id).classList.remove('active'); + } + + // ─── Init ────────────────────────────────────── + + document.addEventListener('DOMContentLoaded', () => { + loadQueue(); + }); + + return { + loadQueue, processQueue, showDetail, showCancelModal, + onMotiveChange, confirmCancel, closeModal, + }; +})(); +``` + +--- + +### Task 10: Integration test + +**Files:** +- Create: `/home/Autopartes/pos/tests/test_plan4_cfdi_accounting.py` + +Full integration test: create sale, verify journal entries, generate CFDI, check queue, process queue (with mocked Horux), verify trial balance, close period. + +- [ ] **Step 1: Create test_plan4_cfdi_accounting.py** + +```python +# /home/Autopartes/pos/tests/test_plan4_cfdi_accounting.py +"""Integration test for Plan 4: CFDI + Accounting. + +Tests the full flow: +1. Create a sale via pos_engine.process_sale() +2. Verify auto-generated journal entries (accounting_engine) +3. Build CFDI XML (cfdi_builder) +4. Enqueue CFDI (cfdi_queue) +5. Process queue with mocked Horux API +6. Verify trial balance +7. Cancel sale, verify reverse journal entry +8. Close fiscal period +""" + +import json +import sys +import os +from datetime import date, datetime +from decimal import Decimal +from unittest.mock import patch, MagicMock + +# Add pos/ to path +sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..')) + + +class FakeG: + """Fake Flask g object for testing.""" + tenant_id = 1 + employee_id = 1 + employee_role = 'owner' + employee_name = 'Test Owner' + branch_id = 1 + permissions = {'pos.sell', 'pos.discount', 'accounting.view', 'invoicing.create'} + device_id = 'test-device' + max_discount_pct = 100 + + +def get_test_conn(): + """Get a connection to a test database. + + In production, this would connect to a real tenant DB. + For this test, we use an in-memory approach or a test DB. + """ + import psycopg2 + # Use the tenant_template or a test DB + conn = psycopg2.connect( + "postgresql://nexus:nexus_autoparts_2026@localhost/tenant_template" + ) + return conn + + +def test_accounting_engine_sale_entry(): + """Test that record_sale_entry creates balanced journal entries.""" + from services.accounting_engine import record_sale_entry, get_next_entry_number + + conn = get_test_conn() + conn.autocommit = False + + try: + # Mock Flask g + with patch('services.accounting_engine.g', FakeG()): + sale = { + 'id': 99999, + 'sale_type': 'cash', + 'payment_method': 'efectivo', + 'subtotal': 1000.00, + 'discount_total': 0, + 'tax_total': 160.00, + 'total': 1160.00, + 'items': [ + {'unit_cost': 600.00, 'quantity': 2}, + {'unit_cost': 100.00, 'quantity': 1}, + ], + } + + entry_id = record_sale_entry(conn, sale) + assert entry_id is not None, "Entry ID should not be None" + + # Verify entry exists + cur = conn.cursor() + cur.execute("SELECT * FROM journal_entries WHERE id = %s", (entry_id,)) + entry = cur.fetchone() + assert entry is not None, "Journal entry should exist" + + # Verify lines are balanced + cur.execute(""" + SELECT SUM(debit), SUM(credit) + FROM journal_entry_lines WHERE journal_entry_id = %s + """, (entry_id,)) + total_debit, total_credit = cur.fetchone() + assert float(total_debit) == float(total_credit), \ + f"Entry must be balanced: debit={total_debit} credit={total_credit}" + + # Verify specific amounts + cur.execute(""" + SELECT a.code, l.debit, l.credit + FROM journal_entry_lines l + JOIN accounts a ON l.account_id = a.id + WHERE l.journal_entry_id = %s + ORDER BY a.code + """, (entry_id,)) + lines = {r[0]: {'debit': float(r[1]), 'credit': float(r[2])} + for r in cur.fetchall()} + + assert lines['110']['debit'] == 1160.00, "Caja debit should be 1160" + assert lines['220']['credit'] == 160.00, "IVA Trasladado credit should be 160" + assert lines['410']['credit'] == 1000.00, "Ventas credit should be 1000" + assert lines['510']['debit'] == 1300.00, "Costo debit should be 1300" + assert lines['130']['credit'] == 1300.00, "Inventarios credit should be 1300" + + cur.close() + print("PASS: test_accounting_engine_sale_entry") + + finally: + conn.rollback() + conn.close() + + +def test_accounting_engine_cancellation_entry(): + """Test that record_cancellation_entry creates reversed entries.""" + from services.accounting_engine import record_sale_entry, record_cancellation_entry + + conn = get_test_conn() + conn.autocommit = False + + try: + with patch('services.accounting_engine.g', FakeG()): + sale = { + 'id': 99998, + 'sale_type': 'cash', + 'payment_method': 'efectivo', + 'subtotal': 500.00, + 'discount_total': 0, + 'tax_total': 80.00, + 'total': 580.00, + 'items': [{'unit_cost': 300.00, 'quantity': 1}], + } + + # Create sale entry first + sale_entry_id = record_sale_entry(conn, sale) + + # Create cancellation entry + cancel_entry_id = record_cancellation_entry(conn, sale) + assert cancel_entry_id is not None + + # Verify the cancellation entry is balanced + cur = conn.cursor() + cur.execute(""" + SELECT SUM(debit), SUM(credit) + FROM journal_entry_lines WHERE journal_entry_id = %s + """, (cancel_entry_id,)) + total_debit, total_credit = cur.fetchone() + assert float(total_debit) == float(total_credit), \ + f"Cancel entry must be balanced: debit={total_debit} credit={total_credit}" + + # Net effect should be zero across both entries + cur.execute(""" + SELECT SUM(debit) - SUM(credit) as net + FROM journal_entry_lines + WHERE journal_entry_id IN (%s, %s) + """, (sale_entry_id, cancel_entry_id)) + net = float(cur.fetchone()[0]) + assert net == 0, f"Net effect should be zero, got {net}" + + cur.close() + print("PASS: test_accounting_engine_cancellation_entry") + + finally: + conn.rollback() + conn.close() + + +def test_cfdi_builder_ingreso(): + """Test that build_ingreso_xml produces valid CFDI 4.0 XML.""" + from services.cfdi_builder import build_ingreso_xml + from lxml import etree + + sale = { + 'id': 12345, + 'subtotal': 1000.00, + 'discount_total': 0, + 'tax_total': 160.00, + 'total': 1160.00, + 'metodo_pago_sat': 'PUE', + 'forma_pago_sat': '01', + 'items': [{ + 'part_number': 'BRK-001', + 'name': 'Pastillas de freno Bosch', + 'quantity': 2, + 'unit_price': 500.00, + 'discount_amount': 0, + 'tax_rate': 0.16, + 'tax_amount': 160.00, + 'subtotal': 1160.00, + 'clave_prod_serv': '25174800', + 'clave_unidad': 'H87', + }], + } + + tenant_config = { + 'rfc': 'TEST010101ABC', + 'razon_social': 'Refaccionaria Test SA de CV', + 'regimen_fiscal': '601', + 'cp': '06600', + 'serie': 'A', + } + + # Test publico general (customer=None) + xml_str = build_ingreso_xml(sale, tenant_config, customer=None) + assert 'test') + assert result['status'] == 'pending' + assert result['provisional_folio'].startswith('PRE-') + assert result['sale_id'] == sale_id + + # Check queue status + status = get_queue_status(conn, {'sale_id': sale_id}) + assert len(status['data']) >= 1 + assert status['data'][0]['status'] == 'pending' + + print("PASS: test_cfdi_queue") + + finally: + conn.rollback() + conn.close() + + +def test_cfdi_queue_process_mock(): + """Test queue processing with mocked Horux API.""" + from services.cfdi_queue import enqueue_cfdi, process_queue + + conn = get_test_conn() + conn.autocommit = False + + try: + cur = conn.cursor() + cur.execute(""" + INSERT INTO sales (branch_id, sale_type, subtotal, tax_total, total, status) + VALUES (1, 'cash', 200, 32, 232, 'completed') + RETURNING id + """) + sale_id = cur.fetchone()[0] + cur.close() + + enqueue_cfdi(conn, sale_id, 'ingreso', 'test xml') + + # Mock the requests.post call + mock_response = MagicMock() + mock_response.status_code = 200 + mock_response.json.return_value = { + 'uuid': 'MOCK-UUID-1234-5678-ABCDEF', + 'xml': 'signed xml', + } + + with patch('services.cfdi_queue.requests.post', return_value=mock_response): + result = process_queue(conn, 'https://horux.test', 'test-api-key') + + assert result['processed'] >= 1, "Should process at least 1 item" + assert result['stamped'] >= 1, "Should stamp at least 1 item" + + print("PASS: test_cfdi_queue_process_mock") + + finally: + conn.rollback() + conn.close() + + +def test_trial_balance_after_sale(): + """Test that trial balance reflects sale journal entries correctly.""" + from services.accounting_engine import record_sale_entry + + conn = get_test_conn() + conn.autocommit = False + + try: + with patch('services.accounting_engine.g', FakeG()): + sale = { + 'id': 88888, + 'sale_type': 'cash', + 'payment_method': 'efectivo', + 'subtotal': 2000.00, + 'discount_total': 0, + 'tax_total': 320.00, + 'total': 2320.00, + 'items': [{'unit_cost': 1200.00, 'quantity': 1}], + } + + record_sale_entry(conn, sale) + + # Query trial balance style + cur = conn.cursor() + today = date.today() + cur.execute(""" + SELECT a.code, SUM(l.debit) as debits, SUM(l.credit) as credits + FROM journal_entry_lines l + JOIN journal_entries e ON l.journal_entry_id = e.id + JOIN accounts a ON l.account_id = a.id + WHERE e.status = 'posted' + AND e.date >= %s AND e.date <= %s + GROUP BY a.code + ORDER BY a.code + """, (f'{today.year}-{today.month:02d}-01', str(today))) + + balances = {} + for code, debits, credits in cur.fetchall(): + balances[code] = {'debits': float(debits), 'credits': float(credits)} + + # Verify Caja received total + assert balances.get('110', {}).get('debits', 0) >= 2320.00 + # Verify Ventas credited subtotal + assert balances.get('410', {}).get('credits', 0) >= 2000.00 + # Verify IVA + assert balances.get('220', {}).get('credits', 0) >= 320.00 + + cur.close() + print("PASS: test_trial_balance_after_sale") + + finally: + conn.rollback() + conn.close() + + +def test_close_fiscal_period(): + """Test fiscal period close and locked entry creation.""" + from services.accounting_engine import record_sale_entry + + conn = get_test_conn() + conn.autocommit = False + + try: + with patch('services.accounting_engine.g', FakeG()): + cur = conn.cursor() + today = date.today() + + # Close the current period + cur.execute(""" + INSERT INTO fiscal_periods (year, month, status, closed_by, closed_at) + VALUES (%s, %s, 'closed', 1, NOW()) + ON CONFLICT (year, month) DO UPDATE SET status = 'closed' + """, (today.year, today.month)) + + # Try to create a sale entry -- should fail + sale = { + 'id': 77777, + 'sale_type': 'cash', + 'payment_method': 'efectivo', + 'subtotal': 100.00, + 'discount_total': 0, + 'tax_total': 16.00, + 'total': 116.00, + 'items': [{'unit_cost': 50.00, 'quantity': 1}], + } + + try: + record_sale_entry(conn, sale) + assert False, "Should have raised ValueError for closed period" + except ValueError as e: + assert 'closed' in str(e).lower() + print("PASS: test_close_fiscal_period") + + cur.close() + + finally: + conn.rollback() + conn.close() + + +def run_all_tests(): + """Run all integration tests.""" + print("=" * 60) + print("Plan 4: CFDI + Accounting Integration Tests") + print("=" * 60) + + tests = [ + test_accounting_engine_sale_entry, + test_accounting_engine_cancellation_entry, + test_cfdi_builder_ingreso, + test_cfdi_builder_pago, + test_cfdi_queue, + test_cfdi_queue_process_mock, + test_trial_balance_after_sale, + test_close_fiscal_period, + ] + + passed = 0 + failed = 0 + for test_fn in tests: + try: + test_fn() + passed += 1 + except Exception as e: + print(f"FAIL: {test_fn.__name__}: {e}") + import traceback + traceback.print_exc() + failed += 1 + + print("=" * 60) + print(f"Results: {passed} passed, {failed} failed out of {len(tests)}") + print("=" * 60) + return failed == 0 + + +if __name__ == '__main__': + success = run_all_tests() + sys.exit(0 if success else 1) +``` + +--- + +## Summary of Changes + +| File | Action | Description | +|------|--------|-------------| +| `pos/services/accounting_engine.py` | CREATE | Auto-generate journal entries for sales, purchases, cash cuts, credit payments, cancellations, and manual entries | +| `pos/services/cfdi_builder.py` | CREATE | Build CFDI 4.0 XML (Ingreso, Egreso, Pago) with lxml | +| `pos/services/cfdi_queue.py` | CREATE | Manage timbrado queue: enqueue, process via Horux, retry with backoff, cancel | +| `pos/blueprints/invoicing_bp.py` | CREATE | HTTP endpoints for CFDI generation, queue management, cancellation, PDF | +| `pos/blueprints/accounting_bp.py` | CREATE | HTTP endpoints for accounts, journal entries, financial reports, fiscal periods | +| `pos/services/pos_engine.py` | MODIFY | Add `record_sale_entry()` in `process_sale()` and `record_cancellation_entry()` in `cancel_sale()` | +| `pos/app.py` | MODIFY | Register `invoicing_bp` and `accounting_bp`, add page routes | +| `pos/templates/accounting.html` | CREATE | Tabbed accounting dashboard (7 tabs) | +| `pos/static/js/accounting.js` | CREATE | Accounting UI: tree, entries, reports, period management | +| `pos/templates/invoicing.html` | CREATE | CFDI queue management page | +| `pos/static/js/invoicing.js` | CREATE | Invoicing UI: queue list, detail, cancel, process | +| `pos/tests/test_plan4_cfdi_accounting.py` | CREATE | Full integration test (8 test cases) | + +## Execution Order + +Tasks can be partially parallelized: + +``` +Task 1 (accounting_engine) ─┐ +Task 2 (cfdi_builder) ├── independent services, can run in parallel +Task 3 (cfdi_queue) ─┘ + │ +Task 4 (invoicing_bp) ──── depends on Tasks 2, 3 +Task 5 (accounting_bp) ─── depends on Task 1 + │ +Task 6 (hook pos_engine) ── depends on Task 1 +Task 7 (register blueprints) ── depends on Tasks 4, 5 + │ +Task 8 (accounting frontend) ── depends on Task 5 +Task 9 (invoicing frontend) ── depends on Task 4 + │ +Task 10 (integration test) ── depends on all above +``` + +## Key Design Decisions + +1. **Accounting errors do not block sales.** The hooks in `pos_engine.py` wrap accounting calls in try/except -- a failed journal entry should never prevent a sale from completing. + +2. **CFDI XML is unsigned.** The POS backend generates structurally valid CFDI 4.0 XML, but signing (CSD) and timbrado (PAC) happen at Horux360. This keeps CSD credentials out of the POS. + +3. **Fiscal period enforcement.** Closed periods block new journal entries but NOT CFDI generation (SAT allows late invoicing). Entries for late CFDIs go into the current open period. + +4. **Queue retry with backoff.** Failed timbrado attempts use exponential backoff (5s, 30s, 2m, 10m, 1h) with max 5 retries. Manual retry resets eligible items. + +5. **Financial reports are real-time SQL.** No materialized views or denormalized tables -- all reports query `journal_entry_lines` directly. This is appropriate for single-tenant refaccionarias with modest transaction volumes.