Files
CRM_AGENTES_CARGA/backend/tests/test_invoices.py
Jair Cedillo 1f8fdf2866 feat(fin): el IVA se calcula por partida, no con un % global
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>
2026-08-11 13:37:26 -05:00

694 lines
30 KiB
Python
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

from decimal import Decimal
from api.v1.modules.crm.accounts import service as accounts_service
from api.v1.modules.crm.accounts.dto import AccountCreate
from api.v1.modules.crm.quotes import service as quotes_service
from api.v1.modules.crm.quotes.dto import QuoteCreate, QuoteItemCreate
from api.v1.modules.fin.invoices import service
from api.v1.modules.fin.invoices.dto import (
InvoiceCreate,
InvoiceItemCreate,
InvoiceItemUpdate,
InvoiceUpdate,
PaymentCreate,
)
from api.v1.modules.ops.shipments import service as shipments_service
from api.v1.modules.ops.shipments.dto import ShipmentCloseInput, ShipmentCreate
T, C = 1, 1
def test_invoice_totals_con_iva_por_partida(db):
"""Sustituye a ``test_invoice_totals_with_tax``, que era incompatible con el cambio.
El test viejo fijaba los mismos 240 de IVA pero sobre partidas **sin** objeto de impuesto:
pasaba porque el % global se aplicaba al subtotal completo sin mirar si las partidas lo
causaban. Ahora el impuesto sale de las partidas, así que se les captura su ObjetoImp 02 y la
exigencia es idéntica, por la razón correcta.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-001", currency="MXN", tax_rate=Decimal("16")), T, C)
obj02 = _obj_imp(db, "02")
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000, tax_object_id=obj02), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="despacho_aduanal", quantity=1, unit_amount=500, tax_object_id=obj02), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 240.0 # 16% de cada partida: 160 + 80
assert float(inv.total) == 1740.0
assert float(inv.balance) == 1740.0
def test_partida_que_no_causa_iva_no_se_lo_cobra(db):
"""El bug que motivó el cambio.
Con la fórmula anterior el % global se aplicaba al subtotal completo, así que una partida no
objeto de impuesto —que en el CFDI va sin nodo de impuestos— igual le cobraba IVA al cliente:
esta misma factura daba 1740 en vez de 1660.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOIVA", tax_rate=Decimal("16")), T, C)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
T, C,
)
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=500,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 1500.0
assert float(inv.tax_amount) == 160.0, "se le cobró IVA a una partida que no lo causa"
assert float(inv.total) == 1660.0
def test_payment_marks_paid(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-002", tax_rate=Decimal("0")), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000), T, C)
service.emit_invoice(db, inv.id, T, C)
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("400"), method="transferencia"), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.paid_amount) == 400.0 and float(inv.balance) == 600.0
assert inv.status == "emitida"
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("600")), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.balance) == 0.0 and inv.status == "pagada" and inv.paid_at is not None
def test_generate_from_shipment_copies_quote_items(db):
acc = accounts_service.create_account(db, AccountCreate(name="Cliente"), T, C)
quote = quotes_service.create_quote(db, QuoteCreate(reference="COT-9", account_id=acc.id, currency="USD"), T, C)
quotes_service.create_quote_item(db, QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500), T, C)
quotes_service.accept_quote(db, quote.id, T, C)
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
# El embarque debe cerrarse operativamente antes de facturar (R-F-01)
shipments_service.close_shipment(
db, shipment.id, ShipmentCloseInput(actual_cost_total=Decimal("1000"), cost_currency="USD"), T, C
)
inv = service.generate_from_shipment(db, shipment.id, T, C)
assert inv.shipment_id == shipment.id
assert inv.account_id == acc.id
assert inv.currency == "USD"
assert float(inv.ops_cost_total) == 1000.0 # costos de operación arrastrados (R-F-02)
items = service.get_items(db, inv.id, T, C)
assert len(items) == 1 and float(items[0].unit_amount) == 1500.0
assert float(inv.subtotal) == 1500.0
# ----- Impuestos derivados por partida (para el CFDI) -----
# El CFDI exige el desglose por partida, pero la factura captura un % global. El traslado de
# IVA se deriva de ese %; estas pruebas fijan que la derivación no invente ni borre nada.
def _iva_de(db, item_id):
from api.v1.modules.fin.invoices.models import InvoiceItemTax
return db.query(InvoiceItemTax).filter(
InvoiceItemTax.invoice_item_id == item_id, InvoiceItemTax.deleted_at.is_(None)
).all()
def _obj_imp(db, code):
from api.v1.modules.fin.catalogs.models import TaxObject
return db.query(TaxObject).filter(TaxObject.code == code).first().id
def test_iva_se_deriva_cuando_la_partida_es_objeto_de_impuesto(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-IVA", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1,
unit_amount=1000, tax_object_id=_obj_imp(db, "02")),
T, C,
)
taxes = _iva_de(db, item.id)
assert len(taxes) == 1
assert float(taxes[0].rate) == 0.16
assert float(taxes[0].amount) == 160.0
assert taxes[0].is_withholding is False
def test_sin_objeto_de_impuesto_no_se_deriva_nada(db):
"""ObjetoImp 01 con nodo de impuestos es motivo de rechazo del SAT."""
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOOBJ", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
assert _iva_de(db, item.id) == []
def test_cambiar_el_porcentaje_recalcula_las_partidas(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-REC", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
taxes = _iva_de(db, item.id)
assert float(taxes[0].rate) == 0.08
assert float(taxes[0].amount) == 80.0
def test_cambiar_el_importe_recalcula_el_iva(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-IMP", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
service.update_item(db, item.id, InvoiceItemUpdate(unit_amount=Decimal("2000")), T, C)
assert float(_iva_de(db, item.id)[0].amount) == 320.0
def test_quitar_el_objeto_de_impuesto_retira_el_traslado(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-QUITA", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
assert len(_iva_de(db, item.id)) == 1
service.update_item(db, item.id, InvoiceItemUpdate(tax_object_id=_obj_imp(db, "01")), T, C)
assert _iva_de(db, item.id) == []
def test_la_derivacion_no_pisa_una_retencion_capturada(db):
"""Ajustar los impuestos a mano desactiva el automatismo para esa partida.
Es la diferencia entre un valor por defecto útil y un automatismo que borra trabajo ajeno.
"""
from api.v1.modules.fin.catalogs.models import Tax
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-RET", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
isr = db.query(Tax).filter(Tax.code == "001").first()
taxes_service.set_item_tax(db, item.id, isr.id, Decimal("0.10"), True, T, C)
iva = db.query(Tax).filter(Tax.code == "002").first()
# Cambiar el % ya no debe tocar ESTA partida: ni la retención capturada ni el IVA, que se
# queda con la tasa que tenía cuando se intervino a mano.
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("8")), T, C)
taxes = {t.tax_id: t for t in _iva_de(db, item.id)}
assert len(taxes) == 2, "se perdió un impuesto capturado a mano"
assert float(taxes[isr.id].amount) == 100.0, "se pisó la retención"
# Éste es el assert que distingue: sin el guard, el IVA habría bajado a 0.08 / 80.0.
assert float(taxes[iva.id].rate) == 0.16, "el automatismo recalculó una partida intervenida"
assert float(taxes[iva.id].amount) == 160.0
# ── Herencia de los datos de facturación del cliente ─────────────────────────
#
# Forma y método de pago se capturaban a mano en cada factura aunque ya vivieran en la ficha del
# cliente, y son justo las claves que detienen el timbrado en validación si faltan.
def _cliente_con_datos_fiscales(db, **overrides):
datos = {
"name": "Importadora Delta",
"payment_form": "03", # Transferencia electrónica de fondos
"payment_method": "PPD", # Pago en parcialidades o diferido
"currency": "MXN",
}
datos.update(overrides)
return accounts_service.create_account(db, AccountCreate(**datos), T, C)
def _clave(db, model, code: str) -> int:
from api.v1.modules.fin.catalogs import service as catalogs_service
fila = catalogs_service.find_by_code(db, model, code)
assert fila is not None, f"el catálogo de pruebas no tiene la clave {code!r}"
return fila.id
def test_la_factura_hereda_forma_metodo_y_moneda_del_cliente(db):
from api.v1.modules.fin.catalogs.models import PaymentForm, PaymentMethod
acc = _cliente_con_datos_fiscales(db, currency="USD")
inv = service.create_invoice(db, InvoiceCreate(reference="F-H1", account_id=acc.id), T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
assert inv.payment_method_id == _clave(db, PaymentMethod, "PPD")
assert inv.currency == "USD"
def test_lo_explicito_manda_sobre_la_ficha_del_cliente(db):
from api.v1.modules.fin.catalogs.models import PaymentForm
acc = _cliente_con_datos_fiscales(db)
efectivo = _clave(db, PaymentForm, "01")
inv = service.create_invoice(
db,
InvoiceCreate(reference="F-H2", account_id=acc.id, payment_form_id=efectivo, currency="EUR"),
T, C,
)
assert inv.payment_form_id == efectivo, "la ficha del cliente pisó un dato capturado"
assert inv.currency == "EUR"
def test_una_forma_de_pago_no_resoluble_no_impide_facturar(db):
"""``crm.accounts.payment_form`` es texto libre sin FK: puede traer basura histórica.
Una ficha que diga "Transferencia" en vez de "03" no puede impedir crear una factura. El
faltante lo reporta después la validación del timbrado, junto al resto de los pendientes.
"""
acc = _cliente_con_datos_fiscales(db, payment_form="Transferencia", payment_method="Contado")
inv = service.create_invoice(db, InvoiceCreate(reference="F-H3", account_id=acc.id), T, C)
assert inv.payment_form_id is None
assert inv.payment_method_id is None
def test_clave_de_forma_de_pago_de_un_digito_se_normaliza(db):
"""c_FormaPago son dos dígitos: '3' y '03' son la misma forma de pago."""
from api.v1.modules.fin.catalogs.models import PaymentForm
acc = _cliente_con_datos_fiscales(db, payment_form="3", payment_method="pue")
inv = service.create_invoice(db, InvoiceCreate(reference="F-H4", account_id=acc.id), T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
assert inv.payment_method_id is not None, "el método de pago en minúsculas debió resolverse"
def test_sin_cliente_no_se_hereda_nada(db):
inv = service.create_invoice(db, InvoiceCreate(reference="F-H5"), T, C)
assert inv.payment_form_id is None
assert inv.payment_method_id is None
assert inv.currency == "MXN", "sin ficha ni captura debe mandar el default de la columna"
def test_cambiar_de_cliente_rehereda_los_datos_de_facturacion(db):
from api.v1.modules.fin.catalogs.models import PaymentForm
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
otro = _cliente_con_datos_fiscales(db, name="Cliente Dos", payment_form="01", currency="USD")
inv = service.create_invoice(db, InvoiceCreate(reference="F-H6", account_id=uno.id), T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
inv = service.update_invoice(db, inv.id, InvoiceUpdate(account_id=otro.id), T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "01"), "no se re-heredó al cambiar cliente"
assert inv.currency == "USD"
def test_al_cambiar_de_cliente_lo_explicito_del_patch_gana(db):
from api.v1.modules.fin.catalogs.models import PaymentForm
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
otro = _cliente_con_datos_fiscales(db, name="Cliente Dos", payment_form="01")
cheque = _clave(db, PaymentForm, "02")
inv = service.create_invoice(db, InvoiceCreate(reference="F-H7", account_id=uno.id), T, C)
inv = service.update_invoice(
db, inv.id, InvoiceUpdate(account_id=otro.id, payment_form_id=cheque), T, C
)
assert inv.payment_form_id == cheque
def test_cambiar_a_un_cliente_sin_datos_no_vacia_lo_capturado(db):
"""Completar, nunca borrar: una ficha vacía no puede tirar un dato que ya se capturó."""
from api.v1.modules.fin.catalogs.models import PaymentForm
uno = _cliente_con_datos_fiscales(db, name="Cliente Uno", payment_form="03")
pelon = accounts_service.create_account(db, AccountCreate(name="Cliente Sin Datos"), T, C)
inv = service.create_invoice(db, InvoiceCreate(reference="F-H8", account_id=uno.id), T, C)
inv = service.update_invoice(db, inv.id, InvoiceUpdate(account_id=pelon.id), T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
assert inv.currency == "MXN"
def test_generate_from_shipment_hereda_datos_de_facturacion(db):
"""La moneda del EMBARQUE gana sobre la de la ficha: es la que se coteó y se operó."""
from api.v1.modules.fin.catalogs.models import PaymentForm, PaymentMethod
acc = _cliente_con_datos_fiscales(db, currency="EUR")
quote = quotes_service.create_quote(
db, QuoteCreate(reference="COT-H", account_id=acc.id, currency="USD"), T, C
)
quotes_service.create_quote_item(
db,
QuoteItemCreate(quote_id=quote.id, concept="flete_internacional", quantity=1, unit_cost=1000, unit_sale=1500),
T, C,
)
quotes_service.accept_quote(db, quote.id, T, C)
shipment = shipments_service.create_shipment_from_quote(db, quote.id, T, C)
shipments_service.close_shipment(
db, shipment.id, ShipmentCloseInput(actual_cost_total=Decimal("1000"), cost_currency="USD"), T, C
)
inv = service.generate_from_shipment(db, shipment.id, T, C)
assert inv.payment_form_id == _clave(db, PaymentForm, "03")
assert inv.payment_method_id == _clave(db, PaymentMethod, "PPD")
assert inv.currency == "USD", "la moneda del embarque debe ganar sobre la de la ficha"
# ── Una factura timbrada no admite cambios ───────────────────────────────────
#
# El CFDI ya existe ante el SAT: editar la factura después haría que ella y su comprobante
# contaran cosas distintas. Se corrige cancelando y refacturando.
def _timbra(db, invoice) -> None:
from api.v1.modules.fin.stamping.models import STATUS_STAMPED, InvoiceStamp
db.add(
InvoiceStamp(
invoice_id=invoice.id, mode="pruebas", status=STATUS_STAMPED,
uuid="aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee",
tenant_id=T, company_id=C,
)
)
db.commit()
def _factura_timbrada(db, reference="F-T1"):
acc = _cliente_con_datos_fiscales(db)
inv = service.create_invoice(db, InvoiceCreate(reference=reference, account_id=acc.id), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept="flete_internacional", quantity=1, unit_amount=1000), T, C
)
_timbra(db, inv)
return inv, item
def test_no_se_puede_cambiar_el_cliente_de_una_factura_timbrada(db):
import pytest
from fastapi import HTTPException
inv, _ = _factura_timbrada(db)
otro = _cliente_con_datos_fiscales(db, name="Otro")
with pytest.raises(HTTPException) as exc:
service.update_invoice(db, inv.id, InvoiceUpdate(account_id=otro.id), T, C)
assert exc.value.status_code == 409
def test_una_factura_timbrada_si_admite_notas_y_pagos(db):
"""El candado es una lista cerrada: cobrar y anotar no alteran el comprobante."""
inv, _ = _factura_timbrada(db, reference="F-T2")
inv = service.update_invoice(db, inv.id, InvoiceUpdate(notes="Pagada por transferencia"), T, C)
assert inv.notes == "Pagada por transferencia"
service.create_payment(db, PaymentCreate(invoice_id=inv.id, amount=Decimal("100")), T, C)
assert float(service.get_invoice(db, inv.id, T, C).paid_amount) == 100.0
def test_no_se_pueden_tocar_las_partidas_de_una_factura_timbrada(db):
import pytest
from fastapi import HTTPException
inv, item = _factura_timbrada(db, reference="F-T3")
with pytest.raises(HTTPException) as exc:
service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept="otros", quantity=1, unit_amount=50), T, C
)
assert exc.value.status_code == 409
with pytest.raises(HTTPException) as exc:
service.update_item(db, item.id, InvoiceItemUpdate(unit_amount=Decimal("2000")), T, C)
assert exc.value.status_code == 409
with pytest.raises(HTTPException) as exc:
service.delete_item(db, item.id, T, C)
assert exc.value.status_code == 409
def test_no_se_puede_editar_el_desglose_de_impuestos_de_una_factura_timbrada(db):
import pytest
from fastapi import HTTPException
from api.v1.modules.fin.invoices import taxes_service
from api.v1.modules.fin.catalogs.models import Tax
inv, item = _factura_timbrada(db, reference="F-T4")
iva = db.query(Tax).filter(Tax.code == "002").first()
with pytest.raises(HTTPException) as exc:
taxes_service.set_item_tax(db, item.id, iva.id, Decimal("0.16"), False, T, C)
assert exc.value.status_code == 409
# ── IVA por partida: redondeo, exentos, tasa 0% y el invariante ──────────────
def _iva(db):
from api.v1.modules.fin.catalogs.models import Tax
return db.query(Tax).filter(Tax.code == "002").first()
def _isr(db):
from api.v1.modules.fin.catalogs.models import Tax
return db.query(Tax).filter(Tax.code == "001").first()
def _concepto(db, code="FLETE-EX", ps_code="78101600", **fiscal):
from api.v1.modules.fin.catalogs.models import ProductService, UnitOfMeasure
from api.v1.modules.fin.concepts import service as concepts_service
from api.v1.modules.fin.concepts.dto import ConceptCreate
return concepts_service.create_concept(
db,
ConceptCreate(
code=code,
description="Concepto de prueba",
product_service_id=db.query(ProductService).filter(ProductService.code == ps_code).one().id,
unit_of_measure_id=db.query(UnitOfMeasure).filter(UnitOfMeasure.code == "E48").one().id,
tax_object_id=_obj_imp(db, "02"),
**fiscal,
),
T, C,
)
def test_el_iva_se_redondea_por_partida_no_sobre_el_subtotal(db):
"""El comprobante suma los Importe de cada traslado, así que la factura tiene que hacer igual.
3 × 10.10 al 16%: por partida son 1.62 × 3 = 4.86; sobre el subtotal agregado darían
cents(30.30 × 0.16) = 4.85. El PAC valida que TotalImpuestosTrasladados sea la suma de los
importes, así que el centavo lo decide el renglón.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-RED", tax_rate=Decimal("16")), T, C)
for i in range(3):
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept=f"p{i}", quantity=1,
unit_amount=Decimal("10.10"), tax_object_id=_obj_imp(db, "02")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 30.30
assert float(inv.tax_amount) == 4.86
def test_el_subtotal_suma_importes_ya_redondeados(db):
"""``Σ round(qty × precio)``, no ``round(Σ)``: es la definición del SubTotal del comprobante."""
inv = service.create_invoice(db, InvoiceCreate(reference="F-SUB", tax_rate=Decimal("0")), T, C)
for i in range(3):
service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept=f"p{i}", quantity=Decimal("0.50"),
unit_amount=Decimal("0.05")),
T, C,
)
inv = service.get_invoice(db, inv.id, T, C)
# cada renglón: cents(0.50 × 0.05) = cents(0.025) = 0.03 → 0.09, no 0.08
assert float(inv.subtotal) == 0.09
def test_borrar_una_partida_retira_su_iva(db):
"""Con el impuesto saliendo de las filas, dejarlas vivas sería un cobro fantasma."""
inv = service.create_invoice(db, InvoiceCreate(reference="F-DEL", tax_rate=Decimal("16")), T, C)
obj02 = _obj_imp(db, "02")
a = service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000, tax_object_id=obj02), T, C)
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="b", quantity=1, unit_amount=500, tax_object_id=obj02), T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 240.0
service.delete_item(db, a.id, T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.subtotal) == 500.0
assert float(inv.tax_amount) == 80.0, "quedó vivo el IVA de la partida borrada"
assert float(inv.total) == 580.0
assert _iva_de(db, a.id) == []
def test_la_retencion_resta_del_total(db):
"""El total de la factura tiene que ser el del comprobante: subtotal + trasladado - retenido.
Antes ``total = subtotal + tax`` ignoraba las retenciones: la factura pedía 1160 y el CFDI
declaraba 1060, así que cobranza perseguía un adeudo inexistente para siempre.
"""
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-RET", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="honorarios", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
taxes_service.set_item_tax(db, item.id, _isr(db).id, Decimal("0.10"), True, T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 160.0
assert float(inv.withheld_amount) == 100.0
assert float(inv.total) == 1060.0
assert float(inv.balance) == 1060.0
def test_capturar_un_impuesto_recalcula_la_factura_al_instante(db):
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-CAP", tax_rate=Decimal("0")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
fila = taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.08"), False, T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 80.0
taxes_service.delete_item_tax(db, fila.id, T, C)
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 0.0
def test_un_impuesto_capturado_no_se_pisa_al_editar_la_partida(db):
"""``is_manual`` como hecho registrado, no inferido de la forma de la fila.
El guard anterior sólo reconocía retenciones o impuestos distintos del IVA, así que un IVA al
8% capturado a mano volvía al 16% nada más por cambiarle la cantidad a la partida.
"""
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-MAN", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "02")),
T, C,
)
taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.08"), False, T, C)
service.update_item(db, item.id, InvoiceItemUpdate(quantity=Decimal("2")), T, C)
filas = _iva_de(db, item.id)
assert len(filas) == 1
assert float(filas[0].rate) == 0.08, "la derivación pisó una tasa capturada a mano"
def test_un_concepto_exento_no_genera_impuesto(db):
"""Exento se declara con ObjetoImp 02 + TipoFactor Exento: la fila existe con importe 0."""
concepto = _concepto(
db, default_tax_id=_iva(db).id, default_tax_factor="Exento",
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-EXE", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
filas = _iva_de(db, item.id)
assert len(filas) == 1, "un exento sí lleva fila: el CFDI lo declara"
assert filas[0].factor == "Exento"
assert filas[0].rate is None
assert float(filas[0].amount) == 0.0
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 0.0
assert float(inv.total) == 1000.0
def test_un_concepto_a_tasa_cero_declara_la_tasa(db):
"""Tasa 0% NO es lo mismo que exento: se declara con TasaOCuota 0.000000.
Antes era incapturable: el ``rate == 0`` borraba el traslado y el timbrado fallaba con
"es objeto de impuesto (02) pero no tiene impuestos capturados".
"""
concepto = _concepto(
db, code="FLETE-0", default_tax_id=_iva(db).id, default_tax_factor="Tasa",
default_tax_rate=Decimal("0"),
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-CERO", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
filas = _iva_de(db, item.id)
assert len(filas) == 1
assert filas[0].factor == "Tasa"
assert float(filas[0].rate) == 0.0
assert float(filas[0].amount) == 0.0
assert float(service.get_invoice(db, inv.id, T, C).total) == 1000.0
def test_el_concepto_manda_sobre_el_porcentaje_de_la_factura(db):
concepto = _concepto(
db, code="FLETE-8", default_tax_id=_iva(db).id, default_tax_factor="Tasa",
default_tax_rate=Decimal("0.08"),
)
inv = service.create_invoice(db, InvoiceCreate(reference="F-CONC", tax_rate=Decimal("16")), T, C)
item = service.create_item(
db, InvoiceItemCreate(invoice_id=inv.id, concept_id=concepto.id, quantity=1, unit_amount=1000), T, C
)
assert float(_iva_de(db, item.id)[0].rate) == 0.08
# Mover el % de la factura no toca una partida cuyo concepto define su impuesto.
service.update_invoice(db, inv.id, InvoiceUpdate(tax_rate=Decimal("16")), T, C)
assert float(_iva_de(db, item.id)[0].rate) == 0.08
assert float(service.get_invoice(db, inv.id, T, C).tax_amount) == 80.0
def test_no_se_capturan_impuestos_en_una_partida_que_no_es_objeto(db):
import pytest
from fastapi import HTTPException
from api.v1.modules.fin.invoices import taxes_service
inv = service.create_invoice(db, InvoiceCreate(reference="F-NOOBJ2", tax_rate=Decimal("0")), T, C)
item = service.create_item(
db,
InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000,
tax_object_id=_obj_imp(db, "01")),
T, C,
)
with pytest.raises(HTTPException) as exc:
taxes_service.set_item_tax(db, item.id, _iva(db).id, Decimal("0.16"), False, T, C)
assert exc.value.status_code == 422
def test_una_factura_previa_conserva_su_formula(db):
"""``taxes_per_item=false``: las facturas de antes del cambio no se recalculan solas.
Es lo que evita que registrarle un pago meses después le baje el total, la deje con saldo
negativo, la marque 'pagada' y le pise el paid_at.
"""
inv = service.create_invoice(db, InvoiceCreate(reference="F-VIEJA", tax_rate=Decimal("16")), T, C)
inv.taxes_per_item = False
db.commit()
# Partidas SIN objeto de impuesto, como las que existían antes del cambio.
service.create_item(db, InvoiceItemCreate(invoice_id=inv.id, concept="a", quantity=1, unit_amount=1000), T, C)
inv = service.get_invoice(db, inv.id, T, C)
assert float(inv.tax_amount) == 160.0, "una factura vieja debe conservar el % global"
assert float(inv.total) == 1160.0