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