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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
178 changes: 178 additions & 0 deletions src/app/api/v1/payroll/[draftId]/execute/route.ts
Original file line number Diff line number Diff line change
@@ -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);
}
}
95 changes: 95 additions & 0 deletions src/app/api/v1/payroll/tax-rates/route.ts
Original file line number Diff line number Diff line change
@@ -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<string, number>)[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<string, unknown> = {
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);
}
}
32 changes: 32 additions & 0 deletions src/server/db/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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],
}),
}));

Loading
Loading