- F0 blindaje: Redis/Celery, pg_hba sin trust LAN, secretos a env 600, backups, webhook auth, jobs muertos - F1 caja: bugs POS, reembolsos al corte, apartados, CFDI, topes de descuento en cotizacion/apartado - F2/F7 IA de catalogo: autocategorizacion, dedup de SKUs con embeddings, OCR de facturas, descripciones, claves SAT, limpieza de fitment - F3 IA en el flujo: vision Yonke, preguntas MeLi, venta cruzada, anomalias, sinonimos, busqueda semantica (rerank/embeddings) - F4/F8 escala y cartera: /metrics+Prometheus+Grafana, pool, score de credito, churn, metricas multi-tenant - Nexus Yonke (M1-M6): modelo de datos 1-de-1, alta por donador, vitrina publica, MeLi usado, consignacion, garantia por dias, reportes, etiquetas - Marketplace B2B entre yonkes (catalogo + pedidos cross-tenant) - Seguridad: auth en /api/admin/* (35 endpoints), alertas por tenant - Migraciones v4.27/v4.28; timers expire-demos y rollup-metrics
1087 lines
36 KiB
Python
1087 lines
36 KiB
Python
# /home/Autopartes/pos/services/accounting_engine.py
|
|
"""Accounting engine: automatic journal entry generation for business operations.
|
|
|
|
Every sale, purchase, cash cut, credit payment, and cancellation produces
|
|
balanced journal entries (polizas). The engine looks up SAT account IDs
|
|
by code and creates journal_entries + journal_entry_lines records.
|
|
|
|
Account codes (from SAT seed):
|
|
110 = Caja
|
|
111 = Bancos
|
|
120 = Clientes
|
|
130 = Inventarios
|
|
140 = IVA Acreditable
|
|
210 = Proveedores
|
|
220 = IVA Trasladado
|
|
240 = Anticipos de Clientes (created on demand for layaways)
|
|
410 = Ventas
|
|
420 = Devoluciones sobre Ventas
|
|
510 = Costo de Mercancia Vendida
|
|
|
|
All functions receive a psycopg2 connection (caller controls commit).
|
|
"""
|
|
|
|
import logging
|
|
from datetime import date
|
|
from decimal import Decimal, ROUND_HALF_UP
|
|
from flask import g
|
|
|
|
logger = logging.getLogger('accounting')
|
|
|
|
|
|
def _to_dec(val):
|
|
"""Convert a value to Decimal for precise arithmetic."""
|
|
if val is None:
|
|
return Decimal('0')
|
|
return Decimal(str(val))
|
|
|
|
|
|
TWO = Decimal('0.01')
|
|
|
|
|
|
def _get_account_id(cur, code):
|
|
"""Look up account ID by code. Raises ValueError if not found."""
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s AND is_active = true", (code,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError(f"Account with code '{code}' not found")
|
|
return row[0]
|
|
|
|
|
|
def _get_account_ids(cur, codes):
|
|
"""Look up multiple account IDs by code. Returns dict {code: id}."""
|
|
cur.execute(
|
|
"SELECT code, id FROM accounts WHERE code = ANY(%s) AND is_active = true",
|
|
(list(codes),)
|
|
)
|
|
rows = {row[0]: row[1] for row in cur.fetchall()}
|
|
missing = set(codes) - set(rows)
|
|
if missing:
|
|
raise ValueError(f"Account(s) with code {sorted(missing)} not found")
|
|
return rows
|
|
|
|
|
|
def _ensure_account(cur, code, name, parent_code, acc_type):
|
|
"""Return the account ID for a code, creating it if it is missing.
|
|
|
|
Tenants seeded before an auxiliary account (e.g. customer advances) was
|
|
introduced do not have it in their chart of accounts, so it is created
|
|
on first use. Creation is idempotent (accounts.code is unique).
|
|
|
|
Args:
|
|
cur: psycopg2 cursor
|
|
code: account code to look up / create
|
|
name: account name used when creating
|
|
parent_code: code of the parent account (None for roots)
|
|
acc_type: 'activo' | 'pasivo' | 'capital' | 'ingreso' | 'costo' | 'gasto'
|
|
|
|
Returns:
|
|
int: account ID
|
|
"""
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
return row[0]
|
|
|
|
parent_id = None
|
|
if parent_code:
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s", (parent_code,))
|
|
parent = cur.fetchone()
|
|
parent_id = parent[0] if parent else None
|
|
|
|
cur.execute("""
|
|
INSERT INTO accounts (code, name, parent_id, type, sat_code, is_system, is_active)
|
|
VALUES (%s, %s, %s, %s, %s, false, true)
|
|
ON CONFLICT (code) DO NOTHING
|
|
RETURNING id
|
|
""", (code, name, parent_id, acc_type, code))
|
|
row = cur.fetchone()
|
|
if row:
|
|
return row[0]
|
|
|
|
cur.execute("SELECT id FROM accounts WHERE code = %s", (code,))
|
|
return cur.fetchone()[0]
|
|
|
|
|
|
def get_next_entry_number(conn):
|
|
"""Get the next sequential journal entry number.
|
|
|
|
Uses a transaction-level advisory lock to prevent duplicate numbers
|
|
when multiple journal entries are created concurrently.
|
|
|
|
Args:
|
|
conn: psycopg2 connection to tenant DB
|
|
|
|
Returns:
|
|
int: next entry number (starts at 1)
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("SELECT pg_advisory_xact_lock(hashtext('journal_entry_number'))")
|
|
cur.execute("SELECT COALESCE(MAX(entry_number), 0) + 1 FROM journal_entries")
|
|
number = cur.fetchone()[0]
|
|
cur.close()
|
|
return number
|
|
|
|
|
|
def _check_period_open(cur, entry_date):
|
|
"""Verify the fiscal period for the given date is open.
|
|
|
|
If no fiscal_periods row exists for the month, it is considered open
|
|
(periods are only created when explicitly closed).
|
|
|
|
Args:
|
|
cur: psycopg2 cursor
|
|
entry_date: date object
|
|
|
|
Raises:
|
|
ValueError: if the period is closed
|
|
"""
|
|
cur.execute("""
|
|
SELECT status FROM fiscal_periods
|
|
WHERE year = %s AND month = %s
|
|
""", (entry_date.year, entry_date.month))
|
|
row = cur.fetchone()
|
|
if row and row[0] == 'closed':
|
|
raise ValueError(
|
|
f"Fiscal period {entry_date.year}-{entry_date.month:02d} is closed. "
|
|
f"Cannot create journal entries in a closed period."
|
|
)
|
|
|
|
|
|
def _create_entry(cur, entry_number, entry_date, entry_type, description,
|
|
reference_type, reference_id, lines, is_auto=True):
|
|
"""Create a journal entry with its lines.
|
|
|
|
Validates that total debits == total credits before inserting.
|
|
|
|
Args:
|
|
cur: psycopg2 cursor
|
|
entry_number: int sequential number
|
|
entry_date: date
|
|
entry_type: 'ingreso' | 'egreso' | 'diario' | 'poliza'
|
|
description: str
|
|
reference_type: 'sale' | 'purchase' | 'cash_register' | 'payment' | None
|
|
reference_id: int or None
|
|
lines: list of dicts with keys: account_id, debit, credit, description
|
|
is_auto: bool (True for system-generated entries)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
|
|
Raises:
|
|
ValueError: if debits != credits
|
|
"""
|
|
# Validate balance
|
|
total_debit = sum(_to_dec(line['debit']) for line in lines)
|
|
total_credit = sum(_to_dec(line['credit']) for line in lines)
|
|
|
|
if total_debit.quantize(TWO, ROUND_HALF_UP) != total_credit.quantize(TWO, ROUND_HALF_UP):
|
|
raise ValueError(
|
|
f"Unbalanced entry: debits={total_debit} credits={total_credit}"
|
|
)
|
|
|
|
try:
|
|
created_by = getattr(g, 'employee_id', None)
|
|
except RuntimeError:
|
|
created_by = None
|
|
|
|
cur.execute("""
|
|
INSERT INTO journal_entries
|
|
(entry_number, date, type, description, reference_type, reference_id,
|
|
status, created_by, is_auto)
|
|
VALUES (%s, %s, %s, %s, %s, %s, 'posted', %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
entry_number, entry_date, entry_type, description,
|
|
reference_type, reference_id, created_by, is_auto
|
|
))
|
|
entry_id = cur.fetchone()[0]
|
|
|
|
for line in lines:
|
|
debit = float(_to_dec(line['debit']).quantize(TWO, ROUND_HALF_UP))
|
|
credit = float(_to_dec(line['credit']).quantize(TWO, ROUND_HALF_UP))
|
|
if debit == 0 and credit == 0:
|
|
continue # skip zero lines
|
|
cur.execute("""
|
|
INSERT INTO journal_entry_lines
|
|
(journal_entry_id, account_id, debit, credit, description)
|
|
VALUES (%s, %s, %s, %s, %s)
|
|
""", (entry_id, line['account_id'], debit, credit, line.get('description', '')))
|
|
|
|
return entry_id
|
|
|
|
|
|
def record_sale_entry(conn, sale):
|
|
"""Generate journal entries for a completed sale.
|
|
|
|
For a cash sale:
|
|
Debit 110 Caja (or 111 Bancos for transferencia/tarjeta) = total
|
|
Credit 410 Ventas = subtotal
|
|
Credit 220 IVA Trasladado = tax_total
|
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
|
|
|
For a credit sale:
|
|
Debit 120 Clientes = total
|
|
Credit 410 Ventas = subtotal
|
|
Credit 220 IVA Trasladado = tax_total
|
|
Debit 510 Costo de Mercancia Vendida = sum(unit_cost * qty)
|
|
Credit 130 Inventarios = sum(unit_cost * qty)
|
|
|
|
Args:
|
|
conn: psycopg2 connection to tenant DB
|
|
sale: dict from process_sale() with keys:
|
|
id, sale_type, payment_method, subtotal, discount_total,
|
|
tax_total, total, items (with unit_cost, quantity)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
sale_id = sale['id']
|
|
sale_type = sale.get('sale_type', 'cash')
|
|
payment_method = sale.get('payment_method', 'efectivo')
|
|
subtotal = _to_dec(sale['subtotal'])
|
|
tax_total = _to_dec(sale['tax_total'])
|
|
total = _to_dec(sale['total'])
|
|
|
|
# Calculate total cost of goods sold
|
|
cost_total = Decimal('0')
|
|
for item in sale.get('items', []):
|
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
|
|
|
# Determine debit account for payment received
|
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
|
|
|
if sale_type == 'credit':
|
|
payment_account_id = accounts['120'] # Clientes
|
|
payment_desc = f'Clientes - Venta a credito #{sale_id}'
|
|
elif payment_method in ('transferencia', 'tarjeta'):
|
|
payment_account_id = accounts['111'] # Bancos
|
|
payment_desc = f'Bancos - Venta #{sale_id} ({payment_method})'
|
|
else:
|
|
payment_account_id = accounts['110'] # Caja
|
|
payment_desc = f'Caja - Venta #{sale_id} (efectivo)'
|
|
|
|
lines = [
|
|
# Payment received (debit)
|
|
{
|
|
'account_id': payment_account_id,
|
|
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': payment_desc,
|
|
},
|
|
# Revenue (credit)
|
|
{
|
|
'account_id': accounts['410'],
|
|
'debit': 0,
|
|
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Ventas - Venta #{sale_id}',
|
|
},
|
|
# IVA Trasladado (credit)
|
|
{
|
|
'account_id': accounts['220'],
|
|
'debit': 0,
|
|
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'IVA Trasladado - Venta #{sale_id}',
|
|
},
|
|
]
|
|
|
|
# Cost of goods sold entries (only if cost > 0)
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Costo mercancia - Venta #{sale_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Inventarios - Venta #{sale_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Venta #{sale_id} - {payment_method}',
|
|
'sale', sale_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_purchase_entry(conn, purchase_data):
|
|
"""Generate journal entries for a purchase (inventory receipt).
|
|
|
|
Debit 130 Inventarios = subtotal (cost of goods)
|
|
Debit 140 IVA Acreditable = tax amount
|
|
Credit 210 Proveedores = total (subtotal + tax)
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
purchase_data: dict with keys:
|
|
reference_id: int (purchase order or operation ID)
|
|
subtotal: float (cost of goods before tax)
|
|
tax_amount: float (IVA 16%)
|
|
total: float (subtotal + tax)
|
|
supplier_name: str (optional, for description)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
ref_id = purchase_data.get('reference_id')
|
|
subtotal = _to_dec(purchase_data['subtotal'])
|
|
tax_amount = _to_dec(purchase_data.get('tax_amount', 0))
|
|
total = _to_dec(purchase_data['total'])
|
|
supplier = purchase_data.get('supplier_name', 'Proveedor')
|
|
|
|
accounts = _get_account_ids(cur, ['130', '140', '210'])
|
|
|
|
lines = [
|
|
{
|
|
'account_id': accounts['130'],
|
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Inventarios - Compra {supplier}',
|
|
},
|
|
{
|
|
'account_id': accounts['210'],
|
|
'debit': 0,
|
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Proveedores - Compra {supplier}',
|
|
},
|
|
]
|
|
|
|
# IVA Acreditable (only if tax > 0)
|
|
if tax_amount > 0:
|
|
lines.append({
|
|
'account_id': accounts['140'],
|
|
'debit': float(tax_amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'IVA Acreditable - Compra {supplier}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
f'Compra - {supplier}',
|
|
'purchase', ref_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_cash_cut_entry(conn, register):
|
|
"""Generate journal entry for a cash register close (corte Z).
|
|
|
|
Moves cash from register (Caja) to bank (Bancos):
|
|
Debit 111 Bancos = closing_amount
|
|
Credit 110 Caja = closing_amount
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
register: dict with keys:
|
|
id: int (cash register ID)
|
|
closing_amount: float
|
|
register_number: int
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
reg_id = register['id']
|
|
amount = _to_dec(register['closing_amount'])
|
|
reg_num = register.get('register_number', '?')
|
|
|
|
if amount <= 0:
|
|
cur.close()
|
|
return None # No entry for zero/negative close
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111'])
|
|
|
|
lines = [
|
|
{
|
|
'account_id': accounts['111'],
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Bancos - Corte caja #{reg_num}',
|
|
},
|
|
{
|
|
'account_id': accounts['110'],
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Caja - Corte caja #{reg_num}',
|
|
},
|
|
]
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
f'Corte de caja #{reg_num} (registro #{reg_id})',
|
|
'cash_register', reg_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_credit_payment_entry(conn, payment):
|
|
"""Generate journal entry for a customer credit payment.
|
|
|
|
Debit 111 Bancos (or 110 Caja) = amount
|
|
Credit 120 Clientes = amount
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
payment: dict with keys:
|
|
customer_id: int
|
|
customer_name: str (optional)
|
|
amount: float
|
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
reference_id: int (optional, e.g. sale_id being paid)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
amount = _to_dec(payment['amount'])
|
|
customer_name = payment.get('customer_name', f"Cliente #{payment['customer_id']}")
|
|
method = payment.get('payment_method', 'efectivo')
|
|
ref_id = payment.get('reference_id')
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '120'])
|
|
|
|
if method in ('transferencia', 'tarjeta'):
|
|
debit_account = accounts['111'] # Bancos
|
|
debit_desc = f'Bancos - Cobro credito {customer_name}'
|
|
else:
|
|
debit_account = accounts['110'] # Caja
|
|
debit_desc = f'Caja - Cobro credito {customer_name}'
|
|
|
|
lines = [
|
|
{
|
|
'account_id': debit_account,
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': debit_desc,
|
|
},
|
|
{
|
|
'account_id': accounts['120'],
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Clientes - Cobro credito {customer_name}',
|
|
},
|
|
]
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Cobro credito - {customer_name} ({method})',
|
|
'payment', ref_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_cancellation_entry(conn, sale):
|
|
"""Generate reverse journal entry for a cancelled sale.
|
|
|
|
This is the exact reverse of record_sale_entry():
|
|
Credit payment account (Caja/Bancos/Clientes) = total
|
|
Debit 410 Ventas = subtotal
|
|
Debit 220 IVA Trasladado = tax_total
|
|
Credit 510 Costo de Mercancia Vendida = cost_total
|
|
Debit 130 Inventarios = cost_total
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
sale: dict with the same structure as record_sale_entry() expects.
|
|
If items are not present, looks up from DB.
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
sale_id = sale['id']
|
|
sale_type = sale.get('sale_type', 'cash')
|
|
payment_method = sale.get('payment_method', 'efectivo')
|
|
subtotal = _to_dec(sale.get('subtotal', 0))
|
|
tax_total = _to_dec(sale.get('tax_total', 0))
|
|
total = _to_dec(sale.get('total', 0))
|
|
|
|
# If sale dict lacks items, look up from DB
|
|
items = sale.get('items')
|
|
if not items:
|
|
cur.execute("""
|
|
SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s
|
|
""", (sale_id,))
|
|
items = [{'unit_cost': float(r[0]) if r[0] else 0, 'quantity': r[1]}
|
|
for r in cur.fetchall()]
|
|
|
|
# If sale dict lacks totals, look up from DB
|
|
if total == 0:
|
|
cur.execute("""
|
|
SELECT subtotal, tax_total, total, sale_type, payment_method
|
|
FROM sales WHERE id = %s
|
|
""", (sale_id,))
|
|
row = cur.fetchone()
|
|
if row:
|
|
subtotal = _to_dec(row[0])
|
|
tax_total = _to_dec(row[1])
|
|
total = _to_dec(row[2])
|
|
sale_type = row[3]
|
|
payment_method = row[4]
|
|
|
|
cost_total = Decimal('0')
|
|
for item in items:
|
|
item_cost = _to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
cost_total += item_cost.quantize(TWO, ROUND_HALF_UP)
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '410', '510'])
|
|
|
|
# Determine which payment account to credit (reverse of debit in sale)
|
|
if sale_type == 'credit':
|
|
payment_account_id = accounts['120']
|
|
payment_desc = f'Clientes - Cancelacion venta #{sale_id}'
|
|
elif payment_method in ('transferencia', 'tarjeta'):
|
|
payment_account_id = accounts['111']
|
|
payment_desc = f'Bancos - Cancelacion venta #{sale_id}'
|
|
else:
|
|
payment_account_id = accounts['110']
|
|
payment_desc = f'Caja - Cancelacion venta #{sale_id}'
|
|
|
|
lines = [
|
|
# Reverse payment (credit the account that was debited)
|
|
{
|
|
'account_id': payment_account_id,
|
|
'debit': 0,
|
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': payment_desc,
|
|
},
|
|
# Reverse revenue (debit Ventas)
|
|
{
|
|
'account_id': accounts['410'],
|
|
'debit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Ventas - Cancelacion venta #{sale_id}',
|
|
},
|
|
# Reverse IVA (debit IVA Trasladado)
|
|
{
|
|
'account_id': accounts['220'],
|
|
'debit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'IVA Trasladado - Cancelacion venta #{sale_id}',
|
|
},
|
|
]
|
|
|
|
# Reverse COGS entries (only if cost > 0)
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Costo mercancia - Cancelacion venta #{sale_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Inventarios - Cancelacion venta #{sale_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'egreso',
|
|
f'Cancelacion venta #{sale_id}',
|
|
'sale', sale_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_return_entry(conn, ret):
|
|
"""Generate the journal entry for a customer return (devolucion).
|
|
|
|
Reverses the revenue side of the original sale and puts the returned
|
|
goods back into inventory:
|
|
Debit 420 Devoluciones sobre Ventas = net (after discount, before tax)
|
|
Debit 220 IVA Trasladado = tax
|
|
Credit 110/111/120 (refund account) = net + tax
|
|
Debit 130 Inventarios = cost
|
|
Credit 510 Costo de Mercancia Vendida = cost
|
|
|
|
The net/tax/cost split is read from return_items joined with sale_items,
|
|
so callers only need the return id and the original sale.
|
|
|
|
Args:
|
|
conn: psycopg2 connection to tenant DB
|
|
ret: dict with keys:
|
|
id: return id
|
|
sale_id: original sale id (optional)
|
|
sale_type: 'cash' | 'credit' (optional, looked up from sales)
|
|
payment_method: original payment method (optional, looked up)
|
|
total: refund total (used only when the item detail is missing)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
return_id = ret['id']
|
|
sale_id = ret.get('sale_id')
|
|
sale_type = ret.get('sale_type')
|
|
payment_method = ret.get('payment_method')
|
|
|
|
cur.execute("""
|
|
SELECT si.unit_price, si.discount_pct, si.tax_rate, si.unit_cost, ri.quantity
|
|
FROM return_items ri
|
|
JOIN sale_items si ON si.id = ri.sale_item_id
|
|
WHERE ri.return_id = %s
|
|
""", (return_id,))
|
|
item_rows = cur.fetchall()
|
|
|
|
if sale_id and (sale_type is None or payment_method is None):
|
|
cur.execute("SELECT sale_type, payment_method FROM sales WHERE id = %s", (sale_id,))
|
|
sale_row = cur.fetchone()
|
|
if sale_row:
|
|
sale_type = sale_type or sale_row[0]
|
|
payment_method = payment_method or sale_row[1]
|
|
|
|
net = Decimal('0')
|
|
tax = Decimal('0')
|
|
cost_total = Decimal('0')
|
|
for unit_price, discount_pct, tax_rate, unit_cost, quantity in item_rows:
|
|
qty = int(quantity or 0)
|
|
line_gross = (_to_dec(unit_price) * qty).quantize(TWO, ROUND_HALF_UP)
|
|
line_net = (line_gross * (Decimal('1') - _to_dec(discount_pct) / Decimal('100'))
|
|
).quantize(TWO, ROUND_HALF_UP)
|
|
net += line_net
|
|
tax += (line_net * _to_dec(tax_rate)).quantize(TWO, ROUND_HALF_UP)
|
|
cost_total += (_to_dec(unit_cost) * qty).quantize(TWO, ROUND_HALF_UP)
|
|
|
|
if net == 0 and tax == 0:
|
|
# No item detail available: fall back to the refund total (no IVA split)
|
|
net = _to_dec(ret.get('total', 0))
|
|
|
|
total = (net + tax).quantize(TWO, ROUND_HALF_UP)
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '120', '130', '220', '420', '510'])
|
|
|
|
if sale_type == 'credit':
|
|
refund_account_id = accounts['120'] # Clientes
|
|
refund_desc = f'Clientes - Devolucion #{return_id}'
|
|
elif payment_method in ('transferencia', 'tarjeta'):
|
|
refund_account_id = accounts['111'] # Bancos
|
|
refund_desc = f'Bancos - Devolucion #{return_id}'
|
|
else:
|
|
refund_account_id = accounts['110'] # Caja
|
|
refund_desc = f'Caja - Devolucion #{return_id}'
|
|
|
|
lines = [
|
|
{
|
|
'account_id': accounts['420'],
|
|
'debit': float(net.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Devoluciones sobre ventas - Devolucion #{return_id}',
|
|
},
|
|
{
|
|
'account_id': refund_account_id,
|
|
'debit': 0,
|
|
'credit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': refund_desc,
|
|
},
|
|
]
|
|
|
|
if tax > 0:
|
|
lines.append({
|
|
'account_id': accounts['220'],
|
|
'debit': float(tax.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'IVA Trasladado - Devolucion #{return_id}',
|
|
})
|
|
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Inventarios - Devolucion #{return_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Costo mercancia - Devolucion #{return_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'egreso',
|
|
f'Devolucion #{return_id}' + (f' - Venta #{sale_id}' if sale_id else ''),
|
|
'return', return_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_layaway_entry(conn, layaway):
|
|
"""Generate the journal entry for a layaway (apartado) payment.
|
|
|
|
A layaway deposit is money held on behalf of the customer, not revenue,
|
|
so it is recorded as a customer advance:
|
|
Debit 110 Caja (or 111 Bancos) = amount
|
|
Credit 240 Anticipos de Clientes = amount
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
layaway: dict with keys:
|
|
id: layaway id
|
|
amount: float
|
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
|
|
Returns:
|
|
int or None: journal entry ID (None when amount <= 0)
|
|
"""
|
|
amount = _to_dec(layaway.get('amount', 0))
|
|
if amount <= 0:
|
|
return None
|
|
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
layaway_id = layaway['id']
|
|
method = layaway.get('payment_method', 'efectivo')
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111'])
|
|
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
|
|
|
if method in ('transferencia', 'tarjeta'):
|
|
debit_account = accounts['111']
|
|
debit_desc = f'Bancos - Apartado #{layaway_id}'
|
|
else:
|
|
debit_account = accounts['110']
|
|
debit_desc = f'Caja - Apartado #{layaway_id}'
|
|
|
|
lines = [
|
|
{
|
|
'account_id': debit_account,
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': debit_desc,
|
|
},
|
|
{
|
|
'account_id': advance_account,
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
|
},
|
|
]
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Apartado #{layaway_id} - anticipo ({method})',
|
|
'layaway', layaway_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def record_layaway_conversion_entry(conn, sale):
|
|
"""Generate the journal entry for a fully paid layaway converted to a sale.
|
|
|
|
Applies the advances collected during the layaway and recognizes the
|
|
revenue, trasladado IVA and cost of goods:
|
|
Debit 240 Anticipos de Clientes = total
|
|
Credit 410 Ventas = subtotal
|
|
Credit 220 IVA Trasladado = tax_total
|
|
Debit 510 Costo de Mercancia Vendida = cost
|
|
Credit 130 Inventarios = cost
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
sale: dict with keys:
|
|
id: sale id created for the layaway
|
|
layaway_id: layaway id (optional)
|
|
subtotal: float (net, after discounts, before tax)
|
|
tax_total: float
|
|
total: float (subtotal + tax_total)
|
|
items: [{unit_cost, quantity}] (optional; read from DB when absent)
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
sale_id = sale['id']
|
|
layaway_id = sale.get('layaway_id')
|
|
subtotal = _to_dec(sale['subtotal'])
|
|
tax_total = _to_dec(sale['tax_total'])
|
|
total = _to_dec(sale['total'])
|
|
|
|
items = sale.get('items')
|
|
if not items:
|
|
cur.execute("SELECT unit_cost, quantity FROM sale_items WHERE sale_id = %s", (sale_id,))
|
|
items = [{'unit_cost': r[0], 'quantity': r[1]} for r in cur.fetchall()]
|
|
|
|
cost_total = Decimal('0')
|
|
for item in items:
|
|
cost_total += (_to_dec(item.get('unit_cost', 0)) * int(item.get('quantity', 1))
|
|
).quantize(TWO, ROUND_HALF_UP)
|
|
|
|
accounts = _get_account_ids(cur, ['130', '220', '410', '510'])
|
|
advance_account = _ensure_account(cur, '240', 'Anticipos de Clientes', '200', 'pasivo')
|
|
|
|
lines = [
|
|
{
|
|
'account_id': advance_account,
|
|
'debit': float(total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Anticipos de clientes - Apartado #{layaway_id}',
|
|
},
|
|
{
|
|
'account_id': accounts['410'],
|
|
'debit': 0,
|
|
'credit': float(subtotal.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Ventas - Apartado #{layaway_id} (venta #{sale_id})',
|
|
},
|
|
{
|
|
'account_id': accounts['220'],
|
|
'debit': 0,
|
|
'credit': float(tax_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'IVA Trasladado - Apartado #{layaway_id} (venta #{sale_id})',
|
|
},
|
|
]
|
|
|
|
if cost_total > 0:
|
|
lines.append({
|
|
'account_id': accounts['510'],
|
|
'debit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Costo mercancia - Apartado #{layaway_id}',
|
|
})
|
|
lines.append({
|
|
'account_id': accounts['130'],
|
|
'debit': 0,
|
|
'credit': float(cost_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': f'Inventarios - Apartado #{layaway_id}',
|
|
})
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'ingreso',
|
|
f'Apartado #{layaway_id} - venta #{sale_id}',
|
|
'layaway', layaway_id, lines
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|
|
|
|
|
|
def ensure_supplier_payments_table(conn):
|
|
"""Create the supplier_payments table if it does not exist (idempotent).
|
|
|
|
The table links each payment to the purchase order (cuentas por pagar)
|
|
it settles. It is created on first use so tenants that predate it work
|
|
without a manual migration.
|
|
"""
|
|
cur = conn.cursor()
|
|
cur.execute("""
|
|
CREATE TABLE IF NOT EXISTS supplier_payments (
|
|
id SERIAL PRIMARY KEY,
|
|
po_id INTEGER REFERENCES purchase_orders(id) ON DELETE CASCADE,
|
|
supplier_id INTEGER REFERENCES suppliers(id) ON DELETE SET NULL,
|
|
amount NUMERIC(12,2) NOT NULL,
|
|
payment_method VARCHAR(20) NOT NULL DEFAULT 'efectivo',
|
|
reference VARCHAR(100),
|
|
employee_id INTEGER REFERENCES employees(id),
|
|
created_at TIMESTAMPTZ DEFAULT NOW()
|
|
)
|
|
""")
|
|
cur.execute(
|
|
"CREATE INDEX IF NOT EXISTS idx_supplier_payments_po ON supplier_payments(po_id)"
|
|
)
|
|
cur.close()
|
|
|
|
|
|
def record_supplier_payment_entry(conn, payment):
|
|
"""Register a payment to a supplier against a purchase order (CxP).
|
|
|
|
Debit 210 Proveedores = amount
|
|
Credit 110 Caja / 111 Bancos = amount
|
|
|
|
The payment is stored in supplier_payments (created on first use) and the
|
|
purchase order is marked 'paid' once the accumulated payments cover its
|
|
total. This is what feeds the 'paid' column of the accounts-payable aging.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
payment: dict with keys:
|
|
po_id: purchase order id (required)
|
|
amount: float (required, in the PO currency)
|
|
payment_method: 'efectivo' | 'transferencia' | 'tarjeta'
|
|
reference: str (optional)
|
|
|
|
Returns:
|
|
dict: {entry_id, payment_id, po_id, status, paid, balance}
|
|
"""
|
|
cur = conn.cursor()
|
|
po_id = payment.get('po_id')
|
|
amount = _to_dec(payment.get('amount', 0))
|
|
if not po_id:
|
|
raise ValueError("po_id is required")
|
|
if amount <= 0:
|
|
raise ValueError("amount must be greater than 0")
|
|
|
|
cur.execute("""
|
|
SELECT supplier_id, total, status FROM purchase_orders WHERE id = %s FOR UPDATE
|
|
""", (po_id,))
|
|
row = cur.fetchone()
|
|
if not row:
|
|
raise ValueError(f"Purchase order {po_id} not found")
|
|
supplier_id, po_total, po_status = row
|
|
if po_status == 'cancelled':
|
|
raise ValueError("Cannot pay a cancelled purchase order")
|
|
|
|
ensure_supplier_payments_table(conn)
|
|
cur.execute("SELECT COALESCE(SUM(amount), 0) FROM supplier_payments WHERE po_id = %s", (po_id,))
|
|
already_paid = _to_dec(cur.fetchone()[0])
|
|
|
|
po_total = _to_dec(po_total)
|
|
paid_total = already_paid + amount
|
|
if paid_total > po_total:
|
|
raise ValueError(
|
|
f"Payment ${amount} exceeds balance ${(po_total - already_paid).quantize(TWO)} "
|
|
f"of purchase order {po_id}"
|
|
)
|
|
|
|
entry_date = date.today()
|
|
_check_period_open(cur, entry_date)
|
|
|
|
try:
|
|
created_by = getattr(g, 'employee_id', None)
|
|
except RuntimeError:
|
|
created_by = None
|
|
|
|
cur.execute("""
|
|
INSERT INTO supplier_payments
|
|
(po_id, supplier_id, amount, payment_method, reference, employee_id)
|
|
VALUES (%s, %s, %s, %s, %s, %s)
|
|
RETURNING id
|
|
""", (
|
|
po_id, supplier_id, float(amount),
|
|
payment.get('payment_method', 'efectivo'),
|
|
payment.get('reference'), created_by
|
|
))
|
|
payment_id = cur.fetchone()[0]
|
|
|
|
accounts = _get_account_ids(cur, ['110', '111', '210'])
|
|
method = payment.get('payment_method', 'efectivo')
|
|
if method in ('transferencia', 'tarjeta'):
|
|
credit_account = accounts['111']
|
|
credit_desc = f'Bancos - Pago a proveedor OC #{po_id}'
|
|
else:
|
|
credit_account = accounts['110']
|
|
credit_desc = f'Caja - Pago a proveedor OC #{po_id}'
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'egreso',
|
|
f'Pago a proveedor - OC #{po_id}',
|
|
'purchase', po_id,
|
|
[
|
|
{
|
|
'account_id': accounts['210'],
|
|
'debit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'credit': 0,
|
|
'description': f'Proveedores - Pago OC #{po_id}',
|
|
},
|
|
{
|
|
'account_id': credit_account,
|
|
'debit': 0,
|
|
'credit': float(amount.quantize(TWO, ROUND_HALF_UP)),
|
|
'description': credit_desc,
|
|
},
|
|
]
|
|
)
|
|
|
|
new_status = 'paid' if paid_total >= po_total else po_status
|
|
if new_status != po_status:
|
|
cur.execute("UPDATE purchase_orders SET status = %s WHERE id = %s", (new_status, po_id))
|
|
|
|
cur.close()
|
|
return {
|
|
'entry_id': entry_id,
|
|
'payment_id': payment_id,
|
|
'po_id': po_id,
|
|
'status': new_status,
|
|
'paid': float(paid_total.quantize(TWO, ROUND_HALF_UP)),
|
|
'balance': float((po_total - paid_total).quantize(TWO, ROUND_HALF_UP)),
|
|
}
|
|
|
|
|
|
def create_manual_entry(conn, entry_data):
|
|
"""Create a manual journal entry (type='diario').
|
|
|
|
Used by accountants for adjustments not tied to a specific operation.
|
|
|
|
Args:
|
|
conn: psycopg2 connection
|
|
entry_data: dict with keys:
|
|
date: str 'YYYY-MM-DD'
|
|
description: str
|
|
lines: [{account_id, debit, credit, description}]
|
|
|
|
Returns:
|
|
int: journal entry ID
|
|
|
|
Raises:
|
|
ValueError: if entry is unbalanced or period is closed
|
|
"""
|
|
cur = conn.cursor()
|
|
entry_date = date.fromisoformat(entry_data['date'])
|
|
_check_period_open(cur, entry_date)
|
|
|
|
entry_number = get_next_entry_number(conn)
|
|
|
|
entry_id = _create_entry(
|
|
cur, entry_number, entry_date, 'diario',
|
|
entry_data.get('description', 'Poliza manual'),
|
|
None, None,
|
|
entry_data['lines'],
|
|
is_auto=False
|
|
)
|
|
|
|
cur.close()
|
|
return entry_id
|