- 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
377 lines
13 KiB
TypeScript
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 });
|
|
}
|
|
}
|