Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions backend/src/routes/payroll.routes.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { getPayrollAnalytics } from '../services/payrollAnalyticsService.js';
import { Request, Response, Router } from 'express';
import { payrollQueryService } from '../services/payroll-query.service.js';
import logger from '../utils/logger.js';
Expand Down Expand Up @@ -428,4 +429,25 @@ router.get('/cache/stats', (req: Request, res: Response) => {
}
});

/**
* GET /api/payroll/analytics
* Employer-only, 12 calendar months of settled real payroll transactions.
* Tenant identity comes from the verified JWT; clients cannot select an
* arbitrary organization's payroll data with a query parameter.
*/
router.get('/analytics', authorizeRoles('EMPLOYER'), async (req: Request, res: Response) => {
const organizationId = req.user?.organizationId;
if (!Number.isSafeInteger(organizationId) || !organizationId || organizationId < 1) {
return res.status(403).json({ error: 'An authenticated organization is required' });
}

try {
const analytics = await getPayrollAnalytics(organizationId);
return res.json({ success: true, data: analytics });
} catch (error) {
logger.error('GET /api/payroll/analytics failed', error);
return res.status(500).json({ success: false, error: 'Unable to load payroll analytics' });
}
});

export default router;
90 changes: 90 additions & 0 deletions backend/src/services/payrollAnalyticsService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
import { pool } from '../config/database.js';

/**
* Completed real payroll transactions, grouped in PostgreSQL (not synthetic
* forecast points). Every query is restricted to the caller's verified tenant.
*
* Never add monetary amounts from different asset codes: the UI filters to one
* selected currency before drawing an amount-based trend or department chart.
* The currency breakdown reports transaction *counts*, not mixed-asset sums.
*/
export interface PayrollMonthlyRow {
month: string;
currency: string;
total: string;
}
export interface PayrollCurrencyRow {
currency: string;
paymentCount: string;
}
export interface PayrollDepartmentRow {
department: string;
currency: string;
total: string;
}
export interface PayrollAnalytics {
monthly: PayrollMonthlyRow[];
currencies: PayrollCurrencyRow[];
departments: PayrollDepartmentRow[];
windowMonths: 12;
}

export async function getPayrollAnalytics(organizationId: number): Promise<PayrollAnalytics> {
if (!Number.isSafeInteger(organizationId) || organizationId < 1) {
throw new TypeError('Authenticated organization ID must be a positive integer');
}
const periodStart =
"date_trunc('month', CURRENT_TIMESTAMP) - INTERVAL '11 months'";

// Include 12 calendar months including the current month. Only settled
// outbound payroll payments and bonuses count; exclude failed/refunded items.
const [monthly, currency, department] = await Promise.all([
pool.query<PayrollMonthlyRow>(
`SELECT to_char(date_trunc('month', t.created_at), 'YYYY-MM') AS month,
t.asset_code AS currency,
SUM(t.amount)::text AS total
FROM transactions t
WHERE t.organization_id = $1
AND t.status = 'completed'
AND t.transaction_type IN ('payment', 'bonus')
AND t.created_at >= ${periodStart}
GROUP BY date_trunc('month', t.created_at), t.asset_code
ORDER BY date_trunc('month', t.created_at), t.asset_code`,
[organizationId]
),
pool.query<PayrollCurrencyRow>(
`SELECT t.asset_code AS currency,
COUNT(*)::text AS "paymentCount"
FROM transactions t
WHERE t.organization_id = $1
AND t.status = 'completed'
AND t.transaction_type IN ('payment', 'bonus')
AND t.created_at >= ${periodStart}
GROUP BY t.asset_code
ORDER BY t.asset_code`,
[organizationId]
),
pool.query<PayrollDepartmentRow>(
`SELECT COALESCE(NULLIF(BTRIM(e.department), ''), 'Unassigned') AS department,
t.asset_code AS currency,
SUM(t.amount)::text AS total
FROM transactions t
LEFT JOIN employees e
ON e.id = t.employee_id AND e.organization_id = t.organization_id
WHERE t.organization_id = $1
AND t.status = 'completed'
AND t.transaction_type IN ('payment', 'bonus')
AND t.created_at >= ${periodStart}
GROUP BY COALESCE(NULLIF(BTRIM(e.department), ''), 'Unassigned'), t.asset_code
ORDER BY department, t.asset_code`,
[organizationId]
),
]);

return {
monthly: monthly.rows,
currencies: currency.rows,
departments: department.rows,
windowMonths: 12,
};
}
9 changes: 9 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import Forecasting from './pages/Forecasting';
import { Routes, Route, Navigate } from 'react-router-dom';
import { useEffect } from 'react';
import Home from './pages/Home';
Expand Down Expand Up @@ -97,6 +98,14 @@ function App() {
</ErrorBoundary>
}
/>
<Route
path="/forecast"
element={
<ErrorBoundary fallback={<ErrorFallback />}>
<Forecasting />
</ErrorBoundary>
}
/>
<Route
path="/reports"
element={
Expand Down
Loading