Files
Autoparts-DB/docs/plans/2026-03-27-pos-plan-4-cfdi-accounting.md
consultoria-as aeac4387df 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) <noreply@anthropic.com>
2026-03-31 04:08:21 +00:00

172 KiB

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
# /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
# /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
# /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
# /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/<int:cfdi_id>', 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/<int:cfdi_id>', 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('/<int:sale_id>/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
# /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/<int:entry_id>', 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:

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):

    # 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):

    # 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:

    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:

    @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
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Contabilidad - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        .tabs { display: flex; gap: 0; border-bottom: 2px solid var(--border); margin-bottom: 1rem; flex-wrap: wrap; }
        .tab { padding: 0.5rem 1rem; cursor: pointer; border: 1px solid transparent;
               border-bottom: none; border-radius: 6px 6px 0 0; font-size: 0.85rem;
               color: var(--text-muted); transition: all 0.2s; }
        .tab:hover { background: var(--bg-hover); color: var(--text); }
        .tab.active { background: var(--bg); color: var(--primary); border-color: var(--border);
                      border-bottom: 2px solid var(--bg); margin-bottom: -2px; font-weight: 600; }
        .tab-content { display: none; }
        .tab-content.active { display: block; }

        /* Accounts tree */
        .tree { list-style: none; padding-left: 0; }
        .tree ul { list-style: none; padding-left: 1.5rem; }
        .tree li { padding: 0.25rem 0; }
        .tree-toggle { cursor: pointer; user-select: none; }
        .tree-toggle::before { content: '\25B6'; display: inline-block; width: 1rem;
                               font-size: 0.7rem; transition: transform 0.2s; }
        .tree-toggle.open::before { transform: rotate(90deg); }
        .tree-code { font-family: monospace; color: var(--text-muted); margin-right: 0.5rem;
                     font-size: 0.85rem; }
        .tree-balance { float: right; font-family: monospace; font-weight: 600; }
        .tree-balance.negative { color: var(--danger); }

        /* Report tables */
        .report-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
        .report-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border);
                           font-weight: 600; color: var(--text-muted); font-size: 0.8rem;
                           text-transform: uppercase; }
        .report-table td { padding: 0.5rem; border-bottom: 1px solid var(--border-light); }
        .report-table .amount { text-align: right; font-family: monospace; }
        .report-table .total-row { font-weight: 700; border-top: 2px solid var(--border); }
        .report-table .subtotal-row { font-weight: 600; background: var(--bg-subtle); }

        /* Period selector */
        .period-selector { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; }
        .period-selector select, .period-selector input { padding: 0.4rem 0.5rem; border: 1px solid var(--border);
                                                          border-radius: 4px; font-size: 0.9rem; }

        /* Entry detail */
        .entry-lines { margin-top: 0.5rem; }
        .entry-header { display: flex; justify-content: space-between; align-items: center;
                        padding: 0.5rem; background: var(--bg-subtle); border-radius: 4px;
                        margin-bottom: 0.5rem; }

        /* Status badges */
        .badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600; }
        .badge-open { background: #dcfce7; color: #166534; }
        .badge-closed { background: #fee2e2; color: #991b1b; }

        /* Form grid */
        .form-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.75rem; margin-bottom: 1rem; }
        .form-group { display: flex; flex-direction: column; gap: 0.25rem; }
        .form-group label { font-size: 0.8rem; font-weight: 600; color: var(--text-muted); }
        .form-group input, .form-group select, .form-group textarea {
            padding: 0.4rem 0.5rem; border: 1px solid var(--border); border-radius: 4px;
            font-size: 0.9rem; }

        /* Modal */
        .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
                         background: rgba(0,0,0,0.5); z-index: 1000; align-items: center;
                         justify-content: center; }
        .modal-overlay.active { display: flex; }
        .modal { background: var(--bg); padding: 1.5rem; border-radius: 8px; width: 90%;
                 max-width: 600px; max-height: 80vh; overflow-y: auto; }
        .modal h3 { margin-top: 0; }

        /* Lines editor */
        .lines-table { width: 100%; border-collapse: collapse; margin-top: 0.5rem; }
        .lines-table th { text-align: left; font-size: 0.8rem; padding: 0.3rem; }
        .lines-table td { padding: 0.3rem; }
        .lines-table input, .lines-table select { width: 100%; padding: 0.3rem; border: 1px solid var(--border);
                                                   border-radius: 3px; font-size: 0.85rem; }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>Contabilidad</h1>
            <div style="display:flex;gap:0.5rem;">
                <a href="/pos/sale" class="btn btn-secondary">POS</a>
                <a href="/pos/invoicing" class="btn btn-secondary">Facturacion</a>
            </div>
        </div>

        <div class="tabs">
            <div class="tab active" data-tab="accounts">Catalogo de Cuentas</div>
            <div class="tab" data-tab="entries">Polizas</div>
            <div class="tab" data-tab="trial-balance">Balanza</div>
            <div class="tab" data-tab="income-statement">Estado de Resultados</div>
            <div class="tab" data-tab="balance-sheet">Balance General</div>
            <div class="tab" data-tab="aging">Antiguedad Saldos</div>
            <div class="tab" data-tab="periods">Periodos</div>
        </div>

        <!-- Tab: Chart of Accounts -->
        <div id="tab-accounts" class="tab-content active">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
                <h2 style="margin:0;">Catalogo de Cuentas</h2>
                <button class="btn btn-primary" onclick="Accounting.showNewAccountModal()">+ Subcuenta</button>
            </div>
            <div id="accounts-tree"></div>
        </div>

        <!-- Tab: Journal Entries -->
        <div id="tab-entries" class="tab-content">
            <div style="display:flex;justify-content:space-between;align-items:center;margin-bottom:1rem;">
                <h2 style="margin:0;">Polizas Contables</h2>
                <button class="btn btn-primary" onclick="Accounting.showNewEntryModal()">+ Poliza Manual</button>
            </div>
            <div class="period-selector">
                <label>Desde:</label>
                <input type="date" id="entries-from">
                <label>Hasta:</label>
                <input type="date" id="entries-to">
                <select id="entries-type">
                    <option value="">Todos</option>
                    <option value="ingreso">Ingreso</option>
                    <option value="egreso">Egreso</option>
                    <option value="diario">Diario</option>
                </select>
                <button class="btn btn-secondary" onclick="Accounting.loadEntries()">Filtrar</button>
            </div>
            <div id="entries-list"></div>
            <div id="entries-pagination"></div>
        </div>

        <!-- Tab: Trial Balance -->
        <div id="tab-trial-balance" class="tab-content">
            <h2>Balanza de Comprobacion</h2>
            <div class="period-selector">
                <label>Anio:</label>
                <input type="number" id="tb-year" min="2020" max="2030" style="width:80px;">
                <label>Mes:</label>
                <select id="tb-month">
                    <option value="1">Enero</option><option value="2">Febrero</option>
                    <option value="3">Marzo</option><option value="4">Abril</option>
                    <option value="5">Mayo</option><option value="6">Junio</option>
                    <option value="7">Julio</option><option value="8">Agosto</option>
                    <option value="9">Septiembre</option><option value="10">Octubre</option>
                    <option value="11">Noviembre</option><option value="12">Diciembre</option>
                </select>
                <button class="btn btn-secondary" onclick="Accounting.loadTrialBalance()">Generar</button>
            </div>
            <div id="trial-balance-content"></div>
        </div>

        <!-- Tab: Income Statement -->
        <div id="tab-income-statement" class="tab-content">
            <h2>Estado de Resultados</h2>
            <div class="period-selector">
                <label>Anio:</label>
                <input type="number" id="is-year" min="2020" max="2030" style="width:80px;">
                <label>Mes:</label>
                <select id="is-month">
                    <option value="1">Enero</option><option value="2">Febrero</option>
                    <option value="3">Marzo</option><option value="4">Abril</option>
                    <option value="5">Mayo</option><option value="6">Junio</option>
                    <option value="7">Julio</option><option value="8">Agosto</option>
                    <option value="9">Septiembre</option><option value="10">Octubre</option>
                    <option value="11">Noviembre</option><option value="12">Diciembre</option>
                </select>
                <button class="btn btn-secondary" onclick="Accounting.loadIncomeStatement()">Generar</button>
            </div>
            <div id="income-statement-content"></div>
        </div>

        <!-- Tab: Balance Sheet -->
        <div id="tab-balance-sheet" class="tab-content">
            <h2>Balance General</h2>
            <div class="period-selector">
                <label>Al dia:</label>
                <input type="date" id="bs-date">
                <button class="btn btn-secondary" onclick="Accounting.loadBalanceSheet()">Generar</button>
            </div>
            <div id="balance-sheet-content"></div>
        </div>

        <!-- Tab: Aging -->
        <div id="tab-aging" class="tab-content">
            <h2>Antiguedad de Saldos</h2>
            <div id="aging-content"></div>
        </div>

        <!-- Tab: Fiscal Periods -->
        <div id="tab-periods" class="tab-content">
            <h2>Periodos Fiscales</h2>
            <div style="margin-bottom:1rem;" id="close-period-form">
                <div class="period-selector">
                    <label>Cerrar periodo:</label>
                    <input type="number" id="cp-year" min="2020" max="2030" style="width:80px;">
                    <select id="cp-month">
                        <option value="1">Enero</option><option value="2">Febrero</option>
                        <option value="3">Marzo</option><option value="4">Abril</option>
                        <option value="5">Mayo</option><option value="6">Junio</option>
                        <option value="7">Julio</option><option value="8">Agosto</option>
                        <option value="9">Septiembre</option><option value="10">Octubre</option>
                        <option value="11">Noviembre</option><option value="12">Diciembre</option>
                    </select>
                    <button class="btn btn-danger" onclick="Accounting.closePeriod()">Cerrar Periodo</button>
                </div>
            </div>
            <div id="periods-list"></div>
        </div>
    </div>

    <!-- New Account Modal -->
    <div class="modal-overlay" id="new-account-modal">
        <div class="modal">
            <h3>Nueva Subcuenta</h3>
            <div class="form-grid">
                <div class="form-group">
                    <label>Codigo</label>
                    <input type="text" id="na-code" placeholder="110.01">
                </div>
                <div class="form-group">
                    <label>Nombre</label>
                    <input type="text" id="na-name" placeholder="Caja chica">
                </div>
                <div class="form-group">
                    <label>Cuenta padre</label>
                    <select id="na-parent"></select>
                </div>
                <div class="form-group">
                    <label>Tipo</label>
                    <select id="na-type">
                        <option value="activo">Activo</option>
                        <option value="pasivo">Pasivo</option>
                        <option value="capital">Capital</option>
                        <option value="ingreso">Ingreso</option>
                        <option value="costo">Costo</option>
                        <option value="gasto">Gasto</option>
                    </select>
                </div>
            </div>
            <div style="display:flex;gap:0.5rem;justify-content:flex-end;">
                <button class="btn btn-secondary" onclick="Accounting.closeModal('new-account-modal')">Cancelar</button>
                <button class="btn btn-primary" onclick="Accounting.createAccount()">Crear</button>
            </div>
        </div>
    </div>

    <!-- New Entry Modal -->
    <div class="modal-overlay" id="new-entry-modal">
        <div class="modal">
            <h3>Nueva Poliza Manual</h3>
            <div class="form-grid">
                <div class="form-group">
                    <label>Fecha</label>
                    <input type="date" id="ne-date">
                </div>
                <div class="form-group">
                    <label>Descripcion</label>
                    <input type="text" id="ne-description" placeholder="Descripcion de la poliza">
                </div>
            </div>
            <h4>Movimientos</h4>
            <table class="lines-table">
                <thead>
                    <tr><th>Cuenta</th><th style="width:120px;">Cargo</th><th style="width:120px;">Abono</th><th style="width:30px;"></th></tr>
                </thead>
                <tbody id="ne-lines"></tbody>
            </table>
            <button class="btn btn-secondary" onclick="Accounting.addEntryLine()" style="margin-top:0.5rem;">+ Linea</button>
            <div id="ne-balance" style="margin-top:0.5rem;font-weight:600;"></div>
            <div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
                <button class="btn btn-secondary" onclick="Accounting.closeModal('new-entry-modal')">Cancelar</button>
                <button class="btn btn-primary" onclick="Accounting.createEntry()">Guardar</button>
            </div>
        </div>
    </div>

    <!-- Entry Detail Modal -->
    <div class="modal-overlay" id="entry-detail-modal">
        <div class="modal">
            <div id="entry-detail-content"></div>
            <div style="text-align:right;margin-top:1rem;">
                <button class="btn btn-secondary" onclick="Accounting.closeModal('entry-detail-modal')">Cerrar</button>
            </div>
        </div>
    </div>

    <script src="/pos/static/js/accounting.js"></script>
