Files
Autoparts-DB/pos/blueprints/accounting_bp.py
consultoria-as a386ca5b4a
Some checks failed
CI / lint-and-test (3.11) (push) Has been cancelled
CI / lint-and-test (3.13) (push) Has been cancelled
Mejoras de sesion: estabilizacion, IA, Nexus Yonke y marketplace B2B
- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos
- F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado
- F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment
- F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings)
- F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant
- Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas
- Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant)
- Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant
- Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
2026-10-12 00:57:31 +00:00

1416 lines
48 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, record_supplier_payment_entry
from services.audit import log_action
accounting_bp = Blueprint('accounting', __name__, url_prefix='/pos/api/accounting')
def _csv_response(rows, header, filename):
"""Build a UTF-8 CSV Response with BOM (Excel-friendly)."""
output = io.StringIO()
writer = csv.writer(output)
writer.writerow(header)
writer.writerows(rows)
csv_data = output.getvalue()
output.close()
return Response(
'\ufeff' + csv_data,
mimetype='text/csv; charset=utf-8',
headers={'Content-Disposition': f'attachment; filename={filename}'}
)
def _period_bounds(year, month):
"""Return (period_start, period_end) ISO dates for a fiscal 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'
return period_start, period_end
def _supplier_paid_totals(cur):
"""Return {po_id: paid_amount} from supplier_payments.
The table is created on first supplier payment, so tenants that never paid
a supplier simply have no data (and no table) yet.
"""
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
return {}
cur.execute("""
SELECT po_id, COALESCE(SUM(amount), 0)
FROM supplier_payments
WHERE po_id IS NOT NULL
GROUP BY po_id
""")
return {r[0]: float(r[1]) for r in cur.fetchall()}
# ─── 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 ──────────────────────────────
def _trial_balance_rows(conn, year, month):
"""Balanza de comprobacion rows for a fiscal month."""
cur = conn.cursor()
period_start, period_end = _period_bounds(year, month)
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()
return rows
@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)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
rows = _trial_balance_rows(conn, year, month)
conn.close()
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
@accounting_bp.route('/trial-balance/export', methods=['GET'])
@require_auth('accounting.view')
def export_trial_balance():
"""Export the balanza de comprobacion as CSV (default) or JSON.
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
rows = _trial_balance_rows(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month}})
return _csv_response(
[[r['code'], r['name'], r['type'], r['saldo_inicial'], r['cargos'],
r['abonos'], r['saldo_final']] for r in rows],
['Cuenta', 'Nombre', 'Tipo', 'Saldo inicial', 'Cargos', 'Abonos', 'Saldo final'],
f'balanza_{year}-{month:02d}.csv'
)
def _income_statement_data(conn, year, month):
"""Estado de resultados grouped by section for a fiscal month."""
cur = conn.cursor()
period_start, period_end = _period_bounds(year, month)
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()
return {
'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('/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)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
data = _income_statement_data(conn, year, month)
conn.close()
return jsonify(data)
@accounting_bp.route('/income-statement/export', methods=['GET'])
@require_auth('accounting.view')
def export_income_statement():
"""Export the estado de resultados as CSV (default) or JSON.
Query params: year, month, format=csv|json
"""
conn = get_tenant_conn(g.tenant_id)
year = int(request.args.get('year', date.today().year))
month = int(request.args.get('month', date.today().month))
fmt = request.args.get('format', 'csv').lower()
data = _income_statement_data(conn, year, month)
conn.close()
if fmt == 'json':
return jsonify(data)
rows = []
for item in data['ingresos']['items']:
rows.append(['Ingresos', item['code'], item['name'], item['amount']])
rows.append(['Ingresos', '', 'Total ingresos', data['ingresos']['total']])
for item in data['costos']['items']:
rows.append(['Costos', item['code'], item['name'], item['amount']])
rows.append(['Costos', '', 'Total costos', data['costos']['total']])
rows.append(['', '', 'Utilidad bruta', data['utilidad_bruta']])
for item in data['gastos']['items']:
rows.append(['Gastos', item['code'], item['name'], item['amount']])
rows.append(['Gastos', '', 'Total gastos', data['gastos']['total']])
rows.append(['', '', 'Utilidad neta', data['utilidad_neta']])
return _csv_response(
rows,
['Seccion', 'Cuenta', 'Nombre', 'Importe'],
f'estado_resultados_{year}-{month:02d}.csv'
)
@accounting_bp.route('/entries/export', methods=['GET'])
@require_auth('accounting.view')
def export_entries():
"""Export the polizas (journal entries) of a period with their lines.
Query params: date_from, date_to, type, reference_type, format=csv|json
CSV output has one row per journal entry line (accountant-friendly).
"""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
fmt = request.args.get('format', 'csv').lower()
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')
where_clauses = ["e.status = 'posted'"]
params = []
if date_from:
where_clauses.append("e.date >= %s")
params.append(date_from)
if date_to:
where_clauses.append("e.date <= %s")
params.append(date_to)
if entry_type:
where_clauses.append("e.type = %s")
params.append(entry_type)
if ref_type:
where_clauses.append("e.reference_type = %s")
params.append(ref_type)
where = " AND ".join(where_clauses)
cur.execute(f"""
SELECT e.entry_number, e.date, e.type, e.description,
e.reference_type, e.reference_id,
a.code, a.name, l.description, l.debit, l.credit
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 {where}
ORDER BY e.date, e.entry_number, l.id
""", params)
rows = []
for r in cur.fetchall():
rows.append({
'entry_number': r[0],
'date': r[1].isoformat() if r[1] else None,
'type': r[2],
'entry_description': r[3],
'reference_type': r[4],
'reference_id': r[5],
'account_code': r[6],
'account_name': r[7],
'line_description': r[8],
'debit': float(r[9] or 0),
'credit': float(r[10] or 0),
})
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'count': len(rows)})
return _csv_response(
[[r['entry_number'], r['date'], r['type'], r['entry_description'],
r['reference_type'], r['reference_id'], r['account_code'],
r['account_name'], r['line_description'], r['debit'], r['credit']]
for r in rows],
['Poliza', 'Fecha', 'Tipo', 'Descripcion poliza', 'Referencia', 'Ref ID',
'Cuenta', 'Nombre cuenta', 'Descripcion', 'Debe', 'Haber'],
f'polizas_{date.today().isoformat()}.csv'
)
@accounting_bp.route('/diot/export', methods=['GET'])
@require_auth('accounting.view')
def export_diot():
"""Export the DIOT (operations with third parties) for a fiscal month.
Data source: purchase orders received during the month, grouped by
supplier. All amounts are expressed in the purchase-order currency (MXN
for locally created orders).
Query params: year, month, format=csv|json
"""
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))
fmt = request.args.get('format', 'csv').lower()
period_start, period_end = _period_bounds(year, month)
cur.execute("""
SELECT s.rfc, s.name,
COALESCE(SUM(po.subtotal), 0) as base,
COALESCE(SUM(po.tax_total), 0) as iva,
COALESCE(SUM(po.total), 0) as total
FROM purchase_orders po
JOIN suppliers s ON s.id = po.supplier_id
WHERE po.status NOT IN ('draft', 'cancelled')
AND po.received_at >= %s AND po.received_at < %s
GROUP BY s.rfc, s.name
ORDER BY s.name
""", (period_start, period_end))
rows = []
for r in cur.fetchall():
rows.append({
'rfc': r[0] or '',
'supplier_name': r[1],
'tipo_tercero': '04', # Proveedor
'tipo_operacion': '02', # Compra de bienes
'base': round(float(r[2]), 2),
'iva_16': round(float(r[3]), 2),
'iva_retenido': 0.0,
'total': round(float(r[4]), 2),
})
cur.close()
conn.close()
if fmt == 'json':
return jsonify({'data': rows, 'period': {'year': year, 'month': month},
'count': len(rows)})
return _csv_response(
[[r['tipo_tercero'], r['tipo_operacion'], r['rfc'], r['supplier_name'],
r['base'], r['iva_16'], r['iva_retenido'], r['total']] for r in rows],
['Tipo de tercero', 'Tipo de operacion', 'RFC', 'Nombre', 'Base',
'IVA 16%', 'IVA retenido', 'Monto total'],
f'diot_{year}-{month:02d}.csv'
)
@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':
paid_by_po = _supplier_paid_totals(cur)
# 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 = paid_by_po.get(po_id, 0.0)
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
paid_by_po = _supplier_paid_totals(cur)
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 = paid_by_po.get(po_id, 0.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 ('Parcial' if paid > 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'}
)
# ─── Accounts Payable payments ─────────────────────
@accounting_bp.route('/supplier-payments', methods=['POST'])
@require_auth('accounting.create')
def create_supplier_payment():
"""Register a payment to a supplier against a purchase order (CxP).
Body: {po_id: int, amount: float, payment_method: str, reference: str}
"""
data = request.get_json() or {}
if not data.get('po_id'):
return jsonify({'error': 'po_id is required'}), 400
conn = get_tenant_conn(g.tenant_id)
try:
result = record_supplier_payment_entry(conn, data)
log_action(conn, 'SUPPLIER_PAYMENT', 'purchase_order', result['po_id'],
new_value={'amount': float(data.get('amount', 0)),
'payment_id': result['payment_id'],
'status': result['status']})
conn.commit()
conn.close()
return jsonify(result), 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
@accounting_bp.route('/supplier-payments', methods=['GET'])
@require_auth('accounting.view')
def list_supplier_payments():
"""List supplier payments. Query params: po_id, supplier_id, page, per_page."""
conn = get_tenant_conn(g.tenant_id)
cur = conn.cursor()
cur.execute("SELECT to_regclass('public.supplier_payments')")
if not cur.fetchone()[0]:
cur.close(); conn.close()
return jsonify({'data': [], 'pagination': {'page': 1, 'per_page': 50, 'total': 0, 'total_pages': 0}})
page = int(request.args.get('page', 1))
per_page = min(int(request.args.get('per_page', 50)), 200)
where_clauses = ["1=1"]
params = []
if request.args.get('po_id'):
where_clauses.append("sp.po_id = %s")
params.append(int(request.args.get('po_id')))
if request.args.get('supplier_id'):
where_clauses.append("sp.supplier_id = %s")
params.append(int(request.args.get('supplier_id')))
where = " AND ".join(where_clauses)
cur.execute(f"SELECT count(*) FROM supplier_payments sp WHERE {where}", params)
total = cur.fetchone()[0]
cur.execute(f"""
SELECT sp.id, sp.po_id, sp.supplier_id, sp.amount, sp.payment_method,
sp.reference, sp.created_at, s.name as supplier_name
FROM supplier_payments sp
LEFT JOIN suppliers s ON sp.supplier_id = s.id
WHERE {where}
ORDER BY sp.created_at DESC
LIMIT %s OFFSET %s
""", params + [per_page, (page - 1) * per_page])
rows = []
for r in cur.fetchall():
rows.append({
'id': r[0], 'po_id': r[1], 'supplier_id': r[2],
'amount': float(r[3]) if r[3] else 0,
'payment_method': r[4], 'reference': r[5],
'created_at': str(r[6]) if r[6] else None,
'supplier_name': r[7],
})
cur.close()
conn.close()
total_pages = (total + per_page - 1) // per_page
return jsonify({
'data': rows,
'pagination': {'page': page, 'per_page': per_page, 'total': total, 'total_pages': total_pages}
})
# ─── 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,
})