- 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)
322 lines
12 KiB
TypeScript
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 });
|
|
}
|
|
}
|