rubis/apps/api/app/services/invoice_totals.ts
ordinarthur e0b47ddfdc feat(invoices): éditeur de factures natif — data model + API (Phase 1)
Pose les fondations pour permettre aux utilisateurs de créer leurs
factures directement dans Rubis (en complément de l'upload OCR existant),
avec snapshots immuables, numérotation strict séquentielle (art. 242
nonies A CGI) et 4 thèmes pré-faits paramétrables.

Data model
- organizations.invoice_settings (JSONB) : thème par défaut, accent color,
  préfixe et compteur de numérotation, mentions légales (pénalités,
  escompte), identité émetteur (SIREN/SIRET/TVA intra/RCS/capital), RIB.
- clients enrichi : SIREN, TVA intra, adresse structurée (lines/zip/city
  /country). Le champ address legacy reste pour les clients pré-feature.
- invoices enrichi : lines (JSONB), client_snapshot + issuer_snapshot
  figés à l'émission, amount_ht/tva, tva_breakdown, payment_terms_days,
  theme_slug + theme_accent_color, is_native, sequence_number (unique
  per org), pdf_generated_at.

API
- GET/PATCH /organizations/me/invoice-settings (resolveInvoiceSettings)
- GET /invoice-themes (4 thèmes : classique, moderne, minimal, élégant)
- POST /invoices/native (séquence strict allouée en transaction,
  totaux recalculés serveur, snapshots immuables)
- POST /invoices/preview-pdf (stream PDF sans persister, stub Phase 1)

Le rendu PDF lui-même (@react-pdf/renderer + templates) arrive en
Phase 2 ; le storeNative crée bien la facture mais pdf_storage_key
reste null jusqu'à Phase 2. Conformité Factur-X visée pour V1.5
(Q3-Q4 2026, avant l'échéance d'émission TPE-PME au 1er sept 2027).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-14 02:07:45 +02:00

88 lines
3.0 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<rate, {ht, tva}>
const byRate = new Map<number, { htCents: number; tvaCents: number }>()
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,
}
}