diff --git a/SEPA_EXPORT_FEATURE.md b/SEPA_EXPORT_FEATURE.md new file mode 100644 index 0000000..80fbb98 --- /dev/null +++ b/SEPA_EXPORT_FEATURE.md @@ -0,0 +1,141 @@ +# Export SEPA XML pour Virements Salaires + +**Date** : 2 novembre 2025 +**Contexte** : Faciliter les virements de salaires pour les clients + +## 🎯 Fonctionnalité + +Permet aux clients d'exporter un fichier XML au format **SEPA ISO 20022 "pain.001.001.03"** pour lancer facilement les virements de salaires depuis leur banque. + +## ✨ Caractéristiques + +### 1. Sélection des paies +- ✅ Checkbox sur chaque ligne de paie non payée +- ✅ Checkbox "Tout sélectionner" dans le header du tableau +- ✅ Bouton "Export fichier bancaire (SEPA)" apparaît quand des paies sont cochées +- ✅ Compteur de paies sélectionnées affiché + +### 2. Regroupement automatique +- ✅ Si un salarié a plusieurs paies sélectionnées → **un seul virement** avec montant total +- ✅ Les références de contrats sont combinées dans le libellé du virement + +### 3. Gestion des IBAN manquants +- ⚠️ Si un salarié n'a pas d'IBAN/BIC → il est **automatiquement exclu** de l'export +- ⚠️ Un modal d'avertissement liste les salariés exclus +- ✅ L'export se fait quand même pour les autres salariés + +### 4. Format SEPA XML +Le fichier généré respecte la norme **ISO 20022 pain.001.001.03** : +- **Donneur d'ordre** : L'entreprise (IBAN depuis `organization_details`) +- **Bénéficiaires** : Les salariés (IBAN/BIC depuis la table `salaries`) +- **Montants** : Net à payer (`net_after_withholding`) +- **Libellés** : **"Nom organisation - Période"** (ex: "Atelier Moz - Octobre 2025") + - Le nom de l'organisation est automatiquement tronqué si trop long (limite SEPA : 140 caractères) + - La période est formatée au format "Mois Année" (ex: "Octobre 2025") +- **Date d'exécution** : Date du jour (modifiable par la banque) + +## 📁 Fichiers modifiés + +### 1. API Route +**`app/api/virements-salaires/export-sepa/route.ts`** +- Récupère les payslips sélectionnés +- Valide les IBAN (format basique) +- Regroupe les paiements par salarié +- Génère le XML SEPA +- Retourne le fichier + métadonnées + +### 2. Page Client +**`app/(app)/virements-salaires/page.tsx`** +- Ajout d'états : `selectedPayslips`, `showExportModal`, `exportWarnings`, `isExporting` +- Ajout de fonctions : + - `togglePayslipSelection()` + - `toggleSelectAll()` + - `handleExportSepa()` +- Ajout de la colonne checkbox dans le tableau +- Ajout du bouton d'export +- Ajout du modal d'avertissement pour salariés sans IBAN + +## 🗄️ Données requises + +### Dans `organization_details` +- ✅ `iban` (IBAN de l'entreprise) - **REQUIS** +- ⚠️ `bic` (BIC de la banque) - Optionnel mais recommandé + +### Dans `salaries` +- ✅ `iban` (IBAN du salarié) - **REQUIS pour inclusion** +- ⚠️ `bic` (BIC de la banque du salarié) - Optionnel + +Si l'entreprise n'a pas d'IBAN → Erreur bloquante +Si un salarié n'a pas d'IBAN → Exclusion automatique avec avertissement + +## 🔄 Workflow utilisateur + +1. Client va sur `/virements-salaires` +2. Client coche les paies à payer +3. Bouton "Export fichier bancaire" apparaît +4. Client clique sur le bouton +5. API génère le XML SEPA +6. Si des salariés n'ont pas d'IBAN → Modal d'avertissement +7. Fichier `virements_sepa_MSGXXX.xml` est téléchargé +8. Client importe le fichier dans son espace bancaire +9. Client valide les virements dans sa banque + +## 📝 Exemple de fichier généré + +```xml + + + + + MSG20251102143025ABC123 + 2025-11-02T14:30:25Z + 3 + 4250.50 + + MA STRUCTURE + + + + MSG20251102143025ABC123-001 + TRF + 3 + 4250.50 + + SEPA + + 2025-11-02 + MA STRUCTURE + FR7630001007941234567890185 + BNPAFRPP + SLEV + + + MSG20251102143025ABC123-001 + 1500.00 + CEPAFRPP + DUPONT Jean + FR7612345678901234567890123 + Atelier Moz - Octobre 2025 + + + + + + +``` + +## 🚀 Prochaines améliorations possibles + +- [ ] Permettre de choisir la date d'exécution +- [ ] Enregistrer l'historique des exports SEPA +- [ ] Marquer automatiquement les paies comme "payées" après export +- [ ] Support du format pain.001.001.09 (version plus récente) +- [ ] Validation IBAN plus stricte avec module de contrôle +- [ ] Récupération automatique du BIC depuis l'IBAN (API externe) + +## 📊 Impact + +- **UX** : Les clients gagnent du temps sur la saisie manuelle des virements +- **Sécurité** : Moins d'erreurs de saisie d'IBAN +- **Traçabilité** : Libellés standardisés avec références de contrats +- **Conformité** : Format SEPA standard accepté par toutes les banques européennes diff --git a/app/(app)/virements-salaires/page.tsx b/app/(app)/virements-salaires/page.tsx index c61423f..00d6d0e 100644 --- a/app/(app)/virements-salaires/page.tsx +++ b/app/(app)/virements-salaires/page.tsx @@ -375,6 +375,20 @@ export default function VirementsPage() { const [pdfUrl, setPdfUrl] = useState(""); const [undoModalOpen, setUndoModalOpen] = useState(false); const [undoPayslipId, setUndoPayslipId] = useState(null); + + // États pour la sélection et l'export SEPA + const [selectedPayslips, setSelectedPayslips] = useState>(new Set()); + const [showExportModal, setShowExportModal] = useState(false); + const [exportWarnings, setExportWarnings] = useState([]); + const [exportSuccess, setExportSuccess] = useState<{ + numberOfTransactions: number; + totalAmount: string; + includedPayments: number; + excludedPayments: number; + } | null>(null); + const [isExporting, setIsExporting] = useState(false); + const [isMarkingPaid, setIsMarkingPaid] = useState(false); + const [showBulkMarkPaidModal, setShowBulkMarkPaidModal] = useState(false); const { data: userInfo, isLoading: isLoadingUser } = useUserInfo(); const { data: organizations, isLoading: isLoadingOrgs, error: orgsError } = useOrganizations(); @@ -516,14 +530,14 @@ export default function VirementsPage() { try { // Optimistic UI: masquer l'élément avant refetch // (on ne modifie pas le cache TanStack ici, on refetch directement après) - await fetch(`/api/payslips/${payslipId}`, { + await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'include', body: JSON.stringify({ transfer_done: true }) }); // Invalider les requêtes liées pour recharger la liste - queryClient.invalidateQueries({ queryKey: ["virements-salaires"] }); + await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] }); } catch (e) { console.error('Erreur marquage payslip:', e); alert('Erreur lors du marquage du virement.'); @@ -533,14 +547,14 @@ export default function VirementsPage() { // Mutation: marquer un payslip comme NON viré (annuler) async function markPayslipUndone(payslipId: string) { try { - await fetch(`/api/payslips/${payslipId}`, { + await fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, credentials: 'include', body: JSON.stringify({ transfer_done: false }) }); // Invalider les requêtes liées pour recharger la liste - queryClient.invalidateQueries({ queryKey: ["virements-salaires"] }); + await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] }); setUndoModalOpen(false); setUndoPayslipId(null); } catch (e) { @@ -555,6 +569,141 @@ export default function VirementsPage() { setUndoModalOpen(true); } + // Gestion de la sélection de payslips + function togglePayslipSelection(payslipId: string) { + setSelectedPayslips(prev => { + const next = new Set(prev); + if (next.has(payslipId)) { + next.delete(payslipId); + } else { + next.add(payslipId); + } + return next; + }); + } + + function toggleSelectAll() { + if (selectedPayslips.size === clientUnpaid.length && clientUnpaid.length > 0) { + setSelectedPayslips(new Set()); + } else { + const allIds = clientUnpaid + .filter(item => item.source === 'payslip' && item.id) + .map(item => item.id); + setSelectedPayslips(new Set(allIds)); + } + } + + // Export SEPA + async function handleExportSepa() { + if (selectedPayslips.size === 0) { + alert('Veuillez sélectionner au moins une fiche de paie'); + return; + } + + setIsExporting(true); + try { + const response = await fetch('/api/virements-salaires/export-sepa', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + credentials: 'include', + body: JSON.stringify({ + payslipIds: Array.from(selectedPayslips), + organizationId: selectedOrgId + }) + }); + + const result = await response.json(); + + if (!response.ok) { + if (result.employeesWithoutIBAN && result.employeesWithoutIBAN.length > 0) { + setExportWarnings(result.employeesWithoutIBAN); + setExportSuccess(null); + setShowExportModal(true); + } else { + throw new Error(result.message || 'Erreur lors de l\'export'); + } + return; + } + + // Préparer les données de succès + setExportSuccess({ + numberOfTransactions: result.metadata.numberOfTransactions, + totalAmount: result.metadata.totalAmount, + includedPayments: result.metadata.includedPayments || result.metadata.numberOfTransactions, + excludedPayments: result.metadata.excludedPayments || 0 + }); + + // Si des salariés n'ont pas d'IBAN mais que d'autres oui + if (result.metadata?.employeesWithoutIBAN && result.metadata.employeesWithoutIBAN.length > 0) { + setExportWarnings(result.metadata.employeesWithoutIBAN); + } else { + setExportWarnings([]); + } + + // Afficher le modal de succès + setShowExportModal(true); + + // Télécharger le fichier XML + const blob = new Blob([result.xml], { type: 'application/xml' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = `virements_sepa_${result.metadata.messageId}.xml`; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + + // Décocher les payslips après export réussi + setSelectedPayslips(new Set()); + + } catch (error) { + console.error('Erreur export SEPA:', error); + alert(error instanceof Error ? error.message : 'Erreur lors de l\'export SEPA'); + } finally { + setIsExporting(false); + } + } + + // Marquage groupé des paies comme payées + async function handleBulkMarkPaid() { + if (selectedPayslips.size === 0) { + alert('Veuillez sélectionner au moins une fiche de paie'); + return; + } + + setShowBulkMarkPaidModal(true); + } + + async function confirmBulkMarkPaid() { + setShowBulkMarkPaidModal(false); + setIsMarkingPaid(true); + try { + const promises = Array.from(selectedPayslips).map(payslipId => + fetch(`/api/virements-salaires/${encodeURIComponent(payslipId)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json', Accept: 'application/json' }, + credentials: 'include', + body: JSON.stringify({ transfer_done: true }) + }) + ); + + await Promise.all(promises); + + // Invalider les requêtes pour recharger la liste + await queryClient.invalidateQueries({ queryKey: ["virements-salaires"] }); + + // Réinitialiser la sélection + setSelectedPayslips(new Set()); + + } catch (error) { + console.error('Erreur marquage groupé:', error); + alert('Erreur lors du marquage groupé des virements'); + } finally { + setIsMarkingPaid(false); + } + } + // Filtrage local pour la recherche ET la période const filteredItems = useMemo((): VirementItem[] => { let result: VirementItem[] = items; @@ -762,6 +911,25 @@ export default function VirementsPage() { + + {/* Card informative SEPA pour clients non-Odentas */} + {!isOdentas && ( +
+
+
+ +
+
+

