# /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/', 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, })