From b2423953d7c6cd47ca7ebc9056d37c3cd9b992bc Mon Sep 17 00:00:00 2001 From: Lewechi Date: Mon, 1 Jun 2026 01:10:42 +0100 Subject: [PATCH] feat: add API endpoint to finalize and execute draft payrolls --- .../api/v1/payroll/[draftId]/execute/route.ts | 178 ++++++++++++++++++ src/app/api/v1/payroll/tax-rates/route.ts | 95 ++++++++++ src/server/db/schema.ts | 32 ++++ src/server/services/activity.service.ts | 156 +++++++++++++++ 4 files changed, 461 insertions(+) create mode 100644 src/app/api/v1/payroll/[draftId]/execute/route.ts create mode 100644 src/app/api/v1/payroll/tax-rates/route.ts create mode 100644 src/server/services/activity.service.ts diff --git a/src/app/api/v1/payroll/[draftId]/execute/route.ts b/src/app/api/v1/payroll/[draftId]/execute/route.ts new file mode 100644 index 00000000..3cf45644 --- /dev/null +++ b/src/app/api/v1/payroll/[draftId]/execute/route.ts @@ -0,0 +1,178 @@ +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 { db, payrolls, fiatTransactions } from "@/server/db"; +import { eq, and } from "drizzle-orm"; +import { users } from "@/server/db/schema"; +import { z } from "zod"; + +const ExecuteDraftSchema = z.object({ + providerId: z.enum(["monnify", "flutterwave"]).default("monnify"), +}); + +/** + * @swagger + * /payroll/{draftId}/execute: + * post: + * summary: Finalize a draft payroll + * description: Finalizes a draft payroll, locks it, and queues fiat payout transactions. + * tags: [Payroll] + * security: + * - bearerAuth: [] + * parameters: + * - in: path + * name: draftId + * required: true + * schema: + * type: string + * format: uuid + * description: The ID of the payroll draft to finalize + * requestBody: + * required: false + * content: + * application/json: + * schema: + * type: object + * properties: + * providerId: + * type: string + * enum: [monnify, flutterwave] + * default: monnify + * responses: + * 200: + * description: Payroll finalized successfully + * 400: + * description: Invalid request or validation failed + * 404: + * description: Draft payroll not found + * 500: + * description: Internal server error + */ +export async function POST( + req: NextRequest, + { params }: { params: Promise<{ draftId: string }> } | { params: { draftId: string } } +) { + try { + const { userId } = await AuthUtils.authenticateRequestOrRefreshCookie(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); + } + + const resolvedParams = await params; + const { draftId } = resolvedParams; + + if (!draftId) { + return ApiResponse.error("Draft ID is required", 400); + } + + let body = {}; + try { + body = await req.json(); + } catch { + // Body is optional + } + + const parsed = ExecuteDraftSchema.safeParse(body); + if (!parsed.success) { + return ApiResponse.error( + "Invalid request body", + 400, + parsed.error.flatten().fieldErrors, + ); + } + + const providerId = parsed.data.providerId; + + // Retrieve the draft payroll + const [draft] = await db + .select() + .from(payrolls) + .where( + and( + eq(payrolls.id, draftId), + eq(payrolls.organizationId, user.organizationId) + ) + ) + .limit(1); + + if (!draft) { + return ApiResponse.error("Draft payroll not found", 404); + } + + if (draft.status !== "draft") { + return ApiResponse.error("Only draft payrolls can be executed", 400); + } + + const totalsData = draft.totals as any; + if (!totalsData || !totalsData.employees || !Array.isArray(totalsData.employees)) { + return ApiResponse.error("Invalid draft data format", 400); + } + + // Verify the draft totals + let calculatedNetPay = 0; + for (const emp of totalsData.employees) { + if (typeof emp.netPay === "number") { + calculatedNetPay += emp.netPay; + } + } + + // Floating point comparison with a small epsilon + const expectedNetPay = totalsData.totals?.netPay || 0; + if (Math.abs(calculatedNetPay - expectedNetPay) > 0.01) { + return ApiResponse.error("Draft totals verification failed", 400); + } + + // Update the payroll status to processing + await db + .update(payrolls) + .set({ status: "processing", updatedAt: new Date() }) + .where(eq(payrolls.id, draftId)); + + // Queue the payout transactions + const transactionsToInsert = totalsData.employees + .filter((emp: any) => typeof emp.netPay === "number" && emp.netPay > 0) + .map((emp: any) => ({ + organizationId: user.organizationId, + amount: Math.round(emp.netPay * 100), // Assuming amount is in smallest currency unit (e.g., kobo/cents) if BigInt + type: "payout" as const, + status: "pending" as const, + provider: providerId, + providerReference: crypto.randomUUID(), // Generate a unique reference + metadata: { + payrollId: draftId, + employeeId: emp.employeeId, + grossPay: emp.grossPay, + deductions: emp.deductions, + }, + })); + + if (transactionsToInsert.length > 0) { + await db.insert(fiatTransactions).values(transactionsToInsert); + } + + // Update the payroll status to completed + await db + .update(payrolls) + .set({ status: "completed", updatedAt: new Date() }) + .where(eq(payrolls.id, draftId)); + + return ApiResponse.success( + null, + "Payroll draft finalized successfully and payouts queued" + ); + } catch (error) { + if (error instanceof AppError) { + return ApiResponse.error(error.message, error.statusCode, error.errors); + } + console.error("[Payroll Execute Error]", error); + return ApiResponse.error("Internal server error", 500); + } +} 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..b8b63f2e --- /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() + .regex(/^[A-Za-z]{2}$/, "State must be a valid two-letter code") + .transform((value) => value.toUpperCase()) + .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 as Record)[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); + } +} diff --git a/src/server/db/schema.ts b/src/server/db/schema.ts index e1e63a1f..2c3e96e8 100644 --- a/src/server/db/schema.ts +++ b/src/server/db/schema.ts @@ -122,6 +122,13 @@ export const auditEventEnum = pgEnum("audit_event", [ "SECURITY_CHANGE", ]); +export const payrollStatusEnum = pgEnum("payroll_status", [ + "draft", + "processing", + "completed", + "failed", +]); + export const organizations = pgTable("organizations", { id: uuid("id").primaryKey().defaultRandom(), name: varchar("name", { length: 255 }).notNull(), @@ -721,3 +728,28 @@ export const signerAudits = pgTable("signer_audits", { index("signer_audits_transaction_hash_idx").on(table.transactionHash), ]); +export const payrolls = pgTable( + "payrolls", + { + id: uuid("id").primaryKey().defaultRandom(), + organizationId: uuid("organization_id") + .references(() => organizations.id, { onDelete: "cascade" }) + .notNull(), + status: payrollStatusEnum("status").default("draft").notNull(), + totals: jsonb("totals"), + createdAt: timestamp("created_at").defaultNow().notNull(), + updatedAt: timestamp("updated_at").defaultNow().notNull(), + }, + (table) => [ + index("payrolls_organization_id_idx").on(table.organizationId), + index("payrolls_status_idx").on(table.status), + ] +); + +export const payrollRelations = relations(payrolls, (helpers: any) => ({ + organization: helpers.one(organizations, { + fields: [payrolls.organizationId], + references: [organizations.id], + }), +})); + diff --git a/src/server/services/activity.service.ts b/src/server/services/activity.service.ts new file mode 100644 index 00000000..64adbc48 --- /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 extends Record { + 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 extends Record { + 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); + } +}