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; +export type UpdateBenefitPlanInput = z.infer; +export type CreateEmployeeBenefitEnrollmentInput = z.infer; +export type UpdateEmployeeBenefitEnrollmentInput = z.infer; +export type CreateDeductionRuleInput = z.infer; +export type UpdateDeductionRuleInput = z.infer; +export type DraftPayslipInput = z.infer; diff --git a/backend/src/services/benefitsService.ts b/backend/src/services/benefitsService.ts new file mode 100644 index 00000000..dff56e77 --- /dev/null +++ b/backend/src/services/benefitsService.ts @@ -0,0 +1,407 @@ +import pool from '../config/database.js'; + +export interface BenefitPlan { + id: number; + organization_id: number; + name: string; + description: string | null; + provider_name: string | null; + provider_wallet_address: string | null; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface EmployeeBenefitEnrollment { + id: number; + organization_id: number; + employee_id: number; + benefit_plan_id: number; + is_active: boolean; + created_at: Date; + updated_at: Date; +} + +export interface DeductionRule { + id: number; + organization_id: number; + name: string; + type: 'percentage' | 'fixed'; + value: string; + description: string | null; + is_active: boolean; + priority: number; + benefit_plan_id: number | null; + employee_id: number | null; + destination_wallet_address: string | null; + destination_kind: 'treasury' | 'provider'; + created_at: Date; + updated_at: Date; +} + +export interface DraftPayslipLine { + source: 'deduction_rule' | 'tax_rule'; + source_id: number; + name: string; + type: 'percentage' | 'fixed'; + value: number; + amount: number; + destination_wallet_address: string | null; + destination_kind: 'treasury' | 'provider'; +} + +export interface DraftPayslip { + organization_id: number; + employee_id: number; + currency: string; + gross_amount: number; + lines: DraftPayslipLine[]; + total_deductions: number; + net_amount: number; +} + +function round7(n: number): number { + return parseFloat(n.toFixed(7)); +} + +export class BenefitsService { + async createBenefitPlan(input: { + organization_id: number; + name: string; + description?: string; + provider_name?: string; + provider_wallet_address?: string; + is_active?: boolean; + }): Promise { + const result = await pool.query( + `INSERT INTO benefit_plans ( + organization_id, name, description, provider_name, provider_wallet_address, is_active + ) VALUES ($1, $2, $3, $4, $5, $6) + RETURNING *`, + [ + input.organization_id, + input.name, + input.description || null, + input.provider_name || null, + input.provider_wallet_address || null, + input.is_active ?? true, + ] + ); + + return result.rows[0]; + } + + async listBenefitPlans(organizationId: number, includeInactive = false): Promise { + const activeClause = includeInactive ? '' : 'AND is_active = TRUE'; + const result = await pool.query( + `SELECT * FROM benefit_plans WHERE organization_id = $1 ${activeClause} ORDER BY created_at DESC`, + [organizationId] + ); + return result.rows; + } + + async updateBenefitPlan( + id: number, + updates: Partial<{ + name: string; + description: string; + provider_name: string; + provider_wallet_address: string; + is_active: boolean; + }> + ): Promise { + const fields: string[] = []; + const values: any[] = []; + let idx = 1; + + for (const [k, v] of Object.entries(updates)) { + if (v !== undefined) { + fields.push(`${k} = $${idx++}`); + values.push(v); + } + } + + if (fields.length === 0) return null; + + values.push(id); + const result = await pool.query( + `UPDATE benefit_plans SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + + return result.rows[0] || null; + } + + async deleteBenefitPlan(id: number): Promise { + const result = await pool.query(`UPDATE benefit_plans SET is_active = FALSE WHERE id = $1`, [id]); + return (result.rowCount ?? 0) > 0; + } + + async upsertEmployeeEnrollment(input: { + organization_id: number; + employee_id: number; + benefit_plan_id: number; + is_active?: boolean; + }): Promise { + const result = await pool.query( + `INSERT INTO employee_benefit_enrollments ( + organization_id, employee_id, benefit_plan_id, is_active + ) VALUES ($1, $2, $3, $4) + ON CONFLICT (employee_id, benefit_plan_id) + DO UPDATE SET is_active = $4, updated_at = NOW() + RETURNING *`, + [input.organization_id, input.employee_id, input.benefit_plan_id, input.is_active ?? true] + ); + + return result.rows[0]; + } + + async listEmployeeEnrollments(organizationId: number, employeeId: number): Promise { + const result = await pool.query( + `SELECT * FROM employee_benefit_enrollments + WHERE organization_id = $1 AND employee_id = $2 + ORDER BY created_at DESC`, + [organizationId, employeeId] + ); + + return result.rows; + } + + async createDeductionRule(input: { + organization_id: number; + name: string; + type: 'percentage' | 'fixed'; + value: number; + description?: string; + is_active?: boolean; + priority?: number; + benefit_plan_id?: number; + employee_id?: number; + destination_wallet_address?: string; + destination_kind?: 'treasury' | 'provider'; + }): Promise { + const result = await pool.query( + `INSERT INTO deduction_rules ( + organization_id, name, type, value, description, is_active, priority, + benefit_plan_id, employee_id, destination_wallet_address, destination_kind + ) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11) + RETURNING *`, + [ + input.organization_id, + input.name, + input.type, + input.value, + input.description || null, + input.is_active ?? true, + input.priority ?? 0, + input.benefit_plan_id || null, + input.employee_id || null, + input.destination_wallet_address || null, + input.destination_kind ?? 'treasury', + ] + ); + + return result.rows[0]; + } + + async listDeductionRules(organizationId: number, includeInactive = false): Promise { + const activeClause = includeInactive ? '' : 'AND is_active = TRUE'; + const result = await pool.query( + `SELECT * FROM deduction_rules WHERE organization_id = $1 ${activeClause} ORDER BY priority ASC, created_at ASC`, + [organizationId] + ); + return result.rows; + } + + async updateDeductionRule(id: number, updates: Partial): Promise { + const allowed = new Set([ + 'name', + 'type', + 'value', + 'description', + 'is_active', + 'priority', + 'benefit_plan_id', + 'employee_id', + 'destination_wallet_address', + 'destination_kind', + ]); + + const fields: string[] = []; + const values: any[] = []; + let idx = 1; + + for (const [k, v] of Object.entries(updates)) { + if (!allowed.has(k)) continue; + if (v !== undefined) { + fields.push(`${k} = $${idx++}`); + values.push(v); + } + } + + if (fields.length === 0) return null; + + values.push(id); + const result = await pool.query( + `UPDATE deduction_rules SET ${fields.join(', ')} WHERE id = $${idx} RETURNING *`, + values + ); + + return result.rows[0] || null; + } + + async deleteDeductionRule(id: number): Promise { + const result = await pool.query(`UPDATE deduction_rules SET is_active = FALSE WHERE id = $1`, [id]); + return (result.rowCount ?? 0) > 0; + } + + private async resolveTreasuryWalletAddress(organizationId: number, assetCode: string): Promise { + const result = await pool.query( + `SELECT wallet_address + FROM wallets + WHERE organization_id = $1 + AND wallet_type IN ('treasury', 'organization') + AND is_active = TRUE + AND asset_code = $2 + ORDER BY created_at ASC + LIMIT 1`, + [organizationId, assetCode] + ); + + return result.rows[0]?.wallet_address || null; + } + + private async resolveProviderWalletAddress(benefitPlanId: number): Promise { + const result = await pool.query( + `SELECT provider_wallet_address + FROM benefit_plans + WHERE id = $1`, + [benefitPlanId] + ); + + return result.rows[0]?.provider_wallet_address || null; + } + + private async isEmployeeEnrolled(employeeId: number, benefitPlanId: number): Promise { + const result = await pool.query( + `SELECT 1 + FROM employee_benefit_enrollments + WHERE employee_id = $1 AND benefit_plan_id = $2 AND is_active = TRUE + LIMIT 1`, + [employeeId, benefitPlanId] + ); + + return result.rows.length > 0; + } + + async generateDraftPayslip(input: { + organization_id: number; + employee_id: number; + gross_amount?: number; + currency?: string; + }): Promise { + const empResult = await pool.query( + `SELECT id, base_salary, base_currency + FROM employees + WHERE id = $1 AND organization_id = $2 AND deleted_at IS NULL`, + [input.employee_id, input.organization_id] + ); + + if (!empResult.rows[0]) { + throw new Error('Employee not found'); + } + + const employee = empResult.rows[0]; + const currency = input.currency || employee.base_currency || 'USDC'; + const gross = round7( + input.gross_amount !== undefined ? input.gross_amount : parseFloat(employee.base_salary || 0) + ); + + const lines: DraftPayslipLine[] = []; + + // Deduction rules + const rulesResult = await pool.query( + `SELECT * + FROM deduction_rules + WHERE organization_id = $1 + AND is_active = TRUE + AND (employee_id IS NULL OR employee_id = $2) + ORDER BY priority ASC, created_at ASC`, + [input.organization_id, input.employee_id] + ); + + for (const rule of rulesResult.rows as DeductionRule[]) { + if (rule.benefit_plan_id) { + const enrolled = await this.isEmployeeEnrolled(input.employee_id, rule.benefit_plan_id); + if (!enrolled) continue; + } + + const ruleValue = parseFloat(rule.value); + const amount = + rule.type === 'percentage' ? round7(gross * (ruleValue / 100)) : round7(ruleValue); + + let destinationWallet = rule.destination_wallet_address; + if (!destinationWallet) { + if (rule.destination_kind === 'provider' && rule.benefit_plan_id) { + destinationWallet = await this.resolveProviderWalletAddress(rule.benefit_plan_id); + } else if (rule.destination_kind === 'treasury') { + destinationWallet = await this.resolveTreasuryWalletAddress(input.organization_id, currency); + } + } + + lines.push({ + source: 'deduction_rule', + source_id: rule.id, + name: rule.name, + type: rule.type, + value: ruleValue, + amount, + destination_wallet_address: destinationWallet || null, + destination_kind: rule.destination_kind, + }); + } + + // Tax rules as deductions + const taxResult = await pool.query( + `SELECT id, name, type, value + FROM tax_rules + WHERE organization_id = $1 AND is_active = TRUE + ORDER BY priority ASC, created_at ASC`, + [input.organization_id] + ); + + for (const tax of taxResult.rows as Array<{ id: number; name: string; type: 'percentage' | 'fixed'; value: string }>) { + const taxValue = parseFloat(tax.value); + const amount = + tax.type === 'percentage' ? round7(gross * (taxValue / 100)) : round7(taxValue); + + const treasuryWallet = await this.resolveTreasuryWalletAddress(input.organization_id, currency); + + lines.push({ + source: 'tax_rule', + source_id: tax.id, + name: tax.name, + type: tax.type, + value: taxValue, + amount, + destination_wallet_address: treasuryWallet || null, + destination_kind: 'treasury', + }); + } + + const totalDeductions = round7(lines.reduce((sum, l) => sum + l.amount, 0)); + const net = Math.max(0, round7(gross - totalDeductions)); + + return { + organization_id: input.organization_id, + employee_id: input.employee_id, + currency, + gross_amount: gross, + lines, + total_deductions: totalDeductions, + net_amount: net, + }; + } +} + +export const benefitsService = new BenefitsService(); diff --git a/frontend/src/hooks/useEmployeePortal.ts b/frontend/src/hooks/useEmployeePortal.ts index cf05e964..590a1958 100644 --- a/frontend/src/hooks/useEmployeePortal.ts +++ b/frontend/src/hooks/useEmployeePortal.ts @@ -1,5 +1,6 @@ import { useState, useEffect, useCallback } from 'react'; import { fetchExchangeRates, getStellarExpertLink } from '../services/currencyConversion'; +import { getMyDeductionsDraftPayslip, DraftPayslip } from '../services/benefitsApi'; /** * Mock transaction data representing incoming payments for an employee. @@ -29,6 +30,7 @@ export interface EmployeeBalance { interface UseEmployeePortalReturn { transactions: EmployeeTransaction[]; balance: EmployeeBalance | null; + deductionsDraft: DraftPayslip | null; isLoading: boolean; error: string | null; selectedCurrency: string; @@ -133,6 +135,7 @@ const ITEMS_PER_PAGE = 8; export function useEmployeePortal(): UseEmployeePortalReturn { const [transactions, setTransactions] = useState([]); const [balance, setBalance] = useState(null); + const [deductionsDraft, setDeductionsDraft] = useState(null); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null); const [selectedCurrency, setSelectedCurrency] = useState('NGN'); @@ -169,6 +172,13 @@ export function useEmployeePortal(): UseEmployeePortalReturn { exchangeRate: rate, lastUpdated: new Date(), }); + + try { + const draft = await getMyDeductionsDraftPayslip(); + setDeductionsDraft(draft); + } catch { + setDeductionsDraft(null); + } } catch (err) { setError(err instanceof Error ? err.message : 'Failed to load data'); } finally { @@ -205,6 +215,7 @@ export function useEmployeePortal(): UseEmployeePortalReturn { return { transactions: paginatedTransactions, balance, + deductionsDraft, isLoading, error, selectedCurrency, diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx index 997026a2..237210f4 100644 --- a/frontend/src/pages/EmployeeEntry.tsx +++ b/frontend/src/pages/EmployeeEntry.tsx @@ -83,8 +83,8 @@ export default function EmployeeEntry() { status: emp.status === 'active' ? ('Active' as const) : ('Inactive' as const), })); setEmployees(mapped); - } catch (error) { - console.error('Failed to fetch employees:', error); + } catch (err) { + console.error('Failed to fetch employees:', err); } finally { setLoading(false); } @@ -110,9 +110,8 @@ export default function EmployeeEntry() { setFormData((prev: EmployeeFormState) => ({ ...prev, [name]: value })); }; - const handleSubmit = async (e: React.FormEvent) => { + const handleSubmit = async (e: React.SyntheticEvent) => { e.preventDefault(); - let generatedWallet: { publicKey: string; secretKey: string } | undefined; if (!formData.walletAddress) { generatedWallet = generateWallet(); @@ -149,7 +148,7 @@ export default function EmployeeEntry() { generatedWallet ? 'A wallet was created for them.' : '' }`, secretKey: generatedWallet?.secretKey, - walletAddress: walletAddress, + walletAddress, employeeName: formData.fullName, }); diff --git a/frontend/src/pages/EmployeePortal.tsx b/frontend/src/pages/EmployeePortal.tsx index aac20e8f..982c358b 100644 --- a/frontend/src/pages/EmployeePortal.tsx +++ b/frontend/src/pages/EmployeePortal.tsx @@ -67,6 +67,7 @@ const EmployeePortal: React.FC = () => { const { transactions, balance, + deductionsDraft, isLoading, error, selectedCurrency, @@ -103,6 +104,96 @@ const EmployeePortal: React.FC = () => { View your salary payments, balances, and transaction history

+ + {/* ── Deductions Breakdown ─────── */} + {deductionsDraft && ( +
+
+

Deductions Breakdown

+
+ +
+
+
Gross Pay
+
+ {formatCurrency(deductionsDraft.gross_amount, 'USD')} +
+
+ +
+
Total Deductions
+
+ {formatCurrency(deductionsDraft.total_deductions, 'USD')} +
+
+ +
+
Net Pay
+
+ {formatCurrency(deductionsDraft.net_amount, 'USD')} +
+
+
+ +
+
+ Deduction + Type + Amount + + + +
+ + {deductionsDraft.lines.length === 0 ? ( +
+ +

No deductions configured

+

Your net pay equals your gross pay for now.

+
+ ) : ( + deductionsDraft.lines.map((line) => ( +
+
+
{line.name}
+
{line.source}
+
+ +
+ {line.type} +
+ +
+
-{formatCurrency(line.amount, 'USD')}
+
+ +
+
{line.destination_kind}
+
+ +
+
+ {line.destination_wallet_address + ? `${line.destination_wallet_address.substring(0, 8)}…${line.destination_wallet_address.substring( + line.destination_wallet_address.length - 6 + )}` + : '—'} +
+
+ +
+
+ )) + )} +
+
+ )} {address && ( => { + const { data } = await axios.get<{ success: boolean; data: DraftPayslip }>( + `${API_BASE_URL}/benefits/me/deductions`, + { + headers: authHeaders(), + } + ); + + return data.data; +}; diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts index 7dac9e97..e13771e3 100644 --- a/frontend/src/utils/api.ts +++ b/frontend/src/utils/api.ts @@ -22,3 +22,30 @@ api.interceptors.request.use( ); export default api; + +export interface ApiError extends Error { + status?: number; + code?: string; +} + +export function createApiError(message: string, status?: number, code?: string): ApiError { + const err = new Error(message) as ApiError; + err.status = status; + err.code = code; + return err; +} + +/** + * Wrapper to ensure Promise rejection reasons are Error instances. + */ +export function safeReject(reason: unknown): Promise { + let message: string; + if (reason instanceof Error) { + message = reason.message; + } else if (typeof reason === 'object' && reason !== null) { + message = JSON.stringify(reason); + } else { + message = String(reason); + } + return Promise.reject(reason instanceof Error ? reason : new Error(message)); +}