JSPM

@classytic/payroll

2.8.0
  • ESM via JSPM
  • ES Module Entrypoint
  • Export Map
  • Keywords
  • License
  • Repository URL
  • TypeScript Types
  • README
  • Created
  • Published
  • Downloads 18
  • Score
    100M100P100Q46432F
  • License MIT

Modern HRM and payroll management for Mongoose - Plugin-based, event-driven, multi-tenant ready. Salary processing, compensation management, tax calculations, and employee lifecycle management.

Package Exports

  • @classytic/payroll
  • @classytic/payroll/calculators
  • @classytic/payroll/core
  • @classytic/payroll/schemas
  • @classytic/payroll/utils

Readme

@classytic/payroll

HRM & Payroll for MongoDB. Multi-tenant, event-driven, type-safe.

Install

npm install @classytic/payroll mongoose @classytic/mongokit

Quick Start

import { createPayrollInstance } from '@classytic/payroll';

const payroll = createPayrollInstance()
  .withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
  .build();

// Hire
await payroll.hire({
  organizationId,
  employment: { email: 'dev@example.com', position: 'Engineer', hireDate: new Date() },
  compensation: { baseAmount: 80000, currency: 'USD', frequency: 'monthly' },
});

// Process salary
await payroll.processSalary({
  organizationId,
  employeeId,
  month: 1,
  year: 2024,
});

Package Exports

Entry Point Description
@classytic/payroll Main API: Payroll class, types, schemas, errors
@classytic/payroll/calculators Pure calculation functions (no DB required)
@classytic/payroll/utils Date, money, validation utilities
@classytic/payroll/schemas Mongoose schema factories

Employee Operations

// Hire
await payroll.hire({
  organizationId,
  employment: { email, employeeId, position, department, hireDate },
  compensation: { baseAmount, currency, frequency },
});

// Get employee
const emp = await payroll.getEmployee({ employeeId, organizationId });

// Update employment
await payroll.updateEmployment({
  employeeId,
  organizationId,
  updates: { position: 'Senior Engineer', department: 'engineering' },
});

// Terminate
await payroll.terminate({
  employeeId,
  organizationId,
  terminationDate: new Date(),
  reason: 'resignation',
});

// Re-hire
await payroll.reHire({ employeeId, organizationId, hireDate: new Date() });

Compensation

// Update salary
await payroll.updateSalary({
  employeeId,
  organizationId,
  compensation: { baseAmount: 90000 },
  effectiveFrom: new Date(),
});

// Add allowance
await payroll.addAllowance({
  employeeId,
  organizationId,
  allowance: {
    type: 'housing',    // 'housing' | 'transport' | 'meal' | 'mobile' | 'medical' | 'bonus' | 'other'
    amount: 2000,
    taxable: true,
  },
});

// Add deduction
await payroll.addDeduction({
  employeeId,
  organizationId,
  deduction: {
    type: 'provident_fund',  // 'tax' | 'loan' | 'advance' | 'provident_fund' | 'insurance'
    amount: 500,
    auto: true,
  },
});

// Update bank details
await payroll.updateBankDetails({
  employeeId,
  organizationId,
  bankDetails: { accountNumber, bankName, routingNumber },
});

Payment Frequencies

Supports multiple payment frequencies with automatic tax annualization:

Frequency baseAmount Periods/Year Example ($104k/year)
monthly Monthly salary 12 $8,666.67/month
bi_weekly Bi-weekly wage 26 $4,000/bi-week
weekly Weekly wage 52 $2,000/week
daily Daily rate 365 $285/day
hourly Hourly rate 2080 $50/hour

Tax is calculated consistently: same annual income = same annual tax, regardless of frequency.

Payroll Processing

// Single employee
const result = await payroll.processSalary({
  organizationId,
  employeeId,
  month: 1,
  year: 2024,
  paymentDate: new Date(),
  paymentMethod: 'bank',
  payrollRunType: 'regular',  // 'regular' | 'supplemental' | 'retroactive' | 'off-cycle'
});
// Returns: { employee, payrollRecord, transaction }

// Bulk processing
const bulk = await payroll.processBulkPayroll({
  organizationId,         // Optional in single-tenant mode or with context.organizationId
  month: 1,
  year: 2024,
  employeeIds: [],        // Optional: specific employees (default: all active + on_leave)
  batchSize: 50,
  concurrency: 5,
  onProgress: (p) => console.log(`${p.percentage}%`),
});
// Returns: { successCount, failCount, totalAmount, successful[], failed[] }

