@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 zodPeers: @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-cyclebonusrun 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
claimVersioncompare-and-swap; race losses throwVersionMismatchError(409), never "not found". - Per-subject error isolation. One bad subject (missing profile, currency mismatch, float-emitting calculator) is recorded on
run.subjects[i].errorand never poisons siblings;run.computeErrorsummarises. If every subject fails, the run bounces back todraft. - Fatal compute failures recover. A transaction-level failure (bridge crash, abort) rolls everything back, bounces the run to
draftwithcomputeError, and emitspayrun:run.compute_failed— the run is always retryable. - Cooperative cancellation. Thread
ctx.signal(anAbortSignal); compute checks it between subjects and mongokit checks it at every op boundary.ctx.retryPolicyflows 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
subjectRefstrings, 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) },
},
});