espace-paie-odentas/app/api/virements-salaires/export-sepa/route.ts
odentas 91e0919274 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
2025-11-03 00:08:21 +01:00

377 lines
13 KiB
TypeScript

// 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 });
}
}