Duplicate Protection

The package provides database-level duplicate protection via a unique compound index:

// Unique index on: (organizationId, employeeId, period.month, period.year, payrollRunType)
// With partial filter: { isVoided: { $eq: false } }

// This allows:
// - One active record per employee per period per run type
// - Multiple run types in same period (regular + supplemental)
// - Re-processing after voiding (requires restorePayroll() first)
// - Re-processing after reversing

Important: Voided records require restorePayroll() before re-processing. Voided is a terminal state that preserves audit trail.

Two-Phase Export

Safe export that only marks records after downstream confirms receipt:

// Phase 1: Prepare (records NOT marked)
const { records, exportId } = await payroll.prepareExport({
  organizationId,
  startDate: new Date('2024-01-01'),
  endDate: new Date('2024-01-31'),
});

// Send to external system...

// Phase 2a: Confirm success (marks records)
await payroll.confirmExport({ organizationId, exportId });

// Phase 2b: Cancel if failed (records stay unmarked)
await payroll.cancelExport({ organizationId, exportId, reason: 'API error' });

Void / Reverse / Restore

// Void unpaid payroll (pending, processing, failed)
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 (blocked if replacement exists)
await payroll.restorePayroll({
  organizationId,
  payrollRecordId,
  reason: 'Voided in error',
});

Status Flow:

PENDING → PROCESSING → PAID → REVERSED
   ↓          ↓
   └──→ VOIDED ←── FAILED
         ↓
       PENDING (restore)

Leave Management

// Request leave
await payroll.requestLeave({
  employeeId,
  organizationId,
  leaveType: 'annual',  // 'annual' | 'sick' | 'unpaid' | 'maternity' | 'paternity'
  startDate: new Date('2024-03-01'),
  endDate: new Date('2024-03-05'),
  reason: 'Vacation',
});

// Approve
await payroll.approveLeave({ leaveRequestId, organizationId, approverId: managerId });

// Reject
await payroll.rejectLeave({
  leaveRequestId,
  organizationId,
  rejectedBy: managerId,
  rejectionReason: 'Insufficient leave balance',
});

// Get balance
const balance = await payroll.getLeaveBalance({ employeeId, organizationId });
// { annual: { total: 20, used: 5, remaining: 15 }, sick: {...}, ... }

Pure Calculators (No DB Required)

Import from @classytic/payroll/calculators for client-side or serverless:

import {
  calculateSalaryBreakdown,
  calculateProRating,
  calculateAttendanceDeduction,
} from '@classytic/payroll/calculators';

Salary Breakdown

const breakdown = calculateSalaryBreakdown({
  employee: {
    hireDate: new Date('2024-01-01'),
    terminationDate: null,
    compensation: {
      baseAmount: 100000,
      frequency: 'monthly',
      currency: 'USD',
      allowances: [
        { type: 'housing', amount: 20000, taxable: true },
        { type: 'transport', amount: 5000, taxable: true },
      ],
      deductions: [
        { type: 'provident_fund', amount: 5000, auto: true },
      ],
    },
  },
  period: {
    month: 3,
    year: 2024,
    startDate: new Date('2024-03-01'),
    endDate: new Date('2024-03-31'),
  },
  attendance: {
    expectedDays: 22,
    actualDays: 20,
  },
  config: {
    allowProRating: true,
    autoDeductions: true,
    defaultCurrency: 'USD',
    attendanceIntegration: true,
  },
  taxBrackets: [
    { min: 0, max: 600000, rate: 0 },
    { min: 600000, max: 1200000, rate: 0.1 },
    { min: 1200000, max: Infinity, rate: 0.2 },
  ],
});

// Returns PayrollBreakdown
{
  baseAmount: number,
  allowances: Array<{ type, amount, taxable }>,
  deductions: Array<{ type, amount, description }>,
  grossSalary: number,
  netSalary: number,
  taxableAmount: number,
  taxAmount: number,
  workingDays: number,
  actualDays: number,
  proRatedAmount: number,
  attendanceDeduction: number,
}

Pro-Rating

import { calculateProRating } from '@classytic/payroll/calculators';

const result = calculateProRating({
  hireDate: new Date('2024-03-15'),
  terminationDate: null,
  periodStart: new Date('2024-03-01'),
  periodEnd: new Date('2024-03-31'),
  workingDays: [1, 2, 3, 4, 5],
  holidays: [],
});

// Returns ProRatingResult
{
  isProRated: true,
  ratio: 0.545,
  periodWorkingDays: 22,
  effectiveWorkingDays: 12,
  reason: 'new_hire',
}

