Files
Autoparts-DB/pos/blueprints/accounting_bp.py
consultoria-as a236187f3a feat: MercadoLibre integration + inventory bulk publish + WhatsApp bridge fixes
- Add MercadoLibre OAuth, listings, orders, webhooks and category search
- New marketplace_external_bp.py, meli_service.py, marketplace_external_service.py
- New marketplace_external.html/js with ML management UI
- Inventory: bulk publish to ML with category autocomplete, listing type and shipping selectors
- Inventory: new .btn--meli styles, select/label CSS fixes
- WhatsApp bridge: rate limiting, 440/515/408 error handling, stale watchdog
- DB migration v3.4_meli_integration.sql for marketplace_listings, orders, sync_queue
- Add Celery tasks for ML sync and webhook processing
- Sidebar: MercadoLibre navigation link
2026-05-26 04:24:07 +00:00

786 lines
26 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
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
@accounting_bp.route('/stats', methods=['GET'])
@require_auth('accounting.read')
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,
})