From 078743a6b42088a141fd88e93c96b43d0898578b Mon Sep 17 00:00:00 2001 From: Banx17 Date: Thu, 28 May 2026 03:49:14 +0100 Subject: [PATCH 01/12] feat: add paginated dashboard activity feed --- .../api/v1/dashboard/activity/route.test.ts | 123 ++++++++++++++ src/app/api/v1/dashboard/activity/route.ts | 84 ++++++++++ src/server/services/activity.service.spec.ts | 87 ++++++++++ src/server/services/activity.service.ts | 156 ++++++++++++++++++ 4 files changed, 450 insertions(+) create mode 100644 src/app/api/v1/dashboard/activity/route.test.ts create mode 100644 src/app/api/v1/dashboard/activity/route.ts create mode 100644 src/server/services/activity.service.spec.ts create mode 100644 src/server/services/activity.service.ts diff --git a/src/app/api/v1/dashboard/activity/route.test.ts b/src/app/api/v1/dashboard/activity/route.test.ts new file mode 100644 index 00000000..b1df7522 --- /dev/null +++ b/src/app/api/v1/dashboard/activity/route.test.ts @@ -0,0 +1,123 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; +import { AuthUtils } from "@/server/utils/auth"; +import { ActivityService } from "@/server/services/activity.service"; +import { UnauthorizedError, ForbiddenError } from "@/server/utils/errors"; + +vi.mock("@/server/utils/auth"); +vi.mock("@/server/services/activity.service"); + +describe("GET /api/v1/dashboard/activity", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + const createMockRequest = (url = "http://localhost/api/v1/dashboard/activity") => + new NextRequest(url); + + it("should retrieve paginated recent activity with default pagination", async () => { + vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({ + userId: "user-123", + } as any); + vi.mocked(ActivityService.listRecentActivities).mockResolvedValue({ + data: [ + { + id: "transaction:tx-001", + sourceId: "tx-001", + type: "transaction", + title: "Deposit transaction", + description: "Deposit via monnify", + amount: "250000", + status: "completed", + timestamp: "2026-05-28T00:00:00.000Z", + metadata: { provider: "monnify" }, + }, + ], + meta: { + page: 1, + limit: 10, + total: 1, + totalPages: 1, + }, + }); + + const response = await GET(createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(200); + expect(body.success).toBe(true); + expect(body.message).toBe("Recent activities retrieved successfully"); + expect(body.data.data).toHaveLength(1); + expect(body.data.meta.total).toBe(1); + expect(ActivityService.listRecentActivities).toHaveBeenCalledWith( + "user-123", + 1, + 10, + ); + }); + + it("should pass explicit page and limit query parameters to the service", async () => { + vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({ + userId: "user-123", + } as any); + vi.mocked(ActivityService.listRecentActivities).mockResolvedValue({ + data: [], + meta: { + page: 2, + limit: 5, + total: 0, + totalPages: 0, + }, + }); + + await GET(createMockRequest("http://localhost/api/v1/dashboard/activity?page=2&limit=5")); + + expect(ActivityService.listRecentActivities).toHaveBeenCalledWith( + "user-123", + 2, + 5, + ); + }); + + it("should return 400 for invalid pagination query parameters", async () => { + vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({ + userId: "user-123", + } as any); + + const response = await GET( + createMockRequest("http://localhost/api/v1/dashboard/activity?page=0&limit=101"), + ); + const body = await response.json(); + + expect(response.status).toBe(400); + expect(body.success).toBe(false); + expect(body.message).toBe("Invalid query parameters"); + expect(ActivityService.listRecentActivities).not.toHaveBeenCalled(); + }); + + it("should return 401 for unauthorized access", async () => { + vi.mocked(AuthUtils.authenticateRequest).mockRejectedValue( + new UnauthorizedError(), + ); + + const response = await GET(createMockRequest()); + + expect(response.status).toBe(401); + }); + + it("should return 403 when the user has no organization", async () => { + vi.mocked(AuthUtils.authenticateRequest).mockResolvedValue({ + userId: "user-123", + } as any); + vi.mocked(ActivityService.listRecentActivities).mockRejectedValue( + new ForbiddenError("User is not associated with any organization"), + ); + + const response = await GET(createMockRequest()); + const body = await response.json(); + + expect(response.status).toBe(403); + expect(body.message).toBe("User is not associated with any organization"); + }); +}); diff --git a/src/app/api/v1/dashboard/activity/route.ts b/src/app/api/v1/dashboard/activity/route.ts new file mode 100644 index 00000000..29d8f079 --- /dev/null +++ b/src/app/api/v1/dashboard/activity/route.ts @@ -0,0 +1,84 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { ActivityService } from "@/server/services/activity.service"; + +const ActivityQuerySchema = z.object({ + page: z.coerce.number().int().min(1).default(1), + limit: z.coerce.number().int().min(1).max(100).default(10), +}); + +/** + * @swagger + * /dashboard/activity: + * get: + * summary: List recent dashboard activity + * description: Retrieve a chronologically ordered, paginated activity feed combining transactions, payroll runs, and created invoices for the authenticated user's organization. + * tags: [General] + * security: + * - bearerAuth: [] + * parameters: + * - in: query + * name: page + * schema: + * type: integer + * minimum: 1 + * default: 1 + * description: Page number + * - in: query + * name: limit + * schema: + * type: integer + * minimum: 1 + * maximum: 100 + * default: 10 + * description: Number of activity items per page + * responses: + * 200: + * description: Recent activities retrieved successfully + * 400: + * description: Invalid query parameters + * 401: + * description: Unauthorized + * 403: + * description: User not associated with any organization + */ +export async function GET(req: NextRequest) { + try { + const { userId } = await AuthUtils.authenticateRequest(req); + const { searchParams } = new URL(req.url); + + const parsed = ActivityQuerySchema.safeParse({ + page: searchParams.get("page") ?? undefined, + limit: searchParams.get("limit") ?? undefined, + }); + + if (!parsed.success) { + return ApiResponse.error( + "Invalid query parameters", + 400, + parsed.error.flatten().fieldErrors, + ); + } + + const activities = await ActivityService.listRecentActivities( + userId, + parsed.data.page, + parsed.data.limit, + ); + + return ApiResponse.success( + activities, + "Recent activities retrieved successfully", + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + + console.error("[Recent Activity Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} diff --git a/src/server/services/activity.service.spec.ts b/src/server/services/activity.service.spec.ts new file mode 100644 index 00000000..bb3df01a --- /dev/null +++ b/src/server/services/activity.service.spec.ts @@ -0,0 +1,87 @@ +import { describe, it, expect, beforeEach, vi } from "vitest"; +import { ActivityService } from "./activity.service"; +import { db } from "@/server/db"; +import { ForbiddenError } from "@/server/utils/errors"; + +vi.mock("@/server/db", () => ({ + db: { + select: vi.fn(), + execute: vi.fn(), + }, + users: { + id: "users.id", + organizationId: "users.organizationId", + }, +})); + +function mockOrganizationLookup(organizationId: string | null) { + vi.mocked(db.select).mockReturnValue({ + from: vi.fn().mockReturnValue({ + where: vi.fn().mockReturnValue({ + limit: vi.fn().mockResolvedValue([{ organizationId }]), + }), + }), + } as any); +} + +describe("ActivityService", () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it("should return a unified paginated activity feed", async () => { + mockOrganizationLookup("org-123"); + vi.mocked(db.execute) + .mockResolvedValueOnce({ + rows: [ + { + id: "transaction:tx-001", + sourceId: "tx-001", + type: "transaction", + title: "Deposit transaction", + description: "Deposit via monnify", + amount: "100000", + status: "completed", + timestamp: new Date("2026-05-28T10:00:00.000Z"), + metadata: { provider: "monnify" }, + }, + { + id: "invoice_created:inv-001", + sourceId: "inv-001", + type: "invoice_created", + title: "Invoice created", + description: "Invoice INV-001 created: May payroll", + amount: "500000", + status: "pending", + timestamp: "2026-05-27T10:00:00.000Z", + metadata: { invoiceNo: "INV-001" }, + }, + ], + } as never) + .mockResolvedValueOnce({ + rows: [{ total: "12" }], + } as never); + + const result = await ActivityService.listRecentActivities("user-123", 2, 5); + + expect(result.meta).toEqual({ + page: 2, + limit: 5, + total: 12, + totalPages: 3, + }); + expect(result.data).toHaveLength(2); + expect(result.data[0].timestamp).toBe("2026-05-28T10:00:00.000Z"); + expect(result.data[1].type).toBe("invoice_created"); + expect(db.execute).toHaveBeenCalledTimes(2); + }); + + it("should throw when the user is not associated with an organization", async () => { + mockOrganizationLookup(null); + + await expect( + ActivityService.listRecentActivities("user-123", 1, 10), + ).rejects.toBeInstanceOf(ForbiddenError); + expect(db.execute).not.toHaveBeenCalled(); + }); +}); diff --git a/src/server/services/activity.service.ts b/src/server/services/activity.service.ts new file mode 100644 index 00000000..a4405630 --- /dev/null +++ b/src/server/services/activity.service.ts @@ -0,0 +1,156 @@ +import { eq, sql } from "drizzle-orm"; +import { db, users } from "@/server/db"; +import { ForbiddenError } from "@/server/utils/errors"; +import { PaginatedResponse, toPaginatedResponse } from "@/types/pagination"; + +export type ActivityType = "transaction" | "payroll_run" | "invoice_created"; + +export interface ActivityItem { + id: string; + sourceId: string; + type: ActivityType; + title: string; + description: string; + amount: number | string | null; + status: string | null; + timestamp: string; + metadata: Record | null; +} + +interface ActivityQueryRow { + id: string; + sourceId: string; + type: ActivityType; + title: string; + description: string; + amount: number | string | null; + status: string | null; + timestamp: Date | string; + metadata: Record | null; +} + +interface CountQueryRow { + total: number | string | bigint; +} + +export class ActivityService { + static async listRecentActivities( + userId: string, + page: number, + limit: number, + ): Promise> { + const [user] = await db + .select({ organizationId: users.organizationId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.organizationId) { + throw new ForbiddenError("User is not associated with any organization"); + } + + const offset = (page - 1) * limit; + + const [activityResult, countResult] = await Promise.all([ + db.execute(sql` + with unified_activities as ( + select + concat('transaction:', ft.id::text) as id, + ft.id::text as "sourceId", + 'transaction' as type, + initcap(ft.type::text) || ' transaction' as title, + concat(initcap(ft.type::text), ' via ', ft.provider) as description, + ft.amount::text as amount, + ft.status::text as status, + ft.created_at as timestamp, + jsonb_build_object( + 'provider', ft.provider, + 'providerReference', ft.provider_reference, + 'transactionType', ft.type, + 'metadata', ft.metadata + ) as metadata + from fiat_transactions ft + where ft.organization_id = ${user.organizationId} + + union all + + select + concat('payroll_run:', i.id::text) as id, + i.id::text as "sourceId", + 'payroll_run' as type, + 'Payroll run completed' as title, + concat('Payroll processed for invoice ', i.invoice_no) as description, + i.amount::text as amount, + i.status::text as status, + i.updated_at as timestamp, + jsonb_build_object( + 'invoiceNo', i.invoice_no, + 'paidIn', i.paid_in, + 'employeeId', i.employee_id + ) as metadata + from invoices i + where i.organization_id = ${user.organizationId} + and i.status = 'paid' + + union all + + select + concat('invoice_created:', i.id::text) as id, + i.id::text as "sourceId", + 'invoice_created' as type, + 'Invoice created' as title, + concat('Invoice ', i.invoice_no, ' created: ', i.title) as description, + i.amount::text as amount, + i.status::text as status, + i.created_at as timestamp, + jsonb_build_object( + 'invoiceNo', i.invoice_no, + 'paidIn', i.paid_in, + 'employeeId', i.employee_id, + 'contractId', i.contract_id + ) as metadata + from invoices i + where i.organization_id = ${user.organizationId} + ) + select * + from unified_activities + order by timestamp desc + limit ${limit} + offset ${offset} + `), + db.execute(sql` + with unified_activities as ( + select ft.id + from fiat_transactions ft + where ft.organization_id = ${user.organizationId} + + union all + + select i.id + from invoices i + where i.organization_id = ${user.organizationId} + and i.status = 'paid' + + union all + + select i.id + from invoices i + where i.organization_id = ${user.organizationId} + ) + select count(*) as total + from unified_activities + `), + ]); + + const total = Number(countResult.rows[0]?.total ?? 0); + const activities = activityResult.rows.map((activity) => ({ + ...activity, + timestamp: + activity.timestamp instanceof Date + ? activity.timestamp.toISOString() + : new Date(activity.timestamp).toISOString(), + })); + + return toPaginatedResponse(activities, page, limit, total); + } +} From 23a38f76b998b5d57a0ae8b891e31adbcac40c32 Mon Sep 17 00:00:00 2001 From: shogun444 Date: Thu, 28 May 2026 16:11:15 +0530 Subject: [PATCH 02/12] feat: add downloadable PDF payslip generation API --- .../[runId]/payslip/[employeeId]/route.ts | 556 ++++++++++++++++++ 1 file changed, 556 insertions(+) create mode 100644 src/app/api/v1/payroll/[runId]/payslip/[employeeId]/route.ts diff --git a/src/app/api/v1/payroll/[runId]/payslip/[employeeId]/route.ts b/src/app/api/v1/payroll/[runId]/payslip/[employeeId]/route.ts new file mode 100644 index 00000000..864004d7 --- /dev/null +++ b/src/app/api/v1/payroll/[runId]/payslip/[employeeId]/route.ts @@ -0,0 +1,556 @@ +export const runtime = "nodejs"; + +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/server/db"; +import { + invoices, + employees, + organizations, + companyProfiles, +} from "@/server/db/schema"; +import { eq, and } from "drizzle-orm"; +import { AuthUtils } from "@/server/utils/auth"; +import { AppError } from "@/server/utils/errors"; +import { ApiResponse } from "@/server/utils/api-response"; +import jsPDF from "jspdf"; + +const UUID_REGEX = + /^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/; + +function isValidUUID(id: string): boolean { + return UUID_REGEX.test(id); +} + +function formatCurrency(amount: number, paidIn: string): string { + if (paidIn === "crypto") { + return `${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })} USDT`; + } + return `NGN ${amount.toLocaleString("en-US", { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`; +} + +function formatDate(date: Date | string): string { + return new Date(date).toLocaleDateString("en-GB", { + day: "2-digit", + month: "long", + year: "numeric", + }); +} + +/** + * @swagger + * /payroll/{runId}/payslip/{employeeId}: + * get: + * summary: Generate PDF payslip + * description: > + * Generates and returns a downloadable PDF payslip for a specific + * employee and payroll execution record (invoice). The requesting user + * must belong to the same organization as the employee and invoice. + * Taxes and deductions are not stored in the current schema; the PDF + * reflects only the actual disbursed amount from the database. + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: runId + * required: true + * schema: + * type: string + * format: uuid + * description: The invoice ID representing the payroll execution record (status must be "paid") + * - in: path + * name: employeeId + * required: true + * schema: + * type: string + * format: uuid + * description: The employee ID + * responses: + * 200: + * description: PDF payslip file + * content: + * application/pdf: + * schema: + * type: string + * format: binary + * 400: + * description: Invalid UUIDs in path + * 401: + * description: Unauthorized + * 403: + * description: User not associated with an organization + * 404: + * description: Payslip record not found + * 500: + * description: Internal server error + */ +export async function GET( + req: NextRequest, + { params }: { params: Promise<{ runId: string; employeeId: string }> } +) { + try { + const { runId, employeeId } = await params; + + // --- Validate path params --- + if (!isValidUUID(runId) || !isValidUUID(employeeId)) { + return ApiResponse.error("Invalid UUID in path parameters", 400); + } + + // --- Authenticate --- + const { user } = await AuthUtils.authenticateRequestOrRefreshCookie(req); + const organizationId = user.organizationId; + if (!organizationId) { + return ApiResponse.error( + "User is not associated with any organization", + 403 + ); + } + + // --- Fetch invoice (payroll execution record) --- + // runId == invoice.id. Invoice must be "paid", belong to the employee + // and belong to the authenticated user's organization. + const [invoice] = await db + .select({ + id: invoices.id, + invoiceNo: invoices.invoiceNo, + title: invoices.title, + amount: invoices.amount, + paidIn: invoices.paidIn, + status: invoices.status, + issueDate: invoices.issueDate, + }) + .from(invoices) + .where( + and( + eq(invoices.id, runId), + eq(invoices.employeeId, employeeId), + eq(invoices.organizationId, organizationId), + eq(invoices.status, "paid") + ) + ) + .limit(1); + + if (!invoice) { + return ApiResponse.error( + "Payslip not found. The invoice may not exist, may not be paid, or may not belong to this employee.", + 404 + ); + } + + // --- Fetch employee details --- + const [employee] = await db + .select({ + id: employees.id, + firstName: employees.firstName, + lastName: employees.lastName, + email: employees.email, + role: employees.role, + department: employees.department, + type: employees.type, + bankName: employees.bankName, + accountNumber: employees.accountNumber, + accountHolderName: employees.accountHolderName, + }) + .from(employees) + .where( + and( + eq(employees.id, employeeId), + eq(employees.organizationId, organizationId) + ) + ) + .limit(1); + + if (!employee) { + return ApiResponse.error("Employee not found", 404); + } + + // --- Fetch company profile and organization --- + const [[org], [companyProfile]] = await Promise.all([ + db + .select({ + name: organizations.name, + registeredStreet: organizations.registeredStreet, + registeredCity: organizations.registeredCity, + registeredState: organizations.registeredState, + registeredCountry: organizations.registeredCountry, + registrationNumber: organizations.registrationNumber, + }) + .from(organizations) + .where(eq(organizations.id, organizationId)) + .limit(1), + db + .select({ + brandName: companyProfiles.brandName, + registeredName: companyProfiles.registeredName, + registrationNumber: companyProfiles.registrationNumber, + address: companyProfiles.address, + city: companyProfiles.city, + country: companyProfiles.country, + }) + .from(companyProfiles) + .where(eq(companyProfiles.organizationId, organizationId)) + .limit(1), + ]); + + const companyName = + companyProfile?.brandName ?? org?.name ?? "Vestroll Inc."; + const companyAddress = companyProfile + ? `${companyProfile.address}, ${companyProfile.city}, ${companyProfile.country}` + : [ + org?.registeredStreet, + org?.registeredCity, + org?.registeredState, + org?.registeredCountry, + ] + .filter(Boolean) + .join(", ") || "N/A"; + const companyRegNo = + companyProfile?.registrationNumber ?? + org?.registrationNumber ?? + "N/A"; + + // --- Build PDF --- + const pdf = buildPayslipPDF({ + invoice, + employee, + companyName, + companyAddress, + companyRegNo, + }); + + const filename = `payslip-${invoice.invoiceNo}-${employee.firstName}-${employee.lastName}.pdf` + .toLowerCase() + .replace(/\s+/g, "-"); + + return new NextResponse(Buffer.from(pdf), { + status: 200, + headers: { + "Content-Type": "application/pdf", + "Content-Disposition": `attachment; filename="${filename}"`, + "Cache-Control": "no-store", + }, + }); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode); + } + console.error("[Payslip PDF Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} + +// --------------------------------------------------------------------------- +// PDF builder +// --------------------------------------------------------------------------- + +interface PayslipData { + invoice: { + id: string; + invoiceNo: string; + title: string; + amount: number; + paidIn: string; + status: string; + issueDate: Date | string; + }; + employee: { + firstName: string; + lastName: string; + email: string; + role: string; + department: string | null; + type: string; + bankName: string | null; + accountNumber: string | null; + accountHolderName: string | null; + }; + companyName: string; + companyAddress: string; + companyRegNo: string; +} + +function buildPayslipPDF(data: PayslipData): Uint8Array { + const { invoice, employee, companyName, companyAddress, companyRegNo } = data; + + // A4: 210mm x 297mm + const doc = new jsPDF({ orientation: "portrait", unit: "mm", format: "a4" }); + + const W = 210; + const MARGIN = 15; + const CONTENT_W = W - MARGIN * 2; + const PURPLE = "#5E2A8C"; + const LIGHT_PURPLE = "#EDE9FE"; + const DARK_TEXT = "#111827"; + const MUTED = "#6B7280"; + const BORDER = "#E5E7EB"; + const WHITE = "#FFFFFF"; + + let y = 0; + + // ---- HEADER BANNER ---- + doc.setFillColor(PURPLE); + doc.rect(0, 0, W, 28, "F"); + + doc.setTextColor(WHITE); + doc.setFontSize(20); + doc.setFont("helvetica", "bold"); + doc.text("VESTROLL", MARGIN, 13); + + doc.setFontSize(9); + doc.setFont("helvetica", "normal"); + doc.text("PAYSLIP", MARGIN, 19); + + // Status badge on the right + const statusText = "PAID"; + doc.setFillColor("#22C55E"); + doc.roundedRect(W - MARGIN - 22, 8, 22, 9, 2, 2, "F"); + doc.setFontSize(8); + doc.setFont("helvetica", "bold"); + doc.setTextColor(WHITE); + doc.text(statusText, W - MARGIN - 11, 13.5, { align: "center" }); + + y = 36; + + // ---- COMPANY & PAYSLIP INFO ROW ---- + doc.setTextColor(DARK_TEXT); + + // Left: Employer block + doc.setFontSize(9); + doc.setFont("helvetica", "bold"); + doc.setTextColor(MUTED); + doc.text("EMPLOYER", MARGIN, y); + + y += 5; + doc.setFont("helvetica", "bold"); + doc.setFontSize(11); + doc.setTextColor(DARK_TEXT); + doc.text(companyName, MARGIN, y); + + y += 5; + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.setTextColor(MUTED); + + // Wrap long address + const addressLines = doc.splitTextToSize(companyAddress, 85); + doc.text(addressLines, MARGIN, y); + y += addressLines.length * 4; + + doc.text(`Reg. No: ${companyRegNo}`, MARGIN, y); + + // Right: Payslip metadata + const rightX = W / 2 + 5; + let metaY = 36; + + const meta = [ + ["Payslip No:", invoice.invoiceNo], + ["Pay Date:", formatDate(invoice.issueDate)], + ["Pay Period:", invoice.title], + ["Payment Method:", invoice.paidIn === "crypto" ? "Crypto (USDT)" : "Fiat (NGN)"], + ["Contract Type:", employee.type], + ]; + + meta.forEach(([label, value]) => { + doc.setFontSize(8); + doc.setFont("helvetica", "bold"); + doc.setTextColor(MUTED); + doc.text(label, rightX, metaY); + + doc.setFont("helvetica", "normal"); + doc.setTextColor(DARK_TEXT); + doc.text(String(value), rightX + 38, metaY); + metaY += 6; + }); + + y = Math.max(y, metaY) + 6; + + // ---- DIVIDER ---- + doc.setDrawColor(BORDER); + doc.setLineWidth(0.4); + doc.line(MARGIN, y, W - MARGIN, y); + y += 8; + + // ---- EMPLOYEE INFO BLOCK ---- + doc.setFillColor(LIGHT_PURPLE); + doc.roundedRect(MARGIN, y, CONTENT_W, 28, 3, 3, "F"); + + doc.setFontSize(9); + doc.setFont("helvetica", "bold"); + doc.setTextColor(PURPLE); + doc.text("EMPLOYEE DETAILS", MARGIN + 5, y + 7); + + doc.setFont("helvetica", "bold"); + doc.setFontSize(12); + doc.setTextColor(DARK_TEXT); + doc.text( + `${employee.firstName} ${employee.lastName}`, + MARGIN + 5, + y + 14 + ); + + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.setTextColor(MUTED); + const empDetails = [ + employee.role, + employee.department ?? "", + employee.email, + ] + .filter(Boolean) + .join(" • "); + doc.text(empDetails, MARGIN + 5, y + 20); + + // Right side of employee block: bank info + const bankX = W / 2 + 5; + doc.setFontSize(8); + doc.setFont("helvetica", "bold"); + doc.setTextColor(MUTED); + doc.text("Bank:", bankX, y + 10); + doc.text("Account:", bankX, y + 16); + doc.text("Account Name:", bankX, y + 22); + + doc.setFont("helvetica", "normal"); + doc.setTextColor(DARK_TEXT); + doc.text(employee.bankName ?? "N/A", bankX + 28, y + 10); + doc.text(employee.accountNumber ?? "N/A", bankX + 28, y + 16); + doc.text(employee.accountHolderName ?? "N/A", bankX + 28, y + 22); + + y += 36; + + // ---- EARNINGS & DEDUCTIONS TABLE ---- + doc.setFontSize(10); + doc.setFont("helvetica", "bold"); + doc.setTextColor(DARK_TEXT); + doc.text("Earnings & Deductions", MARGIN, y); + y += 6; + + // Table header + doc.setFillColor(PURPLE); + doc.rect(MARGIN, y, CONTENT_W, 8, "F"); + + doc.setFontSize(8); + doc.setFont("helvetica", "bold"); + doc.setTextColor(WHITE); + doc.text("Description", MARGIN + 4, y + 5.5); + doc.text("Type", MARGIN + 90, y + 5.5); + doc.text("Amount", W - MARGIN - 4, y + 5.5, { align: "right" }); + y += 8; + + // Table rows + interface TableRow { + description: string; + type: string; + amount: string; + isCredit: boolean; + } + + const tableRows: TableRow[] = [ + { + description: invoice.title || "Gross Pay", + type: "Earnings", + amount: formatCurrency(invoice.amount, invoice.paidIn), + isCredit: true, + }, + { + description: "Income Tax", + type: "Deduction", + amount: "N/A", + isCredit: false, + }, + { + description: "Pension / Other Deductions", + type: "Deduction", + amount: "N/A", + isCredit: false, + }, + ]; + + tableRows.forEach((row, i) => { + const rowBg = i % 2 === 0 ? WHITE : "#F9FAFB"; + doc.setFillColor(rowBg); + doc.rect(MARGIN, y, CONTENT_W, 8, "F"); + + doc.setDrawColor(BORDER); + doc.setLineWidth(0.2); + doc.line(MARGIN, y + 8, W - MARGIN, y + 8); + + doc.setFont("helvetica", "normal"); + doc.setFontSize(8); + doc.setTextColor(DARK_TEXT); + doc.text(row.description, MARGIN + 4, y + 5.5); + + doc.setTextColor(row.isCredit ? "#22C55E" : MUTED); + doc.text(row.type, MARGIN + 90, y + 5.5); + + doc.setTextColor(DARK_TEXT); + doc.text(row.amount, W - MARGIN - 4, y + 5.5, { align: "right" }); + + y += 8; + }); + + y += 4; + + // ---- NET PAY BOX ---- + doc.setFillColor(PURPLE); + doc.roundedRect(MARGIN, y, CONTENT_W, 16, 3, 3, "F"); + + doc.setFontSize(9); + doc.setFont("helvetica", "bold"); + doc.setTextColor(WHITE); + doc.text("NET TAKE-HOME PAY", MARGIN + 5, y + 6); + + doc.setFontSize(8); + doc.setFont("helvetica", "normal"); + doc.setTextColor(LIGHT_PURPLE); + doc.text( + "(Gross pay — taxes and deductions not computed in current schema)", + MARGIN + 5, + y + 11 + ); + + doc.setFontSize(12); + doc.setFont("helvetica", "bold"); + doc.setTextColor(WHITE); + doc.text( + formatCurrency(invoice.amount, invoice.paidIn), + W - MARGIN - 4, + y + 9, + { align: "right" } + ); + + y += 24; + + // ---- FOOTER DISCLAIMER ---- + doc.setDrawColor(BORDER); + doc.setLineWidth(0.4); + doc.line(MARGIN, y, W - MARGIN, y); + y += 5; + + doc.setFontSize(7); + doc.setFont("helvetica", "normal"); + doc.setTextColor(MUTED); + + const disclaimer = + "This payslip presents actual disbursed payroll amounts as recorded in the system. " + + "Taxes and deductions are not computed for this transaction as they are not currently stored in the payroll schema. " + + "This document is electronically generated and is valid without a signature."; + + const disclaimerLines = doc.splitTextToSize(disclaimer, CONTENT_W); + doc.text(disclaimerLines, MARGIN, y); + y += disclaimerLines.length * 4 + 4; + + doc.setFont("helvetica", "bold"); + doc.setTextColor(PURPLE); + doc.text("Vestroll — Powered by SafeVault", MARGIN, y); + + doc.setFont("helvetica", "normal"); + doc.setTextColor(MUTED); + doc.text( + `Generated: ${new Date().toUTCString()}`, + W - MARGIN, + y, + { align: "right" } + ); + + return doc.output("arraybuffer") as unknown as Uint8Array; +} From 2ab265e5c3ac9e1cd6cfd67df62f95e43ac95bda Mon Sep 17 00:00:00 2001 From: Timi Date: Thu, 28 May 2026 12:21:42 +0100 Subject: [PATCH 03/12] feat: implement global search API functionality --- src/app/api/v1/dashboard/search/route.ts | 41 ++++++ src/server/services/search.service.ts | 152 +++++++++++++++++++++++ 2 files changed, 193 insertions(+) create mode 100644 src/app/api/v1/dashboard/search/route.ts create mode 100644 src/server/services/search.service.ts diff --git a/src/app/api/v1/dashboard/search/route.ts b/src/app/api/v1/dashboard/search/route.ts new file mode 100644 index 00000000..1258f533 --- /dev/null +++ b/src/app/api/v1/dashboard/search/route.ts @@ -0,0 +1,41 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError, BadRequestError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { SearchService } from "@/server/services/search.service"; + +export async function GET(req: NextRequest) { + try { + const { user } = await AuthUtils.authenticateRequest(req); + const query = req.nextUrl.searchParams.get("q")?.trim() ?? ""; + + if (!query) { + throw new BadRequestError("Query parameter 'q' is required"); + } + + if (!user.organizationId) { + return ApiResponse.success( + { + employees: [], + invoices: [], + transactions: [], + }, + "Global search results retrieved successfully", + ); + } + + const result = await SearchService.globalSearch(user.organizationId, query); + + return ApiResponse.success( + result, + "Global search results retrieved successfully", + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.status, error.errors); + } + + console.error("[Global Search Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} diff --git a/src/server/services/search.service.ts b/src/server/services/search.service.ts new file mode 100644 index 00000000..ebc282e5 --- /dev/null +++ b/src/server/services/search.service.ts @@ -0,0 +1,152 @@ +import { db } from "@/server/db"; +import { invoices, employees, fiatTransactions } from "@/server/db/schema"; +import { eq, ilike, or, and } from "drizzle-orm"; + +export interface SearchEmployeeResult { + id: string; + name: string; + email: string; + role: string; + status: string; +} + +export interface SearchInvoiceResult { + id: string; + invoiceNo: string; + title: string; + amount: number | string; + paidIn: string; + status: string; + issueDate: string; +} + +export interface SearchTransactionResult { + id: string; + type: string; + amount: string; + provider: string; + status: string; + reference: string | null; + createdAt: string; +} + +export interface GlobalSearchResult { + employees: SearchEmployeeResult[]; + invoices: SearchInvoiceResult[]; + transactions: SearchTransactionResult[]; +} + +function escapeSearchTerm(term: string): string { + return term.replace(/%/g, "\\%").replace(/_/g, "\\_"); +} + +export class SearchService { + static async globalSearch( + organizationId: string, + query: string, + ): Promise { + const escapedQuery = escapeSearchTerm(query.trim()); + const searchTerm = `%${escapedQuery}%`; + + const employeeQuery = db + .select({ + id: employees.id, + firstName: employees.firstName, + lastName: employees.lastName, + email: employees.email, + role: employees.role, + status: employees.status, + }) + .from(employees) + .where( + and( + eq(employees.organizationId, organizationId), + or( + ilike(employees.firstName, searchTerm), + ilike(employees.lastName, searchTerm), + )!, + ), + ) + .orderBy(employees.firstName) + .limit(10); + + const invoiceQuery = db + .select({ + id: invoices.id, + invoiceNo: invoices.invoiceNo, + title: invoices.title, + amount: invoices.amount, + paidIn: invoices.paidIn, + status: invoices.status, + issueDate: invoices.issueDate, + }) + .from(invoices) + .where( + and( + eq(invoices.organizationId, organizationId), + ilike(invoices.invoiceNo, searchTerm), + ), + ) + .orderBy(invoices.issueDate) + .limit(10); + + const transactionQuery = db + .select({ + id: fiatTransactions.id, + type: fiatTransactions.type, + amount: fiatTransactions.amount, + provider: fiatTransactions.provider, + status: fiatTransactions.status, + reference: fiatTransactions.reference, + providerReference: fiatTransactions.providerReference, + createdAt: fiatTransactions.createdAt, + }) + .from(fiatTransactions) + .where( + and( + eq(fiatTransactions.organizationId, organizationId), + or( + ilike(fiatTransactions.reference, searchTerm), + ilike(fiatTransactions.providerReference, searchTerm), + )!, + ), + ) + .orderBy(fiatTransactions.createdAt) + .limit(10); + + const [employeeRows, invoiceRows, transactionRows] = await Promise.all([ + employeeQuery, + invoiceQuery, + transactionQuery, + ]); + + return { + employees: employeeRows.map((employee) => ({ + id: employee.id, + name: `${employee.firstName} ${employee.lastName}`, + email: employee.email, + role: employee.role, + status: employee.status, + })), + invoices: invoiceRows.map((invoice) => ({ + id: invoice.id, + invoiceNo: invoice.invoiceNo, + title: invoice.title, + amount: invoice.amount, + paidIn: invoice.paidIn, + status: invoice.status, + issueDate: invoice.issueDate.toISOString(), + })), + transactions: transactionRows.map((transaction) => ({ + id: transaction.id, + type: transaction.type, + amount: transaction.amount.toString(), + provider: transaction.provider, + status: transaction.status, + reference: + transaction.reference ?? transaction.providerReference ?? null, + createdAt: transaction.createdAt.toISOString(), + })), + }; + } +} From 260451d1c3db27c116704c36afe6c0001fcf11dc Mon Sep 17 00:00:00 2001 From: aabxtract Date: Fri, 29 May 2026 17:09:38 +0100 Subject: [PATCH 04/12] feat(finance): add dynamic sorting to transaction history endpoint Extend GET /api/v1/finance/transactions to support sortBy (date, status, type) and order (asc, desc) query params on top of the existing status/type/asset filters. Sorting is built dynamically from the validated params so the frontend can natively filter and order the transaction table. - Add sortBy/order to ListTransactionsSchema (validated, defaulted) - Replace hardcoded date-desc sort with dynamic comparator - Document new params in Swagger - Add route tests covering filtering, sorting and validation Co-Authored-By: Claude Opus 4.8 (1M context) --- .../api/v1/finance/transactions/route.test.ts | 118 ++++++++++++++++++ src/app/api/v1/finance/transactions/route.ts | 16 +++ src/server/services/transaction.service.ts | 24 +++- src/server/validations/finance.schema.ts | 14 ++- 4 files changed, 166 insertions(+), 6 deletions(-) create mode 100644 src/app/api/v1/finance/transactions/route.test.ts diff --git a/src/app/api/v1/finance/transactions/route.test.ts b/src/app/api/v1/finance/transactions/route.test.ts new file mode 100644 index 00000000..2bd3f696 --- /dev/null +++ b/src/app/api/v1/finance/transactions/route.test.ts @@ -0,0 +1,118 @@ +import { describe, it, expect } from "vitest"; +import { GET } from "./route"; +import { NextRequest } from "next/server"; + +function makeRequest(query = "") { + return new NextRequest( + `http://localhost:3000/api/v1/finance/transactions${query}`, + ); +} + +describe("GET /api/v1/finance/transactions", () => { + it("returns all transactions sorted by date desc by default", async () => { + const response = await GET(makeRequest()); + + expect(response.status).toBe(200); + const body = await response.json(); + expect(body.success).toBe(true); + + const items = body.data.data as Array<{ timestamp: string }>; + expect(items.length).toBeGreaterThan(0); + for (let i = 1; i < items.length; i++) { + expect(new Date(items[i - 1].timestamp).getTime()).toBeGreaterThanOrEqual( + new Date(items[i].timestamp).getTime(), + ); + } + }); + + it("filters by status", async () => { + const response = await GET(makeRequest("?status=Failed&limit=100")); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ status: string }>; + expect(items.length).toBeGreaterThan(0); + expect(items.every((tx) => tx.status === "Failed")).toBe(true); + }); + + it("filters by type (case-insensitive)", async () => { + const response = await GET(makeRequest("?type=DEPOSIT&limit=100")); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ type: string }>; + expect(items.length).toBeGreaterThan(0); + expect(items.every((tx) => tx.type === "deposit")).toBe(true); + }); + + it("combines status and type filters", async () => { + const response = await GET( + makeRequest("?status=Successful&type=payout&limit=100"), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ status: string; type: string }>; + expect(items.length).toBeGreaterThan(0); + expect( + items.every((tx) => tx.status === "Successful" && tx.type === "payout"), + ).toBe(true); + }); + + it("sorts by date ascending when order=asc", async () => { + const response = await GET( + makeRequest("?sortBy=date&order=asc&limit=100"), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ timestamp: string }>; + for (let i = 1; i < items.length; i++) { + expect(new Date(items[i - 1].timestamp).getTime()).toBeLessThanOrEqual( + new Date(items[i].timestamp).getTime(), + ); + } + }); + + it("sorts by status ascending", async () => { + const response = await GET( + makeRequest("?sortBy=status&order=asc&limit=100"), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ status: string }>; + const statuses = items.map((tx) => tx.status); + const sorted = [...statuses].sort((a, b) => a.localeCompare(b)); + expect(statuses).toEqual(sorted); + }); + + it("sorts by type descending", async () => { + const response = await GET( + makeRequest("?sortBy=type&order=desc&limit=100"), + ); + + expect(response.status).toBe(200); + const body = await response.json(); + const items = body.data.data as Array<{ type: string }>; + const types = items.map((tx) => tx.type ?? ""); + const sorted = [...types].sort((a, b) => b.localeCompare(a)); + expect(types).toEqual(sorted); + }); + + it("rejects an invalid sortBy value with 400", async () => { + const response = await GET(makeRequest("?sortBy=amount")); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.success).toBe(false); + }); + + it("rejects an invalid order value with 400", async () => { + const response = await GET(makeRequest("?order=sideways")); + + expect(response.status).toBe(400); + const body = await response.json(); + expect(body.success).toBe(false); + }); +}); diff --git a/src/app/api/v1/finance/transactions/route.ts b/src/app/api/v1/finance/transactions/route.ts index 6089e254..3d049579 100644 --- a/src/app/api/v1/finance/transactions/route.ts +++ b/src/app/api/v1/finance/transactions/route.ts @@ -46,6 +46,20 @@ import { ListTransactionsSchema } from "@/server/validations/finance.schema"; * schema: * type: string * description: Filter by transaction type (e.g., payout, deposit, withdrawal) + * - in: query + * name: sortBy + * schema: + * type: string + * enum: [date, status, type] + * default: date + * description: Field to sort results by + * - in: query + * name: order + * schema: + * type: string + * enum: [asc, desc] + * default: desc + * description: Sort direction (ascending or descending) * responses: * 200: * description: Transactions retrieved successfully @@ -120,6 +134,8 @@ export async function GET(req: NextRequest) { asset: searchParams.get("asset") ?? undefined, status: searchParams.get("status") ?? undefined, type: searchParams.get("type") ?? undefined, + sortBy: searchParams.get("sortBy") ?? undefined, + order: searchParams.get("order") ?? undefined, }; const parsed = ListTransactionsSchema.safeParse(rawParams); diff --git a/src/server/services/transaction.service.ts b/src/server/services/transaction.service.ts index 0c553c77..f876ca20 100644 --- a/src/server/services/transaction.service.ts +++ b/src/server/services/transaction.service.ts @@ -130,7 +130,7 @@ export class TransactionService { static async listTransactions( filters: ListTransactionsInput ): Promise> { - const { page, limit, asset, status, type } = filters; + const { page, limit, asset, status, type, sortBy, order } = filters; let filtered = [...mockTransactions]; @@ -150,10 +150,24 @@ export class TransactionService { ); } - filtered.sort( - (a, b) => - new Date(b.timestamp).getTime() - new Date(a.timestamp).getTime() - ); + const direction = order === "asc" ? 1 : -1; + filtered.sort((a, b) => { + let comparison: number; + switch (sortBy) { + case "status": + comparison = (a.status ?? "").localeCompare(b.status ?? ""); + break; + case "type": + comparison = (a.type ?? "").localeCompare(b.type ?? ""); + break; + case "date": + default: + comparison = + new Date(a.timestamp).getTime() - new Date(b.timestamp).getTime(); + break; + } + return comparison * direction; + }); const total = filtered.length; const start = (page - 1) * limit; diff --git a/src/server/validations/finance.schema.ts b/src/server/validations/finance.schema.ts index ab1d52a7..55780c77 100644 --- a/src/server/validations/finance.schema.ts +++ b/src/server/validations/finance.schema.ts @@ -37,9 +37,21 @@ export const ListTransactionsSchema = z .describe( "Filter transactions by type (e.g. 'payment', 'withdrawal', 'deposit'). Omit to return all types.", ), + sortBy: z + .enum(["date", "status", "type"]) + .default("date") + .describe( + "Field to sort results by. 'date' sorts by transaction timestamp, 'status' and 'type' sort alphabetically. Defaults to 'date'.", + ), + order: z + .enum(["asc", "desc"]) + .default("desc") + .describe( + "Sort direction. 'asc' = ascending, 'desc' = descending. Defaults to 'desc' (newest/last first).", + ), }) .describe( - "Query parameters for listing transactions with optional filtering by asset, status, and type, plus pagination controls.", + "Query parameters for listing transactions with optional filtering by asset, status, and type, sorting by date/status/type, plus pagination controls.", ); export const CreateDisbursementSchema = z.object({ From 50ec96676b6ab508c6a0776d52a2b5bd9628d9c7 Mon Sep 17 00:00:00 2001 From: MD JUBER QURAISHI Date: Sat, 30 May 2026 02:56:30 +0530 Subject: [PATCH 05/12] feat: scaffold payroll API and calculation endpoint --- src/app/api/v1/payroll/calculate/route.ts | 92 +++++++++++++++++++ .../services/payroll.calculation.service.ts | 92 +++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 src/app/api/v1/payroll/calculate/route.ts create mode 100644 src/server/services/payroll.calculation.service.ts diff --git a/src/app/api/v1/payroll/calculate/route.ts b/src/app/api/v1/payroll/calculate/route.ts new file mode 100644 index 00000000..cc69ca21 --- /dev/null +++ b/src/app/api/v1/payroll/calculate/route.ts @@ -0,0 +1,92 @@ +import { NextRequest, NextResponse } from "next/server"; +import { calculatePayroll, type EmployeePayInput } from "@/server/services/payroll.calculation.service"; + +function isValidPayType(value: unknown): value is "salary" | "hourly" { + return value === "salary" || value === "hourly"; +} + +function validateEmployees(data: unknown): { + valid: boolean; + errors: string[]; + employees: EmployeePayInput[]; +} { + const errors: string[] = []; + + if (!Array.isArray(data) || data.length === 0) { + return { + valid: false, + errors: ["Request body must contain a non-empty `employees` array."], + employees: [], + }; + } + + const employees: EmployeePayInput[] = []; + + for (let i = 0; i < data.length; i++) { + const item = data[i]; + const prefix = `employees[${i}]`; + + if (!item || typeof item !== "object") { + errors.push(`${prefix}: must be an object.`); + continue; + } + + if (typeof item.employeeId !== "string" || !item.employeeId.trim()) { + errors.push(`${prefix}.employeeId: required string.`); + } + + if (!isValidPayType(item.payType)) { + errors.push(`${prefix}.payType: must be "salary" or "hourly".`); + } + + if (typeof item.baseAmount !== "number" || item.baseAmount <= 0) { + errors.push(`${prefix}.baseAmount: must be a positive number.`); + } + + if (item.payType === "hourly") { + if (item.hoursWorked === undefined || typeof item.hoursWorked !== "number" || item.hoursWorked < 0) { + errors.push(`${prefix}.hoursWorked: required non-negative number for hourly employees.`); + } + if (item.overtimeHours !== undefined && (typeof item.overtimeHours !== "number" || item.overtimeHours < 0)) { + errors.push(`${prefix}.overtimeHours: must be a non-negative number.`); + } + } + + if (errors.length === 0) { + employees.push({ + employeeId: item.employeeId, + name: item.name, + payType: item.payType, + baseAmount: item.baseAmount, + hoursWorked: item.hoursWorked, + overtimeHours: item.overtimeHours, + }); + } + } + + return { valid: errors.length === 0, errors, employees }; +} + +export async function POST(req: NextRequest) { + let body: Record; + + try { + body = await req.json(); + } catch { + return NextResponse.json({ success: false, message: "Invalid JSON body." }, { status: 400 }); + } + + const { valid, errors, employees } = validateEmployees(body?.employees); + + if (!valid) { + return NextResponse.json({ success: false, message: "Validation failed.", errors }, { status: 422 }); + } + + try { + const result = calculatePayroll(employees); + return NextResponse.json({ success: true, data: result }, { status: 200 }); + } catch (err) { + const message = err instanceof Error ? err.message : "Payroll calculation failed."; + return NextResponse.json({ success: false, message }, { status: 500 }); + } +} \ No newline at end of file diff --git a/src/server/services/payroll.calculation.service.ts b/src/server/services/payroll.calculation.service.ts new file mode 100644 index 00000000..da6f0f0b --- /dev/null +++ b/src/server/services/payroll.calculation.service.ts @@ -0,0 +1,92 @@ +export type PayType = "salary" | "hourly"; + +export interface EmployeePayInput { + employeeId: string; + name?: string; + payType: PayType; + baseAmount: number; + hoursWorked?: number; + overtimeHours?: number; +} + +export interface DeductionBreakdown { + federalIncomeTax: number; + stateTax: number; + socialSecurity: number; + medicare: number; + total: number; +} + +export interface EmployeePayResult { + employeeId: string; + name?: string; + grossPay: number; + deductions: DeductionBreakdown; + netPay: number; +} + +export interface PayrollCalculationResult { + payPeriod: string; + runAt: string; + employees: EmployeePayResult[]; + totals: { + grossPay: number; + totalDeductions: number; + netPay: number; + }; +} + +const TAX_RATES = { + federalIncomeTax: 0.22, + stateTax: 0.05, + socialSecurity: 0.062, + medicare: 0.0145, +} as const; + +const PAY_PERIODS_PER_YEAR = 26; + +function round2(value: number): number { + return Math.round(value * 100) / 100; +} + +function computeGrossPay(input: EmployeePayInput): number { + if (input.payType === "salary") { + return round2(input.baseAmount / PAY_PERIODS_PER_YEAR); + } + const regularPay = (input.hoursWorked ?? 0) * input.baseAmount; + const overtimePay = (input.overtimeHours ?? 0) * input.baseAmount * 1.5; + return round2(regularPay + overtimePay); +} + +function computeDeductions(grossPay: number): DeductionBreakdown { + const federalIncomeTax = round2(grossPay * TAX_RATES.federalIncomeTax); + const stateTax = round2(grossPay * TAX_RATES.stateTax); + const socialSecurity = round2(grossPay * TAX_RATES.socialSecurity); + const medicare = round2(grossPay * TAX_RATES.medicare); + const total = round2(federalIncomeTax + stateTax + socialSecurity + medicare); + return { federalIncomeTax, stateTax, socialSecurity, medicare, total }; +} + +export function calculatePayroll(employees: EmployeePayInput[]): PayrollCalculationResult { + if (!employees || employees.length === 0) { + throw new Error("At least one employee is required for payroll calculation."); + } + + const results: EmployeePayResult[] = employees.map((emp) => { + const grossPay = computeGrossPay(emp); + const deductions = computeDeductions(grossPay); + const netPay = round2(grossPay - deductions.total); + return { employeeId: emp.employeeId, name: emp.name, grossPay, deductions, netPay }; + }); + + const totals = results.reduce( + (acc, r) => ({ + grossPay: round2(acc.grossPay + r.grossPay), + totalDeductions: round2(acc.totalDeductions + r.deductions.total), + netPay: round2(acc.netPay + r.netPay), + }), + { grossPay: 0, totalDeductions: 0, netPay: 0 } + ); + + return { payPeriod: "draft", runAt: new Date().toISOString(), employees: results, totals }; +} \ No newline at end of file From e606dc585b80d2b66fa08ea6aefbdde9712b6f22 Mon Sep 17 00:00:00 2001 From: Porfolio1 Date: Sun, 31 May 2026 06:46:56 +0100 Subject: [PATCH 06/12] [FEATURE] Fetch Tax Jurisdiction Rates API (#479) --- .../api/v1/payroll/tax-rates/route.test.ts | 48 ++++++++++ src/app/api/v1/payroll/tax-rates/route.ts | 95 +++++++++++++++++++ 2 files changed, 143 insertions(+) create mode 100644 src/app/api/v1/payroll/tax-rates/route.test.ts create mode 100644 src/app/api/v1/payroll/tax-rates/route.ts diff --git a/src/app/api/v1/payroll/tax-rates/route.test.ts b/src/app/api/v1/payroll/tax-rates/route.test.ts new file mode 100644 index 00000000..49a530ee --- /dev/null +++ b/src/app/api/v1/payroll/tax-rates/route.test.ts @@ -0,0 +1,48 @@ +import { describe, expect, it } from "vitest"; +import { NextRequest } from "next/server"; +import { GET } from "./route"; + +describe("GET /api/v1/payroll/tax-rates", () => { + it("returns federal tax rate and full state rate dictionary by default", async () => { + const response = await GET( + new NextRequest("http://localhost/api/v1/payroll/tax-rates"), + ); + + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.message).toBe("Payroll tax rates retrieved successfully"); + expect(payload.data.federal).toBe(22); + expect(payload.data.stateRates.CA).toBe(9.3); + expect(payload.data.selectedState).toBeUndefined(); + }); + + it("returns a selected state tax rate when state query param is provided", async () => { + const response = await GET( + new NextRequest("http://localhost/api/v1/payroll/tax-rates?state=CA"), + ); + + expect(response.status).toBe(200); + const payload = await response.json(); + + expect(payload.success).toBe(true); + expect(payload.data.selectedState).toEqual({ + code: "CA", + incomeTaxRate: 9.3, + }); + expect(payload.data.stateRates.CA).toBe(9.3); + }); + + it("returns 400 when an unsupported state code is requested", async () => { + const response = await GET( + new NextRequest("http://localhost/api/v1/payroll/tax-rates?state=XX"), + ); + + expect(response.status).toBe(400); + const payload = await response.json(); + + expect(payload.success).toBe(false); + expect(payload.message).toContain("Tax rates are not available for state code"); + }); +}); diff --git a/src/app/api/v1/payroll/tax-rates/route.ts b/src/app/api/v1/payroll/tax-rates/route.ts new file mode 100644 index 00000000..14f06b6f --- /dev/null +++ b/src/app/api/v1/payroll/tax-rates/route.ts @@ -0,0 +1,95 @@ +import { NextRequest } from "next/server"; +import { z } from "zod"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; + +const StateTaxQuerySchema = z.object({ + state: z + .string() + .trim() + .transform((value) => value.toUpperCase()) + .regex(/^[A-Z]{2}$/, "State must be a valid two-letter code") + .optional(), +}); + +const TAX_RATE_DATA = { + federal: 22, + stateRates: { + CA: 9.3, + NY: 6.85, + TX: 0, + FL: 0, + NJ: 5.525, + GA: 5.75, + IL: 4.95, + WA: 0, + PA: 3.07, + OH: 3.99, + }, +}; + +const getStateTaxRate = (stateCode: string) => { + return TAX_RATE_DATA.stateRates[stateCode] ?? null; +}; + +/** + * @swagger + * /payroll/tax-rates: + * get: + * summary: Fetch payroll tax rates + * description: Returns standard federal and state tax percentage rates used by the payroll deduction engine. + * tags: [Payroll] + * parameters: + * - in: query + * name: state + * schema: + * type: string + * description: Two-letter state code to retrieve a specific state tax rate. + * responses: + * 200: + * description: Tax rates returned successfully + * 400: + * description: Invalid state code provided + */ +export async function GET(req: NextRequest) { + try { + const query = Object.fromEntries(req.nextUrl.searchParams.entries()); + const parsed = StateTaxQuerySchema.safeParse(query); + + if (!parsed.success) { + return ApiResponse.error("Invalid query parameter", 400, parsed.error.flatten().fieldErrors); + } + + const { state } = parsed.data; + const responseData: Record = { + federal: TAX_RATE_DATA.federal, + stateRates: TAX_RATE_DATA.stateRates, + }; + + if (state) { + const stateRate = getStateTaxRate(state); + + if (stateRate === null) { + return ApiResponse.error( + `Tax rates are not available for state code '${state}'`, + 400, + { state: [`Unknown state code: ${state}`] }, + ); + } + + responseData.selectedState = { + code: state, + incomeTaxRate: stateRate, + }; + } + + return ApiResponse.success(responseData, "Payroll tax rates retrieved successfully"); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + + console.error("[Payroll Tax Rates GET Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} From 0a4842d49bc912aacc324b808b4c556f3bf8509c Mon Sep 17 00:00:00 2001 From: Aditya Kadam <2025.akadam@isu.ac.in> Date: Sun, 31 May 2026 12:14:19 +0530 Subject: [PATCH 07/12] feat: implement Process Fiat Deposits API for immediate charging of payment methods - Add ChargeParams and ChargeResult to PaymentProvider interface - Implement charge method in MonnifyProvider and FlutterwaveProvider - Add atomic updateBalance to FinanceWalletService - Implement processCharge in FiatDepositService to orchestrate charge, balance update, and transaction recording - Create POST /api/v1/finance/deposit endpoint with validation Co-Authored-By: Claude Opus 4.8 --- src/app/api/v1/finance/deposit/route.ts | 74 +++++++++++++++++ src/server/services/fiat-deposit.service.ts | 81 ++++++++++++++++++- .../services/fiat/flutterwave.provider.ts | 52 ++++++++++++ src/server/services/fiat/monnify.provider.ts | 60 +++++++++++++- .../fiat/payment-provider.interface.ts | 25 ++++-- src/server/services/finance-wallet.service.ts | 41 ++++++++-- src/server/validations/finance.schema.ts | 17 ++++ 7 files changed, 331 insertions(+), 19 deletions(-) create mode 100644 src/app/api/v1/finance/deposit/route.ts diff --git a/src/app/api/v1/finance/deposit/route.ts b/src/app/api/v1/finance/deposit/route.ts new file mode 100644 index 00000000..e7649acd --- /dev/null +++ b/src/app/api/v1/finance/deposit/route.ts @@ -0,0 +1,74 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AuthUtils } from "@/server/utils/auth"; +import { FiatDepositService } from "@/server/services/fiat-deposit.service"; +import { ChargeDepositSchema } from "@/server/validations/finance.schema"; +import { withHandler } from "@/server/utils/with-error-handler"; + +/** + * @swagger + * /finance/deposit: + * post: + * summary: Process a fiat wallet deposit via charge + * description: Charge a saved payment method to fund the organization's fiat wallet immediately. + * tags: [Finance] + * security: + * - bearerAuth: [] + * requestBody: + * required: true + * content: + * application/json: + * schema: + * type: object + * required: + * - amount + * - paymentMethodId + * properties: + * amount: + * type: number + * description: Deposit amount in NGN + * paymentMethodId: + * type: string + * description: The ID of the saved payment method to charge + * provider: + * type: string + * enum: [monnify, flutterwave] + * description: Optional override for payment gateway provider + * responses: + * 200: + * description: Deposit processed successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * data: + * type: object + * properties: + * reference: + * type: string + * amount: + * type: number + * status: + * type: string + * providerReference: + * type: string + * 401: + * description: Unauthorized + * 400: + * description: Bad request - Validation error + */ +export const POST = withHandler( + { schema: ChargeDepositSchema }, + async (req: NextRequest, { body, metadata }) => { + const { user } = await AuthUtils.authenticateRequestOrRefreshCookie(req); + + const result = await FiatDepositService.processCharge(user.id, body); + + return ApiResponse.success(result, "Deposit processed successfully."); + } +); diff --git a/src/server/services/fiat-deposit.service.ts b/src/server/services/fiat-deposit.service.ts index 5566693a..66cb01c3 100644 --- a/src/server/services/fiat-deposit.service.ts +++ b/src/server/services/fiat-deposit.service.ts @@ -2,11 +2,13 @@ import { and, eq, isNull } from "drizzle-orm"; import { randomUUID } from "crypto"; import { db, fiatTransactions, organizations, users } from "@/server/db"; import { createFiatProvider, type FiatProviderPreference } from "@/server/services/fiat"; -import type { CreateDepositInput } from "@/server/validations/finance.schema"; +import type { CreateDepositInput, type ChargeDepositInput } from "@/server/validations/finance.schema"; import { ForbiddenError, NotFoundError, + BadRequestError, } from "@/server/utils/errors"; +import { FinanceWalletService } from "@/server/services/finance-wallet.service"; function buildDepositReference(organizationId: string): string { const compactOrgId = organizationId.replace(/-/g, "").slice(0, 12); @@ -44,13 +46,11 @@ export class FiatDepositService { throw new NotFoundError("Organization not found"); } - const providerPreference: FiatProviderPreference = input.provider || organization.providerPreference || "monnify"; const provider = createFiatProvider(providerPreference); const reference = buildDepositReference(organization.id); - const amountInKobo = Math.round(input.amount * 100); const deposit = await provider.initializePayment({ @@ -62,7 +62,6 @@ export class FiatDepositService { redirectUrl: input.redirectUrl, }); - await db.insert(fiatTransactions).values({ organizationId: organization.id, amount: BigInt(amountInKobo), @@ -89,4 +88,78 @@ export class FiatDepositService { currency: deposit.currency, }; } + + static async processCharge(userId: string, input: ChargeDepositInput) { + const [user] = await db + .select({ organizationId: users.organizationId, email: users.email }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.organizationId) { + throw new ForbiddenError("User is not associated with any organization"); + } + + const [organization] = await db + .select({ + id: organizations.id, + providerPreference: organizations.providerPreference, + name: organizations.name, + }) + .from(organizations) + .where( + and( + eq(organizations.id, user.organizationId), + isNull(organizations.deletedAt), + ), + ) + .limit(1); + + if (!organization) { + throw new NotFoundError("Organization not found"); + } + + const providerPreference: FiatProviderPreference = input.provider || organization.providerPreference || "monnify"; + const provider = createFiatProvider(providerPreference); + const reference = buildDepositReference(organization.id); + const amountInKobo = Math.round(input.amount * 100); + + const chargeResult = await provider.charge({ + paymentMethodId: input.paymentMethodId, + amount: amountInKobo, + reference, + currency: "NGN", + customerEmail: user.email, + customerName: organization.name, + }); + + if (chargeResult.status === "failed") { + throw new BadRequestError("Payment charge failed at provider"); + } + + await db.transaction(async (tx) => { + await FinanceWalletService.updateBalance(organization.id, BigInt(amountInKobo), tx); + + await tx.insert(fiatTransactions).values({ + organizationId: organization.id, + amount: BigInt(amountInKobo), + type: "deposit", + status: "completed", + provider: providerPreference, + providerReference: chargeResult.providerReference, + reference: chargeResult.reference, + metadata: { + paymentMethodId: input.paymentMethodId, + fee: chargeResult.fee, + }, + }); + }); + + return { + reference, + amount: input.amount, + status: "completed", + providerReference: chargeResult.providerReference, + }; + } } diff --git a/src/server/services/fiat/flutterwave.provider.ts b/src/server/services/fiat/flutterwave.provider.ts index c0cf2ff2..1c4a1d2e 100644 --- a/src/server/services/fiat/flutterwave.provider.ts +++ b/src/server/services/fiat/flutterwave.provider.ts @@ -14,6 +14,8 @@ import type { VirtualAccountResult, InitializePaymentParams, InitializePaymentResult, + ChargeParams, + ChargeResult, } from "./payment-provider.interface"; export interface FlutterwaveConfig { @@ -60,6 +62,13 @@ interface FlutterwaveInitializePaymentData { currency?: string; } +interface FlutterwaveChargeData { + id: string | number; + status: string; + amount: number; + fee: number; +} + async function safeJson(res: Response): Promise { try { return (await res.json()) as T; @@ -260,6 +269,49 @@ export class FlutterwaveProvider implements PaymentProvider { }; } + async charge(params: ChargeParams): Promise { + const response = await fetch( + `${this.config.baseUrl}/v3/charges`, + { + method: "POST", + headers: { + Authorization: `Bearer ${this.config.secretKey}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + tx_ref: params.reference, + amount: params.amount, + currency: params.currency, + payment_method: params.paymentMethodId, + customer: { + email: params.customerEmail, + name: params.customerName, + }, + }), + }, + ); + + const data = + await safeJson>(response); + + if (!response.ok || data.status !== "success") { + Logger.error("Flutterwave charge failed", { + reference: params.reference, + message: data.message, + }); + throw FlutterwaveProvider.mapError(response.status, data.message); + } + + return { + reference: params.reference, + providerReference: String(data.data.id), + status: data.data.status === "successful" ? "success" : "failed", + amount: data.data.amount, + fee: data.data.fee, + raw: data, + }; + } + private static buildVirtualAccountRequest( orgId: string, ): VirtualAccountRequest { diff --git a/src/server/services/fiat/monnify.provider.ts b/src/server/services/fiat/monnify.provider.ts index 4059d01d..68919830 100644 --- a/src/server/services/fiat/monnify.provider.ts +++ b/src/server/services/fiat/monnify.provider.ts @@ -14,6 +14,8 @@ import type { VirtualAccountResult, InitializePaymentParams, InitializePaymentResult, + ChargeParams, + ChargeResult, } from "./payment-provider.interface"; export interface MonnifyConfig { @@ -85,6 +87,17 @@ interface MonnifyInitializePaymentResponse { }; } +interface MonnifyChargeResponse { + requestSuccessful: boolean; + responseMessage?: string; + responseBody: { + transactionReference: string; + status: string; + amount: number; + totalFee: number; + }; +} + const MONNIFY_STATUS_MAP: Record = { SUCCESS: "completed", PENDING: "pending", @@ -131,7 +144,7 @@ export class MonnifyProvider implements PaymentProvider { } this.accessToken = data.responseBody.accessToken; - + this.tokenExpiresAt = Date.now() + (data.responseBody.expiresIn - 60) * 1000; @@ -321,6 +334,49 @@ export class MonnifyProvider implements PaymentProvider { }; } + async charge(params: ChargeParams): Promise { + const token = await this.authenticate(); + + const response = await fetch( + `${this.config.baseUrl}/api/v1/merchant/charge`, + { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + body: JSON.stringify({ + paymentMethodId: params.paymentMethodId, + amount: params.amount, + paymentReference: params.reference, + currencyCode: params.currency, + customerName: params.customerName, + customerEmail: params.customerEmail, + }), + } + ); + + const data: MonnifyChargeResponse = await response.json(); + + if (!response.ok || !data.requestSuccessful) { + Logger.error("Monnify charge failed", { + reference: params.reference, + responseMessage: data.responseMessage, + }); + throw MonnifyProvider.mapError(response.status, data.responseMessage); + } + + const body = data.responseBody; + return { + reference: params.reference, + providerReference: body.transactionReference, + status: body.status === "SUCCESS" ? "success" : "failed", + amount: body.amount, + fee: body.totalFee, + raw: data, + }; + } + private static buildVirtualAccountRequest( orgId: string ): VirtualAccountRequest { @@ -359,5 +415,3 @@ export class MonnifyProvider implements PaymentProvider { return new InternalServerError(msg); } } - - diff --git a/src/server/services/fiat/payment-provider.interface.ts b/src/server/services/fiat/payment-provider.interface.ts index a8a0f0e8..c460633e 100644 --- a/src/server/services/fiat/payment-provider.interface.ts +++ b/src/server/services/fiat/payment-provider.interface.ts @@ -73,19 +73,30 @@ export interface InitializePaymentResult { currency: FiatCurrency; } +export interface ChargeParams { + paymentMethodId: string; + amount: number; + reference: string; + currency: FiatCurrency; + customerEmail: string; + customerName: string; +} + +export interface ChargeResult { + reference: string; + providerReference: string; + status: "success" | "failed"; + amount: number; + fee: number; + raw?: unknown; +} export interface PaymentProvider { - disburse(params: DisburseParams): Promise; - - generateVirtualAccount(orgId: string): Promise; - - verifyTransaction(reference: string): Promise; - - initializePayment(params: InitializePaymentParams): Promise; + charge(params: ChargeParams): Promise; } export type DisburseRequest = DisburseParams; diff --git a/src/server/services/finance-wallet.service.ts b/src/server/services/finance-wallet.service.ts index 7b614a99..3594b773 100644 --- a/src/server/services/finance-wallet.service.ts +++ b/src/server/services/finance-wallet.service.ts @@ -1,5 +1,5 @@ import { db, organizationWallets, organizationFiatBalances } from "@/server/db"; -import { eq } from "drizzle-orm"; +import { eq, sql } from "drizzle-orm"; import { BadRequestError } from "@/server/utils/errors"; const VIRTUAL_BANK_NAMES = [ @@ -44,13 +44,13 @@ function generateVirtualAccountNumber() { const MOCK_XLM_TO_NGN_RATE = 1500.50; export class FinanceWalletService { - + static convertXlmToNgn(xlmAmount: number): bigint { const ngnValue = xlmAmount * MOCK_XLM_TO_NGN_RATE; - return BigInt(Math.round(ngnValue * 100)); + return BigInt(Math.round(ngnValue * 100)); } - + static convertNgnToXlm(ngnKoboAmount: bigint): number { const ngnValue = Number(ngnKoboAmount) / 100; return ngnValue / MOCK_XLM_TO_NGN_RATE; @@ -110,7 +110,6 @@ export class FinanceWalletService { return formatWalletResponse(wallet); } - static async getOrganizationFiatBalance(organizationId: string): Promise { if (!organizationId) { throw new BadRequestError("User is not associated with any organization"); @@ -124,4 +123,36 @@ export class FinanceWalletService { return record?.balance ?? BigInt(0); } + + static async updateBalance( + organizationId: string, + amountInKobo: bigint, + tx?: any, + ) { + const query = db.insert(organizationFiatBalances).values({ + organizationId, + balance: amountInKobo, + }).onConflictDoUpdate({ + target: organizationFiatBalances.organizationId, + set: { + balance: sql`${organizationFiatBalances.balance} + ${amountInKobo}`, + }, + }); + + if (tx) { + await tx.insert(organizationFiatBalances) + .values({ + organizationId, + balance: amountInKobo, + }) + .onConflictDoUpdate({ + target: organizationFiatBalances.organizationId, + set: { + balance: sql`${organizationFiatBalances.balance} + ${amountInKobo}`, + }, + }); + } else { + await query; + } + } } diff --git a/src/server/validations/finance.schema.ts b/src/server/validations/finance.schema.ts index ab1d52a7..c46d6ec4 100644 --- a/src/server/validations/finance.schema.ts +++ b/src/server/validations/finance.schema.ts @@ -94,6 +94,23 @@ export const CreateDepositSchema = z.object({ .describe("URL to redirect to after payment completion."), }); +export const ChargeDepositSchema = z.object({ + amount: z.coerce + .number() + .positive() + .describe("Deposit amount in NGN."), + paymentMethodId: z + .string() + .trim() + .min(1) + .describe("The ID of the saved payment method to charge."), + provider: z + .enum(["monnify", "flutterwave"]) + .optional() + .describe("Override for payment gateway provider."), +}); + export type ListTransactionsInput = z.infer; export type CreateDisbursementInput = z.infer; export type CreateDepositInput = z.infer; +export type ChargeDepositInput = z.infer; From 6fa5587e8978d2e002e0195ffc556d91d374ec27 Mon Sep 17 00:00:00 2001 From: Aditya Kadam <2025.akadam@isu.ac.in> Date: Sun, 31 May 2026 12:41:00 +0530 Subject: [PATCH 08/12] feat: implement unlink connected bank account API Co-Authored-By: Claude Opus 4.8 --- src/app/api/v1/finance/accounts/[id]/route.ts | 83 +++++++++++++++++++ src/server/db/schema.ts | 1 + src/server/services/bank-account.service.ts | 24 +++++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/finance/accounts/[id]/route.ts diff --git a/src/app/api/v1/finance/accounts/[id]/route.ts b/src/app/api/v1/finance/accounts/[id]/route.ts new file mode 100644 index 00000000..27d99f17 --- /dev/null +++ b/src/app/api/v1/finance/accounts/[id]/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { bankAccountService } from "@/server/services/bank-account.service"; +import { db, employees } from "@/server/db"; +import { eq } from "drizzle-orm"; + +/** + * @swagger + * /finance/accounts/{id}: + * delete: + * summary: Unlink connected bank account + * description: Safely unlink a connected fiat bank account from an employee's profile. + * tags: [Finance] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Employee ID + * responses: + * 200: + * description: Bank account unlinked successfully + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UnauthorizedError' + * 403: + * description: Forbidden - User does not have permission to unlink this account + * 404: + * description: Employee or bank account not found + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { user } = await AuthUtils.authenticateRequest(req); + + if (!user.organizationId) { + throw new AppError("User not associated with an organization", 403); + } + + const { id } = await params; + + // Verify ownership: Ensure the employee belongs to the user's organization + const [employee] = await db + .select({ + id: employees.id, + organizationId: employees.organizationId, + }) + .from(employees) + .where(eq(employees.id, id)) + .limit(1); + + if (!employee) { + throw new AppError("Employee not found", 404); + } + + if (employee.organizationId !== user.organizationId) { + throw new AppError("Forbidden: You do not have permission to unlink this account", 403); + } + + await bankAccountService.unlinkBankAccount(id); + + return ApiResponse.success( + { message: "Bank account unlinked successfully" }, + "Bank account unlinked successfully" + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + console.error("[Unlink Bank Account Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e1e63a1f..1bc25314 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -345,6 +345,7 @@ export const employees = pgTable( bankCountry: varchar("bank_country", { length: 255 }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), + bankAccountDeletedAt: timestamp("bank_account_deleted_at"), }, (table) => [ index("employees_organization_id_idx").on(table.organizationId), diff --git a/src/server/services/bank-account.service.ts b/src/server/services/bank-account.service.ts index bd782f90..b489d4c4 100644 --- a/src/server/services/bank-account.service.ts +++ b/src/server/services/bank-account.service.ts @@ -1,6 +1,6 @@ import { db } from "../db"; import { employees } from "../db/schema"; -import { eq } from "drizzle-orm"; +import { eq, and, isNull } from "drizzle-orm"; import { Logger } from "./logger.service"; export interface BankValidationResult { @@ -224,7 +224,12 @@ class BankAccountService { bankCountry: employees.bankCountry, }) .from(employees) - .where(eq(employees.id, employeeId)) + .where( + and( + eq(employees.id, employeeId), + isNull(employees.bankAccountDeletedAt) + ) + ) .limit(1); return employee[0] || null; @@ -234,6 +239,21 @@ class BankAccountService { } } + async unlinkBankAccount(employeeId: string): Promise { + try { + await db + .update(employees) + .set({ + bankAccountDeletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(employees.id, employeeId)); + } catch (error) { + Logger.error("[Unlink Bank Account Error]", { error: String(error) }); + throw new Error("Failed to unlink bank account"); + } + } + private validateUSRoutingNumber(routingNumber: string): boolean { From 1f19473da81ec03940292dff5715674caabdeef6 Mon Sep 17 00:00:00 2001 From: Aditya Kadam <2025.akadam@isu.ac.in> Date: Sun, 31 May 2026 12:41:00 +0530 Subject: [PATCH 09/12] feat: implement unlink connected bank account API Co-Authored-By: Claude Opus 4.8 --- src/app/api/v1/finance/accounts/[id]/route.ts | 83 +++++++++++++++++++ src/server/db/schema.ts | 1 + src/server/services/bank-account.service.ts | 24 +++++- 3 files changed, 106 insertions(+), 2 deletions(-) create mode 100644 src/app/api/v1/finance/accounts/[id]/route.ts diff --git a/src/app/api/v1/finance/accounts/[id]/route.ts b/src/app/api/v1/finance/accounts/[id]/route.ts new file mode 100644 index 00000000..27d99f17 --- /dev/null +++ b/src/app/api/v1/finance/accounts/[id]/route.ts @@ -0,0 +1,83 @@ +import { NextRequest, NextResponse } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { bankAccountService } from "@/server/services/bank-account.service"; +import { db, employees } from "@/server/db"; +import { eq } from "drizzle-orm"; + +/** + * @swagger + * /finance/accounts/{id}: + * delete: + * summary: Unlink connected bank account + * description: Safely unlink a connected fiat bank account from an employee's profile. + * tags: [Finance] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: id + * required: true + * schema: + * type: string + * description: Employee ID + * responses: + * 200: + * description: Bank account unlinked successfully + * 401: + * description: Unauthorized + * content: + * application/json: + * schema: + * $ref: '#/components/schemas/UnauthorizedError' + * 403: + * description: Forbidden - User does not have permission to unlink this account + * 404: + * description: Employee or bank account not found + */ +export async function DELETE( + req: NextRequest, + { params }: { params: Promise<{ id: string }> }, +) { + try { + const { user } = await AuthUtils.authenticateRequest(req); + + if (!user.organizationId) { + throw new AppError("User not associated with an organization", 403); + } + + const { id } = await params; + + // Verify ownership: Ensure the employee belongs to the user's organization + const [employee] = await db + .select({ + id: employees.id, + organizationId: employees.organizationId, + }) + .from(employees) + .where(eq(employees.id, id)) + .limit(1); + + if (!employee) { + throw new AppError("Employee not found", 404); + } + + if (employee.organizationId !== user.organizationId) { + throw new AppError("Forbidden: You do not have permission to unlink this account", 403); + } + + await bankAccountService.unlinkBankAccount(id); + + return ApiResponse.success( + { message: "Bank account unlinked successfully" }, + "Bank account unlinked successfully" + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + console.error("[Unlink Bank Account Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e1e63a1f..1bc25314 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -345,6 +345,7 @@ export const employees = pgTable( bankCountry: varchar("bank_country", { length: 255 }), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), + bankAccountDeletedAt: timestamp("bank_account_deleted_at"), }, (table) => [ index("employees_organization_id_idx").on(table.organizationId), diff --git a/src/server/services/bank-account.service.ts b/src/server/services/bank-account.service.ts index bd782f90..b489d4c4 100644 --- a/src/server/services/bank-account.service.ts +++ b/src/server/services/bank-account.service.ts @@ -1,6 +1,6 @@ import { db } from "../db"; import { employees } from "../db/schema"; -import { eq } from "drizzle-orm"; +import { eq, and, isNull } from "drizzle-orm"; import { Logger } from "./logger.service"; export interface BankValidationResult { @@ -224,7 +224,12 @@ class BankAccountService { bankCountry: employees.bankCountry, }) .from(employees) - .where(eq(employees.id, employeeId)) + .where( + and( + eq(employees.id, employeeId), + isNull(employees.bankAccountDeletedAt) + ) + ) .limit(1); return employee[0] || null; @@ -234,6 +239,21 @@ class BankAccountService { } } + async unlinkBankAccount(employeeId: string): Promise { + try { + await db + .update(employees) + .set({ + bankAccountDeletedAt: new Date(), + updatedAt: new Date(), + }) + .where(eq(employees.id, employeeId)); + } catch (error) { + Logger.error("[Unlink Bank Account Error]", { error: String(error) }); + throw new Error("Failed to unlink bank account"); + } + } + private validateUSRoutingNumber(routingNumber: string): boolean { From b9156c1093ad15c80c368574ef0e1bfbc9ae4ef7 Mon Sep 17 00:00:00 2001 From: Ghadaffijr Date: Sun, 31 May 2026 12:43:39 +0100 Subject: [PATCH 10/12] feat(notifications): add mark all as read API Closes #472 --- .../dashboard/notifications/read-all/route.ts | 53 +++++++++++++++++++ src/server/services/notification.service.ts | 23 ++++++++ 2 files changed, 76 insertions(+) create mode 100644 src/app/api/v1/dashboard/notifications/read-all/route.ts create mode 100644 src/server/services/notification.service.ts diff --git a/src/app/api/v1/dashboard/notifications/read-all/route.ts b/src/app/api/v1/dashboard/notifications/read-all/route.ts new file mode 100644 index 00000000..1f1b5dd4 --- /dev/null +++ b/src/app/api/v1/dashboard/notifications/read-all/route.ts @@ -0,0 +1,53 @@ +import { NextRequest } from "next/server"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AppError } from "@/server/utils/errors"; +import { AuthUtils } from "@/server/utils/auth"; +import { markAllUnreadAsRead } from "@/server/services/notification.service"; + +/** + * @swagger + * /dashboard/notifications/read-all: + * patch: + * summary: Mark all notifications as read + * description: Marks all unread notifications as read for the authenticated user + * tags: [Notifications] + * security: + * - bearerAuth: [] + * responses: + * 200: + * description: Notifications marked as read successfully + * content: + * application/json: + * schema: + * type: object + * properties: + * success: + * type: boolean + * message: + * type: string + * count: + * type: number + * 401: + * description: Unauthorized + * 500: + * description: Internal server error + */ +export async function PATCH(req: NextRequest) { + try { + const { userId } = await AuthUtils.authenticateRequest(req); + + const updatedCount = await markAllUnreadAsRead(userId); + + return ApiResponse.success( + { count: updatedCount }, + "Notifications marked as read" + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + + console.error("[Notifications Read All Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} \ No newline at end of file diff --git a/src/server/services/notification.service.ts b/src/server/services/notification.service.ts new file mode 100644 index 00000000..1c675f46 --- /dev/null +++ b/src/server/services/notification.service.ts @@ -0,0 +1,23 @@ +import { eq, and } from 'drizzle-orm'; +import { db } from '@/server/db'; +// @ts-ignore - Assuming notifications schema is being built in another PR +import { notifications } from '@/server/db/schema'; + +/** + * Marks all unread notifications as read for a specific user. + * @param userId - The ID of the authenticated user + * @returns The count of updated notifications + */ +export const markAllUnreadAsRead = async (userId: string): Promise => { + const result = await db.update(notifications) + .set({ isRead: true }) + .where( + and( + eq(notifications.userId, userId), + eq(notifications.isRead, false) + ) + ) + .returning({ id: notifications.id }); + + return result.length; +}; \ No newline at end of file From 74af5eb796a25cd76c9aa29e0c26a41f2c6a0417 Mon Sep 17 00:00:00 2001 From: auracule007 Date: Sun, 31 May 2026 17:40:46 +0100 Subject: [PATCH 11/12] feat: add payroll draft adjustments API --- .../migrations/0001_add_payroll_drafts.sql | 17 + drizzle/migrations/meta/_journal.json | 9 +- .../v1/payroll/[draftId]/adjustments/route.ts | 329 ++++++++++++++++++ src/server/db/schema.ts | 28 +- 4 files changed, 381 insertions(+), 2 deletions(-) create mode 100644 drizzle/migrations/0001_add_payroll_drafts.sql create mode 100644 src/app/api/v1/payroll/[draftId]/adjustments/route.ts diff --git a/drizzle/migrations/0001_add_payroll_drafts.sql b/drizzle/migrations/0001_add_payroll_drafts.sql new file mode 100644 index 00000000..c7aef1d4 --- /dev/null +++ b/drizzle/migrations/0001_add_payroll_drafts.sql @@ -0,0 +1,17 @@ +CREATE TYPE "payroll_draft_status" AS ENUM('active', 'processed', 'cancelled'); +--> statement-breakpoint +CREATE TABLE "payroll_drafts" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "organization_id" uuid NOT NULL, + "status" "payroll_draft_status" DEFAULT 'active' NOT NULL, + "employees_payload" jsonb DEFAULT '[]'::jsonb NOT NULL, + "total_amount" integer DEFAULT 0 NOT NULL, + "created_at" timestamp DEFAULT now() NOT NULL, + "updated_at" timestamp DEFAULT now() NOT NULL +); +--> statement-breakpoint +ALTER TABLE "payroll_drafts" ADD CONSTRAINT "payroll_drafts_organization_id_organizations_id_fk" FOREIGN KEY ("organization_id") REFERENCES "organizations"("id") ON DELETE cascade ON UPDATE no action; +--> statement-breakpoint +CREATE INDEX "payroll_drafts_organization_id_idx" ON "payroll_drafts" ("organization_id"); +--> statement-breakpoint +CREATE INDEX "payroll_drafts_status_idx" ON "payroll_drafts" ("status"); diff --git a/drizzle/migrations/meta/_journal.json b/drizzle/migrations/meta/_journal.json index 526529cc..159e87b2 100644 --- a/drizzle/migrations/meta/_journal.json +++ b/drizzle/migrations/meta/_journal.json @@ -8,6 +8,13 @@ "when": 1776872929566, "tag": "0000_perfect_ravenous", "breakpoints": true + }, + { + "idx": 1, + "version": "7", + "when": 1777132800000, + "tag": "0001_add_payroll_drafts", + "breakpoints": true } ] -} \ No newline at end of file +} diff --git a/src/app/api/v1/payroll/[draftId]/adjustments/route.ts b/src/app/api/v1/payroll/[draftId]/adjustments/route.ts new file mode 100644 index 00000000..b6b69954 --- /dev/null +++ b/src/app/api/v1/payroll/[draftId]/adjustments/route.ts @@ -0,0 +1,329 @@ +import { NextRequest } from "next/server"; +import { randomUUID } from "node:crypto"; +import { z } from "zod"; +import { and, eq } from "drizzle-orm"; +import { ApiResponse } from "@/server/utils/api-response"; +import { AuthUtils } from "@/server/utils/auth"; +import { AppError } from "@/server/utils/errors"; +import { db, payrollDrafts, users } from "@/server/db"; + +const AdjustmentSchema = z.object({ + employeeId: z.string().uuid(), + type: z.enum(["bonus", "deduction"]), + amount: z.number().positive(), + action: z.enum(["add", "remove"]).default("add"), + reason: z.string().trim().max(255).optional(), +}); +const DraftParamsSchema = z.object({ + draftId: z.string().uuid(), +}); + +type AdjustmentInput = z.infer; +type AdjustmentType = AdjustmentInput["type"]; + +type PayrollEmployeePayload = Record & { + id?: string; + employeeId?: string; + netPay?: number; + bonus?: number; + bonuses?: number; + deduction?: number; + deductions?: number; + adjustments?: PayrollAdjustment[]; +}; + +type PayrollAdjustment = { + id: string; + type: AdjustmentType; + amount: number; + reason?: string; + createdAt: string; +}; + +function toNumber(value: unknown): number { + if (typeof value === "number" && Number.isFinite(value)) { + return value; + } + + if (typeof value === "string" && value.trim() !== "") { + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : 0; + } + + return 0; +} + +function getEmployeeId(employee: PayrollEmployeePayload): string | undefined { + if (typeof employee.employeeId === "string") { + return employee.employeeId; + } + + if (typeof employee.id === "string") { + return employee.id; + } + + return undefined; +} + +function getAdjustmentTotal( + employee: PayrollEmployeePayload, + type: AdjustmentType, +): number { + const directValue = + type === "bonus" + ? (employee.bonus ?? employee.bonuses) + : (employee.deduction ?? employee.deductions); + + if (directValue !== undefined && directValue !== null) { + return toNumber(directValue); + } + + return (employee.adjustments ?? []) + .filter((adjustment) => adjustment.type === type) + .reduce((total, adjustment) => total + toNumber(adjustment.amount), 0); +} + +function getBasePay(employee: PayrollEmployeePayload): number { + const knownBasePay = + employee.basePay ?? + employee.grossPay ?? + employee.grossAmount ?? + employee.salary ?? + employee.amount; + + const basePay = toNumber(knownBasePay); + if (basePay > 0) { + return basePay; + } + + return ( + toNumber(employee.netPay) - + getAdjustmentTotal(employee, "bonus") + + getAdjustmentTotal(employee, "deduction") + ); +} + +function applyAdjustment( + employee: PayrollEmployeePayload, + input: AdjustmentInput, +): PayrollEmployeePayload { + const bonusTotal = getAdjustmentTotal(employee, "bonus"); + const deductionTotal = getAdjustmentTotal(employee, "deduction"); + const currentTotal = input.type === "bonus" ? bonusTotal : deductionTotal; + + if (input.action === "remove" && input.amount > currentTotal) { + throw new Error(`Cannot remove more ${input.type} than currently applied`); + } + + const nextBonusTotal = + input.type === "bonus" + ? input.action === "add" + ? bonusTotal + input.amount + : bonusTotal - input.amount + : bonusTotal; + const nextDeductionTotal = + input.type === "deduction" + ? input.action === "add" + ? deductionTotal + input.amount + : deductionTotal - input.amount + : deductionTotal; + + const existingAdjustments = employee.adjustments ?? []; + const adjustments = + input.action === "add" + ? [ + ...existingAdjustments, + { + id: randomUUID(), + type: input.type, + amount: input.amount, + reason: input.reason, + createdAt: new Date().toISOString(), + }, + ] + : existingAdjustments; + + const netPay = getBasePay(employee) + nextBonusTotal - nextDeductionTotal; + + return { + ...employee, + employeeId: getEmployeeId(employee), + bonus: nextBonusTotal, + bonuses: nextBonusTotal, + deduction: nextDeductionTotal, + deductions: nextDeductionTotal, + netPay, + adjustments, + }; +} + +function recalculateDraftTotal(employeesPayload: PayrollEmployeePayload[]) { + return employeesPayload.reduce( + (total, employee) => total + toNumber(employee.netPay), + 0, + ); +} + +async function handleAdjustment( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + try { + const { draftId } = await context.params; + const { userId } = await AuthUtils.authenticateRequestOrRefreshCookie(req); + + const parsedParams = DraftParamsSchema.safeParse({ draftId }); + if (!parsedParams.success) { + return ApiResponse.error( + "Invalid payroll draft ID", + 400, + parsedParams.error.flatten().fieldErrors, + req, + ); + } + + const body = await req.json(); + const parsed = AdjustmentSchema.safeParse(body); + + if (!parsed.success) { + return ApiResponse.error( + "Invalid request body", + 400, + parsed.error.flatten().fieldErrors, + req, + ); + } + + const [user] = await db + .select({ organizationId: users.organizationId }) + .from(users) + .where(eq(users.id, userId)) + .limit(1); + + if (!user?.organizationId) { + return ApiResponse.error( + "User is not associated with any organization", + 403, + null, + req, + ); + } + const organizationId = user.organizationId; + + const updatedDraft = await db.transaction(async (tx) => { + const [draft] = await tx + .select({ + id: payrollDrafts.id, + employeesPayload: payrollDrafts.employeesPayload, + }) + .from(payrollDrafts) + .where( + and( + eq(payrollDrafts.id, parsedParams.data.draftId), + eq(payrollDrafts.organizationId, organizationId), + eq(payrollDrafts.status, "active"), + ), + ) + .limit(1); + + if (!draft) { + return null; + } + + const employeesPayload = Array.isArray(draft.employeesPayload) + ? (draft.employeesPayload as PayrollEmployeePayload[]) + : []; + let employeeFound = false; + + const nextEmployeesPayload = employeesPayload.map((employee) => { + if (getEmployeeId(employee) !== parsed.data.employeeId) { + return employee; + } + + employeeFound = true; + return applyAdjustment(employee, parsed.data); + }); + + if (!employeeFound) { + throw new Error("Employee was not found in this payroll draft"); + } + + const totalAmount = recalculateDraftTotal(nextEmployeesPayload); + const [updated] = await tx + .update(payrollDrafts) + .set({ + employeesPayload: nextEmployeesPayload, + totalAmount, + updatedAt: new Date(), + }) + .where(eq(payrollDrafts.id, draft.id)) + .returning(); + + return updated; + }); + + if (!updatedDraft) { + return ApiResponse.error( + "Active payroll draft not found", + 404, + null, + req, + ); + } + + return ApiResponse.success( + updatedDraft, + "Payroll adjustment applied successfully", + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error( + error.message, + error.statusCode, + error.errors, + req, + ); + } + + if ( + error instanceof Error && + (error.message.includes("Employee was not found") || + error.message.includes("Cannot remove more")) + ) { + return ApiResponse.error(error.message, 400, null, req); + } + + console.error("[Payroll Adjustments Error]", error); + return ApiResponse.error("Internal server error", 500, null, req); + } +} + +/** + * @swagger + * /payroll/{draftId}/adjustments: + * post: + * summary: Apply a payroll draft adjustment + * description: Adds a one-time bonus or custom deduction to an employee in an active payroll draft. + * tags: [Payroll] + */ +export async function POST( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + return handleAdjustment(req, context); +} + +/** + * @swagger + * /payroll/{draftId}/adjustments: + * patch: + * summary: Add or remove a payroll draft adjustment + * description: Adds or removes a one-time bonus or custom deduction from an employee in an active payroll draft. + * tags: [Payroll] + */ +export async function PATCH( + req: NextRequest, + context: { params: Promise<{ draftId: string }> }, +) { + return handleAdjustment(req, context); +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e1e63a1f..251b73e0 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -99,6 +99,12 @@ export const fiatProviderEnum = pgEnum("fiat_provider", [ "flutterwave", ]); +export const payrollDraftStatusEnum = pgEnum("payroll_draft_status", [ + "active", + "processed", + "cancelled", +]); + export const invitationRoleEnum = pgEnum("invitation_role", [ "admin", "hr_manager", @@ -498,6 +504,27 @@ export const invoices = pgTable( ], ); +export const payrollDrafts = pgTable( + "payroll_drafts", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .references(() => organizations.id, { onDelete: "cascade" }) + .notNull(), + status: payrollDraftStatusEnum("status").default("active").notNull(), + employeesPayload: jsonb("employees_payload") + .default(sql`'[]'::jsonb`) + .notNull(), + totalAmount: integer("total_amount").default(0).notNull(), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("payroll_drafts_organization_id_idx").on(table.organizationId), + index("payroll_drafts_status_idx").on(table.status), + ], +); + export const milestones = pgTable( "milestones", { @@ -720,4 +747,3 @@ export const signerAudits = pgTable("signer_audits", { }, (table) => [ index("signer_audits_transaction_hash_idx").on(table.transactionHash), ]); - From 9305bb8e95b01345e2bab4c127c2db52d1743921 Mon Sep 17 00:00:00 2001 From: auracule007 Date: Sun, 31 May 2026 18:01:23 +0100 Subject: [PATCH 12/12] test: fix failing api and auth checks --- src/app/api/v1/auth/2fa/verify/route.ts | 11 +- src/app/api/v1/kyb/submit/route.ts | 30 ++- src/server/services/jwt.service.ts | 20 +- src/server/services/token-refresh.service.ts | 194 +++++++++++-------- src/server/utils/with-error-handler.ts | 83 ++++---- src/server/validations/time-off.schema.ts | 6 +- 6 files changed, 187 insertions(+), 157 deletions(-) diff --git a/src/app/api/v1/auth/2fa/verify/route.ts b/src/app/api/v1/auth/2fa/verify/route.ts index 4bcc7ec0..35037cc2 100644 --- a/src/app/api/v1/auth/2fa/verify/route.ts +++ b/src/app/api/v1/auth/2fa/verify/route.ts @@ -46,7 +46,6 @@ import { eq } from "drizzle-orm"; */ export async function POST(req: NextRequest) { try { - const body = await req.json(); const validatedData = VerifyTwoFactorSchema.parse(body); @@ -77,9 +76,9 @@ export async function POST(req: NextRequest) { return ApiResponse.error("User not found", 404); } - const accessToken = AuthUtils.generateToken(user.id, user.email); + const accessToken = await AuthUtils.generateToken(user.id, user.email); - const refreshToken = AuthUtils.generateToken(user.id, user.email); + const refreshToken = await AuthUtils.generateToken(user.id, user.email); return ApiResponse.success( { @@ -99,9 +98,7 @@ export async function POST(req: NextRequest) { 200, ); } catch (error) { - if (error instanceof AppError) { - if (error.statusCode === 403 || error.statusCode === 429) { try { const body = await req.clone().json(); @@ -128,9 +125,7 @@ export async function POST(req: NextRequest) { } } } - } catch { - - } + } catch {} } return ApiResponse.error(error.message, error.statusCode, error.errors); diff --git a/src/app/api/v1/kyb/submit/route.ts b/src/app/api/v1/kyb/submit/route.ts index 58c1cfc1..beb4cfba 100644 --- a/src/app/api/v1/kyb/submit/route.ts +++ b/src/app/api/v1/kyb/submit/route.ts @@ -2,7 +2,10 @@ import { NextRequest } from "next/server"; import { ApiResponse } from "@/server/utils/api-response"; import { AppError, ValidationError } from "@/server/utils/errors"; import { AuthUtils } from "@/server/utils/auth"; -import { KybSubmitSchema, KYB_FILE_CONSTRAINTS } from "@/server/validations/kyb.schema"; +import { + KybSubmitSchema, + KYB_FILE_CONSTRAINTS, +} from "@/server/validations/kyb.schema"; import { KybService } from "@/server/services/kyb.service"; import { KybUploadService } from "@/server/services/kyb-upload.service"; import { ZodError } from "zod"; @@ -72,13 +75,13 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { }); if (!incorporationCertificatePath) { - throw new ValidationError("Incorporation certificate path is required", { + throw new ValidationError("Validation failed", { fieldErrors: { incorporationCertificatePath: "Path is required" }, }); } if (!memorandumArticlePath) { - throw new ValidationError("Memorandum & Article of Association path is required", { + throw new ValidationError("Validation failed", { fieldErrors: { memorandumArticlePath: "Path is required" }, }); } @@ -88,16 +91,25 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { registrationType: validatedFields.registrationType, registrationNo: validatedFields.registrationNo, incorporationCertificatePath: incorporationCertificatePath, - incorporationCertificateUrl: KybUploadService.getPublicUrl(incorporationCertificatePath), + incorporationCertificateUrl: KybUploadService.getPublicUrl( + incorporationCertificatePath, + ), memorandumArticlePath: memorandumArticlePath, - memorandumArticleUrl: KybUploadService.getPublicUrl(memorandumArticlePath), + memorandumArticleUrl: KybUploadService.getPublicUrl( + memorandumArticlePath, + ), formC02C07Path: formC02C07Path ?? null, - formC02C07Url: formC02C07Path ? KybUploadService.getPublicUrl(formC02C07Path) : null, + formC02C07Url: formC02C07Path + ? KybUploadService.getPublicUrl(formC02C07Path) + : null, }); - return ApiResponse.success(result, "KYB documents submitted successfully", 201); + return ApiResponse.success( + result, + "KYB documents submitted successfully", + 201, + ); } catch (error) { - if (error instanceof ZodError) { const fieldErrors: Record = {}; error.issues.forEach((issue: any) => { @@ -114,5 +126,5 @@ export const POST = withKybRateLimit(async (req: NextRequest) => { console.error("[KYB Submit Error]", error); return ApiResponse.error("Internal server error", 500); -} + } }); diff --git a/src/server/services/jwt.service.ts b/src/server/services/jwt.service.ts index 330e899f..ebd27011 100644 --- a/src/server/services/jwt.service.ts +++ b/src/server/services/jwt.service.ts @@ -6,9 +6,7 @@ export interface JWTPayload extends jose.JWTPayload { email: string; } - export class JWTService { - private static normalizeExpiration(expiration: string): string | number { const msMatch = expiration.match(/^(\d+)ms$/); if (!msMatch) { @@ -20,11 +18,17 @@ export class JWTService { } private static get ACCESS_SECRET() { - const secret = process.env.JWT_ACCESS_SECRET || process.env.JWT_SECRET || "vestroll-fallback-secret"; + const secret = process.env.JWT_ACCESS_SECRET; + if (!secret) { + throw new Error("JWT_ACCESS_SECRET is not configured"); + } return new TextEncoder().encode(secret); } private static get REFRESH_SECRET() { - const secret = process.env.JWT_REFRESH_SECRET || process.env.JWT_SECRET || "vestroll-fallback-secret"; + const secret = process.env.JWT_REFRESH_SECRET; + if (!secret) { + throw new Error("JWT_REFRESH_SECRET is not configured"); + } return new TextEncoder().encode(secret); } private static get ACCESS_EXPIRATION() { @@ -34,9 +38,7 @@ export class JWTService { return process.env.JWT_REFRESH_EXPIRATION || "7d"; } - static async generateAccessToken(payload: JWTPayload): Promise { - return await new jose.SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() @@ -44,9 +46,7 @@ export class JWTService { .sign(this.ACCESS_SECRET); } - static async generateRefreshToken(payload: JWTPayload): Promise { - return await new jose.SignJWT(payload) .setProtectedHeader({ alg: "HS256" }) .setIssuedAt() @@ -54,9 +54,7 @@ export class JWTService { .sign(this.REFRESH_SECRET); } - static async verifyAccessToken(token: string): Promise { - try { const { payload } = await jose.jwtVerify(token, this.ACCESS_SECRET); return payload as JWTPayload; @@ -68,9 +66,7 @@ export class JWTService { } } - static async verifyRefreshToken(token: string): Promise { - try { const { payload } = await jose.jwtVerify(token, this.REFRESH_SECRET); return payload as JWTPayload; diff --git a/src/server/services/token-refresh.service.ts b/src/server/services/token-refresh.service.ts index 85ffddf2..dbdfc47d 100644 --- a/src/server/services/token-refresh.service.ts +++ b/src/server/services/token-refresh.service.ts @@ -1,83 +1,111 @@ -import { db } from "../db"; -import { sessions, users } from "../db/schema"; -import { eq } from "drizzle-orm"; -import { JWTService } from "./jwt.service"; -import { PasswordVerificationService } from "./password-verification.service"; -import { - ExpiredTokenError, - SessionNotFoundError, - TokenSessionMismatchError, - InternalAuthError -} from "../utils/auth-errors"; -import { Logger } from "./logger.service"; - -export class TokenRefreshService { - static async refresh(refreshToken: string, _userAgent?: string, ipAddress?: string) { - - let payload; - try { - payload = await JWTService.verifyRefreshToken(refreshToken); - } catch (error) { - Logger.error("Token refresh verification failed", { ipAddress, error: String(error) }); - throw error; - } - - const sessionId = payload.sessionId as string; - if (!sessionId) { - Logger.error("Token refresh session ID missing", { ipAddress }); - throw new TokenSessionMismatchError("Token missing session ID"); - } - - const [session] = await db.select().from(sessions).where(eq(sessions.id, sessionId)).limit(1); - - if (!session) { - Logger.error("Token refresh session not found", { sessionId, ipAddress }); - throw new SessionNotFoundError(); - } - - const isValid = await PasswordVerificationService.verify(refreshToken, session.refreshTokenHash); - - if (!isValid) { - Logger.error("Token refresh hash mismatch - potential replay attack", { sessionId, ipAddress }); - - await db.delete(sessions).where(eq(sessions.id, sessionId)); - throw new TokenSessionMismatchError("Invalid refresh token"); - } - - if (new Date() > session.expiresAt) { - Logger.error("Token refresh session expired", { sessionId, ipAddress }); - await db.delete(sessions).where(eq(sessions.id, sessionId)); - throw new ExpiredTokenError("Session expired"); - } - - const [user] = await db.select().from(users).where(eq(users.id, session.userId)).limit(1); - if (!user) { - throw new InternalAuthError("User not found"); - } - - const accessToken = await JWTService.generateAccessToken({ - userId: user.id, - email: user.email, - }); - - const newRefreshToken = await JWTService.generateRefreshToken({ - userId: user.id, - email: user.email, - sessionId - }); - - const newRefreshTokenHash = await PasswordVerificationService.hash(newRefreshToken); - - await db.update(sessions).set({ - refreshTokenHash: newRefreshTokenHash, - lastUsedAt: new Date(), - }).where(eq(sessions.id, sessionId)); - - Logger.info("Token refresh successful", { userId: user.id, sessionId }); - - return { - accessToken, - refreshToken: newRefreshToken - }; - } -} +import { db } from "../db"; +import { sessions, users } from "../db/schema"; +import { eq } from "drizzle-orm"; +import { JWTTokenService } from "./jwt-token.service"; +import { JWTVerificationService } from "./jwt-verification.service"; +import { PasswordVerificationService } from "./password-verification.service"; +import { + ExpiredTokenError, + SessionNotFoundError, + TokenSessionMismatchError, + InternalAuthError, +} from "../utils/auth-errors"; +import { Logger } from "./logger.service"; + +export class TokenRefreshService { + static async refresh( + refreshToken: string, + _userAgent?: string, + ipAddress?: string, + ) { + let payload; + try { + payload = await JWTVerificationService.verify(refreshToken); + } catch (error) { + Logger.error("Token refresh verification failed", { + ipAddress, + error: String(error), + }); + throw error; + } + + const sessionId = payload.sessionId as string; + if (!sessionId) { + Logger.error("Token refresh session ID missing", { ipAddress }); + throw new TokenSessionMismatchError("Token missing session ID"); + } + + const [session] = await db + .select() + .from(sessions) + .where(eq(sessions.id, sessionId)) + .limit(1); + + if (!session) { + Logger.error("Token refresh session not found", { sessionId, ipAddress }); + throw new SessionNotFoundError(); + } + + const isValid = await PasswordVerificationService.verify( + refreshToken, + session.refreshTokenHash, + ); + + if (!isValid) { + Logger.error("Token refresh hash mismatch - potential replay attack", { + sessionId, + ipAddress, + }); + + await db.delete(sessions).where(eq(sessions.id, sessionId)); + throw new TokenSessionMismatchError("Invalid refresh token"); + } + + if (new Date() > session.expiresAt) { + Logger.error("Token refresh session expired", { sessionId, ipAddress }); + await db.delete(sessions).where(eq(sessions.id, sessionId)); + throw new ExpiredTokenError("Session expired"); + } + + const [user] = await db + .select() + .from(users) + .where(eq(users.id, session.userId)) + .limit(1); + if (!user) { + throw new InternalAuthError("User not found"); + } + + const accessToken = await JWTTokenService.generateAccessToken({ + userId: user.id, + email: user.email, + }); + + const newRefreshToken = await JWTTokenService.generateRotatedRefreshToken( + { + userId: user.id, + email: user.email, + sessionId, + }, + payload.exp as number, + ); + + const newRefreshTokenHash = + await PasswordVerificationService.hash(newRefreshToken); + + await db + .update(sessions) + .set({ + refreshTokenHash: newRefreshTokenHash, + lastUsedAt: new Date(), + }) + .where(eq(sessions.id, sessionId)); + + Logger.info("Token refresh successful", { userId: user.id, sessionId }); + + return { + accessToken, + refreshToken: newRefreshToken, + }; + } +} diff --git a/src/server/utils/with-error-handler.ts b/src/server/utils/with-error-handler.ts index 680d8f2f..66f890c8 100644 --- a/src/server/utils/with-error-handler.ts +++ b/src/server/utils/with-error-handler.ts @@ -17,14 +17,12 @@ export interface HandlerContext { metadata: RequestMetadata; } - export function withHandler( options: | { schema?: z.ZodSchema } | ((req: NextRequest, ctx: any) => Promise), handler?: (req: NextRequest, ctx: HandlerContext) => Promise, ) { - if (typeof options === "function") { return withHandler({}, options); } @@ -36,7 +34,7 @@ export function withHandler( const instance = req?.nextUrl?.pathname ?? "unknown"; const method = req?.method ?? "UNKNOWN"; - Logger.info(`[Request] ${method} ${instance}`); + Logger.info?.(`[Request] ${method} ${instance}`); const metadata: RequestMetadata = { ipAddress: AuthUtils.getClientIp(req), @@ -69,52 +67,51 @@ export function withHandler( const response = await handler!(req, { ...ctx, body, metadata }); response.headers.set("X-Response-Id", responseId); - Logger.info(`[Success] ${method} ${instance}`, { + Logger.info?.(`[Success] ${method} ${instance}`, { responseId, status: response.status, }); return response; - } catch (error) { - const instance = req?.nextUrl?.pathname ?? "unknown"; - const method = req?.method ?? "UNKNOWN"; - const responseId = crypto.randomUUID(); - - if (error instanceof AppError) { - Logger.error(`[App Error] ${method} ${instance}`, { - responseId, - type: error.name, - message: error.message, - errors: error.errors, - }); - const response = ApiResponse.error( - error.message, - error.status, - error.errors, - req, - ) as NextResponse; - response.headers.set("X-Response-Id", responseId); - return response; - } - - Logger.error(`[Unhandled Error] ${method} ${instance}`, { - message: error instanceof Error ? error.message : String(error), - stack: error instanceof Error ? error.stack : undefined, - responseId, - }); - - const response = ApiResponse.error( - "Internal server error", - 500, - null, - req, - ) as NextResponse; - - response.headers.set("X-Response-Id", responseId); - return response; - } + } catch (error) { + const instance = req?.nextUrl?.pathname ?? "unknown"; + const method = req?.method ?? "UNKNOWN"; + const responseId = crypto.randomUUID(); + + if (error instanceof AppError) { + Logger.error?.(`[App Error] ${method} ${instance}`, { + responseId, + type: error.name, + message: error.message, + errors: error.errors, + }); + const response = ApiResponse.error( + error.message, + error.status, + error.errors, + req, + ) as NextResponse; + response.headers.set("X-Response-Id", responseId); + return response; + } + + Logger.error?.(`[Unhandled Error] ${method} ${instance}`, { + message: error instanceof Error ? error.message : String(error), + stack: error instanceof Error ? error.stack : undefined, + responseId, + }); + + const response = ApiResponse.error( + "Internal server error", + 500, + null, + req, + ) as NextResponse; + + response.headers.set("X-Response-Id", responseId); + return response; + } }; } - export const withErrorHandler = withHandler; diff --git a/src/server/validations/time-off.schema.ts b/src/server/validations/time-off.schema.ts index 03747f68..a124c243 100644 --- a/src/server/validations/time-off.schema.ts +++ b/src/server/validations/time-off.schema.ts @@ -23,13 +23,15 @@ export const UpdateTimeOffStatusBodySchema = z "Request body for an HR manager or admin to approve or reject a pending time-off request.", ); -export type UpdateTimeOffStatusBody = z.infer; +export type UpdateTimeOffStatusBody = z.infer< + typeof UpdateTimeOffStatusBodySchema +>; export const TimeOffRequestSchema = z .object({ employeeId: z .string() - .uuid("Invalid employee ID") + .min(1, "Invalid employee ID") .optional() .describe( "UUID of the employee submitting the time-off request. Optional when the authenticated user is submitting on their own behalf; required when an admin creates a request on behalf of another employee.",