from datetime import date, datetime, timezone from decimal import Decimal from fastapi import HTTPException, status from sqlalchemy import func from sqlalchemy.orm import Session from api.v1.modules.crm.accounts.models import Account from api.v1.modules.crm.quotes.models import Quote, QuoteItem from api.v1.modules.ops.shipments.models import Shipment from .dto import InvoiceCreate, InvoiceItemCreate, InvoiceItemUpdate, InvoiceUpdate, PaymentCreate from .models import Invoice, InvoiceItem, Payment def _exists(db: Session, model, _id, tenant_id, company_id) -> bool: if _id is None: return True return ( db.query(model.id) .filter(model.id == _id, model.tenant_id == tenant_id, model.company_id == company_id, model.deleted_at.is_(None)) .first() is not None ) def _validate_refs(db: Session, data: dict, tenant_id: int, company_id: int) -> None: for field, model, msg in [ ("account_id", Account, "El cliente asociado no existe"), ("shipment_id", Shipment, "El embarque asociado no existe"), ("quote_id", Quote, "La cotización asociada no existe"), ]: if field in data and not _exists(db, model, data.get(field), tenant_id, company_id): raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg) def _recompute(db: Session, invoice: Invoice) -> None: subtotal = db.query(func.coalesce(func.sum(InvoiceItem.quantity * InvoiceItem.unit_amount), 0)).filter( InvoiceItem.invoice_id == invoice.id, InvoiceItem.deleted_at.is_(None) ).scalar() paid = db.query(func.coalesce(func.sum(Payment.amount), 0)).filter( Payment.invoice_id == invoice.id, Payment.deleted_at.is_(None) ).scalar() subtotal = Decimal(subtotal or 0) rate = Decimal(invoice.tax_rate or 0) tax = (subtotal * rate / Decimal(100)).quantize(Decimal("0.01")) total = subtotal + tax paid = Decimal(paid or 0) invoice.subtotal = subtotal invoice.tax_amount = tax invoice.total = total invoice.paid_amount = paid invoice.balance = total - paid # Estado de cobranza (no toca borrador ni cancelada) if invoice.status in ("emitida", "enviada", "pagada"): if total > 0 and invoice.balance <= 0: invoice.status = "pagada" invoice.paid_at = datetime.now(timezone.utc) elif invoice.status == "pagada" and invoice.balance > 0: invoice.status = "enviada" invoice.paid_at = None # ----- Invoices ----- def get_invoices(db, tenant_id, company_id, search=None, inv_status=None, account_id=None) -> list[Invoice]: q = db.query(Invoice).filter(Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None)) if inv_status: q = q.filter(Invoice.status == inv_status) if account_id is not None: q = q.filter(Invoice.account_id == account_id) if search: q = q.filter(Invoice.reference.ilike(f"%{search}%")) return q.order_by(Invoice.created_at.desc()).all() def get_invoice(db, invoice_id, tenant_id, company_id) -> Invoice: obj = db.query(Invoice).filter( Invoice.id == invoice_id, Invoice.tenant_id == tenant_id, Invoice.company_id == company_id, Invoice.deleted_at.is_(None) ).first() if not obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Factura no encontrada") return obj def create_invoice(db, payload: InvoiceCreate, tenant_id, company_id, user_id=None) -> Invoice: data = payload.model_dump() _validate_refs(db, data, tenant_id, company_id) obj = Invoice(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id) db.add(obj) db.flush() _recompute(db, obj) db.commit() db.refresh(obj) return obj def update_invoice(db, invoice_id, payload: InvoiceUpdate, tenant_id, company_id, user_id=None) -> Invoice: obj = get_invoice(db, invoice_id, tenant_id, company_id) data = payload.model_dump(exclude_unset=True) _validate_refs(db, data, tenant_id, company_id) for f, v in data.items(): setattr(obj, f, v) obj.updated_by = user_id db.flush() _recompute(db, obj) # tax_rate pudo cambiar db.commit() db.refresh(obj) return obj def delete_invoice(db, invoice_id, tenant_id, company_id) -> None: obj = get_invoice(db, invoice_id, tenant_id, company_id) obj.deleted_at = datetime.now(timezone.utc) db.commit() def _set_status(db, invoice_id, tenant_id, company_id, new_status, set_issue=False) -> Invoice: obj = get_invoice(db, invoice_id, tenant_id, company_id) obj.status = new_status if set_issue and not obj.issue_date: obj.issue_date = date.today() if new_status == "enviada": obj.sent_at = datetime.now(timezone.utc) db.commit() db.refresh(obj) return obj def emit_invoice(db, invoice_id, tenant_id, company_id) -> Invoice: return _set_status(db, invoice_id, tenant_id, company_id, "emitida", set_issue=True) def send_invoice(db, invoice_id, tenant_id, company_id) -> Invoice: return _set_status(db, invoice_id, tenant_id, company_id, "enviada", set_issue=True) def cancel_invoice(db, invoice_id, tenant_id, company_id) -> Invoice: return _set_status(db, invoice_id, tenant_id, company_id, "cancelada") def generate_from_shipment(db, shipment_id, tenant_id, company_id, user_id=None) -> Invoice: """Genera la factura de un embarque, tomando los conceptos (venta) de su cotización.""" shipment = db.query(Shipment).filter( Shipment.id == shipment_id, Shipment.tenant_id == tenant_id, Shipment.company_id == company_id, Shipment.deleted_at.is_(None) ).first() if not shipment: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Embarque no encontrado") quote = None if shipment.quote_id: quote = db.query(Quote).filter(Quote.id == shipment.quote_id).first() invoice = Invoice( reference=shipment.reference, shipment_id=shipment.id, quote_id=shipment.quote_id, account_id=shipment.account_id, currency=quote.currency if quote else "MXN", status="borrador", tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id, ) db.add(invoice) db.flush() if quote: q_items = db.query(QuoteItem).filter(QuoteItem.quote_id == quote.id, QuoteItem.deleted_at.is_(None)).all() for qi in q_items: db.add(InvoiceItem( invoice_id=invoice.id, concept=qi.concept, description=qi.description, quantity=qi.quantity, unit_amount=qi.unit_sale, tenant_id=tenant_id, company_id=company_id, )) db.flush() _recompute(db, invoice) db.commit() db.refresh(invoice) return invoice # ----- Items ----- def get_items(db, invoice_id, tenant_id, company_id) -> list[InvoiceItem]: get_invoice(db, invoice_id, tenant_id, company_id) return db.query(InvoiceItem).filter( InvoiceItem.invoice_id == invoice_id, InvoiceItem.tenant_id == tenant_id, InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None) ).order_by(InvoiceItem.id.asc()).all() def _get_item(db, item_id, tenant_id, company_id) -> InvoiceItem: obj = db.query(InvoiceItem).filter( InvoiceItem.id == item_id, InvoiceItem.tenant_id == tenant_id, InvoiceItem.company_id == company_id, InvoiceItem.deleted_at.is_(None) ).first() if not obj: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Concepto no encontrado") return obj def create_item(db, payload: InvoiceItemCreate, tenant_id, company_id) -> InvoiceItem: invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id) item = InvoiceItem(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) db.add(item) db.flush() _recompute(db, invoice) db.commit() db.refresh(item) return item def update_item(db, item_id, payload: InvoiceItemUpdate, tenant_id, company_id) -> InvoiceItem: item = _get_item(db, item_id, tenant_id, company_id) for f, v in payload.model_dump(exclude_unset=True).items(): setattr(item, f, v) db.flush() _recompute(db, get_invoice(db, item.invoice_id, tenant_id, company_id)) db.commit() db.refresh(item) return item def delete_item(db, item_id, tenant_id, company_id) -> None: item = _get_item(db, item_id, tenant_id, company_id) invoice_id = item.invoice_id item.deleted_at = datetime.now(timezone.utc) db.flush() _recompute(db, get_invoice(db, invoice_id, tenant_id, company_id)) db.commit() # ----- Payments ----- def get_payments(db, invoice_id, tenant_id, company_id) -> list[Payment]: get_invoice(db, invoice_id, tenant_id, company_id) return db.query(Payment).filter( Payment.invoice_id == invoice_id, Payment.tenant_id == tenant_id, Payment.company_id == company_id, Payment.deleted_at.is_(None) ).order_by(Payment.id.asc()).all() def create_payment(db, payload: PaymentCreate, tenant_id, company_id) -> Payment: invoice = get_invoice(db, payload.invoice_id, tenant_id, company_id) pay = Payment(**payload.model_dump(), tenant_id=tenant_id, company_id=company_id) db.add(pay) db.flush() _recompute(db, invoice) db.commit() db.refresh(pay) return pay def delete_payment(db, payment_id, tenant_id, company_id) -> None: pay = db.query(Payment).filter( Payment.id == payment_id, Payment.tenant_id == tenant_id, Payment.company_id == company_id, Payment.deleted_at.is_(None) ).first() if not pay: raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail="Pago no encontrado") invoice_id = pay.invoice_id pay.deleted_at = datetime.now(timezone.utc) db.flush() _recompute(db, get_invoice(db, invoice_id, tenant_id, company_id)) db.commit()