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
191 changes: 191 additions & 0 deletions backend/src/controllers/benefitsController.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
import { Request, Response } from 'express';
import { z } from 'zod';
import {
benefitPlanSchema,
updateBenefitPlanSchema,
deductionRuleSchema,
updateDeductionRuleSchema,
employeeBenefitEnrollmentSchema,
draftPayslipSchema,
} from '../schemas/benefitsSchema.js';
import { benefitsService } from '../services/benefitsService.js';
import pool from '../config/database.js';

export class BenefitsController {
static async createBenefitPlan(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const parsed = benefitPlanSchema.parse({ ...req.body, organization_id: organizationId });
const plan = await benefitsService.createBenefitPlan(parsed);
res.status(201).json({ success: true, data: plan });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to create benefit plan', message: (error as Error).message });
}
}

static async listBenefitPlans(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const includeInactive = req.query.includeInactive === 'true';
const plans = await benefitsService.listBenefitPlans(organizationId, includeInactive);
res.json({ success: true, data: plans, count: plans.length });
} catch (error) {
res.status(500).json({ error: 'Failed to list benefit plans', message: (error as Error).message });
}
}

static async updateBenefitPlan(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const updates = updateBenefitPlanSchema.parse(req.body);
const plan = await benefitsService.updateBenefitPlan(id, updates as any);
if (!plan) return res.status(404).json({ error: 'Benefit plan not found' });
res.json({ success: true, data: plan });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to update benefit plan', message: (error as Error).message });
}
}

static async deleteBenefitPlan(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const ok = await benefitsService.deleteBenefitPlan(id);
if (!ok) return res.status(404).json({ error: 'Benefit plan not found' });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete benefit plan', message: (error as Error).message });
}
}

static async upsertEmployeeEnrollment(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const parsed = employeeBenefitEnrollmentSchema.parse({
...req.body,
organization_id: organizationId,
});
const enrollment = await benefitsService.upsertEmployeeEnrollment(parsed);
res.status(201).json({ success: true, data: enrollment });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to upsert enrollment', message: (error as Error).message });
}
}

static async listEmployeeEnrollments(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const employeeId = Number(req.params.employeeId);
const data = await benefitsService.listEmployeeEnrollments(organizationId, employeeId);
res.json({ success: true, data, count: data.length });
} catch (error) {
res.status(500).json({ error: 'Failed to list enrollments', message: (error as Error).message });
}
}

static async createDeductionRule(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const parsed = deductionRuleSchema.parse({ ...req.body, organization_id: organizationId });
const rule = await benefitsService.createDeductionRule(parsed);
res.status(201).json({ success: true, data: rule });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to create deduction rule', message: (error as Error).message });
}
}

static async listDeductionRules(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const includeInactive = req.query.includeInactive === 'true';
const rules = await benefitsService.listDeductionRules(organizationId, includeInactive);
res.json({ success: true, data: rules, count: rules.length });
} catch (error) {
res.status(500).json({ error: 'Failed to list deduction rules', message: (error as Error).message });
}
}

static async updateDeductionRule(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const updates = updateDeductionRuleSchema.parse(req.body);
const rule = await benefitsService.updateDeductionRule(id, updates as any);
if (!rule) return res.status(404).json({ error: 'Deduction rule not found' });
res.json({ success: true, data: rule });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to update deduction rule', message: (error as Error).message });
}
}

static async deleteDeductionRule(req: Request, res: Response) {
try {
const id = Number(req.params.id);
const ok = await benefitsService.deleteDeductionRule(id);
if (!ok) return res.status(404).json({ error: 'Deduction rule not found' });
res.json({ success: true });
} catch (error) {
res.status(500).json({ error: 'Failed to delete deduction rule', message: (error as Error).message });
}
}

static async generateDraftPayslip(req: Request, res: Response) {
try {
const organizationId = Number(req.params.organizationId);
const parsed = draftPayslipSchema.parse({ ...req.body, organization_id: organizationId });
const draft = await benefitsService.generateDraftPayslip(parsed);
res.json({ success: true, data: draft });
} catch (error) {
if (error instanceof z.ZodError) {
return res.status(400).json({ error: 'Validation Error', details: error.issues });
}
res.status(500).json({ error: 'Failed to generate draft payslip', message: (error as Error).message });
}
}

static async getMyDeductions(req: Request, res: Response) {
try {
if (!req.user) {
return res.status(401).json({ error: 'User not authenticated' });
}
if (!req.user.organizationId) {
return res.status(400).json({ error: 'Missing organization for user' });
}

const wallet = req.user.walletAddress;
const orgId = req.user.organizationId;

const employeeLookup = await pool.query(
`SELECT id FROM employees WHERE organization_id = $1 AND wallet_address = $2 AND deleted_at IS NULL LIMIT 1`,
[orgId, wallet]
);

const employeeId = employeeLookup.rows[0]?.id;
if (!employeeId) {
return res.status(404).json({ error: 'Employee not found for current wallet' });
}

const draft = await benefitsService.generateDraftPayslip({
organization_id: orgId,
employee_id: employeeId,
});

res.json({ success: true, data: draft });
} catch (error) {
res.status(500).json({ error: 'Failed to fetch deductions', message: (error as Error).message });
}
}
}
122 changes: 122 additions & 0 deletions backend/src/db/migrations/021_create_benefits_and_deductions.sql
Original file line number Diff line number Diff line change
@@ -0,0 +1,122 @@
-- Migration 021: Benefit plans & deduction rules
-- Adds configurable non-salary payroll components (benefits, retirement, taxes, etc.)

CREATE TABLE IF NOT EXISTS benefit_plans (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
description TEXT,
provider_name VARCHAR(255),
provider_wallet_address VARCHAR(56),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_benefit_plans_org_id ON benefit_plans(organization_id);
CREATE INDEX IF NOT EXISTS idx_benefit_plans_active ON benefit_plans(organization_id, is_active);

CREATE TRIGGER update_benefit_plans_updated_at BEFORE UPDATE ON benefit_plans
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

ALTER TABLE benefit_plans ENABLE ROW LEVEL SECURITY;

CREATE POLICY benefit_plans_isolation_select ON benefit_plans
FOR SELECT
USING (organization_id = current_tenant_id());

CREATE POLICY benefit_plans_isolation_insert ON benefit_plans
FOR INSERT
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY benefit_plans_isolation_update ON benefit_plans
FOR UPDATE
USING (organization_id = current_tenant_id())
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY benefit_plans_isolation_delete ON benefit_plans
FOR DELETE
USING (organization_id = current_tenant_id());

-- Employee enrollments for benefit plans
CREATE TABLE IF NOT EXISTS employee_benefit_enrollments (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
employee_id INTEGER NOT NULL REFERENCES employees(id) ON DELETE CASCADE,
benefit_plan_id INTEGER NOT NULL REFERENCES benefit_plans(id) ON DELETE CASCADE,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT uq_employee_benefit UNIQUE (employee_id, benefit_plan_id)
);

CREATE INDEX IF NOT EXISTS idx_employee_benefit_enrollments_org_id ON employee_benefit_enrollments(organization_id);
CREATE INDEX IF NOT EXISTS idx_employee_benefit_enrollments_employee_id ON employee_benefit_enrollments(employee_id);
CREATE INDEX IF NOT EXISTS idx_employee_benefit_enrollments_plan_id ON employee_benefit_enrollments(benefit_plan_id);

CREATE TRIGGER update_employee_benefit_enrollments_updated_at BEFORE UPDATE ON employee_benefit_enrollments
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

ALTER TABLE employee_benefit_enrollments ENABLE ROW LEVEL SECURITY;

CREATE POLICY employee_benefit_enrollments_isolation_select ON employee_benefit_enrollments
FOR SELECT
USING (organization_id = current_tenant_id());

CREATE POLICY employee_benefit_enrollments_isolation_insert ON employee_benefit_enrollments
FOR INSERT
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY employee_benefit_enrollments_isolation_update ON employee_benefit_enrollments
FOR UPDATE
USING (organization_id = current_tenant_id())
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY employee_benefit_enrollments_isolation_delete ON employee_benefit_enrollments
FOR DELETE
USING (organization_id = current_tenant_id());

-- Generic deduction rules (fixed or percentage). Can optionally link to a benefit plan.
CREATE TABLE IF NOT EXISTS deduction_rules (
id SERIAL PRIMARY KEY,
organization_id INTEGER NOT NULL REFERENCES organizations(id) ON DELETE CASCADE,
name VARCHAR(255) NOT NULL,
type VARCHAR(20) NOT NULL CHECK (type IN ('percentage', 'fixed')),
value DECIMAL(20, 7) NOT NULL CHECK (value >= 0),
description TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
priority INTEGER DEFAULT 0,
benefit_plan_id INTEGER REFERENCES benefit_plans(id) ON DELETE SET NULL,
employee_id INTEGER REFERENCES employees(id) ON DELETE CASCADE,
destination_wallet_address VARCHAR(56),
destination_kind VARCHAR(20) NOT NULL DEFAULT 'treasury' CHECK (destination_kind IN ('treasury', 'provider')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);

CREATE INDEX IF NOT EXISTS idx_deduction_rules_org_id ON deduction_rules(organization_id);
CREATE INDEX IF NOT EXISTS idx_deduction_rules_active ON deduction_rules(organization_id, is_active);
CREATE INDEX IF NOT EXISTS idx_deduction_rules_employee_id ON deduction_rules(employee_id);
CREATE INDEX IF NOT EXISTS idx_deduction_rules_benefit_plan_id ON deduction_rules(benefit_plan_id);

CREATE TRIGGER update_deduction_rules_updated_at BEFORE UPDATE ON deduction_rules
FOR EACH ROW EXECUTE FUNCTION update_updated_at_column();

ALTER TABLE deduction_rules ENABLE ROW LEVEL SECURITY;

CREATE POLICY deduction_rules_isolation_select ON deduction_rules
FOR SELECT
USING (organization_id = current_tenant_id());

CREATE POLICY deduction_rules_isolation_insert ON deduction_rules
FOR INSERT
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY deduction_rules_isolation_update ON deduction_rules
FOR UPDATE
USING (organization_id = current_tenant_id())
WITH CHECK (organization_id = current_tenant_id());

CREATE POLICY deduction_rules_isolation_delete ON deduction_rules
FOR DELETE
USING (organization_id = current_tenant_id());
Loading