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: 19 additions & 3 deletions backend/src/controllers/transactionAuditController.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@ const listQuerySchema = z.object({
employeeId: z.string().optional(),
asset: z.string().optional(),
type: z.enum(['all', 'transaction', 'contract_event']).default('all'),
sort: z
.enum(['created_at', 'stellar_created_at', 'source_account', 'fee_charged', 'operation_count'])
.default('created_at'),
order: z.enum(['asc', 'desc']).default('desc'),
});

export class TransactionAuditController {
Expand Down Expand Up @@ -63,19 +67,31 @@ export class TransactionAuditController {
*/
static async listAuditRecords(req: Request, res: Response) {
try {
const { page, limit, sourceAccount, dateStart, dateEnd, status, employeeId, asset, type } =
listQuerySchema.parse(req.query);
const {
page,
limit,
sourceAccount,
dateStart,
dateEnd,
status,
employeeId,
asset,
type,
sort,
order,
} = listQuerySchema.parse(req.query);
const result = await TransactionAuditService.list(
page,
limit,
sourceAccount as string | undefined,
{ dateStart, dateEnd, status, employeeId, asset, type }
{ dateStart, dateEnd, status, employeeId, asset, type, sort, order }
);

res.json({
data: result.data,
total: result.total,
page,
limit,
totalPages: Math.ceil(result.total / limit),
});
} catch (error) {
Expand Down
145 changes: 124 additions & 21 deletions backend/src/routes/payroll.routes.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Request, Response, Router } from 'express';
import { z } from 'zod';
import { payrollQueryService } from '../services/payroll-query.service.js';
import logger from '../utils/logger.js';
import { authenticateJWT } from '../middlewares/auth.js';
Expand All @@ -17,6 +18,22 @@ function asString(value: unknown): string | undefined {
return undefined;
}

const payrollTransactionsQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['timestamp', 'amount', 'employeeId']).optional(),
order: z.enum(['asc', 'desc']).optional(),
sortBy: z.enum(['timestamp', 'amount', 'employeeId']).optional(),
sortOrder: z.enum(['asc', 'desc']).optional(),
});

const payrollListQuerySchema = z.object({
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
sort: z.enum(['timestamp', 'amount', 'employeeId']).default('timestamp'),
order: z.enum(['asc', 'desc']).default('desc'),
});

// Apply authentication to all payroll routes
router.use(authenticateJWT);

