/** * invoice_totals — calcul des totaux d'une facture native depuis ses lignes. * * Règles (cohérence comptable, jamais de float) : * - totalHtCents par ligne = round(quantity × unitPriceCents). On round par * ligne (et pas seulement sur la somme) parce que c'est ce qui est affiché * dans le PDF et que la somme des arrondis doit matcher l'affichage. * - TVA par ligne = round(totalHtCents × tvaRate / 100) * - amountHtCents = somme des totalHtCents * - amountTvaCents = somme des TVA par ligne * - amountTtcCents = amountHtCents + amountTvaCents * - tvaBreakdown : agrégation par taux (un item par taux distinct) * * NE JAMAIS faire confiance au client pour ces totaux — c'est une exigence * comptable (la facture est une preuve, le total doit être recalculable et * vérifiable). Le SPA peut calculer en local pour l'aperçu, mais le serveur * recalcule à la persistance. */ export interface RawInvoiceLine { id: string description: string quantity: number unitPriceCents: number tvaRate: number } export interface ComputedInvoiceLine extends RawInvoiceLine { /** Total HT de la ligne en centimes (toujours entier, arrondi). */ totalHtCents: number } export interface TvaBreakdownItem { rate: number htCents: number tvaCents: number } export interface ComputedInvoiceTotals { lines: ComputedInvoiceLine[] amountHtCents: number amountTvaCents: number amountTtcCents: number tvaBreakdown: TvaBreakdownItem[] } function roundCents(value: number): number { // Math.round avec banker's rounding ? Non — pour la facturation française // l'usage est l'arrondi à l'unité supérieure pour 0.5. C'est ce que fait // Math.round (vers +∞ pour les positifs). Les montants sont toujours // positifs sur une facture, donc Math.round suffit. return Math.round(value) } export function computeInvoiceTotals(lines: RawInvoiceLine[]): ComputedInvoiceTotals { const computed: ComputedInvoiceLine[] = lines.map((l) => ({ ...l, totalHtCents: roundCents(l.quantity * l.unitPriceCents), })) // Agrégation par taux. Map const byRate = new Map() for (const line of computed) { const lineTvaCents = roundCents((line.totalHtCents * line.tvaRate) / 100) const existing = byRate.get(line.tvaRate) ?? { htCents: 0, tvaCents: 0 } existing.htCents += line.totalHtCents existing.tvaCents += lineTvaCents byRate.set(line.tvaRate, existing) } // Tri par taux croissant pour l'affichage stable dans le PDF. const tvaBreakdown: TvaBreakdownItem[] = Array.from(byRate.entries()) .sort(([a], [b]) => a - b) .map(([rate, { htCents, tvaCents }]) => ({ rate, htCents, tvaCents })) const amountHtCents = tvaBreakdown.reduce((s, b) => s + b.htCents, 0) const amountTvaCents = tvaBreakdown.reduce((s, b) => s + b.tvaCents, 0) const amountTtcCents = amountHtCents + amountTvaCents return { lines: computed, amountHtCents, amountTvaCents, amountTtcCents, tvaBreakdown, } }