El impuesto vivía en dos planos que podían divergir: el dinero salía de invoices.tax_rate aplicado al subtotal completo, y el CFDI sumaba los impuestos de cada partida. Una partida que no causa IVA se lo cobraba igual, y con una retención capturada la factura pedía 1160 mientras el comprobante declaraba 1060 — cobranza persiguiendo un adeudo inexistente. Ahora fin.invoice_item_taxes es la fuente del impuesto y _recompute la lee: total = subtotal + trasladado - retenido, la misma composición del comprobante. Se agrega withheld_amount, porque sin guardarlo el total no cuadraba con subtotal + tax_amount y nada en la fila lo explicaba. NINGUNA factura existente cambia de total. El cálculo se versiona con taxes_per_item: las nuevas nacen en true, las 9 que ya existían quedaron en false con la fórmula que las emitió. Backfillear habría exigido poner tax_object_id='02' en partidas que nadie clasificó — inventar una afirmación fiscal — y _recompute corre desde create_payment, así que un pago meses después le habría bajado el total, dejado saldo negativo, marcado 'pagada' y pisado su paid_at. El rollback es un UPDATE. Conceptos que no causan IVA: fin.concepts gana impuesto, tasa y tipo de factor por defecto, que la partida hereda como ya heredaba las claves fiscales. Exento (ObjetoImp 02 + TipoFactor Exento) y tasa 0% son distintos y ahora los dos son expresables; el 0% era incapturable, el rate==0 borraba el traslado y el timbrado fallaba pidiendo el desglose. Redondeo: manda el comprobante. subtotal = Σ round(qty × precio) por renglón, no round(Σ), y tax_amount es la suma de los importes ya materializados, todo ROUND_HALF_UP con el mismo `cents` que usa el builder. El PAC valida que SubTotal sea la suma de los Importe. Trampas que el cambio cerró: - _build_data construía TaxLine sin factor: un exento se habría timbrado como gravado al 0%, un CFDI incorrecto que el PAC acepta. - CfdiData.transferred no excluía Exento mientras _add_totals sí: una fila exenta con importe dejaba el XML inconsistente consigo mismo. - El guard de captura manual era heurístico (retención o impuesto != IVA), así que un IVA al 8% capturado volvía al 16% por cambiarle la cantidad a la partida. Ahora is_manual es un hecho registrado. - delete_item dejaba los impuestos vivos: cobro fantasma de una partida que ya no existe. - set_item_tax y delete_item_tax no recalculaban la factura. - El PDF imprimía "IVA (16%)" y no mostraba retenciones. Ahora desglosa por (impuesto, factor, tasa) con el mismo criterio del comprobante, y los exentos se listan con su base y sin importe: es lo que explica por qué el total no es subtotal × 1.16. stamp_invoice verifica que invoice.total sea el del comprobante antes de sellar, y falla en vez de corregir: el timbrado es donde el dinero se vuelve irreversible y recalcular ahí cambiaría montos sin que nadie lo vea. Cuota queda fuera con 422 explícito: su importe es cuota × cantidad, no base × tasa. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
160 lines
5.8 KiB
Python
160 lines
5.8 KiB
Python
"""Lógica del catálogo de conceptos de facturación.
|
|
|
|
Todas las consultas filtran por ``tenant_id``, ``company_id`` y ``deleted_at IS NULL``:
|
|
el catálogo es privado de cada empresa dentro de cada tenant.
|
|
"""
|
|
|
|
from datetime import datetime, timezone
|
|
|
|
from fastapi import HTTPException, status
|
|
from sqlalchemy import or_
|
|
from sqlalchemy.orm import Session
|
|
|
|
from ..catalogs.models import ProductService, Tax, TaxObject, UnitOfMeasure
|
|
from .dto import ConceptCreate, ConceptUpdate
|
|
from .models import Concept
|
|
|
|
|
|
def _check_sat_refs(db: Session, data: dict) -> None:
|
|
"""Verifica que las claves del SAT referidas existan antes de guardar."""
|
|
for field, model, msg in [
|
|
("product_service_id", ProductService, "La clave de producto/servicio del SAT no existe"),
|
|
("unit_of_measure_id", UnitOfMeasure, "La unidad de medida del SAT no existe"),
|
|
("tax_object_id", TaxObject, "El objeto de impuesto del SAT no existe"),
|
|
("default_tax_id", Tax, "El impuesto por defecto no existe en el catálogo del SAT"),
|
|
]:
|
|
value = data.get(field)
|
|
if field in data and value is not None:
|
|
if db.query(model.id).filter(model.id == value).first() is None:
|
|
raise HTTPException(status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, detail=msg)
|
|
|
|
# El impuesto por defecto del concepto es un TRASLADO —lo que se le cobra al cliente—, así
|
|
# que tiene que ser trasladable. Un ISR aquí es un error de captura del catálogo, y atajarlo
|
|
# en el concepto evita que se propague a cada partida que lo use.
|
|
default_tax_id = data.get("default_tax_id")
|
|
if default_tax_id is not None:
|
|
tax = db.query(Tax).filter(Tax.id == default_tax_id).first()
|
|
if tax is not None and not tax.is_transferred:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
|
|
detail=f"El impuesto {tax.code} ({tax.description}) no puede trasladarse",
|
|
)
|
|
|
|
|
|
def _check_unique(
|
|
db: Session,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
code: str | None,
|
|
product_service_id: int | None,
|
|
exclude_id: int | None = None,
|
|
) -> None:
|
|
"""Aplica en el service las mismas reglas que los índices únicos parciales.
|
|
|
|
Sin esto el conflicto llegaría al cliente como un IntegrityError crudo; aquí se
|
|
traduce a un 409 con mensaje en español.
|
|
"""
|
|
base = db.query(Concept).filter(
|
|
Concept.tenant_id == tenant_id,
|
|
Concept.company_id == company_id,
|
|
Concept.deleted_at.is_(None),
|
|
)
|
|
if exclude_id is not None:
|
|
base = base.filter(Concept.id != exclude_id)
|
|
|
|
if code is not None and base.filter(Concept.code == code).first() is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail=f"Ya existe un concepto con la clave '{code}' en esta empresa",
|
|
)
|
|
# Regla 1:1 — una clave ProdServ no puede repetirse entre conceptos de la empresa.
|
|
if product_service_id is not None and base.filter(
|
|
Concept.product_service_id == product_service_id
|
|
).first() is not None:
|
|
raise HTTPException(
|
|
status_code=status.HTTP_409_CONFLICT,
|
|
detail="La clave de producto/servicio del SAT ya está asignada a otro concepto de esta empresa",
|
|
)
|
|
|
|
|
|
def get_concepts(
|
|
db: Session,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
search: str | None = None,
|
|
active_only: bool | None = None,
|
|
product_service_id: int | None = None,
|
|
) -> list[Concept]:
|
|
q = db.query(Concept).filter(
|
|
Concept.tenant_id == tenant_id,
|
|
Concept.company_id == company_id,
|
|
Concept.deleted_at.is_(None),
|
|
)
|
|
if active_only is not None:
|
|
q = q.filter(Concept.is_active.is_(active_only))
|
|
if product_service_id is not None:
|
|
q = q.filter(Concept.product_service_id == product_service_id)
|
|
if search:
|
|
term = f"%{search.strip()}%"
|
|
q = q.filter(or_(Concept.code.ilike(term), Concept.description.ilike(term)))
|
|
return q.order_by(Concept.code.asc()).all()
|
|
|
|
|
|
def get_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> Concept:
|
|
obj = db.query(Concept).filter(
|
|
Concept.id == concept_id,
|
|
Concept.tenant_id == tenant_id,
|
|
Concept.company_id == company_id,
|
|
Concept.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_concept(
|
|
db: Session, payload: ConceptCreate, tenant_id: int, company_id: int, user_id: str | None = None
|
|
) -> Concept:
|
|
data = payload.model_dump()
|
|
_check_sat_refs(db, data)
|
|
_check_unique(db, tenant_id, company_id, data["code"], data["product_service_id"])
|
|
obj = Concept(**data, tenant_id=tenant_id, company_id=company_id, created_by=user_id, updated_by=user_id)
|
|
db.add(obj)
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
def update_concept(
|
|
db: Session,
|
|
concept_id: int,
|
|
payload: ConceptUpdate,
|
|
tenant_id: int,
|
|
company_id: int,
|
|
user_id: str | None = None,
|
|
) -> Concept:
|
|
obj = get_concept(db, concept_id, tenant_id, company_id)
|
|
data = payload.model_dump(exclude_unset=True)
|
|
_check_sat_refs(db, data)
|
|
_check_unique(
|
|
db,
|
|
tenant_id,
|
|
company_id,
|
|
data.get("code"),
|
|
data.get("product_service_id"),
|
|
exclude_id=obj.id,
|
|
)
|
|
for field, value in data.items():
|
|
setattr(obj, field, value)
|
|
obj.updated_by = user_id
|
|
db.commit()
|
|
db.refresh(obj)
|
|
return obj
|
|
|
|
|
|
def delete_concept(db: Session, concept_id: int, tenant_id: int, company_id: int) -> None:
|
|
"""Baja lógica: libera la clave ProdServ y el código para un concepto nuevo."""
|
|
obj = get_concept(db, concept_id, tenant_id, company_id)
|
|
obj.deleted_at = datetime.now(timezone.utc)
|
|
db.commit()
|