744 lines
24 KiB
Python
744 lines
24 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
|