# /home/Autopartes/pos/services/pos_engine.py """POS engine: sale processing, totals calculation, pricing, cancellation. All sale operations go through this service. Stock deductions are delegated to inventory_engine.record_sale() — this service NEVER creates inventory operations directly. Monetary amounts: NUMERIC(12,2) in DB, float in Python. Tax: 16% IVA per item (from item.tax_rate field). """ from datetime import datetime, timedelta from decimal import Decimal, ROUND_HALF_UP from flask import g from services.audit import log_action from services.inventory_engine import ( record_sale as inventory_record_sale, record_operation, get_stock, get_stock_bulk, ) from services.accounting_engine import record_sale_entry, record_cancellation_entry from services.currency import convert, to_mxn, get_exchange_rate from services.savings_engine import record_sale_savings def _safe_g(attr, default=None): """Safely read flask.g attribute outside of app context.""" try: return getattr(g, attr, default) except RuntimeError: return default def _to_dec(val): """Convert a value to Decimal for precise arithmetic.""" return Decimal(str(val)) def calculate_totals(items): """Compute subtotal, discount amounts, tax, and total for a list of items. Uses Python Decimal for all intermediate calculations to avoid floating-point accumulation errors. Each line item is rounded individually before summing. Converts back to float only at the end for JSON serialization. Each item dict must have: unit_price, quantity, discount_pct, tax_rate. Returns dict with computed values and enriched items list. Args: items: list of dicts with keys: - unit_price (float): price per unit - quantity (int): number of units - discount_pct (float): discount percentage (0-100) - tax_rate (float): tax rate as decimal (e.g., 0.16 for 16%) Returns: dict: {subtotal, discount_total, tax_total, total, items: [...enriched...]} """ subtotal = Decimal('0') discount_total = Decimal('0') tax_total = Decimal('0') enriched_items = [] TWO = Decimal('0.01') for item in items: qty = int(item['quantity']) price = _to_dec(item['unit_price']) discount_pct = _to_dec(item.get('discount_pct', 0)) tax_rate = _to_dec(item.get('tax_rate', '0.16')) line_gross = (price * qty).quantize(TWO, rounding=ROUND_HALF_UP) line_discount = (line_gross * discount_pct / Decimal('100')).quantize(TWO, rounding=ROUND_HALF_UP) line_after_discount = (line_gross - line_discount).quantize(TWO, rounding=ROUND_HALF_UP) line_tax = (line_after_discount * tax_rate).quantize(TWO, rounding=ROUND_HALF_UP) line_subtotal = (line_after_discount + line_tax).quantize(TWO, rounding=ROUND_HALF_UP) subtotal += line_after_discount discount_total += line_discount tax_total += line_tax enriched_items.append({ **item, 'line_gross': float(line_gross), 'discount_amount': float(line_discount), 'tax_amount': float(line_tax), 'subtotal': float(line_subtotal), }) return { 'subtotal': float(subtotal.quantize(TWO, rounding=ROUND_HALF_UP)), 'discount_total': float(discount_total.quantize(TWO, rounding=ROUND_HALF_UP)), 'tax_total': float(tax_total.quantize(TWO, rounding=ROUND_HALF_UP)), 'total': float((subtotal + tax_total).quantize(TWO, rounding=ROUND_HALF_UP)), 'items': enriched_items, } def get_price_for_customer(inventory_item, customer): """Return the correct price based on the customer's price tier. Args: inventory_item: dict with price_1, price_2, price_3 customer: dict with price_tier (1, 2, or 3), or None for publico general Returns: float: the applicable price """ if customer is None: return float(inventory_item.get('price_1', 0)) tier = customer.get('price_tier', 1) if tier == 3: price = inventory_item.get('price_3', 0) elif tier == 2: price = inventory_item.get('price_2', 0) else: price = inventory_item.get('price_1', 0) return float(price) if price else float(inventory_item.get('price_1', 0)) def get_margin_info(inventory_item, selling_price=None, discount_pct=0): """Return cost, price, margin %, and max discount without losing margin. Only meaningful for employees with pos.view_cost permission (checked by caller). Args: inventory_item: dict with cost, price_1 (or selling_price override) selling_price: override price (if None, uses price_1) discount_pct: current discount percentage Returns: dict: {cost, price, margin_pct, max_discount_pct} """ cost = float(inventory_item.get('cost', 0)) price = float(selling_price) if selling_price else float(inventory_item.get('price_1', 0)) if price <= 0: return {'cost': cost, 'price': price, 'margin_pct': 0.0, 'max_discount_pct': 0.0} effective_price = price * (1 - discount_pct / 100) margin_pct = ((effective_price - cost) / effective_price * 100) if effective_price > 0 else 0.0 # Max discount before margin hits zero: price * (1 - d/100) = cost => d = (1 - cost/price) * 100 max_discount_pct = ((1 - cost / price) * 100) if price > cost else 0.0 return { 'cost': round(cost, 2), 'price': round(price, 2), 'margin_pct': round(margin_pct, 2), 'max_discount_pct': round(max_discount_pct, 2), } def process_sale(conn, sale_data): """Process a complete sale: validate, create records, deduct inventory, record payment. This is the main entry point for creating a sale. It handles the full transaction: 1. Validate all items exist and have sufficient stock 2. Calculate totals 3. Create sale + sale_items records 4. Call inventory_engine.record_sale() for each item (stock deduction) 5. Record payment on cash register 6. Update customer credit balance (if credit sale) 7. Create audit log entry Args: conn: psycopg2 connection to tenant DB (caller controls commit) sale_data: dict with keys: - items: [{inventory_id, quantity, unit_price, discount_pct, tax_rate}] - customer_id: int or None (publico general) - payment_method: 'efectivo' | 'transferencia' | 'tarjeta' | 'mixto' - sale_type: 'cash' | 'credit' | 'mixed' - register_id: int (cash register session ID) - amount_paid: float - payment_details: [{method, amount, reference}] (for mixed payments) - notes: str (optional) Returns: dict: complete sale object with id, items, totals, change Raises: ValueError: on validation errors (insufficient stock, invalid items, etc.) """ cur = conn.cursor() items = sale_data.get('items', []) customer_id = sale_data.get('customer_id') payment_method = sale_data.get('payment_method', 'efectivo') sale_type = sale_data.get('sale_type', 'cash') register_id = sale_data.get('register_id') amount_paid = float(sale_data.get('amount_paid', 0)) payment_details = sale_data.get('payment_details', []) notes = sale_data.get('notes') branch_id = _safe_g('branch_id') employee_id = _safe_g('employee_id') # ── Multi-currency support ─────────────────────────────────────────── currency = sale_data.get('currency', 'MXN') if currency not in ('MXN', 'USD'): raise ValueError(f"Unsupported currency: {currency}. Only MXN and USD are supported.") exchange_rate = sale_data.get('exchange_rate') if currency != 'MXN' and exchange_rate is None: exchange_rate = float(get_exchange_rate(conn, currency, 'MXN')) exchange_rate = float(exchange_rate) if exchange_rate else 1.0 if not items: raise ValueError("No items in sale") # Validate register is open if register_id: cur.execute("SELECT status FROM cash_registers WHERE id = %s", (register_id,)) reg = cur.fetchone() if not reg or reg[0] != 'open': raise ValueError("Cash register is not open") # ─── Batch preload: inventory items + stock + customer credit ───────── inv_ids = [item.get('inventory_id') for item in items] if not inv_ids: raise ValueError("No items in sale") # Lock inventory rows to prevent race conditions on concurrent sales cur.execute(""" SELECT id, part_number, name, cost, price_1, price_2, price_3, tax_rate, branch_id, retail_price FROM inventory WHERE id = ANY(%s) AND is_active = true ORDER BY id FOR UPDATE """, (inv_ids,)) inv_rows = {r[0]: r for r in cur.fetchall()} # Batch stock check stock_map = get_stock_bulk(conn, branch_id) # Validate and enrich items enriched_items = [] for item in items: inv_id = item.get('inventory_id') qty = int(item.get('quantity', 1)) if qty <= 0: raise ValueError(f"Invalid quantity for inventory_id {inv_id}") inv = inv_rows.get(inv_id) if not inv: raise ValueError(f"Inventory item {inv_id} not found or inactive") current_stock = stock_map.get(inv_id, 0) # Use provided price or fetch from inventory unit_price = float(item.get('unit_price', inv[4])) # default to price_1 discount_pct = float(item.get('discount_pct', 0)) tax_rate = float(item.get('tax_rate', inv[7] or 0.16)) unit_cost = float(inv[3]) if inv[3] else 0 # Validate discount against employee max max_discount = float(_safe_g('max_discount_pct', 100) or 100) if _safe_g('employee_role', 'cashier') not in ('owner', 'admin') and discount_pct > max_discount: raise ValueError( f"Discount {discount_pct}% exceeds your maximum allowed {max_discount}% " f"for item {inv[2]}" ) enriched_items.append({ 'inventory_id': inv_id, 'part_number': inv[1], 'name': inv[2], 'quantity': qty, 'unit_price': unit_price, 'unit_cost': unit_cost, 'discount_pct': discount_pct, 'tax_rate': tax_rate, 'branch_id': inv[8], 'stock_before': current_stock, }) # Calculate totals totals = calculate_totals(enriched_items) # Validate credit sale (with row lock to prevent race conditions) if sale_type == 'credit' and customer_id: cur.execute("SELECT credit_limit, credit_balance FROM customers WHERE id = %s FOR UPDATE", (customer_id,)) cust = cur.fetchone() if cust: credit_limit = float(cust[0] or 0) credit_balance = float(cust[1] or 0) credit_available = credit_limit - credit_balance if totals['total'] > credit_available and credit_limit > 0: raise ValueError( f"Insufficient credit. Available: ${credit_available:.2f}, " f"Required: ${totals['total']:.2f}" ) # Calculate change change_given = 0.0 if sale_type == 'cash' and payment_method == 'efectivo': change_given = round(max(amount_paid - totals['total'], 0), 2) # SAT payment method codes metodo_pago_sat = 'PPD' if sale_type == 'credit' else 'PUE' forma_pago_map = { 'efectivo': '01', 'transferencia': '03', 'tarjeta': '04', 'mixto': '99' } forma_pago_sat = forma_pago_map.get(payment_method, '99') # Create sale record (with currency) cur.execute(""" INSERT INTO sales (branch_id, customer_id, employee_id, register_id, sale_type, payment_method, subtotal, discount_total, tax_total, total, amount_paid, change_given, metodo_pago_sat, forma_pago_sat, status, device_id, notes, currency, exchange_rate) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,'completed',%s,%s,%s,%s) RETURNING id, created_at """, ( branch_id, customer_id, employee_id, register_id, sale_type, payment_method, totals['subtotal'], totals['discount_total'], totals['tax_total'], totals['total'], amount_paid, change_given, metodo_pago_sat, forma_pago_sat, _safe_g('device_id'), notes, currency, exchange_rate )) sale_id, created_at = cur.fetchone() # Create sale items (batch insert) and deduct inventory sale_items_data = [] for item in totals['items']: # retail_price from preloaded bulk query (index 9) inv = inv_rows.get(item['inventory_id']) retail_price = inv[9] if inv else None sale_items_data.append(( sale_id, item['inventory_id'], item['part_number'], item['name'], item['quantity'], item['unit_price'], item.get('unit_cost', 0), item['discount_pct'], item['discount_amount'], item['tax_rate'], item['tax_amount'], item['subtotal'], retail_price )) cur.executemany(""" INSERT INTO sale_items (sale_id, inventory_id, part_number, name, quantity, unit_price, unit_cost, discount_pct, discount_amount, tax_rate, tax_amount, subtotal, retail_price, currency, exchange_rate) VALUES (%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s,%s) """, [row + (currency, exchange_rate) for row in sale_items_data]) # Deduct inventory via inventory_engine sale_items = [] for item in totals['items']: # Pre-calculate remaining stock to avoid redundant get_stock() call stock_before = next((i['stock_before'] for i in enriched_items if i['inventory_id'] == item['inventory_id']), 0) remaining_after = stock_before - item['quantity'] inventory_record_sale( conn, item['inventory_id'], item.get('branch_id', branch_id), item['quantity'], sale_id=sale_id, cost_at_time=item.get('unit_cost'), remaining_stock=remaining_after ) sale_items.append({ 'inventory_id': item['inventory_id'], 'part_number': item['part_number'], 'name': item['name'], 'quantity': item['quantity'], 'unit_price': item['unit_price'], 'unit_cost': item.get('unit_cost', 0), 'discount_pct': item['discount_pct'], 'discount_amount': item['discount_amount'], 'tax_rate': item['tax_rate'], 'tax_amount': item['tax_amount'], 'subtotal': item['subtotal'], }) # Record payment on cash register (cash movements for efectivo) if register_id and payment_details: for pd in payment_details: method = pd.get('method', payment_method) amt = float(pd.get('amount', 0)) ref = pd.get('reference', '') cur.execute(""" INSERT INTO sale_payments (sale_id, register_id, method, amount, reference, currency, exchange_rate) VALUES (%s,%s,%s,%s,%s,%s,%s) """, (sale_id, register_id, method, amt, ref, currency, exchange_rate)) elif register_id: cur.execute(""" INSERT INTO sale_payments (sale_id, register_id, method, amount, reference, currency, exchange_rate) VALUES (%s,%s,%s,%s,%s,%s,%s) """, (sale_id, register_id, payment_method, amount_paid, sale_data.get('reference', ''), currency, exchange_rate)) # Update customer credit balance if credit sale if sale_type == 'credit' and customer_id: cur.execute(""" UPDATE customers SET credit_balance = credit_balance + %s WHERE id = %s """, (totals['total'], customer_id)) # Audit log log_action(conn, 'SALE', 'sale', sale_id, new_value={ 'total': totals['total'], 'items_count': len(sale_items), 'payment_method': payment_method, 'sale_type': sale_type, 'customer_id': customer_id, }) cur.close() # Auto-generate accounting entry (non-blocking) # Accounting is always in MXN — convert if sale was in another currency try: total_mxn = to_mxn(totals['total'], currency, rate=exchange_rate, conn=conn) tax_mxn = to_mxn(totals['tax_total'], currency, rate=exchange_rate, conn=conn) sub_mxn = to_mxn(totals['subtotal'] - totals['discount_total'], currency, rate=exchange_rate, conn=conn) record_sale_entry(conn, { 'id': sale_id, 'sale_type': sale_type, 'total': total_mxn, 'tax_total': tax_mxn, 'subtotal': sub_mxn, 'cost_total': sum(item.get('unit_cost', 0) * item['quantity'] for item in enriched_items), 'payment_method': payment_method, }) except Exception: pass # Accounting errors never block sales # Calculate and record savings vs retail price (non-blocking) try: record_sale_savings(conn, sale_id) except Exception: pass # Savings errors never block sales # WhatsApp learning hook (non-blocking) try: from services.wa_learning import check_learning_resolution check_learning_resolution(sale_id, customer_id, conn) except Exception: pass # Learning errors never block sales # Dropshipping webhook hook (non-blocking) try: from services import dropshipping_service as ds_svc from services.webhook_service import dispatch_webhooks_bulk webhook_urls = ds_svc.get_webhook_targets(conn, 'sale_made') if webhook_urls: payload_items = [] for item in enriched_items: remaining = item['stock_before'] - item['quantity'] payload_items.append({ 'sku': item['part_number'], 'name': item['name'], 'quantity_sold': item['quantity'], 'stock_remaining': remaining, 'unit_price': item['unit_price'], }) threading.Thread( target=dispatch_webhooks_bulk, args=(webhook_urls, 'sale_made', { 'sale_id': sale_id, 'items': payload_items, 'total': totals['total'], 'created_at': str(created_at), }), daemon=True ).start() except Exception: pass # Webhook errors never block sales return { 'id': sale_id, 'branch_id': branch_id, 'customer_id': customer_id, 'employee_id': employee_id, 'register_id': register_id, 'sale_type': sale_type, 'payment_method': payment_method, 'subtotal': totals['subtotal'], 'discount_total': totals['discount_total'], 'tax_total': totals['tax_total'], 'total': totals['total'], 'amount_paid': amount_paid, 'change_given': change_given, 'metodo_pago_sat': metodo_pago_sat, 'forma_pago_sat': forma_pago_sat, 'status': 'completed', 'items': sale_items, 'created_at': str(created_at), 'currency': currency, 'exchange_rate': exchange_rate, } def cancel_sale(conn, sale_id, reason): """Cancel a sale: validate permissions, reverse inventory, update credit. Business rules: - Cashiers can only cancel their own sales within 30 minutes - Admins and owners can cancel any sale - Cancelled sales are not deleted, just marked as 'cancelled' - Inventory is restored via RETURN operations - Customer credit balance is adjusted back Args: conn: psycopg2 connection sale_id: int reason: str (mandatory, min 3 chars) Returns: dict: cancellation result Raises: ValueError: on validation errors """ if not reason or len(reason.strip()) < 3: raise ValueError("Cancellation reason is mandatory (min 3 characters)") cur = conn.cursor() # Get sale details cur.execute(""" SELECT id, employee_id, customer_id, sale_type, total, status, created_at, branch_id, register_id FROM sales WHERE id = %s """, (sale_id,)) sale = cur.fetchone() if not sale: raise ValueError("Sale not found") s_id, s_emp_id, s_cust_id, s_type, s_total, s_status, s_created, s_branch, s_register = sale if s_status == 'cancelled': raise ValueError("Sale is already cancelled") # Permission check: cashiers can only cancel own sales within 30 min role = _safe_g('employee_role', 'cashier') emp_id = _safe_g('employee_id') if role == 'cashier': if s_emp_id != emp_id: raise ValueError("Cashiers can only cancel their own sales") if datetime.utcnow() - s_created > timedelta(minutes=30): raise ValueError("Cashiers can only cancel sales within 30 minutes of creation") # Get sale items for inventory reversal cur.execute(""" SELECT inventory_id, quantity, unit_cost FROM sale_items WHERE sale_id = %s """, (sale_id,)) sale_items = cur.fetchall() # Reverse inventory: create RETURN operations (positive quantity) from services.inventory_engine import record_return for inv_id, qty, cost in sale_items: record_return( conn, inv_id, s_branch, qty, sale_id=sale_id, notes=f"Cancelacion venta #{sale_id}: {reason}" ) # Update sale status cur.execute(""" UPDATE sales SET status = 'cancelled', notes = COALESCE(notes || ' | ', '') || %s WHERE id = %s """, (f"CANCELADA: {reason}", sale_id)) # Reverse accounting entry (non-blocking) try: # Fetch full sale data for the reversal entry cur.execute("""SELECT subtotal, tax_total, total, sale_type, payment_method FROM sales WHERE id = %s""", (sale_id,)) _sale_row = cur.fetchone() if _sale_row: record_cancellation_entry(conn, { 'id': sale_id, 'subtotal': float(_sale_row[0]) if _sale_row[0] else 0.0, 'tax_total': float(_sale_row[1]) if _sale_row[1] else 0.0, 'total': float(_sale_row[2]) if _sale_row[2] else 0.0, 'sale_type': _sale_row[3] or 'cash', 'payment_method': _sale_row[4] or 'efectivo', }) except Exception: pass # Accounting errors never block cancellations # Reverse customer credit balance if credit sale if s_type == 'credit' and s_cust_id: cur.execute(""" UPDATE customers SET credit_balance = credit_balance - %s WHERE id = %s """, (float(s_total), s_cust_id)) # Audit log log_action(conn, 'CANCEL', 'sale', sale_id, old_value={'status': 'completed', 'total': float(s_total)}, new_value={'status': 'cancelled', 'reason': reason}) # Push notification to owner/admin (best-effort, non-blocking) try: from services.push_service import notify_owner emp_name = _safe_g('employee_name', 'Empleado') notify_owner( conn, 'Venta Cancelada', f'Venta #{sale_id} (${float(s_total):,.2f}) cancelada por {emp_name}: {reason}', '/pos' ) except Exception: pass # Push failures never block business logic cur.close() return { 'sale_id': sale_id, 'status': 'cancelled', 'reason': reason, 'items_reversed': len(sale_items), 'total_reversed': float(s_total), }