espace-paie-odentas/app/api/cotisations/mensuelles/route.ts
odentas 78c43f0bfa feat: Implémentation complète du système de permissions
- Créer lib/permissions.ts avec toutes les fonctions de vérification
- Protéger routes API: facturation, cotisations, virements (bloquer AGENT)
- Protéger routes API: contrats (bloquer COMPTA)
- Protéger routes API: gestion utilisateurs (bloquer AGENT/COMPTA)
- Empêcher ADMIN de modifier/révoquer/créer SUPER_ADMIN
- Ajouter documentation complète dans PERMISSIONS_MATRIX.md

Système à 5 niveaux:
- STAFF (équipe Odentas)
- SUPER_ADMIN (admin principal, 1 par org, protégé)
- ADMIN (admins secondaires)
- AGENT (opérationnel: contrats/paies/salariés)
- COMPTA (financier lecture seule: cotisations/virements/factures)
2025-11-14 20:25:30 +01:00

322 lines
12 KiB
TypeScript

// app/api/cotisations/mensuelles/route.ts
import { NextResponse } from "next/server";
import { createRouteHandlerClient } from "@supabase/auth-helpers-nextjs";
import { cookies } from "next/headers";
import { getUserPermissions, canAccessCotisations, checkPermissionOrRespond } from "@/lib/permissions";
export const dynamic = 'force-dynamic';
export const revalidate = 0;
export const runtime = 'nodejs';
type Ligne = {
mois: number;
annee: number;
status?: 'avenir' | 'en_cours' | 'ok';
segment?: string | null; // ex: RG, Intermittents
total: number;
urssaf: number;
fts: number; // France Travail Spectacle (ex Pôle Emploi Spectacle)
audiens_retraite: number;
audiens_prevoyance: number;
conges_spectacles: number;
prevoyance_rg: number;
pas: number;
};
const FR_MONTHS: Record<string, number> = {
jan: 1, janvier: 1,
fev: 2, fév: 2, fevrier: 2, février: 2,
mar: 3, mars: 3,
avr: 4, avril: 4,
mai: 5,
jun: 6, juin: 6,
jul: 7, juil: 7, juillet: 7,
aou: 8, août: 8, aout: 8,
sep: 9, sept: 9, septembre: 9,
oct: 10, octobre: 10,
nov: 11, novembre: 11,
dec: 12, décembre: 12, decembre: 12,
};
function parsePeriodLabel(label?: string | null): { y?: number; m?: number } {
if (!label) return {};
const s = label.toString().trim().toLowerCase();
const parts = s.split(/\s+/);
let y: number | undefined;
let m: number | undefined;
for (const p of parts) {
const mm = FR_MONTHS[p];
if (mm) m = mm;
const yMatch = p.match(/^(19|20)\d{2}$/);
if (yMatch) y = parseInt(yMatch[0], 10);
}
return { y, m };
}
function getPaymentStatusForPeriod(y: number, m: number, today: Date): 'avenir' | 'en_cours' | 'ok' {
// Payment window: 15 → 30 of the following month
const nextMonth = m === 12 ? 1 : m + 1;
const nextYear = m === 12 ? y + 1 : y;
const start = new Date(nextYear, nextMonth - 1, 15, 0, 0, 0, 0);
const lastDayOfNextMonth = new Date(nextYear, nextMonth, 0).getDate();
const endDay = Math.min(30, lastDayOfNextMonth);
const end = new Date(nextYear, nextMonth - 1, endDay, 23, 59, 59, 999);
if (today < start) return 'avenir';
if (today <= end) return 'en_cours';
return 'ok'; // après la fenêtre → considéré payé/OK par convention
}
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é: valeur côté serveur (table staff_users)
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 supabase = createRouteHandlerClient({ cookies });
// 🔒 VÉRIFICATION DES PERMISSIONS
const permissions = await getUserPermissions(supabase);
const permissionCheck = canAccessCotisations(permissions);
const errorResponse = checkPermissionOrRespond(permissionCheck);
if (errorResponse) return errorResponse;
const { searchParams } = new URL(req.url);
const year = parseInt(searchParams.get('year') || `${new Date().getFullYear()}`, 10);
const period = (searchParams.get('period') || 'toute_annee').toString();
const fromParam = searchParams.get('from') || undefined; // YYYY-MM-DD
const toParam = searchParams.get('to') || undefined; // YYYY-MM-DD
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 });
}
// Si staff, vérifier si un org_id override est fourni via headers (pour le sélecteur d'organisation)
if (clientInfo.isStaff) {
const headerOrgId = req.headers.get('x-active-org-id');
if (headerOrgId) {
const { data: orgData } = await supabase
.from('organizations')
.select('structure_api')
.eq('id', headerOrgId)
.single();
if (orgData) {
clientInfo = {
id: headerOrgId,
name: orgData.structure_api || 'Staff Access',
isStaff: true
};
}
}
}
// Fetch all contributions for org (volume should be acceptable; filter in memory by period)
let query: any = supabase.from('monthly_contributions').select('*');
if (clientInfo.id) {
query = query.eq('org_id', clientInfo.id);
}
const { data, error } = await query;
if (error) {
console.error('[api/cotisations/mensuelles] supabase error:', error.message);
return NextResponse.json({ error: 'supabase_error', detail: error.message }, { status: 500 });
}
// Determine range of months based on filters
const monthsAllowed = new Set<number>();
const addRange = (mStart: number, mEnd: number) => { for (let m = mStart; m <= mEnd; m++) monthsAllowed.add(m); };
if (period === 'toute_annee') addRange(1, 12);
else if (period === 'premier_semestre') addRange(1, 6);
else if (period === 'second_semestre') addRange(7, 12);
else if (period.startsWith('trimestre_')) {
const q = parseInt(period.split('_')[1], 10) as 1 | 2 | 3 | 4;
const start = (q - 1) * 3 + 1; addRange(start, start + 2);
} else if (period.startsWith('mois_')) {
const m = parseInt(period.split('_')[1], 10); if (!isNaN(m)) addRange(m, m);
} else addRange(1, 12);
const today = new Date();
// Buckets dynamiques par (mois, segment) pour ne pas agréger RG vs Intermittents
const byKey: Record<string, Ligne> = {};
// Date helpers for from/to
// Force: ignore absolute from/to filtering (requested)
const fromDate = null;
const toDate = null;
for (const r of data || []) {
// Decide reference period (year, month)
const { y: yFromLabel, m: mFromLabel } = parsePeriodLabel(r.period_label as string | null);
let y = yFromLabel;
let m = mFromLabel;
// Fallback to due_date → month
if (!y || !m) {
const basis = r.due_date || r.paid_date || r.created_at;
if (basis) {
const d = new Date(basis);
if (!isNaN(d.getTime())) {
y = d.getFullYear();
m = d.getMonth() + 1;
}
}
}
if (!y || !m) continue;
// Filter by selected year and period
if (y !== year) continue;
if (!monthsAllowed.has(m)) continue;
// (from/to ignored for filtering)
// Amount precedence: due → paid → 0
const amount = (r.amount_due ?? r.amount_paid ?? 0) as number;
// Segment/group detection (contrib_type, sinon suffixe dans period_label : "RG" | "Int.")
const rawType = (r as any).contrib_type ?? (r as any).category ?? (r as any).type ?? (r as any).reference ?? '';
let segment: string | null = null;
if (typeof rawType === 'string' && rawType.trim()) segment = rawType.trim();
// Si non renseigné, inspecter la period_label pour les suffixes usuels
if (!segment && typeof r.period_label === 'string') {
const lbl = r.period_label.trim();
if (/\bRG\b\.?$/.test(lbl)) segment = 'RG';
else if (/\bInt\.?$/i.test(lbl)) segment = 'Int.'; // conserver l'abréviation visible
}
if (!segment) {
const s = String(r.status || '').toLowerCase();
if (/\brg\b/.test(s)) segment = 'RG';
else if (/inter/.test(s)) segment = 'Int.';
}
const key = `${y}-${m}-${segment || 'default'}`;
if (!byKey[key]) {
byKey[key] = {
mois: m,
annee: y,
segment,
total: 0,
urssaf: 0,
fts: 0,
audiens_retraite: 0,
audiens_prevoyance: 0,
conges_spectacles: 0,
prevoyance_rg: 0,
pas: 0,
};
}
// Map funds
const fund = String(r.fund || '').toUpperCase();
const row = byKey[key];
switch (fund) {
case 'URSSAF': row.urssaf += Number(amount) || 0; break;
case 'PE_SPECTACLE': row.fts += Number(amount) || 0; break;
case 'AUDIENS_RETRAITE': row.audiens_retraite += Number(amount) || 0; break;
case 'AUDIENS_PREVOYANCE': row.audiens_prevoyance += Number(amount) || 0; break;
case 'CONGES_SPECTACLES': row.conges_spectacles += Number(amount) || 0; break;
case 'PREVOYANCE_RG': row.prevoyance_rg += Number(amount) || 0; break;
case 'MUTUELLE_RG': row.prevoyance_rg += Number(amount) || 0; break; // agrégé dans prevoyance RG pour l'UI
case 'PAS': row.pas += Number(amount) || 0; break;
default: break;
}
// defer status computation to after aggregation
}
// Compute totals per (mois, segment)
Object.values(byKey).forEach((r) => {
r.total = r.urssaf + r.fts + r.audiens_retraite + r.audiens_prevoyance + r.conges_spectacles + r.prevoyance_rg + r.pas;
});
// Compute status per (mois, segment) after aggregation based on window only
Object.values(byKey).forEach((r) => {
r.status = getPaymentStatusForPeriod(r.annee, r.mois, today);
});
// Build only periods that exist; sort desc (recent → ancien), puis segment alpha
const items: Ligne[] = Object.values(byKey)
.filter((r) => monthsAllowed.has(r.mois))
.sort((a, b) => {
if (a.annee !== b.annee) return b.annee - a.annee;
if (a.mois !== b.mois) return b.mois - a.mois;
const sa = (a.segment || '').toLowerCase();
const sb = (b.segment || '').toLowerCase();
return sa.localeCompare(sb);
});
// Total line
const total: Ligne = items.reduce<Ligne>((acc, r) => ({
mois: 0,
annee: year,
status: acc.status || r.status,
segment: null,
total: acc.total + r.total,
urssaf: acc.urssaf + r.urssaf,
fts: acc.fts + r.fts,
audiens_retraite: acc.audiens_retraite + r.audiens_retraite,
audiens_prevoyance: acc.audiens_prevoyance + r.audiens_prevoyance,
conges_spectacles: acc.conges_spectacles + r.conges_spectacles,
prevoyance_rg: acc.prevoyance_rg + r.prevoyance_rg,
pas: acc.pas + r.pas,
}), { mois: 0, annee: year, status: undefined, segment: null, total: 0, urssaf: 0, fts: 0, audiens_retraite: 0, audiens_prevoyance: 0, conges_spectacles: 0, prevoyance_rg: 0, pas: 0 });
return NextResponse.json({ items, total });
} catch (e: any) {
const message = e?.message || 'unknown';
return NextResponse.json({ error: 'internal_server_error', message }, { status: 500 });
}
}