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

@classytic/payrun

v0.1.0

Published

Universal payroll-run engine — framework-agnostic, subject-polymorphic, calculator-pluggable. One engine for corporate payroll, contractor billing, student stipends, commission payouts. Powered by @classytic/mongokit.

Readme

@classytic/payrun

Universal payroll-run engine. One primitive for corporate payroll, contractor billing, stipends, commission payouts — any batch that computes and disburses amounts per subject per period.

Subject-polymorphic. subjectRef: string + subjectModel: string (SourceBridge). ERPNext HR and Odoo HR wire payroll to Employee; payrun stays neutral.

Calculator-pluggable. Earnings, deductions, taxes and contributions are pure functions supplied by an employer or jurisdiction policy pack. The kernel contains no country rates, thresholds, filing fields or bank formats.

Primitives-first. Uses Money, Period, DateRange, ApprovalChain, OutboxStore, ExternalRef from @classytic/primitives/*. No local copies.

Money is integer minor units. Every amount is { amount: int, currency } in cents/paise. Calculator output is validated per line — a float or negative amount fails that subject with InvalidCalculatorAmountError (the sign comes from line.type). The schemas enforce integers at the storage layer too.

Install

npm install @classytic/payrun @classytic/mongokit @classytic/repo-core @classytic/primitives mongoose zod

Peers: @classytic/mongokit >=3.16.0, @classytic/repo-core >=0.6.0, @classytic/primitives >=0.5.0.

Transactions required. compute, markPaid, and setProfile are transactional — MongoDB must run as a replica set / sharded cluster, asserted at boot via repo.capabilities.transactions. For standalone-Mongo local dev pass allowNonTransactional: true (accepts partial-write risk; never in production).

Core shape

PayrollRun (per-period batch)
  period: { year, month }               ← @classytic/primitives/period
  range:  { start, end }                ← DateRange
  currency: 'BDT' | 'USD' | ...
  status: draft → calculating → calculated → approved → paid → cancelled
  approvals?: ApprovalChain             ← @classytic/primitives/approval
  totals: { gross, deductions, net }    ← all Money

Payslip (per-subject, generated on compute)
  runId
  subjectRef, subjectModel
  components: [{ code, type, amount: Money, meta }, ...]
  totals: { gross, deductions, net }

PayComponent (reusable template)
  code: 'basic' | 'overtime' | 'income_tax' | ...
  type: earning | deduction | tax | reimbursement | benefit

CompensationProfile (per-subject snapshot)
  subjectRef, subjectModel
  baseSalary: Money
  payFrequency: monthly | bi_weekly | weekly | daily
  effectiveFrom, effectiveTo?

Quick start

import mongoose from 'mongoose';
import { definePayrun, type PayComponentCalculator } from '@classytic/payrun';

// 1. Host registers calculators — pure functions, primitives types
const basicSalaryCalc: PayComponentCalculator = {
  code: 'basic',
  version: '1.0.0',
  type: 'earning',
  priority: 10,
  async compute({ profile, period }) {
    return [{
      code: 'basic',
      label: 'Basic salary',
      type: 'earning',
      amount: profile.baseSalary,
      meta: { period: `${period.year}-${String(period.month).padStart(2, '0')}` },
    }];
  },
};

const incomeTaxCalc: PayComponentCalculator = {
  code: 'income_tax',
  version: 'policy-pack-version',
  type: 'tax',
  priority: 100, // after earnings
  async compute({ grossSoFar }) {
    // Illustrative only; production calculators come from a reviewed policy pack.
    const tax = Math.round(grossSoFar.amount * 0.1);
    return [{
      code: 'income_tax',
      label: 'Income tax',
      type: 'deduction',
      amount: { amount: tax, currency: grossSoFar.currency },
    }];
  },
};

const payrun = definePayrun({
  tenant: { fieldType: 'objectId' },   // TenantConfig from @classytic/repo-core/tenant
  defaultCurrency: 'BDT',              // fallback when createRun omits currency
}).bind(mongoose.connection, {
  calculators: [basicSalaryCalc, incomeTaxCalc],
});

// Deploy script (production boots use autoIndex: false):
// await payrun.syncIndexes();

// 2. Seed compensation profile
await payrun.repositories.compensationProfile.setProfile({
  subjectRef: String(employee._id),
  subjectModel: 'Employee',
  baseSalary: { amount: 50_000_00, currency: 'BDT' }, // in minor units
  payFrequency: 'monthly',
  effectiveFrom: new Date('2026-01-01'),
}, ctx);

// 3. Create + compute + approve + pay
const run = await payrun.repositories.payrollRun.createRun({
  period: { year: 2026, month: 4 },
  range: { start: new Date('2026-04-01'), end: new Date('2026-04-30') },
  currency: 'BDT',
  subjects: [
    { subjectRef: String(employee._id), subjectModel: 'Employee' },
  ],
}, ctx);

const computed = await payrun.repositories.payrollRun.compute(
  run.runNumber,
  ctx,
);
// computed.totals.gross / deductions / net are Money — engine summed them

const approved = await payrun.repositories.payrollRun.approve(
  run.runNumber,
  {},
  reviewerCtx,
);

// Host disburses (bank / Stripe / mobile wallet) then calls:
await payrun.repositories.payrollRun.markPaid(
  run.runNumber,
  { paidAt: new Date(), paymentRef: 'bank-txn-xyz' },
  ctx,
);

Run lifecycle guarantees

  • One OPEN run per (org, period, runType). A partial unique index rejects a second draft/calculating/calculated/approved run for the same period AND type with a typed 409 DuplicateOpenRunError — an off-cycle bonus run coexists with the regular monthly run. Terminal runs (paid / cancelled) free the slot — retro-adjustment (correction) runs for an already-paid period are first-class (metadata.retroOf).
  • CAS everywhere. Every state transition is a claimVersion compare-and-swap; race losses throw VersionMismatchError (409), never "not found".
  • Per-subject error isolation. One bad subject (missing profile, currency mismatch, float-emitting calculator) is recorded on run.subjects[i].error and never poisons siblings; run.computeError summarises. If every subject fails, the run bounces back to draft.
  • Fatal compute failures recover. A transaction-level failure (bridge crash, abort) rolls everything back, bounces the run to draft with computeError, and emits payrun:run.compute_failed — the run is always retryable.
  • Cooperative cancellation. Thread ctx.signal (an AbortSignal); compute checks it between subjects and mongokit checks it at every op boundary. ctx.retryPolicy flows to driver round-trips the same way.
  • Payslips are immutable. After compute, every mutation verb on the payslip repository throws PayslipImmutableError (403). Corrections go through a retro run.
  • Compute is chunked and resume-safe. Profile reads, bridge summaries, and calculators run outside any transaction; each payslip persists in its own small transaction, so MongoDB's 60s transaction lifetime never covers more than one subject. A crash mid-batch resumes on re-compute — persisted slips are reused (never re-emitted), reported via reusedCount.

Deployment shapes (tenancy & isolation)

All three shapes are engine config, pinned by scenario tests (05 / 06 / 07):

| Shape | Config | Isolation | When | |---|---|---|---| | Field-scoped (default) | tenant: { fieldType: 'objectId' } | Rows partitioned per org inside shared collections; fail-closed reads (unscoped queries throw); unique indexes tenant-prefixed — each branch/tenant runs the same period independently | Multi-branch companies, multi-tenant SaaS | | Single-tenant | tenant: false | No partitioning; open-run uniqueness is global | One company, one payroll | | Physical isolation | Two engines on two mongoose.createConnection(...)s + distinct collectionPrefix | Separate collections, indexes, retention, shard placement — one process. Two engines on ONE connection throw PayrunModelCollisionError (never silently clobber) | HIPAA-adjacent populations, payroll bureaus, contractual data separation |

Compliance posture — honest scope

A library cannot be HIPAA/GDPR/SOC 2 compliant — compliance is a property of the deployment (encryption at rest/in transit, access control, BAAs, audit log retention). What payrun contributes are the primitives an assessor asks about:

  • Isolation: the three shapes above, including physical collection separation.
  • Integrity: immutable payslips (repo-level mutation fence, no runtime escape hatch), CAS transitions, integer-only money, ISO 4217 currency gate.
  • Auditability: every state change emits a catalogued event (Zod-validated, outbox-durable when wired); payslips snapshot compensation AND subject identity at pay time.
  • Retention: soft delete with 7-year TTL on runs/components/profiles; payslips never deleted by the engine.
  • Minimization: the engine stores subjectRef strings, not people — PII beyond the optional display snapshot stays in the host's subject system behind the bridge.

Encryption, authentication, network policy, and erasure workflows are the host's responsibility — by design, not omission.

Events

await payrun.events.subscribe('payrun:*', async (event) => {
  // payrun:run.created
  // payrun:run.computed / payrun:run.compute_failed
  // payrun:run.approved / .rejected
  // payrun:run.paid
  // payrun:run.cancelled
  // payrun:payslip.generated (per subject)
  // payrun:component.created / .archived
  // payrun:profile.set / .deactivated
});

Zod event catalog (arc EventRegistry-ready) ships at the /events subpath:

import { payrunEventDefinitions } from '@classytic/payrun/events';
for (const def of payrunEventDefinitions) registry.register(def);

Transactional dispatch (P8). When an outbox is wired, every event row commits atomically with the business write — an outbox.save failure rolls the verb's transaction back. Transport publishes happen only AFTER commit (no ghost events on rollback). If you own the outer transaction, use payrun.withTransaction(ctx, fn); passing a bare ctx.session without an event queue throws UnmanagedSessionError up front:

await payrun.withTransaction(ctx, async (txCtx) => {
  const run = await payrun.repositories.payrollRun.markPaid(id, input, txCtx);
  await journalRepo.create(entry, { session: txCtx.session }); // cross-package write
  return run;
});

Payroll input authority

New payroll products should compute from an approved immutable boundary:

const payrun = definePayrun(shape).bind(connection, {
  inputAuthority: 'locked-pay-facts',
  bridges: { payFacts: workPeriodPayFactsBridge },
  calculators: jurisdictionPolicyPack.calculators,
});

In this mode the engine refuses to boot without payFacts, refuses live attendance/leave bridges, and freezes input provenance plus calculator versions onto every payslip. live-bridges remains the compatibility default.

Bridges

| Bridge | Purpose | When host wires it | |---|---|---| | subject | Resolve subject snapshots for payslips | UI wants display names on slips | | attendance | Worked hours from muster/other | Hourly / overtime calculators need it | | leave | Approved leave days | Unpaid-leave deductions | | payFacts | Locked, versioned payroll input | Required by locked-pay-facts mode | | ledger | Journal posting on pay | Accounting integration |

Calculators declare required inputs. A missing declared input fails that subject; it is never converted into a plausible zero.

Arc integration

import { defineResource } from '@classytic/arc';

export const runResource = defineResource({
  name: 'payroll-run',
  prefix: '/payroll/runs',
  adapter: createAdapter(payrun.models.PayrollRun, payrun.repositories.payrollRun),
  actions: {
    compute:  { handler: (id, _d, req) => payrun.repositories.payrollRun.compute(id, req.scope) },
    approve:  { handler: (id, d, req) => payrun.repositories.payrollRun.approve(id, d, req.scope) },
    reject:   { handler: (id, d, req) => payrun.repositories.payrollRun.reject(id, d, req.scope) },
    markPaid: { handler: (id, d, req) => payrun.repositories.payrollRun.markPaid(id, d, req.scope) },
    cancel:   { handler: (id, d, req) => payrun.repositories.payrollRun.cancel(id, d, req.scope) },
  },
});