Package Exports
- @classytic/payroll
- @classytic/payroll/calculators
- @classytic/payroll/schemas
- @classytic/payroll/utils
Readme
@classytic/payroll
Enterprise HRM & Payroll for MongoDB. Clean architecture, multi-tenant, type-safe.
Install
npm install @classytic/payroll mongoose @classytic/mongokitQuick Start
import { createPayrollInstance } from '@classytic/payroll';
const payroll = createPayrollInstance()
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
.build();
// Hire employee
await payroll.hire({
organizationId,
employment: { email: 'dev@example.com', position: 'Engineer' },
compensation: { baseSalary: 80000, currency: 'USD' },
});
// Process salary
await payroll.processSalary({
organizationId,
employeeId,
period: { month: 1, year: 2024 },
});Features
- Employee Lifecycle: Hire, update, terminate, re-hire
- Compensation: Salary, allowances, deductions
- Bulk Processing: Handle 10k+ employees with streaming
- Multi-tenant: Automatic organization isolation
- Events & Webhooks: React to payroll events
- Type-safe: Full TypeScript support
Exports
| Entry Point | Description |
|---|---|
@classytic/payroll |
Main API (Payroll class, types, errors) |
@classytic/payroll/schemas |
Mongoose schemas for extending |
@classytic/payroll/utils |
Date, money, validation utilities |
@classytic/payroll/calculators |
Pure calculation functions (no DB) |
Employee Management
// Hire
await payroll.hire({ organizationId, employment, compensation });
// Get employee
const employee = await payroll.getEmployee({ employeeId, organizationId });
// Get by flexible identity (userId, employeeId, or email)
const emp = await payroll.getEmployeeByIdentity({
identity: 'EMP-001', // or userId or email
organizationId,
mode: 'employeeId', // 'userId' | 'employeeId' | 'email' | 'any'
});
// Update
await payroll.updateEmployment({ employeeId, updates: { position: 'Lead' } });
// Terminate
await payroll.terminate({ employeeId, terminationDate, reason: 'resignation' });
// Re-hire
await payroll.reHire({ employeeId, hireDate: new Date() });Listing Employees
Employee listing/queries are done at app level using your models directly:
// Use your EmployeeModel with mongokit or mongoose directly
const employees = await EmployeeModel.find({
organizationId,
'employment.status': 'active'
});
// Or with mongokit repository
const repo = createRepository(EmployeeModel);
const result = await repo.getAll({
filters: { organizationId, 'employment.status': 'active' },
page: 1,
limit: 100,
});Compensation
// Update salary
await payroll.updateSalary({ employeeId, compensation: { baseSalary: 90000 } });
// Add allowance
await payroll.addAllowance({ employeeId, allowance: { type: 'housing', amount: 2000 } });
// Add deduction
await payroll.addDeduction({ employeeId, deduction: { type: 'loan', amount: 500 } });Bulk Processing
await payroll.processBulkPayroll({
organizationId,
period: { month: 1, year: 2024 },
onProgress: ({ current, total }) => console.log(`${current}/${total}`),
});Leave Management
// Request leave
await payroll.requestLeave({
employeeId,
organizationId,
leaveType: 'annual',
startDate: new Date('2024-01-15'),
endDate: new Date('2024-01-17'),
});
// Approve
await payroll.approveLeave({ leaveRequestId, approverId });Void / Reverse / Restore
Payroll corrections with full state tracking:
// Void unpaid payroll
await payroll.voidPayroll({
organizationId,
payrollRecordId,
reason: 'Test payroll',
});
// Reverse paid payroll (creates reversal transaction)
await payroll.reversePayroll({
organizationId,
payrollRecordId,
reason: 'Duplicate payment',
});
// Restore voided payroll
await payroll.restorePayroll({
organizationId,
payrollRecordId,
reason: 'Voided in error',
});State Flow:
PENDING → PROCESSING → PAID → REVERSED
↓ ↓
└──→ VOIDED ←── FAILED
↓
PENDING (restore)Events
payroll.on('employee:hired', (payload) => {
console.log(`New hire: ${payload.employee.email}`);
});
payroll.on('payroll:processed', (payload) => {
console.log(`Salary processed: ${payload.payrollRecord.id}`);
});Webhooks
await payroll.registerWebhook({
organizationId,
url: 'https://api.example.com/webhooks',
events: ['payroll:processed'],
secret: 'your-secret',
});Tenant Modes
Single-Tenant (Recommended for most apps)
For apps serving one organization:
const payroll = createPayrollInstance()
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
.forSingleTenant({ organizationId: YOUR_ORG_ID, autoInject: true })
.build();
// No organizationId needed - auto-injected
await payroll.hire({
employment: { email: 'dev@example.com', position: 'Engineer' },
compensation: { baseSalary: 80000, currency: 'USD' },
});
await payroll.processSalary({
employeeId,
period: { month: 1, year: 2024 },
});
await payroll.getEmployee({ employeeId });
await payroll.updateEmployment({ employeeId, updates: { position: 'Lead' } });
await payroll.terminate({ employeeId, terminationDate, reason: 'resignation' });Multi-Tenant
For SaaS apps serving multiple organizations:
const payroll = createPayrollInstance()
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
.build();
// organizationId required on all operations
await payroll.hire({ organizationId, employment, compensation });
await payroll.processSalary({ organizationId, employeeId, period });
await payroll.getEmployee({ organizationId, employeeId });Pure Calculators
No database required - works in browser/serverless:
import {
calculateSalaryBreakdown,
calculateProRating,
calculateAttendanceDeduction
} from '@classytic/payroll/calculators';
// Calculate salary breakdown
const breakdown = calculateSalaryBreakdown({
baseSalary: 5000,
allowances: [{ type: 'housing', amount: 500 }],
deductions: [{ type: 'tax', percentage: 10 }],
});
// Pro-rate for mid-month joins
const proRated = calculateProRating({
amount: 5000,
startDate: new Date('2024-01-15'),
endDate: new Date('2024-01-31'),
totalDays: 31,
});Shift Compliance
Late penalties, overtime bonuses, night differentials:
import {
calculateShiftCompliance,
DEFAULT_ATTENDANCE_POLICY
} from '@classytic/payroll';
const result = calculateShiftCompliance({
policy: DEFAULT_ATTENDANCE_POLICY,
baseSalary: 5000,
shiftData: {
lateArrivals: [{ minutes: 15 }],
overtime: [{ hours: 2, type: 'weekday' }],
},
});
console.log(result.penalties); // Late penalties
console.log(result.bonuses); // Overtime bonuses
console.log(result.netAdjustment);Configuration
const payroll = createPayrollInstance()
.withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
.withConfig({
currency: 'USD',
payroll: {
attendanceIntegration: true,
autoCreateTransaction: true,
},
leave: {
enabled: true,
defaultBalances: { annual: 20, sick: 10 },
},
})
.build();Timeline Audit
Integrate with @classytic/mongoose-timeline-audit for WHO/WHAT/WHEN tracking:
import timelineAuditPlugin from '@classytic/mongoose-timeline-audit';
import { EMPLOYEE_TIMELINE_CONFIG, PAYROLL_EVENTS } from '@classytic/payroll';
employeeSchema.plugin(timelineAuditPlugin, EMPLOYEE_TIMELINE_CONFIG);
payroll.on('employee:hired', async ({ data }) => {
const employee = await Employee.findById(data.employee.id);
employee.addTimelineEvent(
PAYROLL_EVENTS.EMPLOYEE.HIRED,
`Hired as ${data.employee.position}`,
request
);
await employee.save();
});TypeScript
import type {
EmployeeDocument,
PayrollRecordDocument,
LeaveRequestDocument,
Compensation,
PayrollBreakdown,
} from '@classytic/payroll';Schemas & Indexes
The package exports schema creators and recommended indexes:
import {
createEmployeeSchema,
createPayrollRecordSchema,
applyEmployeeIndexes,
applyPayrollRecordIndexes,
} from '@classytic/payroll/schemas';
// Create schemas
const employeeSchema = createEmployeeSchema();
const payrollRecordSchema = createPayrollRecordSchema();
// Apply recommended indexes (optional)
applyEmployeeIndexes(employeeSchema);
applyPayrollRecordIndexes(payrollRecordSchema);Note on duplicate prevention: The package handles duplicate payroll detection at the application level (idempotency cache + existing record checks). No unique index is enforced by default, giving you control over your indexing strategy.
If you need DB-level uniqueness:
// Add your own unique index if needed
payrollRecordSchema.index(
{ organizationId: 1, employeeId: 1, 'period.month': 1, 'period.year': 1 },
{ unique: true }
);Mongokit Integration
The payroll package is built on @classytic/mongokit for powerful repository patterns and plugins.
Audit Trail Plugin
Automatically track who created/updated records with the built-in audit plugin:
import { Repository } from '@classytic/mongokit';
import { payrollAuditPlugin } from '@classytic/payroll';
import { EmployeeModel } from './models';
// Create repository with audit plugin
const employeeRepo = new Repository(EmployeeModel, [
payrollAuditPlugin({
userId: currentUser._id,
userName: currentUser.name,
organizationId: orgId,
}),
]);
// All creates/updates now auto-capture audit fields
await employeeRepo.create({
employment: { email: 'dev@example.com' },
compensation: { baseSalary: 80000 },
// createdBy, createdAt automatically added
});
await employeeRepo.update(employeeId, {
$set: { 'employment.position': 'Senior' },
// updatedBy, updatedAt automatically added
});Available Audit Plugins
import {
payrollAuditPlugin, // Tracks creates & updates
readAuditPlugin, // Tracks read access (compliance)
fullAuditPlugin, // Combines both + comprehensive events
} from '@classytic/payroll';
// Full audit with compliance tracking
const repo = new Repository(PayrollRecordModel, [
fullAuditPlugin({
userId: currentUser._id,
organizationId: orgId,
}),
]);Custom Mongokit Plugins
Create your own plugins for cross-cutting concerns:
import type { Repository } from '@classytic/mongokit';
// Example: Auto-encrypt sensitive fields
function encryptionPlugin(secretKey: string) {
return (repo: Repository) => {
repo.on('before:create', async (ctx) => {
if (ctx.data.ssn) {
ctx.data.ssn = encrypt(ctx.data.ssn, secretKey);
}
});
repo.on('after:getById', async (ctx) => {
if (ctx.result?.ssn) {
ctx.result.ssn = decrypt(ctx.result.ssn, secretKey);
}
});
};
}
// Apply to repository
const repo = new Repository(EmployeeModel, [
encryptionPlugin(process.env.SECRET_KEY),
payrollAuditPlugin({ userId, organizationId }),
]);Transaction Management
Mongokit provides clean transaction handling:
import { Repository } from '@classytic/mongokit';
const payrollRepo = new Repository(PayrollRecordModel);
// Automatic transaction management
const result = await payrollRepo.withTransaction(async (session) => {
// All operations use the same session
const payroll = await payrollRepo.create(payrollData, { session });
const transaction = await transactionRepo.create(txData, { session });
// Automatic commit on success, rollback on error
return { payroll, transaction };
});Type-Safe Utilities
Use the new type guards for cleaner code:
import {
getEmployeeEmail,
getEmployeeName,
isGuestEmployee,
isDuplicateKeyError,
parseDuplicateKeyError,
} from '@classytic/payroll';
// Type-safe employee identity access
const email = getEmployeeEmail(employee); // Works for guest & user-linked
const name = getEmployeeName(employee); // Fallback to employeeId
if (isGuestEmployee(employee)) {
console.log('Guest employee:', employee.employeeId);
}
// Type-safe error handling
try {
await payroll.hire({ ... });
} catch (error) {
if (isDuplicateKeyError(error)) {
const field = parseDuplicateKeyError(error);
console.error(`Duplicate ${field}`);
}
}Security
- Multi-tenant isolation: All queries scoped by
organizationId - Repository plugin: Auto-injects tenant filter on all operations
- Secure lookups:
findEmployeeSecure()enforces org boundaries - State machines: Prevent invalid status transitions
License
MIT