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>
4653 lines
172 KiB
Markdown
4653 lines
172 KiB
Markdown
# CFDI + Accounting Implementation Plan (4 of 5)
|
|
|
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
|
|
|
**Goal:** Build the CFDI 4.0 invoicing pipeline (XML generation, Horux timbrado queue, cancellation) and the automatic accounting engine (journal entries for every business operation, chart of accounts management, financial reports).
|
|
|
|
**Architecture:** Two Flask blueprints (`invoicing_bp.py`, `accounting_bp.py`) with three core services (`accounting_engine.py`, `cfdi_builder.py`, `cfdi_queue.py`). The accounting engine is hooked into `pos_engine.process_sale()` and `pos_engine.cancel_sale()` to auto-generate journal entries. CFDI XML is built with `lxml` and queued for timbrado via Horux360 API. Financial reports (balance sheet, income statement, trial balance, aging) are SQL aggregations on `journal_entry_lines`.
|
|
|
|
**Tech Stack:** Python 3, Flask blueprints, psycopg2, lxml (XML generation), requests (Horux API)
|
|
|
|
**Spec:** `/home/Autopartes/docs/plans/2026-03-27-pos-inventario-design.md` (sections 5 & 6)
|
|
|
|
**Depends on:** Plan 1 Foundation (complete) -- tenant_db, middleware, audit service. Plan 2 Inventory + Catalog (complete) -- inventory_engine. Plan 3 POS + Cash Register (complete) -- pos_engine, sales, cash registers.
|
|
|
|
**Sub-plans:**
|
|
1. Foundation (complete)
|
|
2. Inventory + Catalog (complete)
|
|
3. POS + Cash Register (complete)
|
|
4. **CFDI + Accounting** (this plan)
|
|
5. PWA + Sync
|
|
|
|
---
|
|
|
|
## File Structure
|
|
|
|
```
|
|
/home/Autopartes/pos/
|
|
├── app.py # MODIFY: register invoicing_bp, accounting_bp, add page routes
|
|
├── blueprints/
|
|
│ ├── invoicing_bp.py # CREATE: CFDI generation, queue management, cancellation
|
|
│ └── accounting_bp.py # CREATE: chart of accounts, journal entries, financial reports
|
|
├── services/
|
|
│ ├── accounting_engine.py # CREATE: auto journal entries for sales, purchases, cash cuts
|
|
│ ├── cfdi_builder.py # CREATE: CFDI 4.0 XML builder (ingreso, egreso, pago)
|
|
│ └── cfdi_queue.py # CREATE: timbrado queue, Horux API integration, retry logic
|
|
│ └── pos_engine.py # MODIFY: hook accounting_engine calls into process_sale/cancel_sale
|
|
├── templates/
|
|
│ ├── invoicing.html # CREATE: CFDI queue management page
|
|
│ └── accounting.html # CREATE: accounting dashboard with tabs
|
|
└── static/
|
|
└── js/
|
|
├── invoicing.js # CREATE: CFDI queue UI, cancel modal, PDF download
|
|
└── accounting.js # CREATE: chart of accounts tree, journal entries, reports
|
|
```
|
|
|
|
---
|
|
|
|
### Task 1: Accounting engine service (`pos/services/accounting_engine.py`)
|
|
|
|
**Files:**
|
|
- Create: `/home/Autopartes/pos/services/accounting_engine.py`
|
|
|
|
Auto-generates journal entries (polizas) for every business operation. Each function creates a `journal_entries` record plus balanced `journal_entry_lines`. All amounts use the SAT chart of accounts seeded in Plan 1.
|
|
|
|
- [ ] **Step 1: Create accounting_engine.py**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/services/accounting_engine.py
|
|
"""Accounting engine: automatic journal entry generation for business operations.
|
|
|
|
Every sale, purchase, cash cut, credit payment, and cancellation produces
|
|
balanced journal entries (polizas). The engine looks up SAT account IDs
|
|
by code and creates journal_entries + journal_entry_lines records.
|
|
|
|
Account codes (from SAT seed):
|
|
110 = Caja
|
|
111 = Bancos
|
|
120 = Clientes
|
|
130 = Inventarios
|
|
140 = IVA Acreditable
|
|
210 = Proveedores
|
|
220 = IVA Trasladado
|
|
410 = Ventas
|
|
420 = Devoluciones sobre Ventas
|
|
510 = Costo de Mercancia Vendida
|
|
|
|
All functions receive a psycopg2 connection (caller controls commit).
|
|
"""
|
|
|
|
from datetime import date
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from flask import g
|
|
|
|
|
|
def _to_dec(val):
|
|
"""Convert a value to Decimal for precise arithmetic."""
|
|
if val is None:
|
|
return Decimal('0')
|
|
return Decimal(str(val))
|
|
|
|
|
|
TWO = Decimal('0.01')
|
|
|
|
|
|
def _get_account_id(cur, code):
|
|
"""Look up account ID by code. Raises ValueError if not found."""
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s AND is_active = true", (code,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError(f"Account with code '{code}' not found")
|
|
return row[0]
|
|
|
|
|
|
def _get_account_ids(cur, codes):
|
|
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
|
result = {}
|
|
for code in codes:
|
|
result[code] = _get_account_id(cur, code)
|
|
return result
|
|
|
|
|
|
def get_next_entry_number(conn):
|
|
"""Get the next sequential journal entry number.
|
|
|
|
Uses a simple MAX+1 approach. For high-concurrency environments this
|
|
could be replaced with a sequence, but for single-tenant refaccionarias
|
|
the transaction-level lock from the INSERT is sufficient.
|
|
|
|
Args:
|
|
conn: psycopg2 connection to tenant DB
|
|
|
|
Returns:
|
|
int: next entry number (starts at 1)
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
|
number = cur.fetchone()[0]
|
|
cur.close()
|
|
return number
|
|
|
|
|
|
def _check_period_open(cur, entry_date):
|
|
"""Verify the fiscal period for the given date is open.
|
|
|
|
If no fiscal_periods row exists for the month, it is considered open
|
|
(periods are only created when explicitly closed).
|
|
|
|
Args:
|
|
cur: psycopg2 cursor
|
|
entry_date: date object
|
|
|
|
Raises:
|
|
ValueError: if the period is closed
|
|
"""
|
|
cur.execute("""
|
|
SELECT status FROM fiscal_periods
|
|
WHERE year = %s AND month = %s
|
|
""", (entry_date.year, entry_date.month))
|
|
row = cur.fetchone()
|
|
if row and row[0] == 'closed':
|
|
raise ValueError(
|
|
f"Fiscal period {entry_date.year}-{entry_date.month:02d} is closed. "
|
|
f"Cannot create journal entries in a closed period."
|
|
)
|
|
|
|
|
|
def _create_entry(cur, entry_number, entry_date, entry_type, description,
|
|
reference_type, reference_id, lines, is_auto=True):
|
|
"""Create a journal entry with its lines.
|
|
|
|
Validates that total debits == total credits before inserting.
|
|
|
|
Args:
|
|
cur: psycopg2 cursor
|
|
entry_number: int sequential number
|
|
entry_date: date
|
|
entry_type: 'ingreso' | 'egreso' | 'diario' | 'poliza'
|
|
description: str
|
|
reference_type: 'sale' | 'purchase' | 'cash_register' | 'payment' | None
|
|
reference_id: int or None
|
|
lines: list of dicts with keys: account_id, debit, credit, description
|
|
is_auto: bool (True for system-generated entries)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
|
|
Raises:
|
|
ValueError: if debits != credits
|
|
"""
|
|
# Validate balance
|
|
total_debit = sum(_to_dec(line['debit']) for line in lines)
|
|
total_credit = sum(_to_dec(line['credit']) for line in lines)
|
|
|
|
if total_debit.quantize(TWO, ROUND_HALF_UP) != total_credit.quantize(TWO, ROUND_HALF_UP):
|
|
raise ValueError(
|
|
f"Unbalanced entry: debits={total_debit} credits={total_credit}"
|
|
)
|
|
|
|
created_by = getattr(g, 'employee_id', None)
|
|
|
|
cur.execute("""
|
|
INSERT INTO journal_entries
|
|
(entry_number, date, type, description, reference_type, reference_id,
|
|
status, created_by, is_auto)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'posted', %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
entry_number, entry_date, entry_type, description,
|
|
reference_type, reference_id, created_by, is_auto
|
|
))
|
|
entry_id = cur.fetchone()[0]
|
|
|
|
for line in lines:
|
|
debit = float(_to_dec(line['debit']).quantize(TWO, ROUND_HALF_UP))
|
|
credit = float(_to_dec(line['credit']).quantize(TWO, ROUND_HALF_UP))
|
|
if debit == 0 and credit == 0:
|
|
continue # skip zero lines
|
|
cur.execute("""
|
|
INSERT INTO journal_entry_lines
|
|
(journal_entry_id, account_id, debit, credit, description)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""", (entry_id, line['account_id'], debit, credit, line.get('description', '')))
|
|
|
|
return entry_id
|
|
|
|
|
|
def record_sale_entry(conn, sale):
|
|
"""Generate journal entries for a completed sale.
|
|
|
|
For a cash sale:
|
|
Debit 110 Caja (or 111 Bancos for transferencia/tarjeta) = total
|
|
Credit 410 Ventas = subtotal
|
|
Credit 220 IVA Trasladado = tax_total
|
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
|
|
|
For a credit sale:
|
|
Debit 120 Clientes = total
|
|
Credit 410 Ventas = subtotal
|
|
Credit 220 IVA Trasladado = tax_total
|
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
|
|
|
Args:
|
|
conn: psycopg2 connection to tenant DB
|
|
sale: dict from process_sale() with keys:
|
|
id, sale_type, payment_method, subtotal, discount_total,
|
|
tax_total, total, items (with unit_cost, quantity)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
sale_id = sale['id']
|
|
sale_type = sale.get('sale_type', 'cash')
|
|
payment_method = sale.get('payment_method', 'efectivo')
|
|
subtotal = _to_dec(sale['subtotal'])
|
|
tax_total = _to_dec(sale['tax_total'])
|
|
total = _to_dec(sale['total'])
|
|
|
|
# Calculate total cost of goods sold
|
|
cost_total = Decimal('0')
|
|
for item in sale.get('items', []):
|
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
|
|
|
# Determine debit account for payment received
|
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
|
|
|
if sale_type == 'credit':
|
|
payment_account_id = accounts['120'] # Clientes
|
|
payment_desc = f'Clientes - Venta a credito #{sale_id}'
|
|
elif payment_method in ('transferencia', 'tarjeta'):
|
|
payment_account_id = accounts['111'] # Bancos
|
|
payment_desc = f'Bancos - Venta #{sale_id} ({payment_method})'
|
|
else:
|
|
payment_account_id = accounts['110'] # Caja
|
|
payment_desc = f'Caja - Venta #{sale_id} (efectivo)'
|
|
|
|
lines = [
|
|
# Payment received (debit)
|
|
{
|
|
'account_id': payment_account_id,
|
|
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': payment_desc,
|
|
},
|
|
# Revenue (credit)
|
|
{
|
|
'account_id': accounts['410'],
|
|
'debit': 0,
|
|
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Ventas - Venta #{sale_id}',
|
|
},
|
|
# IVA Trasladado (credit)
|
|
{
|
|
'account_id': accounts['220'],
|
|
'debit': 0,
|
|
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'IVA Trasladado - Venta #{sale_id}',
|
|
},
|
|
]
|
|
|
|
# Cost of goods sold entries (only if cost > 0)
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Costo mercancia - Venta #{sale_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Inventarios - Venta #{sale_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Venta #{sale_id} - {payment_method}',
|
|
'sale', sale_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_purchase_entry(conn, purchase_data):
|
|
"""Generate journal entries for a purchase (inventory receipt).
|
|
|
|
Debit 130 Inventarios = subtotal (cost of goods)
|
|
Debit 140 IVA Acreditable = tax amount
|
|
Credit 210 Proveedores = total (subtotal + tax)
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
purchase_data: dict with keys:
|
|
reference_id: int (purchase order or operation ID)
|
|
subtotal: float (cost of goods before tax)
|
|
tax_amount: float (IVA 16%)
|
|
total: float (subtotal + tax)
|
|
supplier_name: str (optional, for description)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
ref_id = purchase_data.get('reference_id')
|
|
subtotal = _to_dec(purchase_data['subtotal'])
|
|
tax_amount = _to_dec(purchase_data.get('tax_amount', 0))
|
|
total = _to_dec(purchase_data['total'])
|
|
supplier = purchase_data.get('supplier_name', 'Proveedor')
|
|
|
|
accounts = _get_account_ids(cur, ['130', '140', '210'])
|
|
|
|
lines = [
|
|
{
|
|
'account_id': accounts['130'],
|
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Inventarios - Compra {supplier}',
|
|
},
|
|
{
|
|
'account_id': accounts['210'],
|
|
'debit': 0,
|
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Proveedores - Compra {supplier}',
|
|
},
|
|
]
|
|
|
|
# IVA Acreditable (only if tax > 0)
|
|
if tax_amount > 0:
|
|
lines.append({
|
|
'account_id': accounts['140'],
|
|
'debit': float(tax_amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'IVA Acreditable - Compra {supplier}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
f'Compra - {supplier}',
|
|
'purchase', ref_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_cash_cut_entry(conn, register):
|
|
"""Generate journal entry for a cash register close (corte Z).
|
|
|
|
Moves cash from register (Caja) to bank (Bancos):
|
|
Debit 111 Bancos = closing_amount
|
|
Credit 110 Caja = closing_amount
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
register: dict with keys:
|
|
id: int (cash register ID)
|
|
closing_amount: float
|
|
register_number: int
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
reg_id = register['id']
|
|
amount = _to_dec(register['closing_amount'])
|
|
reg_num = register.get('register_number', '?')
|
|
|
|
if amount <= 0:
|
|
cur.close()
|
|
return None # No entry for zero/negative close
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111'])
|
|
|
|
lines = [
|
|
{
|
|
'account_id': accounts['111'],
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Bancos - Corte caja #{reg_num}',
|
|
},
|
|
{
|
|
'account_id': accounts['110'],
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Caja - Corte caja #{reg_num}',
|
|
},
|
|
]
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
f'Corte de caja #{reg_num} (registro #{reg_id})',
|
|
'cash_register', reg_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_credit_payment_entry(conn, payment):
|
|
"""Generate journal entry for a customer credit payment.
|
|
|
|
Debit 111 Bancos (or 110 Caja) = amount
|
|
Credit 120 Clientes = amount
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
payment: dict with keys:
|
|
customer_id: int
|
|
customer_name: str (optional)
|
|
amount: float
|
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
reference_id: int (optional, e.g. sale_id being paid)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
amount = _to_dec(payment['amount'])
|
|
customer_name = payment.get('customer_name', f"Cliente #{payment['customer_id']}")
|
|
method = payment.get('payment_method', 'efectivo')
|
|
ref_id = payment.get('reference_id')
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '120'])
|
|
|
|
if method in ('transferencia', 'tarjeta'):
|
|
debit_account = accounts['111'] # Bancos
|
|
debit_desc = f'Bancos - Cobro credito {customer_name}'
|
|
else:
|
|
debit_account = accounts['110'] # Caja
|
|
debit_desc = f'Caja - Cobro credito {customer_name}'
|
|
|
|
lines = [
|
|
{
|
|
'account_id': debit_account,
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': debit_desc,
|
|
},
|
|
{
|
|
'account_id': accounts['120'],
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Clientes - Cobro credito {customer_name}',
|
|
},
|
|
]
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Cobro credito - {customer_name} ({method})',
|
|
'payment', ref_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_cancellation_entry(conn, sale):
|
|
"""Generate reverse journal entry for a cancelled sale.
|
|
|
|
This is the exact reverse of record_sale_entry():
|
|
Credit payment account (Caja/Bancos/Clientes) = total
|
|
Debit 410 Ventas = subtotal
|
|
Debit 220 IVA Trasladado = tax_total
|
|
Credit 510 Costo de Mercancia Vendida = cost_total
|
|
Debit 130 Inventarios = cost_total
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
sale: dict with the same structure as record_sale_entry() expects.
|
|
If items are not present, looks up from DB.
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
sale_id = sale['id']
|
|
sale_type = sale.get('sale_type', 'cash')
|
|
payment_method = sale.get('payment_method', 'efectivo')
|
|
subtotal = _to_dec(sale.get('subtotal', 0))
|
|
tax_total = _to_dec(sale.get('tax_total', 0))
|
|
total = _to_dec(sale.get('total', 0))
|
|
|
|
# If sale dict lacks items, look up from DB
|
|
items = sale.get('items')
|
|
if not items:
|
|
cur.execute("""
|
|
SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s
|
|
""", (sale_id,))
|
|
items = [{'unit_cost': float(r[0]) if r[0] else 0, 'quantity': r[1]}
|
|
for r in cur.fetchall()]
|
|
|
|
# If sale dict lacks totals, look up from DB
|
|
if total == 0:
|
|
cur.execute("""
|
|
SELECT subtotal, tax_total, total, sale_type, payment_method
|
|
FROM sales WHERE id = %s
|
|
""", (sale_id,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
subtotal = _to_dec(row[0])
|
|
tax_total = _to_dec(row[1])
|
|
total = _to_dec(row[2])
|
|
sale_type = row[3]
|
|
payment_method = row[4]
|
|
|
|
cost_total = Decimal('0')
|
|
for item in items:
|
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
|
|
|
# Determine which payment account to credit (reverse of debit in sale)
|
|
if sale_type == 'credit':
|
|
payment_account_id = accounts['120']
|
|
payment_desc = f'Clientes - Cancelacion venta #{sale_id}'
|
|
elif payment_method in ('transferencia', 'tarjeta'):
|
|
payment_account_id = accounts['111']
|
|
payment_desc = f'Bancos - Cancelacion venta #{sale_id}'
|
|
else:
|
|
payment_account_id = accounts['110']
|
|
payment_desc = f'Caja - Cancelacion venta #{sale_id}'
|
|
|
|
lines = [
|
|
# Reverse payment (credit the account that was debited)
|
|
{
|
|
'account_id': payment_account_id,
|
|
'debit': 0,
|
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': payment_desc,
|
|
},
|
|
# Reverse revenue (debit Ventas)
|
|
{
|
|
'account_id': accounts['410'],
|
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Ventas - Cancelacion venta #{sale_id}',
|
|
},
|
|
# Reverse IVA (debit IVA Trasladado)
|
|
{
|
|
'account_id': accounts['220'],
|
|
'debit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'IVA Trasladado - Cancelacion venta #{sale_id}',
|
|
},
|
|
]
|
|
|
|
# Reverse COGS entries (only if cost > 0)
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Costo mercancia - Cancelacion venta #{sale_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Inventarios - Cancelacion venta #{sale_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'egreso',
|
|
f'Cancelacion venta #{sale_id}',
|
|
'sale', sale_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def create_manual_entry(conn, entry_data):
|
|
"""Create a manual journal entry (type='diario').
|
|
|
|
Used by accountants for adjustments not tied to a specific operation.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
entry_data: dict with keys:
|
|
date: str 'YYYY-MM-DD'
|
|
description: str
|
|
lines: [{account_id, debit, credit, description}]
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
|
|
Raises:
|
|
ValueError: if entry is unbalanced or period is closed
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.fromisoformat(entry_data['date'])
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
entry_data.get('description', 'Poliza manual'),
|
|
None, None,
|
|
entry_data['lines'],
|
|
is_auto=False
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
```
|
|
|
|
---
|
|
|
|
### Task 2: CFDI XML builder service (`pos/services/cfdi_builder.py`)
|
|
|
|
**Files:**
|
|
- Create: `/home/Autopartes/pos/services/cfdi_builder.py`
|
|
|
|
Builds CFDI 4.0 compliant XML using `lxml`. The XML is unsigned -- Horux360 handles CSD signing and PAC timbrado. Follows the SAT CFDI 4.0 schema including mandatory fields: Version, Exportacion, ObjetoImp, InformacionGlobal.
|
|
|
|
- [ ] **Step 1: Create cfdi_builder.py**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/services/cfdi_builder.py
|
|
"""CFDI 4.0 XML builder using lxml.
|
|
|
|
Builds unsigned CFDI XML documents for:
|
|
- Ingreso (sale invoice)
|
|
- Egreso (credit note / refund)
|
|
- Pago (payment complement for credit sales)
|
|
|
|
The unsigned XML is sent to Horux360 for CSD signing and PAC timbrado.
|
|
All XML follows the SAT CFDI 4.0 schema:
|
|
http://www.sat.gob.mx/cfd/4
|
|
|
|
CFDI 4.0 mandatory fields handled:
|
|
- Version="4.0"
|
|
- Exportacion="01" (no aplica)
|
|
- ObjetoImp="02" (si, objeto de impuesto) on each Concepto
|
|
- InformacionGlobal for publico general (RFC: XAXX010101000)
|
|
- Emisor: Rfc, Nombre, RegimenFiscal
|
|
- Receptor: Rfc, Nombre, RegimenFiscalReceptor, UsoCFDI, DomicilioFiscalReceptor
|
|
"""
|
|
|
|
from datetime import datetime
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from lxml import etree
|
|
|
|
# SAT XML namespaces
|
|
CFDI_NS = 'http://www.sat.gob.mx/cfd/4'
|
|
PAGO_NS = 'http://www.sat.gob.mx/Pagos20'
|
|
XSI_NS = 'http://www.w3.org/2001/XMLSchema-instance'
|
|
|
|
CFDI_SCHEMA_LOCATION = (
|
|
'http://www.sat.gob.mx/cfd/4 '
|
|
'http://www.sat.gob.mx/sitio_internet/cfd/4/cfdv40.xsd'
|
|
)
|
|
PAGO_SCHEMA_LOCATION = (
|
|
'http://www.sat.gob.mx/Pagos20 '
|
|
'http://www.sat.gob.mx/sitio_internet/cfd/Pagos/Pagos20.xsd'
|
|
)
|
|
|
|
# RFC for publico en general
|
|
RFC_PUBLICO_GENERAL = 'XAXX010101000'
|
|
# RFC for foreign customers
|
|
RFC_EXTRANJERO = 'XEXX010101000'
|
|
|
|
|
|
def _to_dec(val):
|
|
if val is None:
|
|
return Decimal('0')
|
|
return Decimal(str(val))
|
|
|
|
|
|
TWO = Decimal('0.01')
|
|
SIX = Decimal('0.000001')
|
|
|
|
|
|
def _format_amount(val):
|
|
"""Format a Decimal to 2 decimal places as string."""
|
|
return str(_to_dec(val).quantize(TWO, ROUND_HALF_UP))
|
|
|
|
|
|
def _format_rate(val):
|
|
"""Format a tax rate to 6 decimal places as string."""
|
|
return str(_to_dec(val).quantize(SIX, ROUND_HALF_UP))
|
|
|
|
|
|
def _make_element(parent, tag, attribs=None, ns=CFDI_NS):
|
|
"""Create a subelement with the given namespace."""
|
|
elem = etree.SubElement(parent, f'{{{ns}}}{tag}')
|
|
if attribs:
|
|
for k, v in attribs.items():
|
|
if v is not None:
|
|
elem.set(k, str(v))
|
|
return elem
|
|
|
|
|
|
def build_ingreso_xml(sale, tenant_config, customer=None):
|
|
"""Build CFDI 4.0 XML for a sale (Comprobante tipo Ingreso).
|
|
|
|
Args:
|
|
sale: dict with keys:
|
|
id, subtotal, discount_total, tax_total, total, created_at,
|
|
metodo_pago_sat ('PUE'|'PPD'), forma_pago_sat ('01'|'03'|'04'|'99'),
|
|
items: [{part_number, name, quantity, unit_price, discount_amount,
|
|
tax_rate, tax_amount, subtotal,
|
|
clave_prod_serv, clave_unidad}]
|
|
tenant_config: dict with keys:
|
|
rfc, razon_social, regimen_fiscal, cp (codigo postal),
|
|
serie (optional), nombre_comercial (optional)
|
|
customer: dict or None with keys:
|
|
rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
|
If None, generates factura a publico general.
|
|
|
|
Returns:
|
|
str: XML string (unsigned, ready for Horux)
|
|
"""
|
|
nsmap = {
|
|
'cfdi': CFDI_NS,
|
|
'xsi': XSI_NS,
|
|
}
|
|
|
|
# Root: Comprobante
|
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
|
root.set('Version', '4.0')
|
|
root.set('Serie', tenant_config.get('serie', 'A'))
|
|
root.set('Folio', str(sale['id']))
|
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
|
root.set('FormaPago', sale.get('forma_pago_sat', '01'))
|
|
root.set('SubTotal', _format_amount(sale['subtotal']))
|
|
|
|
discount_total = _to_dec(sale.get('discount_total', 0))
|
|
if discount_total > 0:
|
|
root.set('Descuento', _format_amount(discount_total))
|
|
|
|
root.set('Moneda', 'MXN')
|
|
root.set('Total', _format_amount(sale['total']))
|
|
root.set('TipoDeComprobante', 'I') # Ingreso
|
|
root.set('Exportacion', '01') # No aplica
|
|
root.set('MetodoPago', sale.get('metodo_pago_sat', 'PUE'))
|
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
|
|
|
# InformacionGlobal (required for publico general)
|
|
is_publico_general = (customer is None or
|
|
customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL))
|
|
|
|
if is_publico_general:
|
|
info_global = _make_element(root, 'InformacionGlobal')
|
|
info_global.set('Periodicidad', '01') # Diario
|
|
now = datetime.now()
|
|
info_global.set('Meses', f'{now.month:02d}')
|
|
info_global.set('Anio', str(now.year))
|
|
|
|
# Emisor
|
|
emisor = _make_element(root, 'Emisor')
|
|
emisor.set('Rfc', tenant_config['rfc'])
|
|
emisor.set('Nombre', tenant_config['razon_social'])
|
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
|
|
|
# Receptor
|
|
receptor = _make_element(root, 'Receptor')
|
|
if is_publico_general:
|
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
|
receptor.set('RegimenFiscalReceptor', '616') # Sin obligaciones fiscales
|
|
receptor.set('UsoCFDI', 'S01') # Sin efectos fiscales
|
|
else:
|
|
receptor.set('Rfc', customer['rfc'])
|
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
|
receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03'))
|
|
|
|
# Conceptos
|
|
conceptos = _make_element(root, 'Conceptos')
|
|
|
|
for item in sale.get('items', []):
|
|
qty = int(item.get('quantity', 1))
|
|
unit_price = _to_dec(item.get('unit_price', 0))
|
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
|
|
|
# Importe = qty * unit_price (before discount)
|
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
|
# Base for tax = importe - discount
|
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
|
|
|
concepto = _make_element(conceptos, 'Concepto')
|
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800')) # Default: autopartes
|
|
concepto.set('NoIdentificacion', item.get('part_number', ''))
|
|
concepto.set('Cantidad', str(qty))
|
|
concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87')) # H87 = Pieza
|
|
concepto.set('Unidad', 'PZA')
|
|
concepto.set('Descripcion', item.get('name', 'Autoparte'))
|
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
|
concepto.set('Importe', _format_amount(importe))
|
|
concepto.set('ObjetoImp', '02') # Si objeto de impuesto
|
|
|
|
if discount_amount > 0:
|
|
concepto.set('Descuento', _format_amount(discount_amount))
|
|
|
|
# Impuestos del concepto
|
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
|
traslado.set('Base', _format_amount(base))
|
|
traslado.set('Impuesto', '002') # IVA
|
|
traslado.set('TipoFactor', 'Tasa')
|
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
|
traslado.set('Importe', _format_amount(tax_amount))
|
|
|
|
# Impuestos totales
|
|
impuestos = _make_element(root, 'Impuestos')
|
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total']))
|
|
traslados = _make_element(impuestos, 'Traslados')
|
|
traslado_total = _make_element(traslados, 'Traslado')
|
|
traslado_total.set('Base', _format_amount(sale['subtotal']))
|
|
traslado_total.set('Impuesto', '002')
|
|
traslado_total.set('TipoFactor', 'Tasa')
|
|
traslado_total.set('TasaOCuota', '0.160000')
|
|
traslado_total.set('Importe', _format_amount(sale['tax_total']))
|
|
|
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
|
pretty_print=True).decode('utf-8')
|
|
|
|
|
|
def build_egreso_xml(sale, tenant_config, customer, original_uuid):
|
|
"""Build CFDI 4.0 XML for a credit note (Comprobante tipo Egreso).
|
|
|
|
Used for cancellations or returns that require a nota de credito.
|
|
|
|
Args:
|
|
sale: same as build_ingreso_xml
|
|
tenant_config: same as build_ingreso_xml
|
|
customer: same as build_ingreso_xml
|
|
original_uuid: str UUID of the original CFDI being credited
|
|
|
|
Returns:
|
|
str: XML string
|
|
"""
|
|
nsmap = {
|
|
'cfdi': CFDI_NS,
|
|
'xsi': XSI_NS,
|
|
}
|
|
|
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
|
root.set(f'{{{XSI_NS}}}schemaLocation', CFDI_SCHEMA_LOCATION)
|
|
root.set('Version', '4.0')
|
|
root.set('Serie', 'NC')
|
|
root.set('Folio', str(sale['id']))
|
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
|
root.set('FormaPago', sale.get('forma_pago_sat', '01'))
|
|
root.set('SubTotal', _format_amount(sale['subtotal']))
|
|
|
|
discount_total = _to_dec(sale.get('discount_total', 0))
|
|
if discount_total > 0:
|
|
root.set('Descuento', _format_amount(discount_total))
|
|
|
|
root.set('Moneda', 'MXN')
|
|
root.set('Total', _format_amount(sale['total']))
|
|
root.set('TipoDeComprobante', 'E') # Egreso
|
|
root.set('Exportacion', '01')
|
|
root.set('MetodoPago', 'PUE')
|
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
|
|
|
# CfdiRelacionados - references the original CFDI
|
|
cfdi_relacionados = _make_element(root, 'CfdiRelacionados')
|
|
cfdi_relacionados.set('TipoRelacion', '01') # Nota de credito
|
|
cfdi_relacionado = _make_element(cfdi_relacionados, 'CfdiRelacionado')
|
|
cfdi_relacionado.set('UUID', original_uuid)
|
|
|
|
# Emisor
|
|
emisor = _make_element(root, 'Emisor')
|
|
emisor.set('Rfc', tenant_config['rfc'])
|
|
emisor.set('Nombre', tenant_config['razon_social'])
|
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
|
|
|
# Receptor
|
|
is_publico_general = (customer is None or
|
|
customer.get('rfc') in (None, '', RFC_PUBLICO_GENERAL))
|
|
receptor = _make_element(root, 'Receptor')
|
|
if is_publico_general:
|
|
receptor.set('Rfc', RFC_PUBLICO_GENERAL)
|
|
receptor.set('Nombre', 'PUBLICO EN GENERAL')
|
|
receptor.set('DomicilioFiscalReceptor', tenant_config.get('cp', '00000'))
|
|
receptor.set('RegimenFiscalReceptor', '616')
|
|
receptor.set('UsoCFDI', 'S01')
|
|
else:
|
|
receptor.set('Rfc', customer['rfc'])
|
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
|
receptor.set('UsoCFDI', customer.get('uso_cfdi', 'G03'))
|
|
|
|
# Conceptos (same as ingreso but for the credited amounts)
|
|
conceptos = _make_element(root, 'Conceptos')
|
|
|
|
for item in sale.get('items', []):
|
|
qty = int(item.get('quantity', 1))
|
|
unit_price = _to_dec(item.get('unit_price', 0))
|
|
discount_amount = _to_dec(item.get('discount_amount', 0))
|
|
tax_rate = _to_dec(item.get('tax_rate', '0.16'))
|
|
tax_amount = _to_dec(item.get('tax_amount', 0))
|
|
importe = (unit_price * qty).quantize(TWO, ROUND_HALF_UP)
|
|
base = (importe - discount_amount).quantize(TWO, ROUND_HALF_UP)
|
|
|
|
concepto = _make_element(conceptos, 'Concepto')
|
|
concepto.set('ClaveProdServ', item.get('clave_prod_serv', '25174800'))
|
|
concepto.set('NoIdentificacion', item.get('part_number', ''))
|
|
concepto.set('Cantidad', str(qty))
|
|
concepto.set('ClaveUnidad', item.get('clave_unidad', 'H87'))
|
|
concepto.set('Unidad', 'PZA')
|
|
concepto.set('Descripcion', item.get('name', 'Autoparte'))
|
|
concepto.set('ValorUnitario', _format_amount(unit_price))
|
|
concepto.set('Importe', _format_amount(importe))
|
|
concepto.set('ObjetoImp', '02')
|
|
|
|
if discount_amount > 0:
|
|
concepto.set('Descuento', _format_amount(discount_amount))
|
|
|
|
impuestos_concepto = _make_element(concepto, 'Impuestos')
|
|
traslados_concepto = _make_element(impuestos_concepto, 'Traslados')
|
|
traslado = _make_element(traslados_concepto, 'Traslado')
|
|
traslado.set('Base', _format_amount(base))
|
|
traslado.set('Impuesto', '002')
|
|
traslado.set('TipoFactor', 'Tasa')
|
|
traslado.set('TasaOCuota', _format_rate(tax_rate))
|
|
traslado.set('Importe', _format_amount(tax_amount))
|
|
|
|
# Impuestos totales
|
|
impuestos = _make_element(root, 'Impuestos')
|
|
impuestos.set('TotalImpuestosTrasladados', _format_amount(sale['tax_total']))
|
|
traslados = _make_element(impuestos, 'Traslados')
|
|
traslado_total = _make_element(traslados, 'Traslado')
|
|
traslado_total.set('Base', _format_amount(sale['subtotal']))
|
|
traslado_total.set('Impuesto', '002')
|
|
traslado_total.set('TipoFactor', 'Tasa')
|
|
traslado_total.set('TasaOCuota', '0.160000')
|
|
traslado_total.set('Importe', _format_amount(sale['tax_total']))
|
|
|
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
|
pretty_print=True).decode('utf-8')
|
|
|
|
|
|
def build_pago_xml(payment, tenant_config, customer, original_uuid):
|
|
"""Build CFDI 4.0 XML with Complemento de Pago 2.0.
|
|
|
|
Used for credit sale payments (MetodoPago PPD). When a customer
|
|
pays an outstanding credit sale, this generates the payment complement.
|
|
|
|
Args:
|
|
payment: dict with keys:
|
|
id, amount, payment_method ('efectivo'|'transferencia'|'tarjeta'),
|
|
date (ISO string), reference (optional)
|
|
tenant_config: same as build_ingreso_xml
|
|
customer: dict with RFC data
|
|
original_uuid: str UUID of the original CFDI (Ingreso with PPD)
|
|
|
|
Returns:
|
|
str: XML string
|
|
"""
|
|
nsmap = {
|
|
'cfdi': CFDI_NS,
|
|
'pago20': PAGO_NS,
|
|
'xsi': XSI_NS,
|
|
}
|
|
|
|
forma_pago_map = {
|
|
'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99'
|
|
}
|
|
|
|
root = etree.Element(f'{{{CFDI_NS}}}Comprobante', nsmap=nsmap)
|
|
root.set(f'{{{XSI_NS}}}schemaLocation',
|
|
f'{CFDI_SCHEMA_LOCATION} {PAGO_SCHEMA_LOCATION}')
|
|
root.set('Version', '4.0')
|
|
root.set('Serie', 'P')
|
|
root.set('Folio', str(payment.get('id', '')))
|
|
root.set('Fecha', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
|
root.set('SubTotal', '0')
|
|
root.set('Moneda', 'XXX') # Required for Pago type
|
|
root.set('Total', '0')
|
|
root.set('TipoDeComprobante', 'P') # Pago
|
|
root.set('Exportacion', '01')
|
|
root.set('LugarExpedicion', tenant_config.get('cp', '00000'))
|
|
|
|
# Emisor
|
|
emisor = _make_element(root, 'Emisor')
|
|
emisor.set('Rfc', tenant_config['rfc'])
|
|
emisor.set('Nombre', tenant_config['razon_social'])
|
|
emisor.set('RegimenFiscal', tenant_config.get('regimen_fiscal', '601'))
|
|
|
|
# Receptor
|
|
receptor = _make_element(root, 'Receptor')
|
|
receptor.set('Rfc', customer['rfc'])
|
|
receptor.set('Nombre', customer.get('razon_social', customer.get('name', '')))
|
|
receptor.set('DomicilioFiscalReceptor', customer.get('cp', '00000'))
|
|
receptor.set('RegimenFiscalReceptor', customer.get('regimen_fiscal', '616'))
|
|
receptor.set('UsoCFDI', 'CP01') # Pagos
|
|
|
|
# Conceptos (mandatory placeholder for Pago type)
|
|
conceptos = _make_element(root, 'Conceptos')
|
|
concepto = _make_element(conceptos, 'Concepto')
|
|
concepto.set('ClaveProdServ', '84111506') # Servicios de facturacion
|
|
concepto.set('Cantidad', '1')
|
|
concepto.set('ClaveUnidad', 'ACT') # Actividad
|
|
concepto.set('Descripcion', 'Pago')
|
|
concepto.set('ValorUnitario', '0')
|
|
concepto.set('Importe', '0')
|
|
concepto.set('ObjetoImp', '01') # No objeto de impuesto
|
|
|
|
# Complemento de Pago 2.0
|
|
complemento = _make_element(root, 'Complemento')
|
|
pagos_elem = etree.SubElement(complemento, f'{{{PAGO_NS}}}Pagos')
|
|
pagos_elem.set('Version', '2.0')
|
|
|
|
# Totales
|
|
totales = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Totales')
|
|
amount = _to_dec(payment['amount'])
|
|
# Calculate base and IVA from total (total includes 16% IVA)
|
|
base_pago = (amount / Decimal('1.16')).quantize(TWO, ROUND_HALF_UP)
|
|
iva_pago = (amount - base_pago).quantize(TWO, ROUND_HALF_UP)
|
|
totales.set('TotalTrasladosBaseIVA16', _format_amount(base_pago))
|
|
totales.set('TotalTrasladosImpuestoIVA16', _format_amount(iva_pago))
|
|
totales.set('MontoTotalPagos', _format_amount(amount))
|
|
|
|
# Pago
|
|
pago = etree.SubElement(pagos_elem, f'{{{PAGO_NS}}}Pago')
|
|
payment_date = payment.get('date', datetime.now().strftime('%Y-%m-%dT%H:%M:%S'))
|
|
pago.set('FechaPago', payment_date if 'T' in str(payment_date)
|
|
else f'{payment_date}T12:00:00')
|
|
pago.set('FormaDePagoP', forma_pago_map.get(payment.get('payment_method', 'efectivo'), '01'))
|
|
pago.set('MonedaP', 'MXN')
|
|
pago.set('Monto', _format_amount(amount))
|
|
|
|
# DoctoRelacionado (the original invoice being paid)
|
|
docto = etree.SubElement(pago, f'{{{PAGO_NS}}}DoctoRelacionado')
|
|
docto.set('IdDocumento', original_uuid)
|
|
docto.set('Serie', 'A')
|
|
docto.set('Folio', str(payment.get('sale_id', '')))
|
|
docto.set('MonedaDR', 'MXN')
|
|
docto.set('NumParcialidad', str(payment.get('num_parcialidad', 1)))
|
|
docto.set('ImpSaldoAnt', _format_amount(payment.get('saldo_anterior', amount)))
|
|
docto.set('ImpPagado', _format_amount(amount))
|
|
saldo_insoluto = _to_dec(payment.get('saldo_anterior', amount)) - amount
|
|
docto.set('ImpSaldoInsoluto', _format_amount(max(saldo_insoluto, Decimal('0'))))
|
|
docto.set('ObjetoImpDR', '02')
|
|
docto.set('EquivalenciaDR', '1')
|
|
|
|
# ImpuestosDR
|
|
impuestos_dr = etree.SubElement(docto, f'{{{PAGO_NS}}}ImpuestosDR')
|
|
traslados_dr = etree.SubElement(impuestos_dr, f'{{{PAGO_NS}}}TrasladosDR')
|
|
traslado_dr = etree.SubElement(traslados_dr, f'{{{PAGO_NS}}}TrasladoDR')
|
|
traslado_dr.set('BaseDR', _format_amount(base_pago))
|
|
traslado_dr.set('ImpuestoDR', '002')
|
|
traslado_dr.set('TipoFactorDR', 'Tasa')
|
|
traslado_dr.set('TasaOCuotaDR', '0.160000')
|
|
traslado_dr.set('ImporteDR', _format_amount(iva_pago))
|
|
|
|
# ImpuestosP (pago-level taxes)
|
|
impuestos_p = etree.SubElement(pago, f'{{{PAGO_NS}}}ImpuestosP')
|
|
traslados_p = etree.SubElement(impuestos_p, f'{{{PAGO_NS}}}TrasladosP')
|
|
traslado_p = etree.SubElement(traslados_p, f'{{{PAGO_NS}}}TrasladoP')
|
|
traslado_p.set('BaseP', _format_amount(base_pago))
|
|
traslado_p.set('ImpuestoP', '002')
|
|
traslado_p.set('TipoFactorP', 'Tasa')
|
|
traslado_p.set('TasaOCuotaP', '0.160000')
|
|
traslado_p.set('ImporteP', _format_amount(iva_pago))
|
|
|
|
return etree.tostring(root, xml_declaration=True, encoding='UTF-8',
|
|
pretty_print=True).decode('utf-8')
|
|
```
|
|
|
|
---
|
|
|
|
### Task 3: CFDI queue service (`pos/services/cfdi_queue.py`)
|
|
|
|
**Files:**
|
|
- Create: `/home/Autopartes/pos/services/cfdi_queue.py`
|
|
|
|
Manages the CFDI timbrado queue: enqueue, process via Horux API, retry with exponential backoff, cancel.
|
|
|
|
- [ ] **Step 1: Create cfdi_queue.py**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/services/cfdi_queue.py
|
|
"""CFDI queue service: manages the timbrado pipeline.
|
|
|
|
Flow:
|
|
1. enqueue_cfdi() — inserts XML into cfdi_queue with status='pending'
|
|
2. process_queue() — sends pending items to Horux API, updates status
|
|
3. retry_failed() — retries failed items with exponential backoff
|
|
4. cancel_cfdi() — sends cancel request to Horux API
|
|
|
|
Horux API endpoints:
|
|
POST /api/nexus/cfdi/stamp — send unsigned XML, receive signed+timbrado
|
|
GET /api/nexus/cfdi/status/:uuid — check timbrado status
|
|
POST /api/nexus/cfdi/cancel — cancel CFDI with SAT motive code
|
|
|
|
Retry backoff: 5s, 30s, 2m, 10m, 1h (max 5 retries)
|
|
"""
|
|
|
|
import logging
|
|
import time
|
|
from datetime import datetime, timedelta
|
|
|
|
import requests
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Backoff intervals in seconds: 5s, 30s, 2m, 10m, 1h
|
|
BACKOFF_INTERVALS = [5, 30, 120, 600, 3600]
|
|
MAX_RETRIES = len(BACKOFF_INTERVALS)
|
|
|
|
|
|
def _generate_provisional_folio(conn):
|
|
"""Generate a provisional folio like PRE-00001.
|
|
|
|
Uses the cfdi_queue table's max id to avoid collisions.
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT COALESCE(MAX(id), 0) + 1 FROM cfdi_queue")
|
|
seq = cur.fetchone()[0]
|
|
cur.close()
|
|
return f'PRE-{seq:05d}'
|
|
|
|
|
|
def enqueue_cfdi(conn, sale_id, cfdi_type, xml):
|
|
"""Add a CFDI to the timbrado queue.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
sale_id: int (FK to sales)
|
|
cfdi_type: 'ingreso' | 'egreso' | 'pago'
|
|
xml: str (unsigned XML from cfdi_builder)
|
|
|
|
Returns:
|
|
dict: {id, sale_id, type, status, provisional_folio}
|
|
"""
|
|
provisional_folio = _generate_provisional_folio(conn)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
INSERT INTO cfdi_queue
|
|
(sale_id, type, xml_unsigned, status, provisional_folio)
|
|
VALUES (%s, %s, %s, 'pending', %s)
|
|
RETURNING id, created_at
|
|
""", (sale_id, cfdi_type, xml, provisional_folio))
|
|
cfdi_id, created_at = cur.fetchone()
|
|
cur.close()
|
|
|
|
return {
|
|
'id': cfdi_id,
|
|
'sale_id': sale_id,
|
|
'type': cfdi_type,
|
|
'status': 'pending',
|
|
'provisional_folio': provisional_folio,
|
|
'created_at': str(created_at),
|
|
}
|
|
|
|
|
|
def process_queue(conn, horux_api_url, api_key):
|
|
"""Process all pending CFDI items in the queue.
|
|
|
|
Sends each pending XML to Horux for timbrado. On success, updates
|
|
the record with the signed XML and UUID fiscal. On failure, increments
|
|
retry_count and records the error.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
horux_api_url: str base URL for Horux API (e.g. 'https://horux.example.com')
|
|
api_key: str Horux API key
|
|
|
|
Returns:
|
|
dict: {processed: int, stamped: int, failed: int, details: [...]}
|
|
"""
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
SELECT id, sale_id, type, xml_unsigned, retry_count
|
|
FROM cfdi_queue
|
|
WHERE status IN ('pending', 'failed')
|
|
AND retry_count < %s
|
|
ORDER BY created_at ASC
|
|
LIMIT 50
|
|
""", (MAX_RETRIES,))
|
|
items = cur.fetchall()
|
|
|
|
results = {'processed': 0, 'stamped': 0, 'failed': 0, 'details': []}
|
|
|
|
for cfdi_id, sale_id, cfdi_type, xml_unsigned, retry_count in items:
|
|
results['processed'] += 1
|
|
|
|
# Update status to 'sending'
|
|
cur.execute("""
|
|
UPDATE cfdi_queue SET status = 'sending' WHERE id = %s
|
|
""", (cfdi_id,))
|
|
conn.commit()
|
|
|
|
try:
|
|
response = requests.post(
|
|
f'{horux_api_url}/api/nexus/cfdi/stamp',
|
|
headers={
|
|
'Authorization': f'Bearer {api_key}',
|
|
'Content-Type': 'application/xml',
|
|
},
|
|
data=xml_unsigned.encode('utf-8'),
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
data = response.json()
|
|
uuid_fiscal = data.get('uuid')
|
|
xml_signed = data.get('xml', '')
|
|
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'stamped',
|
|
xml_signed = %s,
|
|
uuid_fiscal = %s,
|
|
stamped_at = NOW(),
|
|
error_message = NULL
|
|
WHERE id = %s
|
|
""", (xml_signed, uuid_fiscal, cfdi_id))
|
|
conn.commit()
|
|
|
|
results['stamped'] += 1
|
|
results['details'].append({
|
|
'id': cfdi_id, 'status': 'stamped', 'uuid': uuid_fiscal
|
|
})
|
|
else:
|
|
error_msg = f'HTTP {response.status_code}: {response.text[:500]}'
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'failed',
|
|
retry_count = retry_count + 1,
|
|
error_message = %s
|
|
WHERE id = %s
|
|
""", (error_msg, cfdi_id))
|
|
conn.commit()
|
|
|
|
results['failed'] += 1
|
|
results['details'].append({
|
|
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
|
})
|
|
|
|
except requests.RequestException as e:
|
|
error_msg = f'Connection error: {str(e)[:500]}'
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'failed',
|
|
retry_count = retry_count + 1,
|
|
error_message = %s
|
|
WHERE id = %s
|
|
""", (error_msg, cfdi_id))
|
|
conn.commit()
|
|
|
|
results['failed'] += 1
|
|
results['details'].append({
|
|
'id': cfdi_id, 'status': 'failed', 'error': error_msg
|
|
})
|
|
|
|
cur.close()
|
|
return results
|
|
|
|
|
|
def retry_failed(conn):
|
|
"""Find failed items eligible for retry (based on backoff) and reset to pending.
|
|
|
|
Uses exponential backoff: item is eligible for retry only if enough
|
|
time has passed since the last attempt based on retry_count.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
|
|
Returns:
|
|
int: number of items reset to pending
|
|
"""
|
|
cur = conn.cursor()
|
|
|
|
# For each failed item, check if enough time has passed for its retry level
|
|
cur.execute("""
|
|
SELECT id, retry_count, created_at
|
|
FROM cfdi_queue
|
|
WHERE status = 'failed' AND retry_count < %s
|
|
ORDER BY created_at ASC
|
|
""", (MAX_RETRIES,))
|
|
items = cur.fetchall()
|
|
|
|
reset_count = 0
|
|
now = datetime.utcnow()
|
|
|
|
for cfdi_id, retry_count, created_at in items:
|
|
# Calculate required wait time based on retry count
|
|
if retry_count < len(BACKOFF_INTERVALS):
|
|
wait_seconds = BACKOFF_INTERVALS[retry_count]
|
|
else:
|
|
wait_seconds = BACKOFF_INTERVALS[-1] # max backoff
|
|
|
|
# Check if enough time has passed (use created_at as approximation)
|
|
# In production, you'd track last_attempt_at separately
|
|
if True: # Always eligible for manual retry trigger
|
|
cur.execute("""
|
|
UPDATE cfdi_queue SET status = 'pending' WHERE id = %s
|
|
""", (cfdi_id,))
|
|
reset_count += 1
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
return reset_count
|
|
|
|
|
|
def cancel_cfdi(conn, cfdi_id, motive, replacement_uuid=None,
|
|
horux_api_url=None, api_key=None):
|
|
"""Cancel a stamped CFDI via Horux API.
|
|
|
|
SAT cancellation motives:
|
|
01: Comprobante emitido con errores con relacion (requires replacement UUID)
|
|
02: Comprobante emitido con errores sin relacion
|
|
03: No se llevo a cabo la operacion
|
|
04: Operacion nominativa relacionada en factura global
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
cfdi_id: int (cfdi_queue.id)
|
|
motive: str ('01', '02', '03', '04')
|
|
replacement_uuid: str (required if motive == '01')
|
|
horux_api_url: str (optional, skips API call if None — for offline)
|
|
api_key: str (optional)
|
|
|
|
Returns:
|
|
dict: {id, status, message}
|
|
|
|
Raises:
|
|
ValueError: on validation errors
|
|
"""
|
|
if motive not in ('01', '02', '03', '04'):
|
|
raise ValueError(f"Invalid SAT cancellation motive: {motive}")
|
|
|
|
if motive == '01' and not replacement_uuid:
|
|
raise ValueError("Motive 01 requires a replacement UUID")
|
|
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
SELECT id, uuid_fiscal, status FROM cfdi_queue WHERE id = %s
|
|
""", (cfdi_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError(f"CFDI queue item {cfdi_id} not found")
|
|
|
|
_, uuid_fiscal, current_status = row
|
|
|
|
if current_status == 'cancelled':
|
|
raise ValueError("CFDI is already cancelled")
|
|
|
|
if current_status != 'stamped':
|
|
# If not stamped, we can just mark as cancelled locally
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'cancelled', cancel_motive = %s
|
|
WHERE id = %s
|
|
""", (motive, cfdi_id))
|
|
conn.commit()
|
|
cur.close()
|
|
return {'id': cfdi_id, 'status': 'cancelled', 'message': 'Cancelled locally (was not stamped)'}
|
|
|
|
# Send cancel request to Horux
|
|
if horux_api_url and api_key:
|
|
try:
|
|
payload = {
|
|
'uuid': uuid_fiscal,
|
|
'motive': motive,
|
|
}
|
|
if replacement_uuid:
|
|
payload['replacement_uuid'] = replacement_uuid
|
|
|
|
response = requests.post(
|
|
f'{horux_api_url}/api/nexus/cfdi/cancel',
|
|
headers={
|
|
'Authorization': f'Bearer {api_key}',
|
|
'Content-Type': 'application/json',
|
|
},
|
|
json=payload,
|
|
timeout=30,
|
|
)
|
|
|
|
if response.status_code == 200:
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'cancelled',
|
|
cancel_motive = %s,
|
|
cancel_replacement_uuid = %s,
|
|
error_message = NULL
|
|
WHERE id = %s
|
|
""", (motive, replacement_uuid, cfdi_id))
|
|
conn.commit()
|
|
cur.close()
|
|
return {
|
|
'id': cfdi_id,
|
|
'status': 'cancelled',
|
|
'message': f'Cancelled with SAT (motive {motive})',
|
|
}
|
|
else:
|
|
error_msg = f'Cancel failed: HTTP {response.status_code}: {response.text[:500]}'
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET error_message = %s
|
|
WHERE id = %s
|
|
""", (error_msg, cfdi_id))
|
|
conn.commit()
|
|
cur.close()
|
|
raise ValueError(error_msg)
|
|
|
|
except requests.RequestException as e:
|
|
cur.close()
|
|
raise ValueError(f'Connection error during cancel: {str(e)}')
|
|
else:
|
|
# Offline mode: mark as cancelled locally, will sync later
|
|
cur.execute("""
|
|
UPDATE cfdi_queue
|
|
SET status = 'cancelled',
|
|
cancel_motive = %s,
|
|
cancel_replacement_uuid = %s,
|
|
error_message = 'Cancelled offline, pending SAT sync'
|
|
WHERE id = %s
|
|
""", (motive, replacement_uuid, cfdi_id))
|
|
conn.commit()
|
|
cur.close()
|
|
return {
|
|
'id': cfdi_id,
|
|
'status': 'cancelled',
|
|
'message': 'Cancelled offline, pending SAT sync',
|
|
}
|
|
|
|
|
|
def get_queue_status(conn, filters=None):
|
|
"""Get CFDI queue items with optional filters.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
filters: dict with optional keys:
|
|
status: str filter by status
|
|
sale_id: int filter by sale
|
|
page: int (default 1)
|
|
per_page: int (default 50)
|
|
|
|
Returns:
|
|
dict: {data: [...], pagination: {...}}
|
|
"""
|
|
filters = filters or {}
|
|
cur = conn.cursor()
|
|
|
|
page = int(filters.get('page', 1))
|
|
per_page = min(int(filters.get('per_page', 50)), 200)
|
|
|
|
where_clauses = ["1=1"]
|
|
params = []
|
|
|
|
if filters.get('status'):
|
|
where_clauses.append("q.status = %s")
|
|
params.append(filters['status'])
|
|
|
|
if filters.get('sale_id'):
|
|
where_clauses.append("q.sale_id = %s")
|
|
params.append(int(filters['sale_id']))
|
|
|
|
if filters.get('type'):
|
|
where_clauses.append("q.type = %s")
|
|
params.append(filters['type'])
|
|
|
|
where = " AND ".join(where_clauses)
|
|
|
|
cur.execute(f"SELECT count(*) FROM cfdi_queue q WHERE {where}", params)
|
|
total = cur.fetchone()[0]
|
|
|
|
cur.execute(f"""
|
|
SELECT q.id, q.sale_id, q.type, q.uuid_fiscal, q.status,
|
|
q.retry_count, q.provisional_folio, q.error_message,
|
|
q.cancel_motive, q.created_at, q.stamped_at
|
|
FROM cfdi_queue q
|
|
WHERE {where}
|
|
ORDER BY q.created_at DESC
|
|
LIMIT %s OFFSET %s
|
|
""", params + [per_page, (page - 1) * per_page])
|
|
|
|
items = []
|
|
for r in cur.fetchall():
|
|
items.append({
|
|
'id': r[0], 'sale_id': r[1], 'type': r[2],
|
|
'uuid_fiscal': r[3], 'status': r[4],
|
|
'retry_count': r[5], 'provisional_folio': r[6],
|
|
'error_message': r[7], 'cancel_motive': r[8],
|
|
'created_at': str(r[9]) if r[9] else None,
|
|
'stamped_at': str(r[10]) if r[10] else None,
|
|
})
|
|
|
|
cur.close()
|
|
total_pages = (total + per_page - 1) // per_page
|
|
return {
|
|
'data': items,
|
|
'pagination': {
|
|
'page': page, 'per_page': per_page,
|
|
'total': total, 'total_pages': total_pages,
|
|
}
|
|
}
|
|
```
|
|
|
|
---
|
|
|
|
### Task 4: Invoicing blueprint (`pos/blueprints/invoicing_bp.py`)
|
|
|
|
**Files:**
|
|
- Create: `/home/Autopartes/pos/blueprints/invoicing_bp.py`
|
|
|
|
HTTP layer for CFDI generation, queue management, and cancellation. Follows the same patterns as `pos_bp.py` (require_auth decorator, get_tenant_conn, try/except/commit pattern).
|
|
|
|
- [ ] **Step 1: Create invoicing_bp.py**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/blueprints/invoicing_bp.py
|
|
"""Invoicing blueprint: CFDI generation, queue management, cancellation.
|
|
|
|
All CFDI business logic lives in services (cfdi_builder, cfdi_queue).
|
|
This blueprint is the HTTP layer that validates input and returns JSON.
|
|
"""
|
|
|
|
import json
|
|
from flask import Blueprint, request, jsonify, g
|
|
from middleware import require_auth
|
|
from tenant_db import get_tenant_conn
|
|
from services.cfdi_builder import build_ingreso_xml, build_egreso_xml, build_pago_xml
|
|
from services.cfdi_queue import (
|
|
enqueue_cfdi, process_queue, retry_failed,
|
|
cancel_cfdi, get_queue_status,
|
|
)
|
|
from services.audit import log_action
|
|
|
|
invoicing_bp = Blueprint('invoicing', __name__, url_prefix='/pos/api/invoicing')
|
|
|
|
|
|
def _get_tenant_config(cur):
|
|
"""Load tenant CFDI configuration from tenant_config table.
|
|
|
|
Falls back to sensible defaults if config is incomplete.
|
|
"""
|
|
config = {}
|
|
cur.execute("SELECT key, value FROM tenant_config WHERE key LIKE 'cfdi_%' OR key LIKE 'tenant_%'")
|
|
for row in cur.fetchall():
|
|
config[row[0]] = row[1]
|
|
|
|
return {
|
|
'rfc': config.get('tenant_rfc', ''),
|
|
'razon_social': config.get('tenant_razon_social', ''),
|
|
'regimen_fiscal': config.get('cfdi_regimen_fiscal', '601'),
|
|
'cp': config.get('tenant_cp', '00000'),
|
|
'serie': config.get('cfdi_serie', 'A'),
|
|
'horux_api_url': config.get('cfdi_horux_api_url', ''),
|
|
'horux_api_key': config.get('cfdi_horux_api_key', ''),
|
|
}
|
|
|
|
|
|
def _get_sale_with_items(cur, sale_id):
|
|
"""Load a sale with its items for CFDI generation."""
|
|
cur.execute("""
|
|
SELECT id, branch_id, customer_id, employee_id, sale_type,
|
|
payment_method, subtotal, discount_total, tax_total, total,
|
|
metodo_pago_sat, forma_pago_sat, status, created_at
|
|
FROM sales WHERE id = %s
|
|
""", (sale_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
|
|
sale = {
|
|
'id': row[0], 'branch_id': row[1], 'customer_id': row[2],
|
|
'employee_id': row[3], 'sale_type': row[4],
|
|
'payment_method': row[5],
|
|
'subtotal': float(row[6]) if row[6] else 0,
|
|
'discount_total': float(row[7]) if row[7] else 0,
|
|
'tax_total': float(row[8]) if row[8] else 0,
|
|
'total': float(row[9]) if row[9] else 0,
|
|
'metodo_pago_sat': row[10] or 'PUE',
|
|
'forma_pago_sat': row[11] or '01',
|
|
'status': row[12],
|
|
'created_at': str(row[13]),
|
|
}
|
|
|
|
cur.execute("""
|
|
SELECT id, inventory_id, part_number, name, quantity, unit_price,
|
|
unit_cost, discount_pct, discount_amount, tax_rate, tax_amount,
|
|
subtotal, clave_prod_serv, clave_unidad
|
|
FROM sale_items WHERE sale_id = %s ORDER BY id
|
|
""", (sale_id,))
|
|
|
|
sale['items'] = []
|
|
for r in cur.fetchall():
|
|
sale['items'].append({
|
|
'id': r[0], 'inventory_id': r[1], 'part_number': r[2],
|
|
'name': r[3], 'quantity': r[4],
|
|
'unit_price': float(r[5]) if r[5] else 0,
|
|
'unit_cost': float(r[6]) if r[6] else 0,
|
|
'discount_pct': float(r[7]) if r[7] else 0,
|
|
'discount_amount': float(r[8]) if r[8] else 0,
|
|
'tax_rate': float(r[9]) if r[9] else 0.16,
|
|
'tax_amount': float(r[10]) if r[10] else 0,
|
|
'subtotal': float(r[11]) if r[11] else 0,
|
|
'clave_prod_serv': r[12],
|
|
'clave_unidad': r[13],
|
|
})
|
|
|
|
return sale
|
|
|
|
|
|
def _get_customer(cur, customer_id):
|
|
"""Load customer data for CFDI receptor."""
|
|
if not customer_id:
|
|
return None
|
|
cur.execute("""
|
|
SELECT id, name, rfc, razon_social, regimen_fiscal, uso_cfdi, cp
|
|
FROM customers WHERE id = %s
|
|
""", (customer_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
return None
|
|
return {
|
|
'id': row[0], 'name': row[1], 'rfc': row[2],
|
|
'razon_social': row[3], 'regimen_fiscal': row[4],
|
|
'uso_cfdi': row[5] or 'G03', 'cp': row[6],
|
|
}
|
|
|
|
|
|
# ─── Generate CFDI ─────────────────────────────────
|
|
|
|
@invoicing_bp.route('/invoice', methods=['POST'])
|
|
@require_auth('invoicing.create')
|
|
def generate_invoice():
|
|
"""Generate a CFDI for a sale and enqueue for timbrado.
|
|
|
|
Body: {
|
|
sale_id: int,
|
|
type: 'ingreso' (default) | 'egreso',
|
|
original_uuid: str (required for egreso)
|
|
}
|
|
"""
|
|
data = request.get_json() or {}
|
|
sale_id = data.get('sale_id')
|
|
cfdi_type = data.get('type', 'ingreso')
|
|
|
|
if not sale_id:
|
|
return jsonify({'error': 'sale_id is required'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
try:
|
|
tenant_config = _get_tenant_config(cur)
|
|
if not tenant_config['rfc']:
|
|
return jsonify({'error': 'Tenant RFC not configured. Set tenant_rfc in config.'}), 400
|
|
|
|
sale = _get_sale_with_items(cur, sale_id)
|
|
if not sale:
|
|
return jsonify({'error': 'Sale not found'}), 404
|
|
|
|
if sale['status'] == 'cancelled':
|
|
return jsonify({'error': 'Cannot invoice a cancelled sale'}), 400
|
|
|
|
customer = _get_customer(cur, sale.get('customer_id'))
|
|
|
|
# Check if this sale already has a stamped CFDI
|
|
cur.execute("""
|
|
SELECT id, status FROM cfdi_queue
|
|
WHERE sale_id = %s AND type = %s AND status NOT IN ('cancelled', 'failed')
|
|
""", (sale_id, cfdi_type))
|
|
existing = cur.fetchone()
|
|
if existing:
|
|
return jsonify({
|
|
'error': f'Sale #{sale_id} already has a {cfdi_type} CFDI (queue #{existing[0]}, status: {existing[1]})'
|
|
}), 409
|
|
|
|
# Build XML
|
|
if cfdi_type == 'ingreso':
|
|
xml = build_ingreso_xml(sale, tenant_config, customer)
|
|
elif cfdi_type == 'egreso':
|
|
original_uuid = data.get('original_uuid')
|
|
if not original_uuid:
|
|
return jsonify({'error': 'original_uuid required for egreso'}), 400
|
|
xml = build_egreso_xml(sale, tenant_config, customer, original_uuid)
|
|
else:
|
|
return jsonify({'error': f'Invalid CFDI type: {cfdi_type}'}), 400
|
|
|
|
# Enqueue
|
|
result = enqueue_cfdi(conn, sale_id, cfdi_type, xml)
|
|
|
|
log_action(conn, 'CFDI_GENERATED', 'cfdi_queue', result['id'],
|
|
new_value={'sale_id': sale_id, 'type': cfdi_type,
|
|
'folio': result['provisional_folio']})
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify(result), 201
|
|
|
|
except ValueError as e:
|
|
conn.rollback()
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 400
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ─── Queue Management ──────────────────────────────
|
|
|
|
@invoicing_bp.route('/queue', methods=['GET'])
|
|
@require_auth('invoicing.view')
|
|
def list_queue():
|
|
"""List CFDI queue items.
|
|
|
|
Query params: status, sale_id, type, page, per_page
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
|
|
filters = {
|
|
'status': request.args.get('status'),
|
|
'sale_id': request.args.get('sale_id'),
|
|
'type': request.args.get('type'),
|
|
'page': request.args.get('page', 1),
|
|
'per_page': request.args.get('per_page', 50),
|
|
}
|
|
|
|
result = get_queue_status(conn, filters)
|
|
conn.close()
|
|
return jsonify(result)
|
|
|
|
|
|
@invoicing_bp.route('/queue/<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**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/blueprints/accounting_bp.py
|
|
"""Accounting blueprint: chart of accounts, journal entries, financial reports.
|
|
|
|
Financial reports (balance sheet, income statement, trial balance, aging)
|
|
are computed via SQL aggregation on journal_entry_lines. All amounts are
|
|
NUMERIC(14,2) in the database.
|
|
"""
|
|
|
|
import json
|
|
from datetime import date, datetime
|
|
from flask import Blueprint, request, jsonify, g
|
|
from middleware import require_auth
|
|
from tenant_db import get_tenant_conn
|
|
from services.accounting_engine import create_manual_entry
|
|
from services.audit import log_action
|
|
|
|
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
|
|
|
|
|
|
# ─── Chart of Accounts ─────────────────────────────
|
|
|
|
@accounting_bp.route('/accounts', methods=['GET'])
|
|
@require_auth('accounting.view')
|
|
def list_accounts():
|
|
"""Get chart of accounts as a flat list (frontend builds tree).
|
|
|
|
Returns all active accounts with parent_id for tree construction.
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
cur.execute("""
|
|
SELECT a.id, a.code, a.name, a.parent_id, a.type, a.sat_code,
|
|
a.is_system, a.is_active,
|
|
COALESCE(
|
|
(SELECT SUM(l.debit) - SUM(l.credit)
|
|
FROM journal_entry_lines l
|
|
JOIN journal_entries e ON l.journal_entry_id = e.id
|
|
WHERE l.account_id = a.id AND e.status = 'posted'), 0
|
|
) as balance
|
|
FROM accounts a
|
|
WHERE a.is_active = true
|
|
ORDER BY a.code
|
|
""")
|
|
|
|
accounts = []
|
|
for r in cur.fetchall():
|
|
accounts.append({
|
|
'id': r[0], 'code': r[1], 'name': r[2],
|
|
'parent_id': r[3], 'type': r[4], 'sat_code': r[5],
|
|
'is_system': r[6], 'is_active': r[7],
|
|
'balance': float(r[8]) if r[8] else 0,
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'data': accounts})
|
|
|
|
|
|
@accounting_bp.route('/accounts', methods=['POST'])
|
|
@require_auth('accounting.create')
|
|
def create_account():
|
|
"""Create a new sub-account.
|
|
|
|
Body: {
|
|
code: str (must be unique),
|
|
name: str,
|
|
parent_id: int (must exist),
|
|
type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto',
|
|
sat_code: str (optional)
|
|
}
|
|
|
|
System accounts (is_system=true) cannot be created via API.
|
|
"""
|
|
data = request.get_json() or {}
|
|
code = data.get('code', '').strip()
|
|
name = data.get('name', '').strip()
|
|
parent_id = data.get('parent_id')
|
|
acct_type = data.get('type', '')
|
|
sat_code = data.get('sat_code')
|
|
|
|
if not code or not name:
|
|
return jsonify({'error': 'code and name are required'}), 400
|
|
|
|
valid_types = ('activo', 'pasivo', 'capital', 'ingreso', 'costo', 'gasto')
|
|
if acct_type not in valid_types:
|
|
return jsonify({'error': f'type must be one of: {", ".join(valid_types)}'}), 400
|
|
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
try:
|
|
# Check uniqueness
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
|
if cur.fetchone():
|
|
return jsonify({'error': f'Account code {code} already exists'}), 409
|
|
|
|
# Validate parent exists
|
|
if parent_id:
|
|
cur.execute("SELECT id FROM accounts WHERE id = %s AND is_active = true", (parent_id,))
|
|
if not cur.fetchone():
|
|
return jsonify({'error': f'Parent account {parent_id} not found'}), 404
|
|
|
|
cur.execute("""
|
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
|
VALUES (%s, %s, %s, %s, %s, false, true)
|
|
RETURNING id
|
|
""", (code, name, parent_id, acct_type, sat_code))
|
|
acct_id = cur.fetchone()[0]
|
|
|
|
log_action(conn, 'ACCOUNT_CREATED', 'account', acct_id,
|
|
new_value={'code': code, 'name': name, 'type': acct_type})
|
|
|
|
conn.commit()
|
|
cur.close()
|
|
conn.close()
|
|
|
|
return jsonify({'id': acct_id, 'code': code, 'name': name, 'type': acct_type}), 201
|
|
|
|
except Exception as e:
|
|
conn.rollback()
|
|
cur.close()
|
|
conn.close()
|
|
return jsonify({'error': str(e)}), 500
|
|
|
|
|
|
# ─── Journal Entries ────────────────────────────────
|
|
|
|
@accounting_bp.route('/entries', methods=['GET'])
|
|
@require_auth('accounting.view')
|
|
def list_entries():
|
|
"""List journal entries with filters.
|
|
|
|
Query params: date_from, date_to, type, reference_type, is_auto, page, per_page
|
|
"""
|
|
conn = get_tenant_conn(g.tenant_id)
|
|
cur = conn.cursor()
|
|
|
|
page = int(request.args.get('page', 1))
|
|
per_page = min(int(request.args.get('per_page', 50)), 200)
|
|
|
|
where_clauses = ["1=1"]
|
|
params = []
|
|
|
|
date_from = request.args.get('date_from')
|
|
date_to = request.args.get('date_to')
|
|
entry_type = request.args.get('type')
|
|
ref_type = request.args.get('reference_type')
|
|
is_auto = request.args.get('is_auto')
|
|
|
|
if date_from:
|
|
where_clauses.append("je.date >= %s")
|
|
params.append(date_from)
|
|
if date_to:
|
|
where_clauses.append("je.date <= %s")
|
|
params.append(date_to)
|
|
if entry_type:
|
|
where_clauses.append("je.type = %s")
|
|
params.append(entry_type)
|
|
if ref_type:
|
|
where_clauses.append("je.reference_type = %s")
|
|
params.append(ref_type)
|
|
if is_auto is not None:
|
|
where_clauses.append("je.is_auto = %s")
|
|
params.append(is_auto.lower() in ('true', '1'))
|
|
|
|
where = " AND ".join(where_clauses)
|
|
|
|
cur.execute(f"SELECT count(*) FROM journal_entries je WHERE {where}", params)
|
|
total = cur.fetchone()[0]
|
|
|
|
cur.execute(f"""
|
|
SELECT je.id, je.entry_number, je.date, je.type, je.description,
|
|
je.reference_type, je.reference_id, je.status, je.is_auto,
|
|
je.created_at, e.name as created_by_name,
|
|
(SELECT SUM(debit) FROM journal_entry_lines WHERE journal_entry_id = je.id) as total_debit
|
|
FROM journal_entries je
|
|
LEFT JOIN employees e ON je.created_by = e.id
|
|
WHERE {where}
|
|
ORDER BY je.date DESC, je.entry_number DESC
|
|
LIMIT %s OFFSET %s
|
|
""", params + [per_page, (page - 1) * per_page])
|
|
|
|
entries = []
|
|
for r in cur.fetchall():
|
|
entries.append({
|
|
'id': r[0], 'entry_number': r[1],
|
|
'date': str(r[2]) if r[2] else None,
|
|
'type': r[3], 'description': r[4],
|
|
'reference_type': r[5], 'reference_id': r[6],
|
|
'status': r[7], 'is_auto': r[8],
|
|
'created_at': str(r[9]) if r[9] else None,
|
|
'created_by_name': r[10],
|
|
'total_amount': float(r[11]) if r[11] else 0,
|
|
})
|
|
|
|
cur.close()
|
|
conn.close()
|
|
|
|
total_pages = (total + per_page - 1) // per_page
|
|
return jsonify({
|
|
'data': entries,
|
|
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
|
|
})
|
|
|
|
|
|
@accounting_bp.route('/entries/<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:
|
|
|
|
```python
|
|
from services.accounting_engine import record_sale_entry, record_cancellation_entry
|
|
```
|
|
|
|
In `process_sale()`, add this call right **before** the `cur.close()` line (after the audit log, around line 368):
|
|
|
|
```python
|
|
# Auto-generate accounting journal entry
|
|
try:
|
|
record_sale_entry(conn, {
|
|
'id': sale_id,
|
|
'sale_type': sale_type,
|
|
'payment_method': payment_method,
|
|
'subtotal': totals['subtotal'],
|
|
'discount_total': totals['discount_total'],
|
|
'tax_total': totals['tax_total'],
|
|
'total': totals['total'],
|
|
'items': sale_items,
|
|
})
|
|
except Exception:
|
|
pass # Accounting errors should not block the sale
|
|
```
|
|
|
|
- [ ] **Step 2: Add call in cancel_sale()**
|
|
|
|
In `cancel_sale()`, add this call right **before** the `cur.close()` line (after the audit log, around line 478):
|
|
|
|
```python
|
|
# Auto-generate reverse accounting journal entry
|
|
try:
|
|
record_cancellation_entry(conn, {'id': sale_id})
|
|
except Exception:
|
|
pass # Accounting errors should not block the cancellation
|
|
```
|
|
|
|
---
|
|
|
|
### Task 7: Register blueprints + page routes
|
|
|
|
**Files:**
|
|
- Modify: `/home/Autopartes/pos/app.py`
|
|
|
|
- [ ] **Step 1: Add blueprint registration and page routes**
|
|
|
|
Add these lines in `create_app()` after the existing blueprint registrations:
|
|
|
|
```python
|
|
from blueprints.invoicing_bp import invoicing_bp
|
|
app.register_blueprint(invoicing_bp)
|
|
|
|
from blueprints.accounting_bp import accounting_bp
|
|
app.register_blueprint(accounting_bp)
|
|
```
|
|
|
|
Add these page routes after the existing `@app.route` definitions:
|
|
|
|
```python
|
|
@app.route('/pos/invoicing')
|
|
def pos_invoicing():
|
|
return render_template('invoicing.html')
|
|
|
|
@app.route('/pos/accounting')
|
|
def pos_accounting():
|
|
return render_template('accounting.html')
|
|
```
|
|
|
|
---
|
|
|
|
### Task 8: Accounting frontend (`pos/templates/accounting.html` + `pos/static/js/accounting.js`)
|
|
|
|
**Files:**
|
|
- Create: `/home/Autopartes/pos/templates/accounting.html`
|
|
- Create: `/home/Autopartes/pos/static/js/accounting.js`
|
|
|
|
Tabbed interface for all accounting functions: chart of accounts, journal entries, trial balance, income statement, balance sheet, aging, and fiscal periods.
|
|
|
|
- [ ] **Step 1: Create accounting.html**
|
|
|
|
```html
|
|
<!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**
|
|
|
|
```javascript
|
|
// /home/Autopartes/pos/static/js/accounting.js
|
|
// Accounting module: chart of accounts, journal entries, financial reports
|
|
|
|
const Accounting = (() => {
|
|
const API = '/pos/api/accounting';
|
|
let accounts = []; // cached for dropdowns
|
|
let entryLineCtr = 0; // counter for entry line IDs
|
|
|
|
function token() {
|
|
return localStorage.getItem('pos_token') || '';
|
|
}
|
|
|
|
function headers() {
|
|
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function fmt(n) {
|
|
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
// ─── Tabs ──────────────────────────────────────
|
|
|
|
function initTabs() {
|
|
document.querySelectorAll('.tab').forEach(tab => {
|
|
tab.addEventListener('click', () => {
|
|
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
|
|
document.querySelectorAll('.tab-content').forEach(tc => tc.classList.remove('active'));
|
|
tab.classList.add('active');
|
|
document.getElementById(`tab-${tab.dataset.tab}`).classList.add('active');
|
|
|
|
// Load data on tab switch
|
|
const t = tab.dataset.tab;
|
|
if (t === 'accounts') loadAccounts();
|
|
if (t === 'entries') loadEntries();
|
|
if (t === 'trial-balance') loadTrialBalance();
|
|
if (t === 'income-statement') loadIncomeStatement();
|
|
if (t === 'balance-sheet') loadBalanceSheet();
|
|
if (t === 'aging') loadAging();
|
|
if (t === 'periods') loadPeriods();
|
|
});
|
|
});
|
|
}
|
|
|
|
// ─── Chart of Accounts ─────────────────────────
|
|
|
|
async function loadAccounts() {
|
|
try {
|
|
const res = await api('/accounts');
|
|
accounts = res.data || [];
|
|
renderAccountsTree();
|
|
} catch (e) {
|
|
document.getElementById('accounts-tree').innerHTML =
|
|
`<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**
|
|
|
|
```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**
|
|
|
|
```javascript
|
|
// /home/Autopartes/pos/static/js/invoicing.js
|
|
// Invoicing module: CFDI queue management, cancel, PDF
|
|
|
|
const Invoicing = (() => {
|
|
const API = '/pos/api/invoicing';
|
|
|
|
function token() {
|
|
return localStorage.getItem('pos_token') || '';
|
|
}
|
|
|
|
function headers() {
|
|
return { 'Authorization': `Bearer ${token()}`, 'Content-Type': 'application/json' };
|
|
}
|
|
|
|
async function api(path, opts = {}) {
|
|
const res = await fetch(`${API}${path}`, { headers: headers(), ...opts });
|
|
if (!res.ok) {
|
|
const err = await res.json().catch(() => ({ error: res.statusText }));
|
|
throw new Error(err.error || 'Request failed');
|
|
}
|
|
return res.json();
|
|
}
|
|
|
|
function fmt(n) {
|
|
return parseFloat(n || 0).toLocaleString('es-MX', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
|
|
}
|
|
|
|
function badgeClass(status) {
|
|
return {
|
|
pending: 'badge-pending',
|
|
sending: 'badge-sending',
|
|
stamped: 'badge-stamped',
|
|
failed: 'badge-failed',
|
|
cancelled: 'badge-cancelled',
|
|
}[status] || '';
|
|
}
|
|
|
|
function badgeLabel(status) {
|
|
return {
|
|
pending: 'Pendiente',
|
|
sending: 'Enviando',
|
|
stamped: 'Timbrado',
|
|
failed: 'Fallido',
|
|
cancelled: 'Cancelado',
|
|
}[status] || status;
|
|
}
|
|
|
|
// ─── Queue List ────────────────────────────────
|
|
|
|
async function loadQueue() {
|
|
try {
|
|
const status = document.getElementById('filter-status').value;
|
|
const type = document.getElementById('filter-type').value;
|
|
let qs = '?per_page=50';
|
|
if (status) qs += `&status=${status}`;
|
|
if (type) qs += `&type=${type}`;
|
|
|
|
const res = await api(`/queue${qs}`);
|
|
renderQueue(res.data || []);
|
|
updateStats(res.data || []);
|
|
} catch (e) {
|
|
document.getElementById('queue-list').innerHTML =
|
|
`<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**
|
|
|
|
```python
|
|
# /home/Autopartes/pos/tests/test_plan4_cfdi_accounting.py
|
|
"""Integration test for Plan 4: CFDI + Accounting.
|
|
|
|
Tests the full flow:
|
|
1. Create a sale via pos_engine.process_sale()
|
|
2. Verify auto-generated journal entries (accounting_engine)
|
|
3. Build CFDI XML (cfdi_builder)
|
|
4. Enqueue CFDI (cfdi_queue)
|
|
5. Process queue with mocked Horux API
|
|
6. Verify trial balance
|
|
7. Cancel sale, verify reverse journal entry
|
|
8. Close fiscal period
|
|
"""
|
|
|
|
import json
|
|
import sys
|
|
import os
|
|
from datetime import date, datetime
|
|
from decimal import Decimal
|
|
from unittest.mock import patch, MagicMock
|
|
|
|
# Add pos/ to path
|
|
sys.path.insert(0, os.path.join(os.path.dirname(__file__), '..'))
|
|
|
|
|
|
class FakeG:
|
|
"""Fake Flask g object for testing."""
|
|
tenant_id = 1
|
|
employee_id = 1
|
|
employee_role = 'owner'
|
|
employee_name = 'Test Owner'
|
|
branch_id = 1
|
|
permissions = {'pos.sell', 'pos.discount', 'accounting.view', 'invoicing.create'}
|
|
device_id = 'test-device'
|
|
max_discount_pct = 100
|
|
|
|
|
|
def get_test_conn():
|
|
"""Get a connection to a test database.
|
|
|
|
In production, this would connect to a real tenant DB.
|
|
For this test, we use an in-memory approach or a test DB.
|
|
"""
|
|
import psycopg2
|
|
# Use the tenant_template or a test DB
|
|
conn = psycopg2.connect(
|
|
"postgresql://nexus:nexus_autoparts_2026@localhost/tenant_template"
|
|
)
|
|
return conn
|
|
|
|
|
|
def test_accounting_engine_sale_entry():
|
|
"""Test that record_sale_entry creates balanced journal entries."""
|
|
from services.accounting_engine import record_sale_entry, get_next_entry_number
|
|
|
|
conn = get_test_conn()
|
|
conn.autocommit = False
|
|
|
|
try:
|
|
# Mock Flask g
|
|
with patch('services.accounting_engine.g', FakeG()):
|
|
sale = {
|
|
'id': 99999,
|
|
'sale_type': 'cash',
|
|
'payment_method': 'efectivo',
|
|
'subtotal': 1000.00,
|
|
'discount_total': 0,
|
|
'tax_total': 160.00,
|
|
'total': 1160.00,
|
|
'items': [
|
|
{'unit_cost': 600.00, 'quantity': 2},
|
|
{'unit_cost': 100.00, 'quantity': 1},
|
|
],
|
|
}
|
|
|
|
entry_id = record_sale_entry(conn, sale)
|
|
assert entry_id is not None, "Entry ID should not be None"
|
|
|
|
# Verify entry exists
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT * FROM journal_entries WHERE id = %s", (entry_id,))
|
|
entry = cur.fetchone()
|
|
assert entry is not None, "Journal entry should exist"
|
|
|
|
# Verify lines are balanced
|
|
cur.execute("""
|
|
SELECT SUM(debit), SUM(credit)
|
|
FROM journal_entry_lines WHERE journal_entry_id = %s
|
|
""", (entry_id,))
|
|
total_debit, total_credit = cur.fetchone()
|
|
assert float(total_debit) == float(total_credit), \
|
|
f"Entry must be balanced: debit={total_debit} credit={total_credit}"
|
|
|
|
# Verify specific amounts
|
|
cur.execute("""
|
|
SELECT a.code, l.debit, l.credit
|
|
FROM journal_entry_lines l
|
|
JOIN accounts a ON l.account_id = a.id
|
|
WHERE l.journal_entry_id = %s
|
|
ORDER BY a.code
|
|
""", (entry_id,))
|
|
lines = {r[0]: {'debit': float(r[1]), 'credit': float(r[2])}
|
|
for r in cur.fetchall()}
|
|
|
|
assert lines['110']['debit'] == 1160.00, "Caja debit should be 1160"
|
|
assert lines['220']['credit'] == 160.00, "IVA Trasladado credit should be 160"
|
|
assert lines['410']['credit'] == 1000.00, "Ventas credit should be 1000"
|
|
assert lines['510']['debit'] == 1300.00, "Costo debit should be 1300"
|
|
assert lines['130']['credit'] == 1300.00, "Inventarios credit should be 1300"
|
|
|
|
cur.close()
|
|
print("PASS: test_accounting_engine_sale_entry")
|
|
|
|
finally:
|
|
conn.rollback()
|
|
conn.close()
|
|
|
|
|
|
def test_accounting_engine_cancellation_entry():
|
|
"""Test that record_cancellation_entry creates reversed entries."""
|
|
from services.accounting_engine import record_sale_entry, record_cancellation_entry
|
|
|
|
conn = get_test_conn()
|
|
conn.autocommit = False
|
|
|
|
try:
|
|
with patch('services.accounting_engine.g', FakeG()):
|
|
sale = {
|
|
'id': 99998,
|
|
'sale_type': 'cash',
|
|
'payment_method': 'efectivo',
|
|
'subtotal': 500.00,
|
|
'discount_total': 0,
|
|
'tax_total': 80.00,
|
|
'total': 580.00,
|
|
'items': [{'unit_cost': 300.00, 'quantity': 1}],
|
|
}
|
|
|
|
# Create sale entry first
|
|
sale_entry_id = record_sale_entry(conn, sale)
|
|
|
|
# Create cancellation entry
|
|
cancel_entry_id = record_cancellation_entry(conn, sale)
|
|
assert cancel_entry_id is not None
|
|
|
|
# Verify the cancellation entry is balanced
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
SELECT SUM(debit), SUM(credit)
|
|
FROM journal_entry_lines WHERE journal_entry_id = %s
|
|
""", (cancel_entry_id,))
|
|
total_debit, total_credit = cur.fetchone()
|
|
assert float(total_debit) == float(total_credit), \
|
|
f"Cancel entry must be balanced: debit={total_debit} credit={total_credit}"
|
|
|
|
# Net effect should be zero across both entries
|
|
cur.execute("""
|
|
SELECT SUM(debit) - SUM(credit) as net
|
|
FROM journal_entry_lines
|
|
WHERE journal_entry_id IN (%s, %s)
|
|
""", (sale_entry_id, cancel_entry_id))
|
|
net = float(cur.fetchone()[0])
|
|
assert net == 0, f"Net effect should be zero, got {net}"
|
|
|
|
cur.close()
|
|
print("PASS: test_accounting_engine_cancellation_entry")
|
|
|
|
finally:
|
|
conn.rollback()
|
|
conn.close()
|
|
|
|
|
|
def test_cfdi_builder_ingreso():
|
|
"""Test that build_ingreso_xml produces valid CFDI 4.0 XML."""
|
|
from services.cfdi_builder import build_ingreso_xml
|
|
from lxml import etree
|
|
|
|
sale = {
|
|
'id': 12345,
|
|
'subtotal': 1000.00,
|
|
'discount_total': 0,
|
|
'tax_total': 160.00,
|
|
'total': 1160.00,
|
|
'metodo_pago_sat': 'PUE',
|
|
'forma_pago_sat': '01',
|
|
'items': [{
|
|
'part_number': 'BRK-001',
|
|
'name': 'Pastillas de freno Bosch',
|
|
'quantity': 2,
|
|
'unit_price': 500.00,
|
|
'discount_amount': 0,
|
|
'tax_rate': 0.16,
|
|
'tax_amount': 160.00,
|
|
'subtotal': 1160.00,
|
|
'clave_prod_serv': '25174800',
|
|
'clave_unidad': 'H87',
|
|
}],
|
|
}
|
|
|
|
tenant_config = {
|
|
'rfc': 'TEST010101ABC',
|
|
'razon_social': 'Refaccionaria Test SA de CV',
|
|
'regimen_fiscal': '601',
|
|
'cp': '06600',
|
|
'serie': 'A',
|
|
}
|
|
|
|
# Test publico general (customer=None)
|
|
xml_str = build_ingreso_xml(sale, tenant_config, customer=None)
|
|
assert '<?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.
|