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>
694 lines
30 KiB
Python
694 lines
30 KiB
Python
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
|