Files
Autoparts-DB/pos/blueprints/accounting_bp.py
consultoria-as 2bdeb2973a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
fix(audit): corrige errores criticos y mayores, mejora UX/accesibilidad y optimiza rendimiento
- Arregla @require_auth, permisos, race conditions, locks de caja/stock
- Elimina N+1 en layaway, flotilla, dashboard y global_invoice
- Asegura folios atomicos para CFDI, ordenes de servicio y polizas
- Protege client_secret de MercadoLibre en backend
- Conecta botones/filtros de config, customers, accounting e invoicing
- Mejora accesibilidad (labels/aria-label) y estados de carga/vacio
- Limpia accounting.js obsoleto y consolida accounting.v9.js
- Actualiza cache busting a v32 y Service Worker a v32
- Documenta todo en docs/AUDIT_Y_MEJORAS_2026-06-15.md

Tests: 35 passed
2026-06-29 23:54:58 +00:00

1064 lines
36 KiB
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
import csv
import io
from datetime import date, datetime, timedelta
from flask import Blueprint, request, jsonify, g, Response
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.
Returns individual credit sales with outstanding balance when type=receivable
(default), or purchase orders payable to suppliers when type=payable.
"""
report_type = request.args.get('type', 'receivable')
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
rows = []
if report_type == 'payable':
# Accounts payable: purchase orders to suppliers that are not paid/cancelled
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.id, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected_date = r[4]
status = r[5]
vendor_name = r[7]
paid = 0 # TODO: sum supplier payments when that table is added
balance = round(total - paid, 2)
if balance <= 0:
continue
days_overdue = (datetime.now(created_at.tzinfo) - expected_date).days if expected_date and created_at else 0
if days_overdue > 0:
po_status = 'overdue'
label = 'Vencida'
elif paid > 0:
po_status = 'partial'
label = 'Parcial'
else:
po_status = 'pending'
label = 'Pendiente'
rows.append({
'po_id': po_id,
'invoice': invoice,
'vendor_name': vendor_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': expected_date.isoformat() if expected_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': po_status,
'status_label': label,
})
else:
# Accounts receivable: credit sales to customers
cur.execute("""
SELECT s.id, s.total, s.created_at, s.status,
c.id, c.name, c.rfc,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
for r in cur.fetchall():
sale_id = r[0]
total = float(r[1]) if r[1] else 0
created_at = r[2]
status = r[3]
customer_name = r[5]
payments_total = float(r[7]) if r[7] else 0
paid = payments_total
balance = round(total - paid, 2)
if balance <= 0:
continue
due_date = created_at + timedelta(days=30) if created_at else None
days_overdue = (datetime.now(created_at.tzinfo) - due_date).days if due_date else 0
if days_overdue > 0:
sale_status = 'overdue'
label = 'Vencida'
elif paid > 0:
sale_status = 'partial'
label = 'Parcial'
else:
sale_status = 'pending'
label = 'Vigente'
rows.append({
'sale_id': sale_id,
'invoice': f'VTA-{sale_id}',
'customer_name': customer_name,
'issue_date': created_at.isoformat() if created_at else None,
'due_date': due_date.isoformat() if due_date else None,
'total': total,
'paid': paid,
'balance': balance,
'days_overdue': days_overdue,
'status': sale_status,
'status_label': label,
})
totals = {
'count': len(rows),
'total': round(sum(r['total'] for r in rows), 2),
'paid': round(sum(r['paid'] for r in rows), 2),
'balance': round(sum(r['balance'] for r in rows), 2),
}
cur.close()
conn.close()
return jsonify({'data': rows, 'totals': totals})
@accounting_bp.route('/aging-summary', methods=['GET'])
@require_auth('accounting.view')
def aging_summary():
"""Customer-level accounts receivable summary (used by reports)."""
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 - COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as balance,
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]
balance = round(float(r[6]) if r[6] else 0, 2)
if balance <= 0:
continue
days = r[8] or 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,
}
if days <= 0:
customers[cust_id]['corriente'] += balance
elif days <= 30:
customers[cust_id]['d1_30'] += balance
elif days <= 60:
customers[cust_id]['d31_60'] += balance
elif days <= 90:
customers[cust_id]['d61_90'] += balance
else:
customers[cust_id]['d90_plus'] += balance
customers[cust_id]['total'] += balance
result = list(customers.values())
for c in result:
c['credit_balance'] = round(c['total'], 2)
for key in ('corriente', 'd1_30', 'd31_60', 'd61_90', 'd90_plus', 'total'):
c[key] = round(c[key], 2)
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),
'count': len(result),
}
cur.close()
conn.close()
return jsonify({'data': result, 'totals': totals})
@accounting_bp.route('/aging/export', methods=['GET'])
@require_auth('accounting.view')
def export_aging():
"""Export receivables or payables to CSV/PDF."""
report_type = request.args.get('type', 'receivable')
fmt = request.args.get('format', 'csv').lower()
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
if report_type == 'payable':
# Payables always CSV for now
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['OC', 'Proveedor', 'Fecha emision', 'Fecha vencimiento',
'Total', 'Pagado', 'Saldo', 'Dias vencido', 'Estado'])
cur.execute("""
SELECT po.id, po.supplier_invoice, po.total, po.created_at, po.expected_date,
po.status, s.name
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('paid', 'cancelled')
ORDER BY po.created_at DESC
""")
for r in cur.fetchall():
po_id = r[0]
invoice = r[1] or f'OC-{po_id}'
total = float(r[2]) if r[2] else 0
created_at = r[3]
expected = r[4]
status = r[5]
vendor = r[6]
paid = 0
balance = round(total - paid, 2)
days_overdue = (datetime.now(created_at.tzinfo) - expected).days if expected and created_at else 0
status_label = 'Vencida' if days_overdue > 0 else 'Pendiente'
writer.writerow([
invoice, vendor,
created_at.strftime('%Y-%m-%d') if created_at else '',
expected.strftime('%Y-%m-%d') if expected else '',
total, paid, balance, days_overdue, status_label
])
cur.close(); conn.close()
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=cuentas_por_pagar_{date.today().isoformat()}.csv'}
)
# Receivables: CSV or PDF
cur.execute("""
SELECT s.id, s.subtotal, s.tax_total, s.total, s.created_at,
c.name,
COALESCE((SELECT SUM(amount) FROM sale_payments sp WHERE sp.sale_id = s.id), 0) as payments_total
FROM sales s
JOIN customers c ON c.id = s.customer_id
WHERE s.sale_type = 'credit'
AND s.status = 'completed'
ORDER BY s.created_at DESC
""")
rows = []
for r in cur.fetchall():
sale_id = r[0]
subtotal = float(r[1]) if r[1] else 0
tax = float(r[2]) if r[2] else 0
total = float(r[3]) if r[3] else 0
created_at = r[4]
customer_name = r[5]
payments_total = float(r[6]) if r[6] else 0
balance = round(total - payments_total, 2)
if balance <= 0:
continue
rows.append({
'date': created_at.strftime('%d/%m/%Y') if created_at else '',
'customer': customer_name,
'subtotal': subtotal,
'tax': tax,
'total': total,
'balance': balance,
})
cur.close(); conn.close()
if fmt == 'pdf':
from fpdf import FPDF
class ReceivablesPDF(FPDF):
def header(self):
self.set_font('Arial', 'B', 14)
self.cell(0, 10, 'Reporte de Ventas por Cobrar', 0, 1, 'C')
self.set_font('Arial', '', 10)
self.cell(0, 6, f'Generado: {date.today().strftime("%d/%m/%Y")}', 0, 1, 'C')
self.ln(4)
def footer(self):
self.set_y(-15)
self.set_font('Arial', 'I', 8)
self.cell(0, 10, f'Pagina {self.page_no()}', 0, 0, 'C')
pdf = ReceivablesPDF('L', 'mm', 'A4')
pdf.add_page()
pdf.set_font('Arial', 'B', 10)
pdf.set_fill_color(230, 230, 230)
col_widths = [30, 95, 35, 30, 35, 35]
headers = ['FECHA', 'CLIENTE', 'SUBTOTAL', 'IVA', 'TOTAL', 'SALDO']
for i, h in enumerate(headers):
pdf.cell(col_widths[i], 10, h, 1, 0, 'C', True)
pdf.ln()
pdf.set_font('Arial', '', 9)
totals = {'subtotal': 0, 'tax': 0, 'total': 0, 'balance': 0}
for row in rows:
pdf.cell(col_widths[0], 8, row['date'], 1, 0, 'C')
pdf.cell(col_widths[1], 8, row['customer'][:50], 1, 0, 'L')
pdf.cell(col_widths[2], 8, f"{row['subtotal']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[3], 8, f"{row['tax']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[4], 8, f"{row['total']:.2f}", 1, 0, 'R')
pdf.cell(col_widths[5], 8, f"{row['balance']:.2f}", 1, 1, 'R')
totals['subtotal'] += row['subtotal']
totals['tax'] += row['tax']
totals['total'] += row['total']
totals['balance'] += row['balance']
pdf.set_font('Arial', 'B', 9)
pdf.cell(col_widths[0] + col_widths[1], 8, 'TOTAL', 1, 0, 'R', True)
pdf.cell(col_widths[2], 8, f"{totals['subtotal']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[3], 8, f"{totals['tax']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[4], 8, f"{totals['total']:.2f}", 1, 0, 'R', True)
pdf.cell(col_widths[5], 8, f"{totals['balance']:.2f}", 1, 1, 'R', True)
pdf_bytes = bytes(pdf.output(dest='S'))
return Response(
pdf_bytes,
mimetype='application/pdf',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.pdf'}
)
# CSV fallback
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(['Fecha', 'Cliente', 'Subtotal', 'IVA', 'Total', 'Saldo'])
for row in rows:
writer.writerow([row['date'], row['customer'], row['subtotal'], row['tax'], row['total'], row['balance']])
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename=ventas_por_cobrar_{date.today().isoformat()}.csv'}
)
# ─── 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
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.view')
def api_accounting_stats():
"""Return counts for tab badges: receivables (asset accounts with balance) and payables (liability accounts with balance)."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
# Count asset accounts with positive balance (cuentas por cobrar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'activo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.debit), 0) - COALESCE(SUM(l.credit), 0) > 0
) x
""")
cxc = cur.fetchone()[0] or 0
# Count liability accounts with positive balance (cuentas por pagar)
cur.execute("""
SELECT COUNT(*) FROM (
SELECT a.id
FROM accounts a
LEFT JOIN journal_entry_lines l ON l.account_id = a.id
WHERE a.type = 'pasivo' AND a.is_active = true
GROUP BY a.id
HAVING COALESCE(SUM(l.credit), 0) - COALESCE(SUM(l.debit), 0) > 0
) x
""")
cxp = cur.fetchone()[0] or 0
cur.close()
conn.close()
return jsonify({
'cuentas_cobrar': cxc,
'cuentas_pagar': cxp,
})