</body>
</html>
  • Step 2: Create accounting.js
// /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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderAccountsTree() {
        const container = document.getElementById('accounts-tree');
        if (!accounts.length) { container.innerHTML = '<p>No hay cuentas.</p>'; 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 = '<ul class="tree">';
            for (const acct of children) {
                const hasChildren = byParent[acct.id] && byParent[acct.id].length > 0;
                const balClass = acct.balance < 0 ? 'negative' : '';
                html += '<li>';
                if (hasChildren) {
                    html += `<span class="tree-toggle open" onclick="this.classList.toggle('open');this.nextElementSibling.style.display=this.classList.contains('open')?'block':'none'">`;
                } else {
                    html += '<span style="display:inline-block;width:1rem;">';
                }
                html += `<span class="tree-code">${acct.code}</span> ${acct.name}`;
                html += `<span class="tree-balance ${balClass}">$${fmt(acct.balance)}</span>`;
                html += '</span>';
                if (hasChildren) {
                    html += `<div>${buildUl(acct.id)}</div>`;
                }
                html += '</li>';
            }
            html += '</ul>';
            return html;
        }

        container.innerHTML = buildUl('root');
    }

    function showNewAccountModal() {
        const sel = document.getElementById('na-parent');
        sel.innerHTML = '<option value="">-- Sin padre --</option>';
        accounts.forEach(a => {
            sel.innerHTML += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
        });
        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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderEntries(entries) {
        const container = document.getElementById('entries-list');
        if (!entries.length) { container.innerHTML = '<p>No hay polizas en este periodo.</p>'; return; }

        let html = `<table class="report-table">
            <thead><tr>
                <th>#</th><th>Fecha</th><th>Tipo</th><th>Descripcion</th>
                <th>Referencia</th><th class="amount">Monto</th><th>Auto</th>
            </tr></thead><tbody>`;

        for (const e of entries) {
            html += `<tr style="cursor:pointer;" onclick="Accounting.showEntryDetail(${e.id})">
                <td>${e.entry_number}</td>
                <td>${e.date || ''}</td>
                <td>${e.type || ''}</td>
                <td>${e.description || ''}</td>
                <td>${e.reference_type ? `${e.reference_type} #${e.reference_id}` : ''}</td>
                <td class="amount">$${fmt(e.total_amount)}</td>
                <td>${e.is_auto ? 'Si' : 'No'}</td>
            </tr>`;
        }
        html += '</tbody></table>';
        container.innerHTML = html;
    }

    async function showEntryDetail(entryId) {
        try {
            const entry = await api(`/entries/${entryId}`);
            let html = `<h3>Poliza #${entry.entry_number}</h3>
                <div class="entry-header">
                    <span><strong>Fecha:</strong> ${entry.date}</span>
                    <span><strong>Tipo:</strong> ${entry.type}</span>
                    <span><strong>Estado:</strong> ${entry.status}</span>
                </div>
                <p>${entry.description || ''}</p>
                <table class="report-table">
                    <thead><tr>
                        <th>Cuenta</th><th>Nombre</th><th class="amount">Cargo</th>
                        <th class="amount">Abono</th><th>Nota</th>
                    </tr></thead><tbody>`;

            for (const l of entry.lines) {
                html += `<tr>
                    <td class="tree-code">${l.account_code}</td>
                    <td>${l.account_name}</td>
                    <td class="amount">${l.debit ? '$' + fmt(l.debit) : ''}</td>
                    <td class="amount">${l.credit ? '$' + fmt(l.credit) : ''}</td>
                    <td>${l.description || ''}</td>
                </tr>`;
            }

            html += `<tr class="total-row">
                <td colspan="2">Totales</td>
                <td class="amount">$${fmt(entry.total_debit)}</td>
                <td class="amount">$${fmt(entry.total_credit)}</td>
                <td></td>
            </tr></tbody></table>`;

            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 = '<option value="">Seleccionar</option>';
        accounts.forEach(a => {
            if (a.parent_id) { // Only leaf accounts
                acctOptions += `<option value="${a.id}">${a.code} - ${a.name}</option>`;
            }
        });

        tr.innerHTML = `
            <td><select onchange="Accounting.updateEntryBalance()">${acctOptions}</select></td>
            <td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
            <td><input type="number" step="0.01" min="0" value="0" onchange="Accounting.updateEntryBalance()"></td>
            <td><button onclick="this.closest('tr').remove();Accounting.updateEntryBalance();"
                 style="border:none;background:none;color:var(--danger);cursor:pointer;font-size:1.1rem;">x</button></td>`;
        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 = `<span style="color:var(--success);">Cuadrada: Cargos $${fmt(totalDebit)} = Abonos $${fmt(totalCredit)}</span>`;
        } else {
            el.innerHTML = `<span style="color:var(--danger);">Descuadre: $${fmt(Math.abs(diff))} (Cargos $${fmt(totalDebit)} / Abonos $${fmt(totalCredit)})</span>`;
        }
    }

    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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderTrialBalance(data) {
        const rows = data.data || [];
        let html = `<table class="report-table">
            <thead><tr>
                <th>Codigo</th><th>Cuenta</th>
                <th class="amount">Saldo Inicial</th><th class="amount">Cargos</th>
                <th class="amount">Abonos</th><th class="amount">Saldo Final</th>
            </tr></thead><tbody>`;

        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 += `<tr>
                <td class="tree-code">${r.code}</td><td>${r.name}</td>
                <td class="amount">$${fmt(r.saldo_inicial)}</td>
                <td class="amount">$${fmt(r.cargos)}</td>
                <td class="amount">$${fmt(r.abonos)}</td>
                <td class="amount">$${fmt(r.saldo_final)}</td>
            </tr>`;
        }

        html += `<tr class="total-row">
            <td colspan="2">Totales</td>
            <td class="amount">$${fmt(totals.si)}</td>
            <td class="amount">$${fmt(totals.c)}</td>
            <td class="amount">$${fmt(totals.a)}</td>
            <td class="amount">$${fmt(totals.sf)}</td>
        </tr></tbody></table>`;

        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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderIncomeStatement(data) {
        let html = '<table class="report-table"><tbody>';

        // Ingresos
        html += '<tr class="subtotal-row"><td colspan="2"><strong>INGRESOS</strong></td></tr>';
        for (const item of data.ingresos.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
        }
        html += `<tr class="subtotal-row"><td>Total Ingresos</td><td class="amount">$${fmt(data.ingresos.total)}</td></tr>`;

        // Costos
        html += '<tr class="subtotal-row"><td colspan="2"><strong>COSTOS</strong></td></tr>';
        for (const item of data.costos.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
        }
        html += `<tr class="subtotal-row"><td>Total Costos</td><td class="amount">$${fmt(data.costos.total)}</td></tr>`;

        // Utilidad bruta
        html += `<tr class="total-row"><td><strong>UTILIDAD BRUTA</strong></td><td class="amount"><strong>$${fmt(data.utilidad_bruta)}</strong></td></tr>`;

        // Gastos
        html += '<tr class="subtotal-row"><td colspan="2"><strong>GASTOS</strong></td></tr>';
        for (const item of data.gastos.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.amount)}</td></tr>`;
        }
        html += `<tr class="subtotal-row"><td>Total Gastos</td><td class="amount">$${fmt(data.gastos.total)}</td></tr>`;

        // Utilidad neta
        const netColor = data.utilidad_neta >= 0 ? 'var(--success)' : 'var(--danger)';
        html += `<tr class="total-row"><td><strong>UTILIDAD NETA</strong></td>
            <td class="amount" style="color:${netColor};"><strong>$${fmt(data.utilidad_neta)}</strong></td></tr>`;

        html += '</tbody></table>';
        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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderBalanceSheet(data) {
        const balancedBadge = data.balanced
            ? '<span class="badge badge-open">Cuadrado</span>'
            : '<span class="badge badge-closed">Descuadrado</span>';

        let html = `<p>Al ${data.as_of} ${balancedBadge}</p>
            <table class="report-table"><tbody>`;

        // Activo
        html += '<tr class="subtotal-row"><td colspan="2"><strong>ACTIVO</strong></td></tr>';
        for (const item of data.activo.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
        }
        html += `<tr class="total-row"><td>Total Activo</td><td class="amount">$${fmt(data.activo.total)}</td></tr>`;

        // Pasivo
        html += '<tr class="subtotal-row"><td colspan="2"><strong>PASIVO</strong></td></tr>';
        for (const item of data.pasivo.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
        }
        html += `<tr class="total-row"><td>Total Pasivo</td><td class="amount">$${fmt(data.pasivo.total)}</td></tr>`;

        // Capital
        html += '<tr class="subtotal-row"><td colspan="2"><strong>CAPITAL</strong></td></tr>';
        for (const item of data.capital.items) {
            html += `<tr><td style="padding-left:2rem;">${item.code} - ${item.name}</td><td class="amount">$${fmt(item.balance)}</td></tr>`;
        }
        html += `<tr class="total-row"><td>Total Capital</td><td class="amount">$${fmt(data.capital.total)}</td></tr>`;

        html += `<tr class="total-row"><td><strong>Pasivo + Capital</strong></td>
            <td class="amount"><strong>$${fmt(data.pasivo.total + data.capital.total)}</strong></td></tr>`;

        html += '</tbody></table>';
        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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderAging(data) {
        const rows = data.data || [];
        let html = `<table class="report-table">
            <thead><tr>
                <th>Cliente</th><th class="amount">Corriente</th>
                <th class="amount">1-30d</th><th class="amount">31-60d</th>
                <th class="amount">61-90d</th><th class="amount">90+d</th>
                <th class="amount">Total</th>
            </tr></thead><tbody>`;

        for (const r of rows) {
            html += `<tr>
                <td>${r.name}</td>
                <td class="amount">$${fmt(r.corriente)}</td>
                <td class="amount">$${fmt(r.d1_30)}</td>
                <td class="amount">$${fmt(r.d31_60)}</td>
                <td class="amount">$${fmt(r.d61_90)}</td>
                <td class="amount">$${fmt(r.d90_plus)}</td>
                <td class="amount"><strong>$${fmt(r.total)}</strong></td>
            </tr>`;
        }

        const t = data.totals || {};
        html += `<tr class="total-row">
            <td>Totales</td>
            <td class="amount">$${fmt(t.corriente)}</td>
            <td class="amount">$${fmt(t.d1_30)}</td>
            <td class="amount">$${fmt(t.d31_60)}</td>
            <td class="amount">$${fmt(t.d61_90)}</td>
            <td class="amount">$${fmt(t.d90_plus)}</td>
            <td class="amount"><strong>$${fmt(t.total)}</strong></td>
        </tr></tbody></table>`;

        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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    function renderPeriods(periods) {
        const months = ['', 'Enero', 'Febrero', 'Marzo', 'Abril', 'Mayo', 'Junio',
                        'Julio', 'Agosto', 'Septiembre', 'Octubre', 'Noviembre', 'Diciembre'];
        let html = `<table class="report-table">
            <thead><tr><th>Periodo</th><th>Estado</th><th>Cerrado por</th><th>Fecha cierre</th></tr></thead><tbody>`;

        for (const p of periods) {
            const badge = p.status === 'closed'
                ? '<span class="badge badge-closed">Cerrado</span>'
                : '<span class="badge badge-open">Abierto</span>';
            html += `<tr>
                <td>${months[p.month]} ${p.year}</td>
                <td>${badge}</td>
                <td>${p.closed_by_name || '-'}</td>
                <td>${p.closed_at ? new Date(p.closed_at).toLocaleDateString('es-MX') : '-'}</td>
            </tr>`;
        }
        html += '</tbody></table>';
        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
<!DOCTYPE html>
<html lang="es">
<head>
    <meta charset="UTF-8">
    <meta name="viewport" content="width=device-width, initial-scale=1.0">
    <title>Facturacion - Nexus POS</title>
    <link rel="stylesheet" href="/pos/static/css/common.css">
    <style>
        .queue-filters { display: flex; gap: 0.5rem; align-items: center; margin-bottom: 1rem; flex-wrap: wrap; }
        .queue-filters select, .queue-filters input { padding: 0.4rem 0.5rem; border: 1px solid var(--border);
                                                      border-radius: 4px; font-size: 0.9rem; }

        .queue-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
        .queue-table th { text-align: left; padding: 0.5rem; border-bottom: 2px solid var(--border);
                          font-weight: 600; color: var(--text-muted); font-size: 0.8rem; text-transform: uppercase; }
        .queue-table td { padding: 0.5rem; border-bottom: 1px solid var(--border-light); }
        .queue-table tr:hover { background: var(--bg-hover); cursor: pointer; }

        .badge { padding: 0.15rem 0.5rem; border-radius: 4px; font-size: 0.75rem; font-weight: 600;
                 display: inline-block; }
        .badge-pending { background: #fef3c7; color: #92400e; }
        .badge-sending { background: #dbeafe; color: #1e40af; }
        .badge-stamped { background: #dcfce7; color: #166534; }
        .badge-failed { background: #fee2e2; color: #991b1b; }
        .badge-cancelled { background: #f3f4f6; color: #6b7280; }

        .modal-overlay { display: none; position: fixed; top: 0; left: 0; right: 0; bottom: 0;
                         background: rgba(0,0,0,0.5); z-index: 1000; align-items: center;
                         justify-content: center; }
        .modal-overlay.active { display: flex; }
        .modal { background: var(--bg); padding: 1.5rem; border-radius: 8px; width: 90%;
                 max-width: 700px; max-height: 80vh; overflow-y: auto; }
        .modal h3 { margin-top: 0; }

        .xml-preview { background: #1e293b; color: #e2e8f0; padding: 1rem; border-radius: 6px;
                       font-family: monospace; font-size: 0.8rem; white-space: pre-wrap;
                       word-break: break-all; max-height: 300px; overflow-y: auto; }

        .detail-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 0.5rem; margin-bottom: 1rem; }
        .detail-item { padding: 0.5rem; background: var(--bg-subtle); border-radius: 4px; }
        .detail-item label { display: block; font-size: 0.75rem; color: var(--text-muted); font-weight: 600; }
        .detail-item span { font-size: 0.95rem; }

        .form-group { margin-bottom: 0.75rem; }
        .form-group label { display: block; font-size: 0.8rem; font-weight: 600; color: var(--text-muted);
                            margin-bottom: 0.25rem; }
        .form-group select, .form-group input { width: 100%; padding: 0.4rem 0.5rem; border: 1px solid var(--border);
                                                border-radius: 4px; font-size: 0.9rem; }

        .stats { display: flex; gap: 1rem; margin-bottom: 1rem; }
        .stat-card { background: var(--bg-subtle); padding: 0.75rem 1rem; border-radius: 6px; flex: 1;
                     text-align: center; }
        .stat-card .number { font-size: 1.5rem; font-weight: 700; }
        .stat-card .label { font-size: 0.8rem; color: var(--text-muted); }
    </style>
</head>
<body>
    <div class="container">
        <div class="header">
            <h1>Facturacion CFDI</h1>
            <div style="display:flex;gap:0.5rem;">
                <button class="btn btn-primary" onclick="Invoicing.processQueue()">Procesar Cola</button>
                <a href="/pos/sale" class="btn btn-secondary">POS</a>
                <a href="/pos/accounting" class="btn btn-secondary">Contabilidad</a>
            </div>
        </div>

        <!-- Stats -->
        <div class="stats" id="queue-stats"></div>

        <!-- Filters -->
        <div class="queue-filters">
            <select id="filter-status">
                <option value="">Todos los estados</option>
                <option value="pending">Pendiente</option>
                <option value="sending">Enviando</option>
                <option value="stamped">Timbrado</option>
                <option value="failed">Fallido</option>
                <option value="cancelled">Cancelado</option>
            </select>
            <select id="filter-type">
                <option value="">Todos los tipos</option>
                <option value="ingreso">Ingreso</option>
                <option value="egreso">Egreso</option>
                <option value="pago">Pago</option>
            </select>
            <button class="btn btn-secondary" onclick="Invoicing.loadQueue()">Filtrar</button>
        </div>

        <!-- Queue List -->
        <div id="queue-list"></div>
        <div id="queue-pagination"></div>
    </div>

    <!-- Detail Modal -->
    <div class="modal-overlay" id="detail-modal">
        <div class="modal">
            <div id="detail-content"></div>
            <div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
                <button class="btn btn-secondary" onclick="Invoicing.closeModal('detail-modal')">Cerrar</button>
            </div>
        </div>
    </div>

    <!-- Cancel Modal -->
    <div class="modal-overlay" id="cancel-modal">
        <div class="modal">
            <h3>Cancelar CFDI</h3>
            <input type="hidden" id="cancel-cfdi-id">
            <div class="form-group">
                <label>Motivo de cancelacion (SAT)</label>
                <select id="cancel-motive" onchange="Invoicing.onMotiveChange()">
                    <option value="">Seleccionar motivo</option>
                    <option value="01">01 - Con relacion (requiere UUID sustituto)</option>
                    <option value="02">02 - Sin relacion</option>
                    <option value="03">03 - No se llevo a cabo la operacion</option>
                    <option value="04">04 - Operacion nominativa en factura global</option>
                </select>
            </div>
            <div class="form-group" id="replacement-uuid-group" style="display:none;">
                <label>UUID del CFDI sustituto</label>
                <input type="text" id="cancel-replacement-uuid" placeholder="xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx">
            </div>
            <div style="display:flex;gap:0.5rem;justify-content:flex-end;margin-top:1rem;">
                <button class="btn btn-secondary" onclick="Invoicing.closeModal('cancel-modal')">Cancelar</button>
                <button class="btn btn-danger" onclick="Invoicing.confirmCancel()">Confirmar Cancelacion</button>
            </div>
        </div>
    </div>

    <script src="/pos/static/js/invoicing.js"></script>
</body>
</html>
  • Step 2: Create invoicing.js
// /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 =
                `<p style="color:var(--danger);">Error: ${e.message}</p>`;
        }
    }

    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 = `
            <div class="stat-card"><div class="number">${counts.pending}</div><div class="label">Pendientes</div></div>
            <div class="stat-card"><div class="number">${counts.sending}</div><div class="label">Enviando</div></div>
            <div class="stat-card"><div class="number">${counts.stamped}</div><div class="label">Timbrados</div></div>
            <div class="stat-card"><div class="number">${counts.failed}</div><div class="label">Fallidos</div></div>
            <div class="stat-card"><div class="number">${counts.cancelled}</div><div class="label">Cancelados</div></div>`;
    }

    function renderQueue(items) {
        const container = document.getElementById('queue-list');
        if (!items.length) { container.innerHTML = '<p>No hay CFDIs en la cola.</p>'; return; }

        let html = `<table class="queue-table">
            <thead><tr>
                <th>#</th><th>Venta</th><th>Tipo</th><th>Folio</th>
                <th>UUID</th><th>Estado</th><th>Reintentos</th><th>Fecha</th><th>Acciones</th>
            </tr></thead><tbody>`;

        for (const item of items) {
            const uuid = item.uuid_fiscal
                ? `${item.uuid_fiscal.substring(0, 8)}...`
                : '-';
            html += `<tr>
                <td>${item.id}</td>
                <td>#${item.sale_id}</td>
                <td>${item.type}</td>
                <td>${item.provisional_folio || '-'}</td>
                <td title="${item.uuid_fiscal || ''}">${uuid}</td>
                <td><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></td>
                <td>${item.retry_count || 0}</td>
                <td>${item.created_at ? new Date(item.created_at).toLocaleDateString('es-MX') : ''}</td>
                <td>
                    <button class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
                        onclick="Invoicing.showDetail(${item.id})">Ver</button>
                    ${item.status === 'stamped' ? `<button class="btn btn-danger" style="padding:0.2rem 0.4rem;font-size:0.8rem;"
                        onclick="Invoicing.showCancelModal(${item.id})">Cancelar</button>` : ''}
                    ${item.sale_id ? `<a href="/pos/api/invoicing/${item.sale_id}/pdf" target="_blank"
                        class="btn btn-secondary" style="padding:0.2rem 0.4rem;font-size:0.8rem;">PDF</a>` : ''}
                </td>
            </tr>`;
        }
        html += '</tbody></table>';
        container.innerHTML = html;
    }

    // ─── Detail ────────────────────────────────────

    async function showDetail(cfdiId) {
        try {
            const item = await api(`/queue/${cfdiId}`);
            let html = `<h3>CFDI #${item.id}</h3>
                <div class="detail-grid">
                    <div class="detail-item"><label>Venta</label><span>#${item.sale_id}</span></div>
                    <div class="detail-item"><label>Tipo</label><span>${item.type}</span></div>
                    <div class="detail-item"><label>Estado</label><span class="badge ${badgeClass(item.status)}">${badgeLabel(item.status)}</span></div>
                    <div class="detail-item"><label>Folio Provisional</label><span>${item.provisional_folio || '-'}</span></div>
                    <div class="detail-item"><label>UUID Fiscal</label><span>${item.uuid_fiscal || '-'}</span></div>
                    <div class="detail-item"><label>Reintentos</label><span>${item.retry_count}</span></div>
                    <div class="detail-item"><label>Creado</label><span>${item.created_at || '-'}</span></div>
                    <div class="detail-item"><label>Timbrado</label><span>${item.stamped_at || '-'}</span></div>
                </div>`;

            if (item.error_message) {
                html += `<p style="color:var(--danger);"><strong>Error:</strong> ${item.error_message}</p>`;
            }
            if (item.cancel_motive) {
                html += `<p><strong>Motivo cancelacion:</strong> ${item.cancel_motive}</p>`;
            }

            // XML preview
            const xml = item.xml_signed || item.xml_unsigned;
            if (xml) {
                html += `<h4>XML</h4><div class="xml-preview">${escapeHtml(xml)}</div>`;
            }

            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
# /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 '<?xml' in xml_str, "Should have XML declaration"
    assert 'Version="4.0"' in xml_str, "Should be CFDI 4.0"
    assert 'Exportacion="01"' in xml_str, "Should have Exportacion=01"
    assert 'TipoDeComprobante="I"' in xml_str, "Should be Ingreso"
    assert 'XAXX010101000' in xml_str, "Should have publico general RFC"
    assert 'InformacionGlobal' in xml_str, "Should have InformacionGlobal for publico general"
    assert 'ObjetoImp="02"' in xml_str, "Conceptos should have ObjetoImp=02"

    # Verify it parses as valid XML
    root = etree.fromstring(xml_str.encode('utf-8'))
    assert root is not None

    # Test with customer
    customer = {
        'rfc': 'CUSR900101AAA',
        'razon_social': 'Cliente de Prueba',
        'regimen_fiscal': '612',
        'uso_cfdi': 'G03',
        'cp': '01000',
    }
    xml_str2 = build_ingreso_xml(sale, tenant_config, customer)
    assert 'CUSR900101AAA' in xml_str2, "Should have customer RFC"
    assert 'InformacionGlobal' not in xml_str2, "Should NOT have InformacionGlobal for named customer"

    print("PASS: test_cfdi_builder_ingreso")


def test_cfdi_builder_pago():
    """Test that build_pago_xml produces valid Complemento de Pago 2.0."""
    from services.cfdi_builder import build_pago_xml
    from lxml import etree

    payment = {
        'id': 1,
        'amount': 5800.00,
        'payment_method': 'transferencia',
        'date': '2026-03-31',
        'sale_id': 100,
        'num_parcialidad': 1,
        'saldo_anterior': 5800.00,
    }
    tenant_config = {
        'rfc': 'TEST010101ABC',
        'razon_social': 'Refaccionaria Test SA de CV',
        'regimen_fiscal': '601',
        'cp': '06600',
    }
    customer = {
        'rfc': 'CUSR900101AAA',
        'razon_social': 'Cliente de Prueba',
        'regimen_fiscal': '612',
        'uso_cfdi': 'CP01',
        'cp': '01000',
    }
    original_uuid = 'AAAAAAAA-BBBB-CCCC-DDDD-EEEEEEEEEEEE'

    xml_str = build_pago_xml(payment, tenant_config, customer, original_uuid)
    assert 'TipoDeComprobante="P"' in xml_str, "Should be Pago type"
    assert 'Moneda="XXX"' in xml_str, "Should have XXX currency for Pago"
    assert 'Pagos' in xml_str, "Should have Pagos complement"
    assert 'Version="2.0"' in xml_str, "Should have Pagos 2.0"
    assert original_uuid in xml_str, "Should reference original UUID"

    root = etree.fromstring(xml_str.encode('utf-8'))
    assert root is not None
    print("PASS: test_cfdi_builder_pago")


def test_cfdi_queue():
    """Test enqueue and queue status."""
    from services.cfdi_queue import enqueue_cfdi, get_queue_status

    conn = get_test_conn()
    conn.autocommit = False

    try:
        # We need a sale_id that exists. Create a minimal sale.
        cur = conn.cursor()
        cur.execute("""
            INSERT INTO sales (branch_id, sale_type, subtotal, tax_total, total, status)
            VALUES (1, 'cash', 100, 16, 116, 'completed')
            RETURNING id
        """)
        sale_id = cur.fetchone()[0]
        cur.close()

        result = enqueue_cfdi(conn, sale_id, 'ingreso', '<xml>test</xml>')
        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', '<cfdi>test xml</cfdi>')

        # 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': '<cfdi>signed xml</cfdi>',
        }

        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.