feature/AS-herencia-datos-facturacion #12

Merged
jcedilloAS merged 5 commits from feature/AS-herencia-datos-facturacion into main 2026-08-11 20:52:55 +00:00
Member
No description provided.
jcedilloAS added 5 commits 2026-08-11 20:52:04 +00:00
Forma de pago, método de pago y moneda 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. Ni el alta manual ni generate_from_shipment las prellenaban.

- _inherit_account_billing espeja _resolve_item_concept, el patrón de herencia que ya
  usa el módulo: lo explícito manda sobre la ficha, y se completa sin borrar. Si la
  ficha trae texto que no resuelve a una clave del SAT no se asigna nada, así que
  cambiar de cliente nunca vacía un dato ya capturado.
- find_by_code traduce el texto del Account a id de catálogo. Normaliza ('3' -> '03',
  'pue' -> 'PUE') y devuelve None sin lanzar: una ficha mal capturada no puede impedir
  facturar, el faltante lo reporta el timbrado junto al resto.
- Se invoca al crear, al cambiar de cliente (re-herencia) y en generate_from_shipment,
  donde la moneda del embarque gana sobre la de la ficha: es la que se coteó y operó.

Incluye el candado de inmutabilidad con timbre, que la herencia hacía necesario: había
un solo campo protegido (stamping_mode) y todo lo demás de una factura ya timbrada era
editable — cliente, folio, moneda, partidas e impuestos — con lo que la factura y su
CFDI podían contar cosas distintas. _reject_if_stamped generaliza esa guarda sobre una
lista cerrada de campos del comprobante, y sin lista en partidas e impuestos. Cobrar y
anotar siguen permitidos: no alteran el CFDI. send_invoice deja de regenerar el PDF de
una factura timbrada, que reescribía en MinIO el documento que el cliente ya recibió.

Dos cosas que la herencia obligaba a arreglar:

1. currency y tax_rate tenían default no nulo en el DTO y el frontend sembraba
   {currency:'MXN', tax_rate:16}, así que el backend nunca podía distinguir "no lo
   eligió" de "eligió eso" y la herencia habría sido código muerto. Ahora son
   opcionales; un None se retira del payload para que mande el default de la columna.
2. saveHeader mandaba el objeto completo, con lo que al cambiar de cliente el PATCH
   llevaba las claves del cliente anterior. Ahora manda solo el delta.

Se agrega fin.invoices.exchange_rate: heredar una moneda distinta de MXN producía
facturas no timbrables en silencio, porque _build_data pasaba exchange_rate=None
siempre y el validador lo exige. La validación sigue siendo del builder, que acumula
todos los faltantes juntos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
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>
Al elegir un concepto del catálogo, la partida ya heredaba sus claves del SAT, pero eso
pasaba en silencio dentro del backend: en pantalla los tres selects se quedaban en
"Selecciona…" aunque el concepto los tuviera configurados, y no había forma de ver qué
impuesto iba a aplicar antes de guardar.

Backend:
- unit_amount se hereda del unit_price del concepto. Estaba prellenado SOLO por el
  formulario web, así que cualquier otro cliente de la API tenía que teclearlo. Requiere
  que unit_amount sea opcional en InvoiceItemCreate: con el default 0 de InvoiceItemBase
  siempre llegaba valor y el service no podía distinguir "no lo capturó" de "capturó 0".
  Un 0 explícito se respeta — una partida de cortesía es una decisión, no un campo vacío.

Frontend:
- Al elegir el concepto se copian precio y las tres claves del SAT a los campos, para que
  se vean antes de guardar. Elegir una partida genérica las limpia, en vez de arrastrar las
  del concepto anterior a algo que no las tiene.
- Se muestra el impuesto que se aplicará, resuelto con la misma precedencia del backend:
  el del concepto si lo define, el % de la factura si no, y "no causa impuesto" cuando el
  objeto de impuesto no es 02.

Lo que se cambie en el formulario sigue mandando sobre el catálogo.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
La partida guarda el mismo texto en dos columnas: `concept`, de 60 caracteres, que es la
que lee el PDF, y `description`, de 255, que es la que el CFDI prefiere
(`it.description or it.concept`). Al heredar de un concepto del catálogo solo se llenaba la
primera, así que el comprobante declaraba el texto cortado a 60 aunque el catálogo lo
tuviera entero: "Flete marítimo internacional puerta a puerta con seguro de c".

Ahora se heredan las dos, recortada y completa. Lo capturado a mano sigue mandando.

El PDF imprimía `concept — description`, que con las dos heredadas habría repetido el
texto —una vez cortado y otra entero—. `etiqueta_partida` lo resuelve por prefijo: cuando
description empieza con concept imprime solo la larga, y cuando son textos distintos
—una clave genérica más el detalle que alguien escribió— sigue imprimiendo los dos.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
CI ejecuta `pytest tests/` sin exclusiones y con `set -e`. En main, cuatro módulos no
coleccionaban, así que pytest se interrumpía y **no corría ni una prueba del backend** —
verificado contra main: "Interrupted: 4 errors during collection". Los tres primeros ya se
arreglaron en esta rama; aquí va el cuarto y las dos fallas que quedaban.

test_efc_entrega_documento.py: recupera sus 13 pruebas. Importaba crm.expedientes y creaba el
Document con expediente_id / efc_sync_state / efc_document_ref, columnas que no existen —eran
del expediente paralelo que 5c4df59 descartó—. El valor de estas pruebas está en
deliver_file_row (ensure-then-upload, corte directo, idempotencia por crm_document_ref), que
trabaja contra la fila del outbox y no necesita esas columnas. Las tres aserciones sobre el
estado del documento se reenfocan a lo que sí es observable: el acuse y el diagnóstico viven en
la fila del outbox, y del documento se comprueba lo único que _marcar_documento_entregado sí
persiste — que suelta su file_key al confirmar, y que NO lo suelta cuando la entrega falla.

test_contrato_efc.py: sus dos pruebas quedan skipped con el motivo completo. Afirman un API
/expedientes/* de nueve endpoints que este repo no implementa, y un DocumentResponse sin
file_key/file_url con columnas efc_*. No son arreglos de una línea: el contrato es la mitad de
un acuerdo que EFC afirma contra una copia idéntica, el frontend usa file_key para descargar, y
las columnas no existen. Se marca PENDIENTE DECISIÓN en vez de dejar CI rojo tapando el resto.

Resultado: 354 pruebas corriendo, 2 skipped, cero fallas.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
jcedilloAS merged commit 5708e12d7b into main 2026-08-11 20:52:55 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: ADUANASOFT/CRM_AGENTES_CARGA#12
No description provided.