npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

@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
                │
                ▼
            Accounting

Do 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/hr

Then, 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 generate

hr init:

  • Finds prisma/schema.prisma (or apps/api/prisma/schema.prisma, or --schema)
  • Requires a PostgreSQL datasource
  • Appends models between // @companyio/hr:begin and // @companyio/hr:end
  • Copies a host-owned migration into the host prisma/migrations folder
  • 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 status

Status 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.post against 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 tenant
  • branch_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.