feat(pos): add accounting blueprint — reports, entries, periods
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
743
pos/blueprints/accounting_bp.py
Normal file
743
pos/blueprints/accounting_bp.py
Normal file
@@ -0,0 +1,743 @@
|
||||
# /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
|
||||
Reference in New Issue
Block a user