Events

payroll.on('employee:hired', (payload) => { /* { employee, organizationId } */ });
payroll.on('employee:terminated', (payload) => { /* { employee, reason } */ });
payroll.on('salary:processed', (payload) => { /* { payrollRecord, transaction } */ });
payroll.on('payroll:completed', (payload) => { /* { summary, period } */ });
payroll.on('payroll:exported', (payload) => { /* { exportId, recordCount } */ });

Webhooks

// Register webhook
payroll.registerWebhook({
  url: 'https://api.example.com/webhooks',
  events: ['salary:processed', 'employee:hired'],
  secret: 'your-secret',
});

// Verify signature in handler
const signature = req.headers['x-payroll-signature'];
const timestamp = req.headers['x-payroll-timestamp'];
const signedPayload = `${timestamp}.${JSON.stringify(req.body)}`;
const expected = crypto.createHmac('sha256', secret).update(signedPayload).digest('hex');

Configuration

Multi-Tenant (Default)

const payroll = createPayrollInstance()
  .withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
  .withConfig({
    payroll: {
      defaultCurrency: 'USD',
      attendanceIntegration: true,
      allowProRating: true,
      autoDeductions: true,
    },
  })
  .build();

// organizationId required on all operations
await payroll.hire({ organizationId, employment, compensation });

Single-Tenant

const payroll = createPayrollInstance()
  .withModels({ EmployeeModel, PayrollRecordModel, TransactionModel })
  .forSingleTenant({ organizationId: YOUR_ORG_ID, autoInject: true })
  .build();

// organizationId auto-injected
await payroll.hire({ employment, compensation });

Key Types

import type {
  // Documents
  EmployeeDocument,
  PayrollRecordDocument,
  LeaveRequestDocument,

  // Core types
  Compensation,
  Allowance,
  Deduction,
  PayrollBreakdown,
  TaxBracket,
  BankDetails,

  // Params
  HireEmployeeParams,
  ProcessSalaryParams,
  ProcessBulkPayrollParams,
  ExportPayrollParams,

  // Results
  ProcessSalaryResult,
  BulkPayrollResult,

  // Enums
  EmployeeStatus,      // 'active' | 'on_leave' | 'suspended' | 'terminated'
  PayrollStatus,       // 'pending' | 'processing' | 'paid' | 'failed' | 'voided' | 'reversed'
  PayrollRunType,      // 'regular' | 'supplemental' | 'retroactive' | 'off-cycle'
  LeaveType,           // 'annual' | 'sick' | 'unpaid' | 'maternity' | 'paternity'
  AllowanceType,       // 'housing' | 'transport' | 'meal' | 'mobile' | 'medical' | 'bonus' | 'other'
  DeductionType,       // 'tax' | 'loan' | 'advance' | 'provident_fund' | 'insurance'
  PaymentFrequency,    // 'monthly' | 'bi_weekly' | 'weekly' | 'daily' | 'hourly'
  PaymentMethod,       // 'bank' | 'cash' | 'check'
} from '@classytic/payroll';

Schemas

import {
  createEmployeeSchema,
  createPayrollRecordSchema,
  employeeIndexes,
  payrollRecordIndexes,
} from '@classytic/payroll/schemas';

// Create with custom fields
const employeeSchema = createEmployeeSchema({
  skills: [String],
  certifications: [{ name: String, date: Date }],
});

// Apply indexes
employeeIndexes.forEach(idx => employeeSchema.index(idx.fields, idx.options));

Utilities

import {
  // Date
  addDays, addMonths, diffInDays, startOfMonth, endOfMonth,
  getPayPeriod, getWorkingDaysInMonth,

  // Money (banker's rounding)
  roundMoney, percentageOf, prorateAmount,

  // Query builders
  toObjectId, isValidObjectId,
} from '@classytic/payroll/utils';

Error Handling

import {
  PayrollError,
  DuplicatePayrollError,
  EmployeeNotFoundError,
  NotEligibleError,
  ValidationError,
} from '@classytic/payroll';

try {
  await payroll.processSalary({ organizationId, employeeId, month, year });
} catch (error) {
  if (error instanceof DuplicatePayrollError) {
    // Already processed for this period + run type
  } else if (error instanceof EmployeeNotFoundError) {
    // Employee doesn't exist
  } else if (error instanceof NotEligibleError) {
    // Employee not eligible (terminated, etc.)
  }
}

License

MIT