From 87f8d7fdbe8c43decfcfabf52c6081e605f48fea Mon Sep 17 00:00:00 2001
From: pope-h
Date: Mon, 9 Mar 2026 23:59:51 +0100
Subject: [PATCH 1/4] feat: implement Benefits & Automatic Deductions Engine
- Add DB migration for benefit_plans, employee_benefit_enrollments, deduction_rules with RLS
- Add Zod schemas for validation
- Implement CRUD services and controllers for benefit plans, enrollments, deduction rules
- Add draft payslip generation with deduction lines and wallet routing
- Wire benefits routes under /api/v1/benefits with auth and tenant isolation
- Add frontend service and employee portal deductions breakdown view
- Support percentage and fixed deductions; route to treasury/provider wallets
Closes #152
---
backend/src/controllers/benefitsController.ts | 191 ++++++++
.../021_create_benefits_and_deductions.sql | 122 ++++++
backend/src/routes/benefitsRoutes.ts | 115 +++++
backend/src/routes/v1/index.ts | 2 +
backend/src/schemas/benefitsSchema.ts | 54 +++
backend/src/services/benefitsService.ts | 407 ++++++++++++++++++
frontend/src/hooks/useEmployeePortal.ts | 11 +
frontend/src/pages/EmployeePortal.tsx | 87 ++++
frontend/src/services/benefitsApi.ts | 40 ++
9 files changed, 1029 insertions(+)
create mode 100644 backend/src/controllers/benefitsController.ts
create mode 100644 backend/src/db/migrations/021_create_benefits_and_deductions.sql
create mode 100644 backend/src/routes/benefitsRoutes.ts
create mode 100644 backend/src/schemas/benefitsSchema.ts
create mode 100644 backend/src/services/benefitsService.ts
create mode 100644 frontend/src/services/benefitsApi.ts
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/EmployeePortal.tsx b/frontend/src/pages/EmployeePortal.tsx
index aac20e8f..c8fb96ff 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,92 @@ 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
+ Destination
+ Wallet
+
+
+
+ {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;
+};
From 68c65c0462200008d447882ba73f0ba5172c4ed3 Mon Sep 17 00:00:00 2001
From: pope-h
Date: Tue, 10 Mar 2026 00:12:41 +0100
Subject: [PATCH 2/4] fix: resolve lint errors in EmployeeEntry and api utility
MIME-Version: 1.0
Content-Type: text/plain; charset=UTF-8
Content-Transfer-Encoding: 8bit
- Remove third argument from notifySuccess (API accepts 1–2)
- Improve safeReject to JSON-stringify objects instead of '[object Object]'
- Replace deprecated React.FormEvent with React.SyntheticEvent
- Flatten nested ternary in safeReject to explicit if/else for clarity
---
frontend/src/pages/EmployeeEntry.tsx | 50 +++++++++++++---------------
frontend/src/utils/api.ts | 26 +++++++++++++++
2 files changed, 49 insertions(+), 27 deletions(-)
create mode 100644 frontend/src/utils/api.ts
diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx
index 4b86f5de..78713e70 100644
--- a/frontend/src/pages/EmployeeEntry.tsx
+++ b/frontend/src/pages/EmployeeEntry.tsx
@@ -103,38 +103,34 @@ export default function EmployeeEntry() {
setFormData((prev) => ({ ...prev, [name]: value }));
};
- const handleSubmit = (e: React.FormEvent) => {
+ const handleSubmit = (e: React.SyntheticEvent) => {
e.preventDefault();
+ void (() => {
+ let generatedWallet: { publicKey: string; secretKey: string } | undefined;
+ if (!formData.walletAddress) {
+ generatedWallet = generateWallet();
+ setFormData((prev) => ({
+ ...prev,
+ walletAddress: generatedWallet!.publicKey,
+ }));
+ }
- let generatedWallet: { publicKey: string; secretKey: string } | undefined;
- if (!formData.walletAddress) {
- generatedWallet = generateWallet();
- setFormData((prev) => ({
- ...prev,
- walletAddress: generatedWallet!.publicKey,
- }));
- }
-
- const submitData = {
- ...formData,
- walletAddress: generatedWallet ? generatedWallet.publicKey : formData.walletAddress,
- };
+ const submitData = {
+ ...formData,
+ walletAddress: generatedWallet ? generatedWallet.publicKey : formData.walletAddress,
+ };
- console.log('Form submitted, employee saved:', submitData);
+ console.log('Form submitted, employee saved:', submitData);
- notifySuccess(
- `${submitData.fullName} added successfully!`,
- generatedWallet ? 'A new Stellar wallet was generated for this employee.' : undefined
- );
+ notifySuccess(`${submitData.fullName} added successfully!`);
- setNotification({
- message: `Employee ${submitData.fullName} added successfully! ${
- generatedWallet ? 'A wallet was created for them.' : ''
- }`,
- secretKey: generatedWallet?.secretKey,
- walletAddress: submitData.walletAddress,
- employeeName: submitData.fullName,
- });
+ setNotification({
+ message: 'Employee added successfully!',
+ secretKey: generatedWallet?.secretKey,
+ walletAddress: submitData.walletAddress,
+ employeeName: submitData.fullName,
+ });
+ })();
};
if (isAdding) {
diff --git a/frontend/src/utils/api.ts b/frontend/src/utils/api.ts
new file mode 100644
index 00000000..62d51032
--- /dev/null
+++ b/frontend/src/utils/api.ts
@@ -0,0 +1,26 @@
+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));
+}
From c583c14f4dc46adcc871fc188eb115c18ab92354 Mon Sep 17 00:00:00 2001
From: pope-h
Date: Tue, 10 Mar 2026 00:17:19 +0100
Subject: [PATCH 3/4] fix: resolve merge-related lint errors in EmployeeEntry
and api utility
- Add BackendEmployee and EmployeesResponse types; remove any
- Type API response and error handling safely
- Remove unnecessary async wrapper in handleSubmit; use real async/await
- Fix floating promises with void where needed
- Wrap form onSubmit to prevent no-misused-promises error
- Ensure axios interceptor rejects with an Error instance
---
frontend/src/pages/EmployeeEntry.tsx | 110 +++++++++++++++------------
frontend/src/utils/api.ts | 2 +-
2 files changed, 63 insertions(+), 49 deletions(-)
diff --git a/frontend/src/pages/EmployeeEntry.tsx b/frontend/src/pages/EmployeeEntry.tsx
index 0884d3eb..13740bd5 100644
--- a/frontend/src/pages/EmployeeEntry.tsx
+++ b/frontend/src/pages/EmployeeEntry.tsx
@@ -28,6 +28,22 @@ interface EmployeeItem {
status?: 'Active' | 'Inactive';
}
+interface BackendEmployee {
+ id: number;
+ first_name: string;
+ last_name: string;
+ email: string;
+ position?: string;
+ job_title?: string;
+ wallet_address?: string;
+ status: 'active' | 'inactive';
+}
+
+interface EmployeesResponse {
+ data: BackendEmployee[];
+ pagination?: unknown;
+}
+
const initialFormState: EmployeeFormState = {
fullName: '',
walletAddress: '',
@@ -58,9 +74,9 @@ export default function EmployeeEntry() {
const fetchEmployees = async () => {
try {
setLoading(true);
- const response = await api.get('/employees');
+ const response = await api.get('/employees');
// Backend returns { data: [...], pagination: {...} }
- const mapped = response.data.data.map((emp: any) => ({
+ const mapped: EmployeeItem[] = response.data.data.map((emp) => ({
id: String(emp.id),
name: `${emp.first_name} ${emp.last_name}`,
email: emp.email,
@@ -69,15 +85,15 @@ export default function EmployeeEntry() {
status: emp.status === 'active' ? 'Active' : 'Inactive',
}));
setEmployees(mapped);
- } catch (error) {
- console.error('Failed to fetch employees:', error);
+ } catch (err) {
+ console.error('Failed to fetch employees:', err);
} finally {
setLoading(false);
}
};
useEffect(() => {
- fetchEmployees();
+ void fetchEmployees();
}, []);
useEffect(() => {
@@ -98,54 +114,52 @@ export default function EmployeeEntry() {
const handleSubmit = async (e: React.SyntheticEvent) => {
e.preventDefault();
- void (async () => {
- let generatedWallet: { publicKey: string; secretKey: string } | undefined;
- if (!formData.walletAddress) {
- generatedWallet = generateWallet();
- }
+ let generatedWallet: { publicKey: string; secretKey: string } | undefined;
+ if (!formData.walletAddress) {
+ generatedWallet = generateWallet();
+ }
- const walletAddress = generatedWallet ? generatedWallet.publicKey : formData.walletAddress;
+ const walletAddress = generatedWallet ? generatedWallet.publicKey : formData.walletAddress;
- // Split name into first and last
- const nameParts = formData.fullName.trim().split(' ');
- const firstName = nameParts[0] || 'Unknown';
- const lastName = nameParts.slice(1).join(' ') || 'Employee';
+ // Split name into first and last
+ const nameParts = formData.fullName.trim().split(' ');
+ const firstName = nameParts[0] || 'Unknown';
+ const lastName = nameParts.slice(1).join(' ') || 'Employee';
- const payload = {
- first_name: firstName,
- last_name: lastName,
- email: formData.email,
- wallet_address: walletAddress,
- position: formData.role, // Mapping role to position for now as per minimal demo
- base_salary: 0, // Default for now
- base_currency: formData.currency,
- status: 'active',
- };
+ const payload = {
+ first_name: firstName,
+ last_name: lastName,
+ email: formData.email,
+ wallet_address: walletAddress,
+ position: formData.role, // Mapping role to position for now as per minimal demo
+ base_salary: 0, // Default for now
+ base_currency: formData.currency,
+ status: 'active',
+ };
- try {
- await api.post('/employees', payload);
-
- notifySuccess(
- `${formData.fullName} added successfully!`,
- generatedWallet ? 'A new Stellar wallet was generated for this employee.' : undefined
- );
+ try {
+ await api.post('/employees', payload);
+
+ notifySuccess(
+ `${formData.fullName} added successfully!`,
+ generatedWallet ? 'A new Stellar wallet was generated for this employee.' : undefined
+ );
- setNotification({
- message: `Employee ${formData.fullName} added successfully! ${
- generatedWallet ? 'A wallet was created for them.' : ''
- }`,
- secretKey: generatedWallet?.secretKey,
- walletAddress,
- employeeName: formData.fullName,
- });
+ setNotification({
+ message: `Employee ${formData.fullName} added successfully! ${
+ generatedWallet ? 'A wallet was created for them.' : ''
+ }`,
+ secretKey: generatedWallet?.secretKey,
+ walletAddress,
+ employeeName: formData.fullName,
+ });
- // Reset form and refresh list
- setFormData(initialFormState);
- fetchEmployees();
- } catch (error) {
- console.error('Failed to add employee:', error);
- }
- })();
+ // Reset form and refresh list
+ setFormData(initialFormState);
+ void fetchEmployees();
+ } catch (err) {
+ console.error('Failed to add employee:', err);
+ }
};
if (isAdding) {
@@ -236,7 +250,7 @@ const handleSubmit = async (e: React.SyntheticEvent) => {