@companyio/hr
v0.2.8
Published
Reusable HR / employee management package for multi-tenant business applications
Downloads
935
Readme
@companyio/hr
Installable HR / employee management for multi-tenant SaaS apps (Fuel, school, POS, restaurant, general ERP).
Contacts owns who someone is. HR owns employment. A future @companyio/payroll package will own payroll calculation. @companyio/accounting (optional) owns financial effects.
This package does not create a second database, a DATABASE_URL, or its own Prisma Client. HR tables live in the host application’s existing PostgreSQL database.
Architecture
ContactsParty (identity)
│
│ partyId
▼
HrEmployee (employment profile)
│
├── HrEmployment / history
├── HrSalary / components
├── HrAssignment (generic host entity)
├── HrBankAccount / HrEmergencyContact / HrDocument
├── HrObligation
├── HrAdvance
└── HrLoan
│
▼
future Payroll
│
▼
AccountingDo not store salary, designation, bank accounts, loans, or advances on ContactsParty.
What the host must provide
| Host owns | Package owns |
| --------------------------------------------------- | ------------------------------------------------------ |
| PostgreSQL + DATABASE_URL | Employee domain, history, obligations, advances, loans |
| Prisma schema and migrations | Schema source (prisma/hr.prisma) and hr init |
| Authentication / sessions | Fastify routes via attachHR |
| Mapping session → { main_business_id, branch_id } | Persistence (Prisma + in-memory for tests) |
| Contacts parties | Optional identity adapter |
| Accounting (optional) | Optional books adapter |
Requirements: Node.js, PostgreSQL, Prisma on the host, zod. Fastify ≥ 4 is required only if you call attachHR. @companyio/contacts is required for identity checks. @companyio/accounting is optional.
Money
HR money values are decimal major units (e.g. 50000.50 PKR) rounded via integer cents (toCents / fromCents). The same convention is used by Accounting. Do not invent a separate minor-unit API in HR.
Tenant context
type HRContext = {
main_business_id: string;
branch_id: string;
user_id?: string;
};What HR is not
- Not Payroll (no payslips, tax, statutory net-pay engine — that is future
@companyio/payroll) - Not Fuel / School / POS (use generic
HrAssignment.externalType/externalRefId) - Not Accounting (obligations live in HR; journals live in Accounting)
HrPayrollPeriod is an HR calendar window for attendance / salary months (open/close). It is not a payroll run.
Install
pnpm add @companyio/hrThen, from the host app directory that contains Prisma:
pnpm exec hr init
# optional: pnpm exec hr init --schema path/to/schema.prisma
pnpm --filter @companyio/api exec prisma migrate deploy --config prisma7.config.ts
pnpm prisma generatehr init:
- Finds
prisma/schema.prisma(orapps/api/prisma/schema.prisma, or--schema) - Requires a PostgreSQL datasource
- Appends models between
// @companyio/hr:beginand// @companyio/hr:end - Copies a host-owned migration into the host
prisma/migrationsfolder - Never creates a database or runs migrate
If models are already present, the CLI prints that HR is already initialized and does not duplicate them.
pnpm exec hr statusStatus reports package version, schema path, markers, HrEmployee, Contacts detection, Accounting detection, migration presence, and initialization state.
Concepts
Employee identity
Create a Contacts PERSON first. HR stores partyId only.
const party = await contacts.parties.create(context, {
type: 'PERSON',
name: 'Ahmed Ali',
phone: '03001111111',
nationalId: '35202-1234567-1',
});
const employee = await hr.employees.create(context, {
partyId: party.id,
employeeCode: 'EMP-001',
joiningDate: '2024-01-01',
designation: 'Cashier',
});Name, phone, email, address, and CNIC stay on Contacts (nationalId). HR adds the EMPLOYEE role on the party.
An application User is not required. Link one with Contacts:
await contacts.parties.addReference(context, party.id, {
system: 'auth',
type: 'user',
externalId: user.id,
});Employee codes
Codes are unique per main_business_id + branch_id. Names may repeat. If you omit a code, HR assigns EMP-001, EMP-002, …
Employees are archived (INACTIVE) or terminated, not physically deleted.
Employment and salary history
A promotion or salary change creates a new row. Previous rows stay with ENDED / SUPERSEDED and an end date.
Salary rows have basicSalary plus optional components (ALLOWANCE, OVERTIME, COMMISSION, BONUS, OTHER) so a future payroll package can read the agreed structure without HR calculating net pay.
Assignments
Do not put stationId / campusId / storeId on HrEmployee. Use:
await hr.employees.addAssignment(context, employee.id, {
externalType: 'fuel_station', // or school_campus, pos_store, …
externalRefId: 'station_123',
isPrimary: true,
startDate: '2024-01-01',
});Obligations, advances, loans
HR stores what is owed or outstanding. Accounting journals are not duplicated here.
- Obligations:
SALARY,BONUS,OVERTIME,COMMISSION,ADVANCE,LOAN,DEDUCTION,REIMBURSEMENT,OTHER - Advances:
REQUESTED→APPROVED→PAID→PARTIALLY_SETTLED/SETTLED - Loans: principal, outstanding, repayments
Accounting (optional)
import { createAccountingHRAdapter } from '@companyio/hr';
const hr = createHR({
prisma,
getContext,
identity: createContactsIdentityAdapter(contacts),
accounting: createAccountingHRAdapter(accounting),
});The adapter uses existing accounting APIs only:
- Salary cash payment:
operations.post({ kind: 'salary' }) - Advance / loan:
journals.postagainst prepaid (1400) or receivable (1200) vs cash/bank - Accounting party:
externalType = "employee",externalRefId = employee.id - Idempotency key:
hr:{kind}:{referenceId}
Without the adapter, advances and loans still work as HR records.
Monthly salary run
GET /salary-run?yearMonth=YYYY-MM and POST /salary-run/pay calculate each employee’s due for the calendar month:
- Full month if they worked the whole month
- From join date through month-end if they joined mid-month (join on the 12th of a 30-day month → 19 days)
- Through termination date if they left mid-month
Amount is (monthly salary ÷ days in month) × payable days. Paying posts operations.post({ kind: 'salary' }) through the accounting adapter.
Tenancy
Every HR row is scoped by both:
main_business_id— SaaS tenantbranch_id— branch, station, campus, store
Map both ids from the authenticated session. Request bodies cannot authorize a different tenant.
Wire Fastify
import Fastify from 'fastify';
import { attachHR, createContactsIdentityAdapter } from '@companyio/hr';
const app = Fastify();
attachHR(app, {
prisma,
prefix: '/api/v1/hr',
authenticate: async (request) => request.user ?? null,
getContext: (_request, user) => ({
main_business_id: user.main_business_id,
branch_id: user.branch_id,
}),
identity: createContactsIdentityAdapter(contacts),
});Routes (prefix default /api/v1/hr):
| Method | Path |
| --------- | ------------------------------------------------------------------------------------------------- |
| GET/POST | /employees |
| GET/PATCH | /employees/:id |
| POST | /employees/:id/archive |
| POST | /employees/:id/terminate |
| GET/POST | /employees/:id/employment |
| GET/POST | /employees/:id/salary |
| GET/POST | /employees/:id/assignments |
| GET/POST | /employees/:id/bank-accounts |
| GET/POST | /employees/:id/emergency-contacts |
| GET/POST | /employees/:id/documents |
| GET/POST | /employees/:id/obligations |
| GET/POST | /employees/:id/advances |
| GET/POST | /employees/:id/loans |
| POST | /obligations/:obligationId/payments |
| POST | /advances/:advanceId/approve |
| POST | /advances/:advanceId/pay |
| POST | /loans/:loanId/repayments |
| GET | /reports/advances, /reports/loans, /reports/obligations |
| GET | /salary-run |
| POST | /salary-run/pay |
| GET/POST | /departments, /positions, /shifts, /leave-types, /payroll-periods |
| GET/POST | /employees/:id/shifts, /attendance, /overtime, /deductions, /leave |
| GET | /employees/:id/summary |
| POST | /payroll-periods/:id/close, /overtime/:id/decide, /leave/:id/decide, /advances/:id/reject |
Bank accounts are not included in the employee list payload.
Fuel stations use generic HrAssignment (externalType = "fuel_station"). Optional catalog: seedPumpCatalog() creates typical departments/positions without hard-coding them as employee types.
await hr.workforce.createDepartment(context, { name: 'Fuel Operations' });
await hr.workforce.createPosition(context, { departmentId, name: 'Pump Attendant' });
await hr.workforce.createShift(context, { name: 'Night', startTime: '00:00', endTime: '08:00' });
await hr.workforce.recordAttendance(context, employeeId, {
workDate: '2026-09-01',
status: 'PRESENT',
});
const summary = await hr.workforce.getEmployeeSummary(context, employeeId, '2026-09');Bank accounts are not included in the employee list payload.
Service API
const hr = createHR({
prisma,
getContext,
identity: createContactsIdentityAdapter(contacts),
});
await hr.employees.create(context, { partyId, employeeCode: 'EMP-001' });
await hr.employees.addEmployment(context, id, { designation: 'Lead', startDate: '2026-01-01' });
await hr.employees.addSalary(context, id, { effectiveFrom: '2026-01-01', basicSalary: 200000 });
await hr.advances.requestAdvance(context, id, { amount: 10000, requestedOn: '2026-01-10' });
await hr.loans.createLoan(context, id, { principalAmount: 50000, startDate: '2026-02-01' });Tests can use createMemoryHRStore() instead of Prisma.
Staff and HrEmployee
Pump staff is HrEmployee plus a Contacts PERSON party. Salary obligations and salary payments belong to HR. Station rent belongs to Expenses and Payments. There is no separate Fuel Employee table.
backfillFuelEmployees is only for hosts that still have a legacy staff table to copy. The Fuel host creates Contacts + HR rows directly.
Future payroll
Payroll should read HrEmployee, employment, salary structure, assignments, advances, loans, and obligations, then post net pay through Accounting. HR does not calculate gross/net, tax, or payslips.
