espace-paie-odentas/app/api/staff/virements-salaires/generate-pdf/route.ts

488 lines
19 KiB
TypeScript

import { NextRequest, NextResponse } from "next/server";
import { S3Client, PutObjectCommand, GetObjectCommand } from "@aws-sdk/client-s3";
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
// =============================================================================
// Helper: Poll PDFMonkey document status
// =============================================================================
async function pollDocumentStatus(
documentUrl: string,
apiKey: string,
maxAttempts = 10,
intervalMs = 3000
): Promise<{ status: string; download_url?: string }> {
for (let i = 0; i < maxAttempts; i++) {
await new Promise((resolve) => setTimeout(resolve, intervalMs));
const res = await fetch(documentUrl, {
headers: { Authorization: `Bearer ${apiKey}` },
});
if (!res.ok) {
throw new Error(`Poll failed: ${res.statusText}`);
}
const data = await res.json();
const doc = data.document || data;
const status = doc.status;
if (status === "success") {
return { status, download_url: doc.download_url };
}
if (status === "failure") {
throw new Error("PDFMonkey document generation failed");
}
}
throw new Error("PDFMonkey polling timed out");
}
// =============================================================================
// Helper: Format date as DD/MM/YYYY
// =============================================================================
function formatDateFR(dateString: string | null | undefined): string {
if (!dateString) return "";
try {
const date = new Date(dateString);
return date.toLocaleDateString("fr-FR", {
day: "2-digit",
month: "2-digit",
year: "numeric",
});
} catch {
return "";
}
}
// =============================================================================
// Helper: Format amount
// =============================================================================
function formatAmount(amount: string | number | null | undefined): string {
if (!amount) return "0,00";
try {
const num = typeof amount === "string" ? parseFloat(amount) : amount;
return new Intl.NumberFormat("fr-FR", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
}).format(num);
} catch {
return "0,00";
}
}
// =============================================================================
// Helper: Format month period as "Mois YYYY"
// =============================================================================
function formatMonthPeriod(periodMonth: string | null | undefined): string {
if (!periodMonth) return "";
try {
const date = new Date(periodMonth);
return date.toLocaleDateString("fr-FR", {
month: "long",
year: "numeric",
});
} catch {
return "";
}
}
// =============================================================================
// Helper: Slugify organization name for S3 path
// =============================================================================
function slugify(text: string): string {
return text
.toString()
.toLowerCase()
.normalize("NFD") // Decompose accented characters
.replace(/[\u0300-\u036f]/g, "") // Remove diacritics
.replace(/[^a-z0-9]+/g, "-") // Replace non-alphanumeric with hyphens
.replace(/^-+|-+$/g, ""); // Remove leading/trailing hyphens
}
// =============================================================================
// POST /api/staff/virements-salaires/generate-pdf
// =============================================================================
export async function POST(req: NextRequest) {
try {
console.log("[generate-pdf] Starting PDF generation");
// 1) Check auth
const supabase = createRouteHandlerClient({ cookies });
const {
data: { session },
error: sessionError,
} = await supabase.auth.getSession();
if (sessionError || !session) {
console.error("[generate-pdf] Auth error:", sessionError);
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
const user = session.user;
console.log("[generate-pdf] User authenticated:", user.id);
// 2) Check if staff
const { data: staffData, error: staffError } = await supabase
.from("staff_users")
.select("is_staff")
.eq("user_id", user.id)
.maybeSingle();
const isStaff = staffData?.is_staff || false;
console.log("[generate-pdf] Is staff:", isStaff);
if (!isStaff) {
return NextResponse.json({ error: "Forbidden: staff only" }, { status: 403 });
}
// 3) Parse request body
const body = await req.json();
const { salary_transfer_id } = body;
console.log("[generate-pdf] Salary transfer ID:", salary_transfer_id);
if (!salary_transfer_id) {
return NextResponse.json({ error: "Missing salary_transfer_id" }, { status: 400 });
}
// 4) Get salary transfer details
console.log("[generate-pdf] Fetching salary transfer...");
const { data: salaryTransfer, error: stError } = await supabase
.from("salary_transfers")
.select("*")
.eq("id", salary_transfer_id)
.single();
if (stError || !salaryTransfer) {
console.error("[generate-pdf] Salary transfer fetch error:", stError);
return NextResponse.json({ error: "Salary transfer not found", details: stError?.message }, { status: 404 });
}
console.log("[generate-pdf] Salary transfer found:", { org_id: salaryTransfer.org_id, period_month: salaryTransfer.period_month });
// 5) Get organization details
console.log("[generate-pdf] Fetching organization and details...");
const { data: organization, error: orgError } = await supabase
.from("organizations")
.select("*")
.eq("id", salaryTransfer.org_id)
.single();
if (orgError || !organization) {
console.error("[generate-pdf] Organization fetch error:", orgError);
return NextResponse.json({ error: "Organization not found", details: orgError?.message }, { status: 404 });
}
console.log("[generate-pdf] Organization found:", organization.name);
// 5b) Get organization_details
const { data: orgDetails, error: orgDetailsError } = await supabase
.from("organization_details")
.select("*")
.eq("org_id", salaryTransfer.org_id)
.single();
if (orgDetailsError) {
console.warn("[generate-pdf] orgDetails fetch error:", orgDetailsError);
}
console.log("[generate-pdf] Organization details loaded:", orgDetails ? "✓" : "✗");
// 6) Get payslips for this period
console.log("[generate-pdf] Fetching payslips for period...");
const periodMonth = salaryTransfer.period_month; // Already in YYYY-MM-01 format
console.log("[generate-pdf] Period month:", periodMonth);
console.log("[generate-pdf] Organization ID:", salaryTransfer.org_id);
const { data: payslips, error: payslipsError } = await supabase
.from("payslips")
.select(`
*,
cddu_contracts (
employee_matricule,
contract_number,
analytique,
profession,
salaries (
nom,
prenom,
iban
)
)
`)
.eq("organization_id", salaryTransfer.org_id)
.eq("period_month", periodMonth);
console.log("[generate-pdf] Payslips query result:", {
error: payslipsError,
count: payslips?.length || 0,
sample: payslips?.[0] ? {
id: payslips[0].id,
net_amount: payslips[0].net_amount,
has_contract: !!payslips[0].cddu_contracts,
has_salarie: !!payslips[0].cddu_contracts?.salaries
} : null
});
if (payslipsError) {
console.error("[generate-pdf] Payslips fetch error:", payslipsError);
return NextResponse.json({
error: "Failed to fetch payslips",
details: (payslipsError as any)?.message || "Unknown error",
debugInfo: {
org_id: salaryTransfer.org_id,
period_month: periodMonth
}
}, { status: 500 });
}
if (!payslips || payslips.length === 0) {
console.warn("[generate-pdf] No payslips found for this period!");
console.warn("[generate-pdf] Query params:", {
organization_id: salaryTransfer.org_id,
period_month: periodMonth
});
// Continue anyway - we'll generate a PDF with empty payslips array
} else {
console.log("[generate-pdf] Found", payslips.length, "payslips");
// Log details about contracts
const withContracts = payslips.filter(p => p.cddu_contracts).length;
const withSalaries = payslips.filter(p => p.cddu_contracts?.salaries).length;
console.log("[generate-pdf] Payslips with contracts:", withContracts);
console.log("[generate-pdf] Payslips with salaries:", withSalaries);
// Log first payslip sample
console.log("[generate-pdf] First payslip sample:", JSON.stringify(payslips[0], null, 2));
}
// 7) Build PDFMonkey payload
console.log("═══════════════════════════════════════════════════════════");
console.log("[generate-pdf] 🔍 BUILDING PDFMONKEY PAYLOAD");
console.log("[generate-pdf] 📊 Payslips count:", payslips?.length || 0);
console.log("═══════════════════════════════════════════════════════════");
// Build line items for payslips
const lineItems = (payslips || []).map((p: any) => {
const contract = p.cddu_contracts;
const salarie = contract?.salaries;
// Get employee name
const employee_name = `${salarie?.prenom || ""} ${salarie?.nom || ""}`.trim();
console.log("[generate-pdf] 👤 Processing payslip:", {
payslip_id: p.id,
has_contract: !!contract,
has_salarie: !!salarie,
employee_name,
net_amount: p.net_amount
});
return {
employee_name,
matricule: contract?.employee_matricule || "",
contrat: contract?.contract_number || "",
montant: parseFloat(p.net_amount || 0),
analytique: contract?.analytique || "",
profession: contract?.profession || "",
};
});
console.log("[generate-pdf] ✅ Line items built:", lineItems.length, "items");
if (lineItems.length > 0) {
console.log("[generate-pdf] 📝 First line item:", JSON.stringify(lineItems[0], null, 2));
}
// Calculate totals
const totalNet = (payslips || []).reduce((sum: number, p: any) => {
const net = typeof p.net_amount === "string" ? parseFloat(p.net_amount) : (p.net_amount || 0);
return sum + net;
}, 0);
console.log("[generate-pdf] Total net amount:", totalNet);
// Get callsheet date (date d'appel)
const callsheetDate = formatDateFR(salaryTransfer.callsheet_date || salaryTransfer.created_at);
// Get limit date (date d'échéance)
const limitDate = formatDateFR(salaryTransfer.deadline);
// Calculate number of payslips
const nbrePayslips = payslips?.length || 0;
// Generate transfer reference using code_employeur from organization_details
const codeEmployeur = orgDetails?.code_employeur || organization.code || "ORG";
const transferReference = `${codeEmployeur}-${salaryTransfer.num_appel || "00000"}`;
console.log("═══════════════════════════════════════════════════════════");
console.log("[generate-pdf] 📋 PAYLOAD SUMMARY:");
console.log("[generate-pdf] Transfer reference:", transferReference);
console.log("[generate-pdf] Total net:", totalNet);
console.log("[generate-pdf] Number of payslips:", nbrePayslips);
console.log("[generate-pdf] Number of line items:", lineItems.length);
console.log("═══════════════════════════════════════════════════════════");
const pdfMonkeyPayload = {
document: {
document_template_id: "F4BCB5FF-1AB1-4CEE-B57F-82A6B9893E9E",
status: "pending",
payload: {
// Client information (simplified from organization)
client_address: orgDetails?.adresse || "",
client_cp: orgDetails?.cp || "",
client_city: orgDetails?.ville || "",
client_code: organization.code || "",
client_name: organization.name || "",
// Callsheet information
callsheet_date: callsheetDate,
limit_date: limitDate,
callsheet_number: salaryTransfer.num_appel || "00000",
transfer_method: salaryTransfer.mode || "Virement SEPA",
// Period and amounts
periode: formatMonthPeriod(salaryTransfer.period_month),
total_salaires_nets: totalNet,
solde_compte_client: 0.0, // À implémenter si nécessaire
total_transfer: totalNet, // Pour l'instant, même que total_salaires_nets
deposit: 0.0, // À implémenter si nécessaire
// Payslip count and reference
nbre_paies: nbrePayslips,
transfer_reference: transferReference,
// Line items (payslips array)
lineItems: lineItems,
},
meta: {},
},
};
console.log("═══════════════════════════════════════════════════════════");
console.log("[generate-pdf] 🚀 FINAL PAYLOAD TO PDFMONKEY:");
console.log("[generate-pdf] Payload prepared with", lineItems.length, "payslips");
console.log("[generate-pdf] lineItems in payload:", pdfMonkeyPayload.document.payload.lineItems.length);
console.log("═══════════════════════════════════════════════════════════");
if (lineItems.length === 0) {
console.warn("[generate-pdf] ⚠️ WARNING: Payload has no payslips!");
console.warn("[generate-pdf] This will generate an empty PDF!");
} else {
console.log("[generate-pdf] ✅ Sample line item in payload:", JSON.stringify(lineItems[0], null, 2));
}
// 8) Call PDFMonkey API
console.log("[generate-pdf] 📤 Calling PDFMonkey API...");
const pdfMonkeyUrl = process.env.PDFMONKEY_URL || "https://api.pdfmonkey.io/api/v1/documents";
const pdfMonkeyApiKey = process.env.PDFMONKEY_API_KEY;
if (!pdfMonkeyApiKey) {
console.error("[generate-pdf] Missing PDFMONKEY_API_KEY");
return NextResponse.json({ error: "Missing PDFMONKEY_API_KEY" }, { status: 500 });
}
const createRes = await fetch(pdfMonkeyUrl, {
method: "POST",
headers: {
Authorization: `Bearer ${pdfMonkeyApiKey}`,
"Content-Type": "application/json",
},
body: JSON.stringify(pdfMonkeyPayload),
});
if (!createRes.ok) {
const errorText = await createRes.text();
console.error("[generate-pdf] PDFMonkey create error:", errorText);
return NextResponse.json(
{ error: "PDFMonkey API error", details: errorText },
{ status: createRes.status }
);
}
const createData = await createRes.json();
console.log("[generate-pdf] PDFMonkey response:", createData);
const documentId = createData.document?.id;
if (!documentId) {
console.error("[generate-pdf] No document ID in response");
return NextResponse.json({ error: "No document ID returned from PDFMonkey" }, { status: 500 });
}
// Construct the document URL for polling
const documentUrl = `${pdfMonkeyUrl}/${documentId}`;
console.log("[generate-pdf] Document URL for polling:", documentUrl);
// 9) Poll for completion
console.log("[generate-pdf] Polling document status...");
const { status, download_url } = await pollDocumentStatus(documentUrl, pdfMonkeyApiKey, 15, 3000);
console.log("[generate-pdf] Poll result:", { status, has_download_url: !!download_url });
if (status !== "success" || !download_url) {
console.error("[generate-pdf] PDF generation failed or timed out");
return NextResponse.json({ error: "PDF generation failed or timed out", status }, { status: 500 });
}
// 10) Download PDF
console.log("[generate-pdf] Downloading PDF from:", download_url);
const pdfRes = await fetch(download_url);
if (!pdfRes.ok) {
console.error("[generate-pdf] Failed to download PDF:", pdfRes.status, pdfRes.statusText);
return NextResponse.json({ error: "Failed to download PDF" }, { status: 500 });
}
const pdfBuffer = Buffer.from(await pdfRes.arrayBuffer());
console.log("[generate-pdf] PDF downloaded, size:", pdfBuffer.length, "bytes");
// 11) Upload to S3
console.log("[generate-pdf] Uploading to S3...");
const s3Client = new S3Client({
region: process.env.AWS_REGION || "eu-west-3",
credentials: {
accessKeyId: process.env.AWS_ACCESS_KEY_ID!,
secretAccessKey: process.env.AWS_SECRET_ACCESS_KEY!,
},
});
const bucketName = "odentas-docs";
const clientSlug = slugify(organization.name || "client");
const s3Key = `documents/${clientSlug}/appel-virement/${salary_transfer_id}-${Date.now()}.pdf`;
console.log("[generate-pdf] S3 target:", { bucket: bucketName, key: s3Key });
const uploadCommand = new PutObjectCommand({
Bucket: bucketName,
Key: s3Key,
Body: pdfBuffer,
ContentType: "application/pdf",
});
await s3Client.send(uploadCommand);
console.log("[generate-pdf] S3 upload successful");
// Generate a presigned URL valid for 7 days
const getObjectCommand = new GetObjectCommand({
Bucket: bucketName,
Key: s3Key,
});
const s3Url = await getSignedUrl(s3Client, getObjectCommand, { expiresIn: 604800 }); // 7 days in seconds
console.log("[generate-pdf] Presigned URL generated (expires in 7 days)");
// 12) Update salary_transfers with callsheet_url
console.log("[generate-pdf] Updating salary_transfers table...");
const { error: updateError } = await supabase
.from("salary_transfers")
.update({
callsheet_url: s3Url,
updated_at: new Date().toISOString(),
})
.eq("id", salary_transfer_id);
if (updateError) {
console.error("[generate-pdf] Failed to update salary_transfers:", updateError);
return NextResponse.json(
{ error: "PDF generated but failed to update database", url: s3Url, details: updateError.message },
{ status: 500 }
);
}
console.log("[generate-pdf] Database updated successfully");
// 13) Return success
console.log("[generate-pdf] PDF generation complete!");
return NextResponse.json({
success: true,
callsheet_url: s3Url,
payslips_count: payslips?.length || 0,
total_net: totalNet,
});
} catch (err: any) {
console.error("[generate-pdf] Unexpected error:", err);
console.error("[generate-pdf] Stack trace:", err.stack);
return NextResponse.json({
error: err.message || "Internal server error",
stack: process.env.NODE_ENV === 'development' ? err.stack : undefined
}, { status: 500 });
}
}