Expand All @@ -40,9 +57,10 @@ router.use(isolateOrganization);
* - startDate: Start date (ISO 8601)
* - endDate: End date (ISO 8601)
* - page: Page number (default: 1)
* - limit: Records per page (default: 50, max: 500)
* - sortBy: Sort field (timestamp, amount, employeeId)
* - sortOrder: Sort order (asc, desc)
* - limit: Records per page (default: 20, max: 100)
* - sort: Sort field (timestamp, amount, employeeId)
* - order: Sort order (asc, desc)
* - sortBy/sortOrder: Backwards-compatible aliases for sort/order
*/
router.get('/transactions', async (req: Request, res: Response) => {
try {
Expand All @@ -56,6 +74,8 @@ router.get('/transactions', async (req: Request, res: Response) => {
endDate,
page,
limit,
sort,
order,
sortBy,
sortOrder,
} = req.query;
Expand All @@ -67,6 +87,24 @@ router.get('/transactions', async (req: Request, res: Response) => {
});
}

const paginationResult = payrollTransactionsQuerySchema.safeParse({
page: asString(page),
limit: asString(limit),
sort: asString(sort),
order: asString(order),
sortBy: asString(sortBy),
sortOrder: asString(sortOrder),
});
if (!paginationResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: paginationResult.error.issues,
});
}

const pagination = paginationResult.data;
const selectedSort = pagination.sort ?? pagination.sortBy ?? 'timestamp';
const selectedOrder = pagination.order ?? pagination.sortOrder ?? 'desc';
const query = {
organizationPublicKey: orgPublicKeyStr,
employeeId: asString(employeeId),
Expand All @@ -77,15 +115,23 @@ router.get('/transactions', async (req: Request, res: Response) => {
endDate: asString(endDate) ? new Date(asString(endDate)!) : undefined,
};

const result = await payrollQueryService.queryPayroll(query, Number(page), Number(limit), {
enrichPayrollData: true,
sortBy: (sortBy as any) || 'timestamp',
sortOrder: (sortOrder as any) || 'desc',
});
const result = await payrollQueryService.queryPayroll(
query,
pagination.page,
pagination.limit,
{
enrichPayrollData: true,
sortBy: selectedSort,
sortOrder: selectedOrder,
}
);

res.json({
success: true,
data: result,
data: {
...result,
totalPages: result.pageCount,
},
});
} catch (error) {
logger.error('GET /api/payroll/transactions failed', error);
Expand All @@ -103,7 +149,7 @@ router.get('/transactions', async (req: Request, res: Response) => {
router.get('/employees/:employeeId', async (req: Request, res: Response) => {
try {
const { employeeId } = req.params;
const { orgPublicKey, startDate, endDate, page, limit } = req.query;
const { orgPublicKey, startDate, endDate, page, limit, sort, order } = req.query;

const orgPublicKeyStr = asString(orgPublicKey);
if (!orgPublicKeyStr) {
Expand All @@ -112,18 +158,37 @@ router.get('/employees/:employeeId', async (req: Request, res: Response) => {
});
}

const paginationResult = payrollListQuerySchema.safeParse({
page: asString(page),
limit: asString(limit),
sort: asString(sort),
order: asString(order),
});
if (!paginationResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: paginationResult.error.issues,
});
}

const pagination = paginationResult.data;
const result = await payrollQueryService.getEmployeePayroll(
orgPublicKeyStr,
employeeId as string,
asString(startDate) ? new Date(asString(startDate)!) : undefined,
asString(endDate) ? new Date(asString(endDate)!) : undefined,
Number(page),
Number(limit)
pagination.page,
pagination.limit,
pagination.sort,
pagination.order
);

res.json({
success: true,
data: result,
data: {
...result,
totalPages: result.pageCount,
},
});
} catch (error) {
logger.error(`GET /api/payroll/employees/${req.params.employeeId} failed`, error);
Expand Down Expand Up @@ -177,7 +242,7 @@ router.get('/employees/:employeeId/summary', async (req: Request, res: Response)
router.get('/batches/:batchId', async (req: Request, res: Response) => {
try {
const { batchId } = req.params;
const { orgPublicKey, page, limit } = req.query;
const { orgPublicKey, page, limit, sort, order } = req.query;

const orgPublicKeyStr = asString(orgPublicKey);
if (!orgPublicKeyStr) {
Expand All @@ -186,16 +251,35 @@ router.get('/batches/:batchId', async (req: Request, res: Response) => {
});
}

const paginationResult = payrollListQuerySchema.safeParse({
page: asString(page),
limit: asString(limit),
sort: asString(sort),
order: asString(order),
});
if (!paginationResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: paginationResult.error.issues,
});
}

const pagination = paginationResult.data;
const result = await payrollQueryService.getPayrollBatch(
orgPublicKeyStr,
batchId as string,
Number(page),
Number(limit)
pagination.page,
pagination.limit,
pagination.sort,
pagination.order
);

res.json({
success: true,
data: result,
data: {
...result,
totalPages: result.pageCount,
},
});
} catch (error) {
logger.error(`GET /api/payroll/batches/${req.params.batchId} failed`, error);
Expand Down Expand Up @@ -282,7 +366,7 @@ router.get('/audit', async (req: Request, res: Response) => {
*/
router.get('/search/memo', async (req: Request, res: Response) => {
try {
const { orgPublicKey, pattern, page, limit } = req.query;
const { orgPublicKey, pattern, page, limit, sort, order } = req.query;

const orgPublicKeyStr = asString(orgPublicKey);
const patternStr = asString(pattern);
Expand All @@ -292,16 +376,35 @@ router.get('/search/memo', async (req: Request, res: Response) => {
});
}

const paginationResult = payrollListQuerySchema.safeParse({
page: asString(page),
limit: asString(limit),
sort: asString(sort),
order: asString(order),
});
if (!paginationResult.success) {
return res.status(400).json({
error: 'Validation Error',
details: paginationResult.error.issues,
});
}

const pagination = paginationResult.data;
const result = await payrollQueryService.searchByMemoPattern(
orgPublicKeyStr,
patternStr,
Number(page),
Number(limit)
pagination.page,
pagination.limit,
pagination.sort,
pagination.order
);

res.json({
success: true,
data: result,
data: {
...result,
totalPages: result.pageCount,
},
});
} catch (error) {
logger.error('GET /api/payroll/search/memo failed', error);
Expand Down
8 changes: 6 additions & 2 deletions backend/src/schemas/employeeSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,12 +16,16 @@ export const createEmployeeSchema = z.object({
export const updateEmployeeSchema = createEmployeeSchema.partial().omit({ organization_id: true });

export const employeeQuerySchema = z.object({
page: z.string().regex(/^\d+$/).transform(Number).optional().default('1' as any), // Fix: mismatch between number and string default
limit: z.string().regex(/^\d+$/).transform(Number).optional().default('10' as any), // Fix: mismatch between number and string default
page: z.coerce.number().int().min(1).default(1),
limit: z.coerce.number().int().min(1).max(100).default(20),
search: z.string().optional(),
status: z.enum(['active', 'inactive', 'pending']).optional(),
department: z.string().optional(),
organization_id: z.string().regex(/^\d+$/).transform(Number).optional(),
sort: z
.enum(['created_at', 'first_name', 'last_name', 'email', 'department', 'status'])
.default('created_at'),
order: z.enum(['asc', 'desc']).default('desc'),
});

export type CreateEmployeeInput = z.infer<typeof createEmployeeSchema>;
Expand Down
27 changes: 27 additions & 0 deletions backend/src/services/__tests__/employeeService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,33 @@ describe('EmployeeService', () => {
);
});

it('preserves total and filters when a requested employee page is past the end', async () => {
(mockPool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [] })
.mockResolvedValueOnce({ rows: [{ total_count: '17' }] });

const result = await employeeService.findAll(
42,
{ page: 5, limit: 10, department: 'IT', search: 'Jane' } as any
);

expect(result.data).toEqual([]);
expect(result.pagination).toEqual({ total: 17, page: 5, limit: 10, totalPages: 2 });
expect(mockPool.query).toHaveBeenCalledTimes(2);

const [pageSql, pageArgs] = (mockPool.query as jest.Mock).mock.calls[0];
const [countSql, countArgs] = (mockPool.query as jest.Mock).mock.calls[1];
expect(pageSql).toContain('count(*) OVER() as total_count');
expect(countSql).toContain('SELECT COUNT(*) AS total_count');
expect(countSql).not.toContain('LIMIT');
expect(countSql).toContain('organization_id = $');
expect(countSql).toContain('department = $');
expect(countSql).toContain('first_name ILIKE $');
expect(countArgs).toEqual([42, 'IT', '%Jane%']);
expect(pageSql).toContain('LIMIT $4 OFFSET $5');
expect(pageArgs).toEqual([42, 'IT', '%Jane%', 10, 40]);
});

it('should filter by department', async () => {
(mockPool.query as jest.Mock).mockResolvedValueOnce({
rows: [],
Expand Down
29 changes: 27 additions & 2 deletions backend/src/services/__tests__/transactionAuditService.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,10 +79,35 @@ describe('TransactionAuditService', () => {
});

const countQuery = (mockPool.query as jest.Mock).mock.calls[0];
expect(countQuery[0]).toContain('pal.employee_id = $');
expect(countQuery[0]).toContain('pf.employee_id = $');
expect(countQuery[1]).toContain('emp-123');
});

it('uses matching payroll detail predicates for totals and page rows', async () => {
// Transactions with multiple payroll detail rows must preserve a match
// even if the selected employee/asset is not the aggregate maximum.
(mockPool.query as jest.Mock)
.mockResolvedValueOnce({ rows: [{ count: '1' }] })
.mockResolvedValueOnce({ rows: [{ id: 17, employee_name: 'Jane Doe', asset: 'USDC' }] });

const result = await TransactionAuditService.list(1, 20, undefined, {
employeeId: '3', asset: 'USDC',
});
expect(result.total).toBe(1);
expect(result.data).toHaveLength(1);
const [countSql, countArgs] = (mockPool.query as jest.Mock).mock.calls[0];
const [dataSql, dataArgs] = (mockPool.query as jest.Mock).mock.calls[1];
expect(countSql).toContain('EXISTS (SELECT 1 FROM payroll_audit_logs pf');
expect(dataSql).toContain('EXISTS (SELECT 1 FROM payroll_audit_logs pf');
expect(countSql).toContain('pf.employee_id = $1');
expect(dataSql).toContain('pf.asset_code = $2');
expect(countSql).toContain('COUNT(*) FROM transaction_audit_logs tal');
expect(dataSql).toContain('FROM payroll_audit_logs pf WHERE pf.employee_id = $1 AND pf.asset_code = $2');
expect(countArgs).toEqual(['3', 'USDC']);
expect(dataSql).toContain('LIMIT $3 OFFSET $4');
expect(dataArgs).toEqual(['3', 'USDC', 20, 0]);
});

it('should apply asset filter correctly', async () => {
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [{ count: '0' }] });
(mockPool.query as jest.Mock).mockResolvedValueOnce({ rows: [] });
Expand All @@ -92,7 +117,7 @@ describe('TransactionAuditService', () => {
});

const countQuery = (mockPool.query as jest.Mock).mock.calls[0];
expect(countQuery[0]).toContain('pal.asset_code = $');
expect(countQuery[0]).toContain('pf.asset_code = $');
expect(countQuery[1]).toContain('USDC');
});
});
Expand Down
Loading