Suite 101 · arquitectura
Un cobro y su CFDI son el mismo hecho contado dos veces. Si el sistema los deja capturar por separado, tarde o temprano no coinciden, y el que no coincide es el número que se le da al SAT. Esto es cómo quedaron amarrados: la espera de la factura vive en el movimiento, el total sale del movimiento, y la marca de «ya está» se apaga sola.
Antes, «a este pago le falta la factura» vivía en la orden de compra, en su columna con_factura. Tenía sentido mientras el único camino a un egreso era una orden: se pedía la compra, se decía si iba a llegar factura, y al pagarla la lista de pendientes salía de ahí.
Pero un cobro a un cliente no tiene orden de compra, y nunca la va a tener. Al querer la misma lista del lado de los ingresos —que es lo que pidió Mike— no había dónde escribir la espera. Con la estructura vieja, la única salida habría sido inventarle una orden ficticia a cada cobro: un renglón que no representa nada, sólo para tener dónde guardar una bandera.
La migración 0012_factura_esperada.sql mueve el dato a donde pertenece: movimientos.requiere_factura. La espera de una factura es un hecho del movimiento, no del papel que lo originó. Los dos lados quedan iguales, y la lista de pendientes es una sola consulta —requiere_factura = 1 AND facturado = 0— en vez de dos caminos distintos según de dónde venga el dinero.
Las órdenes viejas no se perdieron: el relleno de la migración copia con_factura a los movimientos que ya existían. Y hubo que tocar algo que no se ve al leer: cuando se paga una orden, el egreso que nace tiene que nacer ya con la expectativa puesta. La primera versión no lo hacía, y esos egresos nacían fuera de la lista. Una migración que mueve un dato tiene que revisar quién lo ESCRIBE, no nada más quién lo lee.
Al capturar la factura de un cobro, la pantalla pide el folio fiscal, el RFC, la fecha y el IVA. No pide el total: lo toma del movimiento.
Es la decisión más chica de esta entrega y la que más dinero cuida. Si el total se tecleara aparte, existiría el caso de una factura por una cifra y su cobro por otra —un dígito de más, un renglón copiado del mes pasado— y el IVA saldría de en medio, con cara de correcto. Nadie revisa dos números que se ven bien por separado. Al sacarlo del movimiento, el caso simplemente no existe.
Por lo mismo, capturar la factura nunca crea un movimiento aparte. Una factura no es un cobro más: es el papel de un cobro que ya está registrado. Cada vez que un sistema deja que el papel genere su propio renglón, el dinero aparece dos veces.
En las listas de movimientos, los que ya tienen factura traen un icono. Sale igual en ingresos y en egresos, porque lo que marca es que el movimiento está fiscalizado, no de qué lado del libro está.
Lo que se probó no es el icono —son dos líneas de pantalla— sino dos cosas que no se ven:
La primera, que el dato viaje en la lista. Si la API podara facturado de la respuesta, o el adaptador lo perdiera al convertir, el icono no saldría nunca, y no habría manera de notarlo más que a ojo, pantalla por pantalla. Una prueba pide la lista con las mismas funciones que usan las dos pantallas y comprueba que el campo está.
La segunda, que la marca se apague. Es la mitad que se olvida. Al cancelar la factura de un movimiento la marca se quita; si se quedara pegada, se vería un movimiento «facturado» cuya factura ya no existe, y ése es justo el que hay que perseguir. Una marca que se prende y no se apaga miente peor que no tener ninguna.
La prueba que sube el XML de la factura y lo cuelga del movimiento salió roja con sin_sesion. No era la prueba.
Subir un archivo es multipart, y el cliente común de dash101 manda JSON, así que la subida armaba su propio fetch a un lado, con credentials: 'include'. En el navegador eso alcanza —la cookie la pone el navegador—, pero fuera del navegador no hay galletero: la sesión la guarda a mano el cliente común, en una variable suya, y ese fetch de afuera no la conocía.
Dicho de otro modo: la subida era el único camino de dash101 que ninguna prueba podía recorrer, y llevaba meses sirviendo en producción porque en el navegador funcionaba de casualidad. Estaba anotado en el código con un comentario honesto —«es el único lugar que habla con la API sin el cliente común»— y el comentario no alarmó a nadie, empezando por quien lo escribió.
El arreglo no fue arreglar el camino: fue arreglar el cliente común para que ya no hiciera falta salirse de él. Cuatro líneas —reconocer una forma multipart y no pisarle el Content-Type, porque la frontera la pone fetch— y la excepción desapareció en vez de quedar documentada.
352 pruebas de la API en verde, con la migración 0012 comprobada aparte con sqlite3 en memoria: que la columna nace con su valor por omisión, que el relleno copia lo que estaba en las órdenes y nada más, y que ninguna cifra vieja se movió. En dash101, 123 pruebas contra staging, entre ellas cuatro nuevas para la marca y una que recorre entero el camino de «Ya se facturó»: crear el movimiento, crear el CFDI por su total, ligarlos y colgar el archivo.
Del lado de la API, /fiscal/pendientes acepta ?tipo= y rechaza cualquier otro valor con un error nombrado, en vez de contestar una lista vacía. Una consulta mal escrita que devuelve cero se lee igual que «no hay pendientes», y ésa es la clase de silencio que se vuelve una declaración equivocada tres meses después.