# /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