feat: Ajout export SEPA et marquage groupé des virements salaires
- Ajout export fichier SEPA XML (norme ISO 20022 pain.001.001.03) * Sélection multiple via checkboxes * Génération XML pour virements bancaires groupés * Validation IBAN et gestion des salariés sans RIB * Modal de succès/avertissements * Référence: Nom organisation - Période - Ajout marquage groupé des paies comme payées * Sélection multiple des paies * Modal de confirmation * Actualisation automatique sans refresh - Nouvelle route API /api/virements-salaires/[id] (PATCH) * Mise à jour transfer_done et transfer_done_at - Amélioration UX * Card informative pour clients non-Odentas * Modal informatif dans 'En savoir plus' * Messages clairs et cohérents
This commit is contained in:
parent
897af4b23a
commit
91e0919274
4 changed files with 968 additions and 6 deletions
141
SEPA_EXPORT_FEATURE.md
Normal file
141
SEPA_EXPORT_FEATURE.md
Normal file
|
|
@ -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
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03">
|
||||
<CstmrCdtTrfInitn>
|
||||
<GrpHdr>
|
||||
<MsgId>MSG20251102143025ABC123</MsgId>
|
||||
<CreDtTm>2025-11-02T14:30:25Z</CreDtTm>
|
||||
<NbOfTxs>3</NbOfTxs>
|
||||
<CtrlSum>4250.50</CtrlSum>
|
||||
<InitgPty>
|
||||
<Nm>MA STRUCTURE</Nm>
|
||||
</InitgPty>
|
||||
</GrpHdr>
|
||||
<PmtInf>
|
||||
<PmtInfId>MSG20251102143025ABC123-001</PmtInfId>
|
||||
<PmtMtd>TRF</PmtMtd>
|
||||
<NbOfTxs>3</NbOfTxs>
|
||||
<CtrlSum>4250.50</CtrlSum>
|
||||
<PmtTpInf>
|
||||
<SvcLvl><Cd>SEPA</Cd></SvcLvl>
|
||||
</PmtTpInf>
|
||||
<ReqdExctnDt>2025-11-02</ReqdExctnDt>
|
||||
<Dbtr><Nm>MA STRUCTURE</Nm></Dbtr>
|
||||
<DbtrAcct><Id><IBAN>FR7630001007941234567890185</IBAN></Id></DbtrAcct>
|
||||
<DbtrAgt><FinInstnId><BIC>BNPAFRPP</BIC></FinInstnId></DbtrAgt>
|
||||
<ChrgBr>SLEV</ChrgBr>
|
||||
|
||||
<CdtTrfTxInf>
|
||||
<PmtId><EndToEndId>MSG20251102143025ABC123-001</EndToEndId></PmtId>
|
||||
<Amt><InstdAmt Ccy="EUR">1500.00</InstdAmt></Amt>
|
||||
<CdtrAgt><FinInstnId><BIC>CEPAFRPP</BIC></FinInstnId></CdtrAgt>
|
||||
<Cdtr><Nm>DUPONT Jean</Nm></Cdtr>
|
||||
<CdtrAcct><Id><IBAN>FR7612345678901234567890123</IBAN></Id></CdtrAcct>
|
||||
<RmtInf><Ustrd>Atelier Moz - Octobre 2025</Ustrd></RmtInf>
|
||||
</CdtTrfTxInf>
|
||||
|
||||
<!-- Autres virements... -->
|
||||
</PmtInf>
|
||||
</CstmrCdtTrfInitn>
|
||||
</Document>
|
||||
```
|
||||
|
||||
## 🚀 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
|
||||
|
|
@ -376,6 +376,20 @@ export default function VirementsPage() {
|
|||
const [undoModalOpen, setUndoModalOpen] = useState(false);
|
||||
const [undoPayslipId, setUndoPayslipId] = useState<string | null>(null);
|
||||
|
||||
// États pour la sélection et l'export SEPA
|
||||
const [selectedPayslips, setSelectedPayslips] = useState<Set<string>>(new Set());
|
||||
const [showExportModal, setShowExportModal] = useState(false);
|
||||
const [exportWarnings, setExportWarnings] = useState<string[]>([]);
|
||||
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();
|
||||
const queryClient = useQueryClient();
|
||||
|
|
@ -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() {
|
|||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Card informative SEPA pour clients non-Odentas */}
|
||||
{!isOdentas && (
|
||||
<div className="mt-4 rounded-xl border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
|
||||
<Info className="h-4 w-4 text-blue-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-900 mb-2">
|
||||
<span className="font-semibold">Nouveau : Export SEPA</span> — 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.
|
||||
</p>
|
||||
<p className="text-sm text-blue-900">
|
||||
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é.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Tableau principal */}
|
||||
|
|
@ -860,10 +1028,56 @@ export default function VirementsPage() {
|
|||
</section>
|
||||
) : (
|
||||
<section className="rounded-2xl border bg-white">
|
||||
{/* Bouton export SEPA si des payslips sont sélectionnés */}
|
||||
{selectedPayslips.size > 0 && (
|
||||
<div className="border-b bg-blue-50 px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="text-sm font-medium text-blue-900">
|
||||
{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''} sélectionnée{selectedPayslips.size > 1 ? 's' : ''}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedPayslips(new Set())}
|
||||
className="text-xs text-blue-600 hover:text-blue-800 underline"
|
||||
>
|
||||
Tout décocher
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={handleBulkMarkPaid}
|
||||
disabled={isMarkingPaid}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Check className="w-4 h-4" />
|
||||
{isMarkingPaid ? 'Marquage en cours...' : 'Marquer comme payé'}
|
||||
</button>
|
||||
<button
|
||||
onClick={handleExportSepa}
|
||||
disabled={isExporting}
|
||||
className="inline-flex items-center gap-2 px-4 py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
{isExporting ? 'Export en cours...' : 'Exporter fichier bancaire (SEPA)'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b bg-slate-50/80">
|
||||
<Th className="w-12">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPayslips.size > 0 && selectedPayslips.size === clientUnpaid.filter(it => it.source === 'payslip').length}
|
||||
onChange={toggleSelectAll}
|
||||
className="rounded"
|
||||
title="Tout sélectionner / Tout décocher"
|
||||
/>
|
||||
</Th>
|
||||
<Th>Salarié·e</Th>
|
||||
<Th>Contrat</Th>
|
||||
<Th>Profession</Th>
|
||||
|
|
@ -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 && (
|
||||
<tr className="bg-gradient-to-r from-indigo-50 to-purple-50 border-b-2 border-indigo-200">
|
||||
<th colSpan={6} className="px-4 py-3 text-left">
|
||||
<th colSpan={7} className="px-4 py-3 text-left">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="text-sm font-semibold text-indigo-900">
|
||||
Salaires à payer
|
||||
|
|
@ -901,11 +1115,23 @@ export default function VirementsPage() {
|
|||
) : isError ? (
|
||||
<tr><td colSpan={8} className="py-12 text-center text-rose-500">Erreur : {(error as any)?.message || 'imprévue'}</td></tr>
|
||||
) : (clientUnpaid.length === 0 && clientRecent.length === 0) ? (
|
||||
<tr><td colSpan={8} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
|
||||
<tr><td colSpan={9} className="py-12 text-center text-slate-500">Aucun virement de salaires trouvé.</td></tr>
|
||||
) : (
|
||||
<>
|
||||
{clientUnpaid.map((it) => (
|
||||
<tr key={`unpaid-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
|
||||
<Td>
|
||||
{it.source === 'payslip' ? (
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selectedPayslips.has(it.id)}
|
||||
onChange={() => togglePayslipSelection(it.id)}
|
||||
className="rounded"
|
||||
/>
|
||||
) : (
|
||||
<div className="w-4"></div>
|
||||
)}
|
||||
</Td>
|
||||
<Td>
|
||||
{it.salarie_matricule ? (
|
||||
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
|
||||
|
|
@ -976,6 +1202,7 @@ export default function VirementsPage() {
|
|||
)}
|
||||
{clientRecent.map((it) => (
|
||||
<tr key={`recent-${it.source}-${it.id}`} className="border-b last:border-b-0 hover:bg-slate-50/50">
|
||||
<Td><span></span></Td>
|
||||
<Td>
|
||||
{it.salarie_matricule ? (
|
||||
<a href={`/salaries/${encodeURIComponent(it.salarie_matricule)}`} target="_blank" rel="noreferrer" className="text-blue-600 hover:underline font-medium">
|
||||
|
|
@ -1042,6 +1269,20 @@ export default function VirementsPage() {
|
|||
<button onClick={() => setAboutOpen(false)} className="text-slate-500 hover:text-slate-900">✕</button>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 text-sm">
|
||||
{/* Card informative pour tous les clients */}
|
||||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-4">
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="rounded-lg bg-blue-100 p-2 mt-0.5">
|
||||
<Info className="h-4 w-4 text-blue-700" />
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm text-blue-900">
|
||||
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.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
En fin de mois, nous vous envoyons un <strong>appel à virement</strong> avec le <strong>total des salaires nets</strong> de la période concernée.
|
||||
</p>
|
||||
|
|
@ -1157,6 +1398,51 @@ export default function VirementsPage() {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation pour le marquage groupé */}
|
||||
{showBulkMarkPaidModal && (
|
||||
<div className="fixed inset-0 z-[1000]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => setShowBulkMarkPaidModal(false)}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div role="dialog" aria-modal="true" className="w-full max-w-md rounded-2xl border bg-white shadow-xl">
|
||||
<div className="p-5 border-b bg-green-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-100 p-2">
|
||||
<Check className="h-5 w-5 text-green-700" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-green-900">Marquer comme payé</h2>
|
||||
</div>
|
||||
</div>
|
||||
<div className="p-5 space-y-4 text-sm">
|
||||
<p className="text-slate-700">
|
||||
Voulez-vous vraiment marquer <strong>{selectedPayslips.size} paie{selectedPayslips.size > 1 ? 's' : ''}</strong> comme payée{selectedPayslips.size > 1 ? 's' : ''} ?
|
||||
</p>
|
||||
<p className="text-slate-600">
|
||||
En cas d'erreur, vous pourrez marquer la paie comme non payée à tout moment.
|
||||
</p>
|
||||
</div>
|
||||
<div className="p-4 border-t flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => setShowBulkMarkPaidModal(false)}
|
||||
className="px-4 py-2 rounded-lg border hover:bg-slate-50 text-sm transition-colors"
|
||||
>
|
||||
Annuler
|
||||
</button>
|
||||
<button
|
||||
onClick={confirmBulkMarkPaid}
|
||||
disabled={isMarkingPaid}
|
||||
className="px-4 py-2 rounded-lg bg-green-600 text-white hover:bg-green-700 text-sm transition-colors disabled:opacity-50"
|
||||
>
|
||||
{isMarkingPaid ? 'Marquage en cours...' : 'Confirmer'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal de confirmation pour annuler le marquage */}
|
||||
{undoModalOpen && undoPayslipId && (
|
||||
<div className="fixed inset-0 z-[1000]">
|
||||
|
|
@ -1201,6 +1487,108 @@ export default function VirementsPage() {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal d'avertissement pour les salariés sans IBAN */}
|
||||
{showExportModal && (
|
||||
<div className="fixed inset-0 z-[1000]">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/40"
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
setExportSuccess(null);
|
||||
setExportWarnings([]);
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div role="dialog" aria-modal="true" className="w-full max-w-lg rounded-2xl border bg-white shadow-xl">
|
||||
{/* Header avec succès ou erreur */}
|
||||
{exportSuccess ? (
|
||||
<div className="p-5 border-b bg-green-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-green-100 p-2">
|
||||
<Check className="h-5 w-5 text-green-700" />
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="text-base font-semibold text-green-900">Fichier SEPA généré avec succès !</h2>
|
||||
<p className="text-sm text-green-700">Le téléchargement a démarré automatiquement</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<div className="p-5 border-b bg-red-50">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg bg-red-100 p-2">
|
||||
<X className="h-5 w-5 text-red-700" />
|
||||
</div>
|
||||
<h2 className="text-base font-semibold text-red-900">Export impossible</h2>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="p-5 space-y-4 text-sm">
|
||||
{/* Résumé de l'export si succès */}
|
||||
{exportSuccess && (
|
||||
<div className="bg-slate-50 rounded-lg p-4 space-y-2">
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Nombre de virements :</span>
|
||||
<span className="font-semibold text-slate-900">{exportSuccess.numberOfTransactions}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center">
|
||||
<span className="text-slate-600">Montant total :</span>
|
||||
<span className="font-semibold text-slate-900">{exportSuccess.totalAmount} €</span>
|
||||
</div>
|
||||
{exportSuccess.excludedPayments > 0 && (
|
||||
<>
|
||||
<div className="border-t border-slate-200 my-2"></div>
|
||||
<div className="flex justify-between items-center text-amber-700">
|
||||
<span>Salariés inclus :</span>
|
||||
<span className="font-semibold">{exportSuccess.includedPayments}</span>
|
||||
</div>
|
||||
<div className="flex justify-between items-center text-amber-700">
|
||||
<span>Salariés exclus (sans RIB) :</span>
|
||||
<span className="font-semibold">{exportSuccess.excludedPayments}</span>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Liste des salariés sans RIB */}
|
||||
{exportWarnings.length > 0 && (
|
||||
<>
|
||||
<div className={`${exportSuccess ? 'border-t border-slate-200 pt-4' : ''}`}>
|
||||
<p className="text-slate-700 font-medium mb-2">
|
||||
{exportSuccess ? 'Salariés exclus (sans RIB) :' : 'Tous les salariés sélectionnés n\'ont pas de RIB :'}
|
||||
</p>
|
||||
<ul className="list-disc list-inside space-y-1 text-slate-600 bg-amber-50 rounded-lg p-3 border border-amber-200">
|
||||
{exportWarnings.map((name, index) => (
|
||||
<li key={index}>{name}</li>
|
||||
))}
|
||||
</ul>
|
||||
<p className="text-slate-600 mt-3">
|
||||
Ces salariés doivent <strong>envoyer leur RIB depuis leur Espace Transat</strong> pour pouvoir être inclus dans les prochains exports de virements.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="p-4 border-t flex justify-end">
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowExportModal(false);
|
||||
setExportSuccess(null);
|
||||
setExportWarnings([]);
|
||||
}}
|
||||
className="px-4 py-2 rounded-lg bg-blue-600 text-white hover:bg-blue-700 text-sm"
|
||||
>
|
||||
{exportSuccess ? 'Fermer' : 'J\'ai compris'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
56
app/api/virements-salaires/[id]/route.ts
Normal file
56
app/api/virements-salaires/[id]/route.ts
Normal file
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
377
app/api/virements-salaires/export-sepa/route.ts
Normal file
377
app/api/virements-salaires/export-sepa/route.ts
Normal file
|
|
@ -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<string, GroupedPayment>();
|
||||
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 = '<?xml version="1.0" encoding="UTF-8"?>\n';
|
||||
xml += '<Document xmlns="urn:iso:std:iso:20022:tech:xsd:pain.001.001.03" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">\n';
|
||||
xml += ' <CstmrCdtTrfInitn>\n';
|
||||
|
||||
// Group Header
|
||||
xml += ' <GrpHdr>\n';
|
||||
xml += ` <MsgId>${messageId}</MsgId>\n`;
|
||||
xml += ` <CreDtTm>${creationDateTime}</CreDtTm>\n`;
|
||||
xml += ` <NbOfTxs>${numberOfTransactions}</NbOfTxs>\n`;
|
||||
xml += ` <CtrlSum>${totalAmount.toFixed(2)}</CtrlSum>\n`;
|
||||
xml += ' <InitgPty>\n';
|
||||
xml += ` <Nm>${debtorName}</Nm>\n`;
|
||||
xml += ' </InitgPty>\n';
|
||||
xml += ' </GrpHdr>\n';
|
||||
|
||||
// Payment Information
|
||||
xml += ' <PmtInf>\n';
|
||||
xml += ` <PmtInfId>${messageId}-001</PmtInfId>\n`;
|
||||
xml += ' <PmtMtd>TRF</PmtMtd>\n';
|
||||
xml += ` <NbOfTxs>${numberOfTransactions}</NbOfTxs>\n`;
|
||||
xml += ` <CtrlSum>${totalAmount.toFixed(2)}</CtrlSum>\n`;
|
||||
xml += ' <PmtTpInf>\n';
|
||||
xml += ' <SvcLvl>\n';
|
||||
xml += ' <Cd>SEPA</Cd>\n';
|
||||
xml += ' </SvcLvl>\n';
|
||||
xml += ' </PmtTpInf>\n';
|
||||
xml += ` <ReqdExctnDt>${requestedExecutionDate}</ReqdExctnDt>\n`;
|
||||
xml += ' <Dbtr>\n';
|
||||
xml += ` <Nm>${debtorName}</Nm>\n`;
|
||||
xml += ' </Dbtr>\n';
|
||||
xml += ' <DbtrAcct>\n';
|
||||
xml += ' <Id>\n';
|
||||
xml += ` <IBAN>${debtorIBAN}</IBAN>\n`;
|
||||
xml += ' </Id>\n';
|
||||
xml += ' </DbtrAcct>\n';
|
||||
if (debtorBIC) {
|
||||
xml += ' <DbtrAgt>\n';
|
||||
xml += ' <FinInstnId>\n';
|
||||
xml += ` <BIC>${debtorBIC}</BIC>\n`;
|
||||
xml += ' </FinInstnId>\n';
|
||||
xml += ' </DbtrAgt>\n';
|
||||
}
|
||||
xml += ' <ChrgBr>SLEV</ChrgBr>\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 += ' <CdtTrfTxInf>\n';
|
||||
xml += ' <PmtId>\n';
|
||||
xml += ` <EndToEndId>${endToEndId}</EndToEndId>\n`;
|
||||
xml += ' </PmtId>\n';
|
||||
xml += ' <Amt>\n';
|
||||
xml += ` <InstdAmt Ccy="EUR">${payment.totalAmount.toFixed(2)}</InstdAmt>\n`;
|
||||
xml += ' </Amt>\n';
|
||||
if (payment.bic) {
|
||||
xml += ' <CdtrAgt>\n';
|
||||
xml += ' <FinInstnId>\n';
|
||||
xml += ` <BIC>${payment.bic}</BIC>\n`;
|
||||
xml += ' </FinInstnId>\n';
|
||||
xml += ' </CdtrAgt>\n';
|
||||
}
|
||||
xml += ' <Cdtr>\n';
|
||||
xml += ` <Nm>${creditorName}</Nm>\n`;
|
||||
xml += ' </Cdtr>\n';
|
||||
xml += ' <CdtrAcct>\n';
|
||||
xml += ' <Id>\n';
|
||||
xml += ` <IBAN>${payment.iban}</IBAN>\n`;
|
||||
xml += ' </Id>\n';
|
||||
xml += ' </CdtrAcct>\n';
|
||||
xml += ' <RmtInf>\n';
|
||||
xml += ` <Ustrd>${remittanceInfo}</Ustrd>\n`;
|
||||
xml += ' </RmtInf>\n';
|
||||
xml += ' </CdtTrfTxInf>\n';
|
||||
});
|
||||
|
||||
xml += ' </PmtInf>\n';
|
||||
xml += ' </CstmrCdtTrfInitn>\n';
|
||||
xml += '</Document>';
|
||||
|
||||
// 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 });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue