diff --git a/backend/src/controllers/benefitsController.ts b/backend/src/controllers/benefitsController.ts
new file mode 100644
index 00000000..5db0cbcc
--- /dev/null
+++ b/backend/src/controllers/benefitsController.ts
@@ -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 });
+ }
+ }
+}
diff --git a/backend/src/db/migrations/021_create_benefits_and_deductions.sql b/backend/src/db/migrations/021_create_benefits_and_deductions.sql
new file mode 100644
index 00000000..c733857c
--- /dev/null
+++ b/backend/src/db/migrations/021_create_benefits_and_deductions.sql
@@ -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());
diff --git a/backend/src/routes/benefitsRoutes.ts b/backend/src/routes/benefitsRoutes.ts
new file mode 100644
index 00000000..a1ff53da
--- /dev/null
+++ b/backend/src/routes/benefitsRoutes.ts
@@ -0,0 +1,115 @@
+import { Router, Request, Response, NextFunction } from 'express';
+import { BenefitsController } from '../controllers/benefitsController.js';
+import { authenticateJWT } from '../middlewares/auth.js';
+import { authorizeRoles, isolateOrganization } from '../middlewares/rbac.js';
+import { setTenantContext } from '../middleware/tenantContext.js';
+
+const router = Router();
+
+router.use(authenticateJWT);
+router.use(isolateOrganization);
+
+const setTenantFromJwt = (req: Request, res: Response, next: NextFunction) => {
+ if (!req.user?.organizationId) {
+ return res.status(400).json({ error: 'Missing organizationId in token' });
+ }
+ (req as any).tenantId = req.user.organizationId;
+ return next();
+};
+
+// Benefit Plans
+router.post(
+ '/organizations/:organizationId/plans',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.createBenefitPlan
+);
+
+router.get(
+ '/organizations/:organizationId/plans',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.listBenefitPlans
+);
+
+router.put(
+ '/organizations/:organizationId/plans/:id',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.updateBenefitPlan
+);
+
+router.delete(
+ '/organizations/:organizationId/plans/:id',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.deleteBenefitPlan
+);
+
+// Employee benefit enrollments
+router.post(
+ '/organizations/:organizationId/enrollments',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.upsertEmployeeEnrollment
+);
+
+router.get(
+ '/organizations/:organizationId/employees/:employeeId/enrollments',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.listEmployeeEnrollments
+);
+
+// Deduction rules
+router.post(
+ '/organizations/:organizationId/deduction-rules',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.createDeductionRule
+);
+
+router.get(
+ '/organizations/:organizationId/deduction-rules',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.listDeductionRules
+);
+
+router.put(
+ '/organizations/:organizationId/deduction-rules/:id',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.updateDeductionRule
+);
+
+router.delete(
+ '/organizations/:organizationId/deduction-rules/:id',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.deleteDeductionRule
+);
+
+// Draft payslip (gross vs net)
+router.post(
+ '/organizations/:organizationId/draft-payslips',
+ authorizeRoles('EMPLOYER'),
+ setTenantFromJwt,
+ setTenantContext,
+ BenefitsController.generateDraftPayslip
+);
+
+// Employee view: deductions breakdown for the authenticated wallet
+router.get('/me/deductions', authorizeRoles('EMPLOYEE'), setTenantFromJwt, setTenantContext, BenefitsController.getMyDeductions);
+
+export default router;
diff --git a/backend/src/routes/v1/index.ts b/backend/src/routes/v1/index.ts
index a0593ca2..76467122 100644
--- a/backend/src/routes/v1/index.ts
+++ b/backend/src/routes/v1/index.ts
@@ -20,6 +20,7 @@ import rateLimitRoutes from '../rateLimitRoutes.js';
import freezeRoutes from '../freezeRoutes.js';
import contractUpgradeRoutes from '../contractUpgradeRoutes.js';
import forecastRoutes from '../forecastRoutes.js';
+import benefitsRoutes from '../benefitsRoutes.js';
const router = Router();
@@ -42,5 +43,6 @@ router.use('/multisig', apiRateLimit(), multiSigRoutes);
router.use('/rate-limit', apiRateLimit(), rateLimitRoutes);
router.use('/freeze', apiRateLimit(), freezeRoutes);
router.use('/contracts', apiRateLimit(), contractUpgradeRoutes);
+router.use('/benefits', dataRateLimit(), benefitsRoutes);
export default router;
diff --git a/backend/src/schemas/benefitsSchema.ts b/backend/src/schemas/benefitsSchema.ts
new file mode 100644
index 00000000..ae24458c
--- /dev/null
+++ b/backend/src/schemas/benefitsSchema.ts
@@ -0,0 +1,54 @@
+import { z } from 'zod';
+
+export const benefitPlanSchema = z.object({
+ organization_id: z.number().int().positive(),
+ name: z.string().min(1).max(255),
+ description: z.string().optional(),
+ provider_name: z.string().max(255).optional(),
+ provider_wallet_address: z.string().length(56).optional(),
+ is_active: z.boolean().optional().default(true),
+});
+
+export const updateBenefitPlanSchema = benefitPlanSchema.partial().omit({ organization_id: true });
+
+export const employeeBenefitEnrollmentSchema = z.object({
+ organization_id: z.number().int().positive(),
+ employee_id: z.number().int().positive(),
+ benefit_plan_id: z.number().int().positive(),
+ is_active: z.boolean().optional().default(true),
+});
+
+export const updateEmployeeBenefitEnrollmentSchema = employeeBenefitEnrollmentSchema
+ .partial()
+ .omit({ organization_id: true, employee_id: true, benefit_plan_id: true });
+
+export const deductionRuleSchema = z.object({
+ organization_id: z.number().int().positive(),
+ name: z.string().min(1).max(255),
+ type: z.enum(['percentage', 'fixed']),
+ value: z.number().nonnegative(),
+ description: z.string().optional(),
+ is_active: z.boolean().optional().default(true),
+ priority: z.number().int().optional().default(0),
+ benefit_plan_id: z.number().int().positive().optional(),
+ employee_id: z.number().int().positive().optional(),
+ destination_wallet_address: z.string().length(56).optional(),
+ destination_kind: z.enum(['treasury', 'provider']).optional().default('treasury'),
+});
+
+export const updateDeductionRuleSchema = deductionRuleSchema.partial().omit({ organization_id: true });
+
+export const draftPayslipSchema = z.object({
+ employee_id: z.number().int().positive(),
+ organization_id: z.number().int().positive(),
+ gross_amount: z.number().nonnegative().optional(),
+ currency: z.string().max(12).optional(),
+});
+
+export type CreateBenefitPlanInput = z.infer
No deductions configured
+Your net pay equals your gross pay for now.
+