+ Nouveau : Export SEPA — Générez un fichier XML (norme ISO 20022) pour effectuer vos virements salaires groupés depuis votre banque, si celle-ci le permet. Il vous suffit de cocher les paies concernées. +

+

+ Vous pouvez également marquer plusieurs salaires comme payés en une seule fois en sélectionnant les paies et en cliquant sur Marquer comme payé. +

+
+
+
+ )} {/* Tableau principal */} @@ -860,10 +1028,56 @@ export default function VirementsPage() { ) : (
+ {/* Bouton export SEPA si des payslips sont sélectionnés */} + {selectedPayslips.size > 0 && ( +
+
+
+
+ {selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''} sélectionnée{selectedPayslips.size > 1 ? 's' : ''} +
+ +
+
+ + +
+
+
+ )} +
+ @@ -876,7 +1090,7 @@ export default function VirementsPage() { {/* Sous-header avec le total des nets à payer (salaires non payés) */} {!isLoading && !isError && clientUnpaid.length > 0 && ( - ) : (clientUnpaid.length === 0 && clientRecent.length === 0) ? ( - + ) : ( <> {clientUnpaid.map((it) => ( + +
+ 0 && selectedPayslips.size === clientUnpaid.filter(it => it.source === 'payslip').length} + onChange={toggleSelectAll} + className="rounded" + title="Tout sélectionner / Tout décocher" + /> + Salarié·e Contrat Profession
+
Salaires à payer @@ -901,11 +1115,23 @@ export default function VirementsPage() { ) : isError ? (
Erreur : {(error as any)?.message || 'imprévue'}
Aucun virement de salaires trouvé.
Aucun virement de salaires trouvé.
+ {it.source === 'payslip' ? ( + togglePayslipSelection(it.id)} + className="rounded" + /> + ) : ( +
+ )} +
{it.salarie_matricule ? ( @@ -976,6 +1202,7 @@ export default function VirementsPage() { )} {clientRecent.map((it) => (
{it.salarie_matricule ? ( @@ -1042,6 +1269,20 @@ export default function VirementsPage() {
+ {/* Card informative pour tous les clients */} +
+
+
+ +
+
+

+ Les informations ci-dessous ne concernent que les clients ayant confié la gestion de leurs virements de salaires à Odentas. Vous pouvez nous confier cette gestion sans surcoût, contactez-nous pour plus d'informations. +

+
+
+
+

En fin de mois, nous vous envoyons un appel à virement avec le total des salaires nets de la période concernée.

@@ -1157,6 +1398,51 @@ export default function VirementsPage() {
)} + {/* Modal de confirmation pour le marquage groupé */} + {showBulkMarkPaidModal && ( +
+
setShowBulkMarkPaidModal(false)} + /> +
+
+
+
+
+ +
+

Marquer comme payé

+
+
+
+

+ Voulez-vous vraiment marquer {selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''} comme payée{selectedPayslips.size > 1 ? 's' : ''} ? +

+

+ En cas d'erreur, vous pourrez marquer la paie comme non payée à tout moment. +

+
+
+ + +
+
+
+
+ )} + {/* Modal de confirmation pour annuler le marquage */} {undoModalOpen && undoPayslipId && (
@@ -1201,6 +1487,108 @@ export default function VirementsPage() {
)} + + {/* Modal d'avertissement pour les salariés sans IBAN */} + {showExportModal && ( +
+
{ + setShowExportModal(false); + setExportSuccess(null); + setExportWarnings([]); + }} + /> +
+
+ {/* Header avec succès ou erreur */} + {exportSuccess ? ( +
+
+
+ +
+
+

Fichier SEPA généré avec succès !

+

Le téléchargement a démarré automatiquement

+
+
+
+ ) : ( +
+
+
+ +
+

Export impossible

+
+
+ )} + +
+ {/* Résumé de l'export si succès */} + {exportSuccess && ( +
+
+ Nombre de virements : + {exportSuccess.numberOfTransactions} +
+
+ Montant total : + {exportSuccess.totalAmount} € +
+ {exportSuccess.excludedPayments > 0 && ( + <> +
+
+ Salariés inclus : + {exportSuccess.includedPayments} +
+
+ Salariés exclus (sans RIB) : + {exportSuccess.excludedPayments} +
+ + )} +
+ )} + + {/* Liste des salariés sans RIB */} + {exportWarnings.length > 0 && ( + <> +
+

+ {exportSuccess ? 'Salariés exclus (sans RIB) :' : 'Tous les salariés sélectionnés n\'ont pas de RIB :'} +

+
    + {exportWarnings.map((name, index) => ( +
  • {name}
  • + ))} +
+

+ Ces salariés doivent envoyer leur RIB depuis leur Espace Transat pour pouvoir être inclus dans les prochains exports de virements. +

+
+ + )} +
+ +
+ +
+
+
+
+ )}
); } diff --git a/app/api/virements-salaires/[id]/route.ts b/app/api/virements-salaires/[id]/route.ts new file mode 100644 index 0000000..196743e --- /dev/null +++ b/app/api/virements-salaires/[id]/route.ts @@ -0,0 +1,56 @@ +import { createRouteHandlerClient } from '@supabase/auth-helpers-nextjs'; +import { cookies } from 'next/headers'; +import { NextRequest, NextResponse } from 'next/server'; + +export async function PATCH( + request: NextRequest, + { params }: { params: { id: string } } +) { + try { + const supabase = createRouteHandlerClient({ cookies }); + + // Vérifier l'authentification + const { data: { session } } = await supabase.auth.getSession(); + if (!session) { + return NextResponse.json({ error: 'Non authentifié' }, { status: 401 }); + } + + const payslipId = params.id; + const body = await request.json(); + const { transfer_done } = body; + + if (typeof transfer_done !== 'boolean') { + return NextResponse.json( + { error: 'Le champ transfer_done doit être un booléen' }, + { status: 400 } + ); + } + + // Mettre à jour la fiche de paie + const { data, error } = await supabase + .from('payslips') + .update({ + transfer_done, + transfer_done_at: transfer_done ? new Date().toISOString() : null + }) + .eq('id', payslipId) + .select() + .single(); + + if (error) { + console.error('Erreur mise à jour payslip:', error); + return NextResponse.json( + { error: 'Erreur lors de la mise à jour' }, + { status: 500 } + ); + } + + return NextResponse.json({ success: true, data }); + } catch (error) { + console.error('Erreur API virements-salaires PATCH:', error); + return NextResponse.json( + { error: 'Erreur serveur' }, + { status: 500 } + ); + } +} diff --git a/app/api/virements-salaires/export-sepa/route.ts b/app/api/virements-salaires/export-sepa/route.ts new file mode 100644 index 0000000..aa53d22 --- /dev/null +++ b/app/api/virements-salaires/export-sepa/route.ts @@ -0,0 +1,377 @@ +// app/api/virements-salaires/export-sepa/route.ts +import { NextResponse } from "next/server"; +import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs"; +import { cookies } from "next/headers"; + +export const dynamic = 'force-dynamic'; +export const revalidate = 0; +export const runtime = 'nodejs'; + +// Fonction pour générer un identifiant de message unique +function generateMessageId(): string { + const now = new Date(); + const timestamp = now.toISOString().replace(/[-:T.]/g, '').slice(0, 14); + const random = Math.random().toString(36).substring(2, 8).toUpperCase(); + return `MSG${timestamp}${random}`; +} + +// Fonction pour formater la date au format ISO (YYYY-MM-DD) +function formatDateISO(date: Date = new Date()): string { + return date.toISOString().split('T')[0]; +} + +// Fonction pour formater la date-heure ISO 8601 +function formatDateTimeISO(date: Date = new Date()): string { + return date.toISOString(); +} + +// Fonction pour nettoyer et limiter les caractères XML +function sanitizeXML(str: string, maxLength?: number): string { + let cleaned = str + .replace(/[<>&'"]/g, '') // Supprimer les caractères XML problématiques + .replace(/[^\x20-\x7E\xA0-\xFF]/g, '') // Garder uniquement les caractères imprimables + .trim(); + + if (maxLength && cleaned.length > maxLength) { + cleaned = cleaned.substring(0, maxLength); + } + + return cleaned; +} + +// Fonction pour valider un IBAN (basique) +function isValidIBAN(iban: string): boolean { + const cleaned = iban.replace(/\s/g, '').toUpperCase(); + return /^[A-Z]{2}[0-9]{2}[A-Z0-9]+$/.test(cleaned) && cleaned.length >= 15 && cleaned.length <= 34; +} + +// Fonction pour extraire le BIC depuis l'IBAN si non fourni (approximatif) +function extractBICFromIBAN(iban: string): string | null { + // Cette fonction est approximative - dans un cas réel, il faudrait une base de données BIC + // Pour la France, on peut extraire le code banque mais pas le BIC complet + return null; +} + +// Fonction pour formater une période (YYYY-MM-DD) en texte (ex: "Octobre 2025") +function formatPeriod(periodStart: string | null): string { + if (!periodStart) return ''; + + try { + const date = new Date(periodStart); + const monthNames = [ + 'Janvier', 'Février', 'Mars', 'Avril', 'Mai', 'Juin', + 'Juillet', 'Août', 'Septembre', 'Octobre', 'Novembre', 'Décembre' + ]; + const month = monthNames[date.getMonth()]; + const year = date.getFullYear(); + return `${month} ${year}`; + } catch { + return ''; + } +} + +type PayslipDetail = { + id: string; + net_after_withholding: number; + contract_id: string; + period_start: string | null; + cddu_contracts: { + contract_number: string; + employee_id: string; + salaries: { + id: string; + nom: string; + prenom: string; + iban: string | null; + bic: string | null; + }; + }; +}; + +type GroupedPayment = { + employeeId: string; + employeeName: string; + iban: string; + bic: string; + totalAmount: number; + payslipIds: string[]; + references: string[]; + period?: string; // Format "Octobre 2025" +}; + +export async function POST(req: Request) { + try { + const supabase = createRouteHandlerClient({ cookies }); + const { data: { session } } = await supabase.auth.getSession(); + + if (!session) { + return NextResponse.json({ error: 'unauthorized' }, { status: 401 }); + } + + const body = await req.json(); + const { payslipIds, organizationId } = body; + + if (!payslipIds || !Array.isArray(payslipIds) || payslipIds.length === 0) { + return NextResponse.json({ error: 'payslipIds_required' }, { status: 400 }); + } + + if (!organizationId) { + return NextResponse.json({ error: 'organizationId_required' }, { status: 400 }); + } + + // Récupérer les informations de l'organisation (donneur d'ordre) + const { data: orgData, error: orgError } = await supabase + .from('organizations') + .select(` + id, + name, + structure_api, + organization_details( + iban, + bic, + code_employeur + ) + `) + .eq('id', organizationId) + .single(); + + if (orgError || !orgData) { + return NextResponse.json({ error: 'organization_not_found' }, { status: 404 }); + } + + const orgDetails = Array.isArray(orgData.organization_details) + ? orgData.organization_details[0] + : orgData.organization_details; + + // Vérifier que l'organisation a un IBAN + if (!orgDetails?.iban) { + return NextResponse.json({ + error: 'missing_organization_iban', + message: 'L\'IBAN de votre structure n\'est pas renseigné' + }, { status: 400 }); + } + + // Récupérer les détails des payslips avec les informations des salariés + const { data: payslips, error: payslipsError } = await supabase + .from('payslips') + .select(` + id, + net_after_withholding, + contract_id, + period_start, + cddu_contracts!inner( + contract_number, + employee_id, + salaries!inner( + id, + nom, + prenom, + iban, + bic + ) + ) + `) + .in('id', payslipIds) + .eq('organization_id', organizationId); + + if (payslipsError) { + console.error('Error fetching payslips:', payslipsError); + return NextResponse.json({ error: 'database_error' }, { status: 500 }); + } + + if (!payslips || payslips.length === 0) { + return NextResponse.json({ error: 'no_payslips_found' }, { status: 404 }); + } + + // Regrouper les paiements par salarié + const paymentsByEmployee = new Map(); + const employeesWithoutIBAN: string[] = []; + + for (const payslip of payslips as any[]) { + const contract = payslip.cddu_contracts; + const salary = contract.salaries; + + const employeeId = salary.id; + const employeeName = `${salary.nom || ''} ${salary.prenom || ''}`.trim(); + const iban = salary.iban; + const bic = salary.bic; + const amount = parseFloat(payslip.net_after_withholding || 0); + const periodFormatted = formatPeriod(payslip.period_start); + + // Vérifier si l'IBAN est présent et valide + if (!iban || !isValidIBAN(iban)) { + if (!employeesWithoutIBAN.includes(employeeName)) { + employeesWithoutIBAN.push(employeeName); + } + continue; // Exclure ce payslip + } + + // Regrouper les paiements par employé + if (paymentsByEmployee.has(employeeId)) { + const existing = paymentsByEmployee.get(employeeId)!; + existing.totalAmount += amount; + existing.payslipIds.push(payslip.id); + existing.references.push(contract.contract_number); + // Mettre à jour la période si elle n'est pas encore définie + if (!existing.period && periodFormatted) { + existing.period = periodFormatted; + } + } else { + paymentsByEmployee.set(employeeId, { + employeeId, + employeeName, + iban: iban.replace(/\s/g, ''), + bic: bic || '', // Le BIC peut être déduit par la banque si vide + totalAmount: amount, + payslipIds: [payslip.id], + references: [contract.contract_number], + period: periodFormatted + }); + } + } + + // Si tous les salariés n'ont pas d'IBAN, retourner une erreur + if (paymentsByEmployee.size === 0) { + return NextResponse.json({ + error: 'no_valid_payments', + message: 'Aucun salarié n\'a d\'IBAN valide', + employeesWithoutIBAN + }, { status: 400 }); + } + + // Calculer le montant total et le nombre de transactions + const payments = Array.from(paymentsByEmployee.values()); + const totalAmount = payments.reduce((sum, p) => sum + p.totalAmount, 0); + const numberOfTransactions = payments.length; + + // Générer le fichier SEPA XML (pain.001.001.03) + const messageId = generateMessageId(); + const creationDateTime = formatDateTimeISO(); + const requestedExecutionDate = formatDateISO(); // Aujourd'hui ou date souhaitée + const debtorIBAN = orgDetails.iban.replace(/\s/g, ''); + const debtorBIC = orgDetails.bic || ''; + const debtorName = sanitizeXML(orgData.structure_api || orgData.name, 70); + + let xml = '\n'; + xml += '\n'; + xml += ' \n'; + + // Group Header + xml += ' \n'; + xml += ` ${messageId}\n`; + xml += ` ${creationDateTime}\n`; + xml += ` ${numberOfTransactions}\n`; + xml += ` ${totalAmount.toFixed(2)}\n`; + xml += ' \n'; + xml += ` ${debtorName}\n`; + xml += ' \n'; + xml += ' \n'; + + // Payment Information + xml += ' \n'; + xml += ` ${messageId}-001\n`; + xml += ' TRF\n'; + xml += ` ${numberOfTransactions}\n`; + xml += ` ${totalAmount.toFixed(2)}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ' SEPA\n'; + xml += ' \n'; + xml += ' \n'; + xml += ` ${requestedExecutionDate}\n`; + xml += ' \n'; + xml += ` ${debtorName}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ' \n'; + xml += ` ${debtorIBAN}\n`; + xml += ' \n'; + xml += ' \n'; + if (debtorBIC) { + xml += ' \n'; + xml += ' \n'; + xml += ` ${debtorBIC}\n`; + xml += ' \n'; + xml += ' \n'; + } + xml += ' SLEV\n'; + + // Credit Transfer Transaction Information (une par bénéficiaire) + payments.forEach((payment, index) => { + const endToEndId = `${messageId}-${String(index + 1).padStart(3, '0')}`; + const creditorName = sanitizeXML(payment.employeeName, 70); + + // Format: "Nom organisation - Période" (ex: "Atelier Moz - Octobre 2025") + // Limite SEPA: 140 caractères pour le libellé + const orgName = debtorName; + const periodText = payment.period || 'Salaire'; + + // Calculer l'espace disponible pour le nom de l'organisation + // Format: "OrgName - Période" → " - " = 3 caractères + const separator = ' - '; + const maxOrgNameLength = 140 - separator.length - periodText.length; + + // Tronquer le nom de l'organisation si nécessaire + const truncatedOrgName = orgName.length > maxOrgNameLength + ? orgName.substring(0, maxOrgNameLength - 3) + '...' + : orgName; + + const remittanceInfo = sanitizeXML(`${truncatedOrgName}${separator}${periodText}`, 140); + + xml += ' \n'; + xml += ' \n'; + xml += ` ${endToEndId}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ` ${payment.totalAmount.toFixed(2)}\n`; + xml += ' \n'; + if (payment.bic) { + xml += ' \n'; + xml += ' \n'; + xml += ` ${payment.bic}\n`; + xml += ' \n'; + xml += ' \n'; + } + xml += ' \n'; + xml += ` ${creditorName}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ' \n'; + xml += ` ${payment.iban}\n`; + xml += ' \n'; + xml += ' \n'; + xml += ' \n'; + xml += ` ${remittanceInfo}\n`; + xml += ' \n'; + xml += ' \n'; + }); + + xml += ' \n'; + xml += ' \n'; + xml += ''; + + // Retourner le XML avec les métadonnées + return NextResponse.json({ + success: true, + xml, + metadata: { + messageId, + numberOfTransactions, + totalAmount: totalAmount.toFixed(2), + currency: 'EUR', + executionDate: requestedExecutionDate, + debtorName, + employeesWithoutIBAN: employeesWithoutIBAN.length > 0 ? employeesWithoutIBAN : undefined, + includedPayments: payments.length, + excludedPayments: employeesWithoutIBAN.length + } + }); + + } catch (error) { + console.error('Error generating SEPA XML:', error); + return NextResponse.json({ + error: 'internal_server_error', + message: error instanceof Error ? error.message : 'Unknown error' + }, { status: 500 }); + } +}