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>
95 lines
3.4 KiB
TypeScript
95 lines
3.4 KiB
TypeScript
/**
|
|
* Cliente API — Catálogo de conceptos de facturación.
|
|
*
|
|
* Cada concepto está ligado 1:1 a una clave de producto/servicio del SAT dentro de la
|
|
* empresa; el backend responde 409 si la clave ya está tomada.
|
|
*/
|
|
import { api } from '$lib/api';
|
|
import type { SatCatalogItem, SatUnitOfMeasure } from './catalogs';
|
|
|
|
export interface Concept {
|
|
id: number;
|
|
code: string;
|
|
description: string;
|
|
product_service_id: number;
|
|
unit_of_measure_id: number | null;
|
|
tax_object_id: number | null;
|
|
// Configuración fiscal por defecto: es lo que permite tener conceptos que no causan IVA.
|
|
// La tasa va como FRACCIÓN (0.16 es el 16%), no como el porcentaje de la factura.
|
|
// 'Exento' y una tasa 0 son distintos: el exento no se declara con TasaOCuota.
|
|
default_tax_id: number | null;
|
|
default_tax_rate: number | null;
|
|
default_tax_factor: 'Tasa' | 'Exento' | null;
|
|
unit_price: number | null;
|
|
currency: string;
|
|
is_active: boolean;
|
|
notes: string | null;
|
|
product_service: SatCatalogItem | null;
|
|
unit_of_measure: SatUnitOfMeasure | null;
|
|
tax_object: SatCatalogItem | null;
|
|
default_tax: SatCatalogItem | null;
|
|
tenant_id: number;
|
|
company_id: number;
|
|
created_by: string | null;
|
|
updated_by: string | null;
|
|
created_at: string;
|
|
updated_at: string;
|
|
}
|
|
|
|
export interface ConceptInput {
|
|
code: string;
|
|
description: string;
|
|
product_service_id: number;
|
|
unit_of_measure_id?: number | null;
|
|
tax_object_id?: number | null;
|
|
// Configuración fiscal por defecto: es lo que permite tener conceptos que no causan IVA.
|
|
// La tasa va como FRACCIÓN (0.16 es el 16%), no como el porcentaje de la factura.
|
|
// 'Exento' y una tasa 0 son distintos: el exento no se declara con TasaOCuota.
|
|
default_tax_id?: number | null;
|
|
default_tax_rate?: number | null;
|
|
default_tax_factor?: 'Tasa' | 'Exento' | null;
|
|
unit_price?: number | null;
|
|
currency?: string;
|
|
is_active?: boolean;
|
|
notes?: string | null;
|
|
}
|
|
|
|
export const conceptsAPI = {
|
|
async list(
|
|
companyId: number,
|
|
params?: { search?: string; active_only?: boolean; product_service_id?: number }
|
|
): Promise<Concept[]> {
|
|
const qs = new URLSearchParams({ company_id: String(companyId) });
|
|
if (params?.search) qs.set('search', params.search);
|
|
if (params?.active_only !== undefined) qs.set('active_only', String(params.active_only));
|
|
if (params?.product_service_id !== undefined)
|
|
qs.set('product_service_id', String(params.product_service_id));
|
|
const res = await api.get<Concept[]>(`/v1/fin/concepts?${qs}`);
|
|
if (res.error) throw new Error(res.error);
|
|
return res.data!;
|
|
},
|
|
|
|
async get(id: number, companyId: number): Promise<Concept> {
|
|
const res = await api.get<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
|
if (res.error) throw new Error(res.error);
|
|
return res.data!;
|
|
},
|
|
|
|
async create(data: ConceptInput, companyId: number): Promise<Concept> {
|
|
const res = await api.post<Concept>(`/v1/fin/concepts?company_id=${companyId}`, data);
|
|
if (res.error) throw new Error(res.error);
|
|
return res.data!;
|
|
},
|
|
|
|
async update(id: number, data: Partial<ConceptInput>, companyId: number): Promise<Concept> {
|
|
const res = await api.patch<Concept>(`/v1/fin/concepts/${id}?company_id=${companyId}`, data);
|
|
if (res.error) throw new Error(res.error);
|
|
return res.data!;
|
|
},
|
|
|
|
async remove(id: number, companyId: number): Promise<void> {
|
|
const res = await api.delete(`/v1/fin/concepts/${id}?company_id=${companyId}`);
|
|
if (res.error) throw new Error(res.error);
|
|
}
|
|
};
|