espace-paie-odentas/app/api/facturation/route.ts
2025-10-12 17:05:46 +02:00

184 lines
6.9 KiB
TypeScript

// app/api/facturation/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';
// Lazily import AWS SDK pieces to avoid client bundle impact
async function getS3Presigner() {
const [{ S3Client, GetObjectCommand }, { getSignedUrl }] = await Promise.all([
import("@aws-sdk/client-s3"),
import("@aws-sdk/s3-request-presigner"),
]);
return { S3Client, GetObjectCommand, getSignedUrl };
}
function mapStatus(input?: string | null, row?: any):
| "payee"
| "annulee"
| "prete"
| "emise"
| "en_cours"
| string {
if (!input && row) {
if (row?.payment_date) return "payee";
return "emise";
}
const s = String(input || "").trim().toLowerCase();
if (!s) return "emise";
if (/(payée|payee|paid)/i.test(s)) return "payee";
if (/(annulée|annulee|cancell|cancel)/i.test(s)) return "annulee";
if (/(prête|prepar|ready|prete)/i.test(s)) return "prete";
if (/(en\s*cours|pending|due)/i.test(s)) return "en_cours";
if (/(émise|emise|issued)/i.test(s)) return "emise";
return s;
}
async function getOrganizationFromDatabase(supabase: any, userId: string) {
try {
const { data: memberData } = await supabase
.from('organization_members')
.select('org_id')
.eq('user_id', userId)
.single();
if (!memberData?.org_id) return null;
const { data: orgData } = await supabase
.from('organizations')
.select('structure_api')
.eq('id', memberData.org_id)
.single();
if (!orgData?.structure_api) return null;
return { id: memberData.org_id as string, name: orgData.structure_api as string, isStaff: false };
} catch {
return null;
}
}
async function getClientInfoFromSession(session: any, supabase: any) {
const userMeta = session?.user?.user_metadata || {};
const appMeta = session?.user?.app_metadata || {};
// Priorité: table staff_users côté serveur
let isStaff = false;
try {
const { data: staffRow } = await supabase.from('staff_users').select('is_staff').eq('user_id', session.user.id).maybeSingle();
isStaff = !!staffRow?.is_staff;
} catch (e) {
isStaff = Boolean((userMeta.is_staff === true || userMeta.role === 'staff') || (Array.isArray(appMeta.roles) && appMeta.roles.includes('staff')));
}
if (isStaff) {
const cookieStore = cookies();
const activeOrgId = cookieStore.get('active_org_id')?.value;
if (!activeOrgId) {
return { id: null, name: 'Staff Access', isStaff: true };
}
const { data: orgData } = await supabase.from('organizations').select('structure_api').eq('id', activeOrgId).single();
return { id: activeOrgId, name: orgData?.structure_api || 'Staff Access', isStaff: true };
}
const orgInfo = await getOrganizationFromDatabase(supabase, session.user.id);
if (!orgInfo) throw new Error('User is not associated with any organization');
return orgInfo;
}
export async function GET(req: Request) {
try {
const url = new URL(req.url);
const page = Math.max(1, parseInt(url.searchParams.get('page') || '1', 10));
const limit = Math.max(1, Math.min(50, parseInt(url.searchParams.get('limit') || '25', 10)));
const from = (page - 1) * limit;
const to = from + limit - 1;
const supabase = createRouteHandlerClient({ cookies });
const { data: { session } } = await supabase.auth.getSession();
if (!session) return NextResponse.json({ error: 'unauthorized' }, { status: 401 });
let clientInfo;
try {
clientInfo = await getClientInfoFromSession(session, supabase);
} catch (e) {
const message = e instanceof Error ? e.message : String(e);
return NextResponse.json({ error: 'forbidden', message }, { status: 403 });
}
// 1) SEPA info from organization_details
let details: any = null;
let detailsError: any = null;
if (clientInfo.id) {
const res = await supabase.from('organization_details').select('iban, bic').eq('org_id', clientInfo.id).maybeSingle();
details = res.data;
detailsError = res.error;
}
if (detailsError && detailsError.code !== 'PGRST116') {
console.error('[api/facturation] details error:', detailsError.message);
}
const sepa = {
enabled: Boolean(details?.iban && details?.bic),
iban: details?.iban || null,
bic: details?.bic || null,
mandate_url: null as string | null,
};
// 2) Invoices from Supabase
let query: any = supabase.from('invoices').select('*', { count: 'exact' });
if (clientInfo.id) {
query = query.eq('org_id', clientInfo.id);
}
query = query.order('invoice_date', { ascending: false, nullsFirst: false }).order('created_at', { ascending: false }).range(from, to);
const { data, error, count } = await query;
if (error) {
console.error('[api/facturation] invoices error:', error.message);
return NextResponse.json({ error: 'supabase_error', detail: error.message }, { status: 500 });
}
// 3) Presign S3 URLs for PDFs
let signer: any = null;
const bucket = (process.env.AWS_S3_BUCKET || 'odentas-docs').trim();
const region = process.env.AWS_REGION || 'eu-west-3';
const expireSeconds = Math.max(60, Math.min(60 * 60, Number(process.env.INVOICE_URL_EXPIRES ?? 900)));
const maybeSign = async (key?: string | null) => {
if (!key) return null;
try {
if (!signer) {
const { S3Client, GetObjectCommand, getSignedUrl } = await getS3Presigner();
signer = { S3Client, GetObjectCommand, getSignedUrl, client: new S3Client({ region }) };
}
const cmd = new signer.GetObjectCommand({ Bucket: bucket, Key: key });
const url = await signer.getSignedUrl(signer.client, cmd, { expiresIn: expireSeconds });
return url as string;
} catch (e) {
console.error('[api/facturation] presign error for key', key, e);
return null;
}
};
const items = await Promise.all((data || []).map(async (r: any) => ({
id: r.id as string,
numero: (r.invoice_number ?? null) as string | null,
periode: (r.period_label ?? null) as string | null,
date: (r.invoice_date ?? null) as string | null,
montant_ht: (typeof r.amount_ht === 'number' ? r.amount_ht : parseFloat(r.amount_ht || '0')) || 0,
montant_ttc: (typeof r.amount_ttc === 'number' ? r.amount_ttc : parseFloat(r.amount_ttc || '0')) || 0,
statut: mapStatus(r.status, r),
pdf: await maybeSign(r.pdf_s3_key || null),
})));
const total = typeof count === 'number' ? count : items.length;
const hasMore = from + items.length < total;
return NextResponse.json({
sepa,
factures: { items, page, limit, hasMore },
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return NextResponse.json({ error: 'internal_server_error', message }, { status: 500 });
}
}