@classytic/leave
v0.1.0
Published
Universal leave / time-off engine — framework-agnostic, subject-polymorphic. Handles leave policies, balances, requests, approvals, and cancellations for employees, students, patients, gym members — anyone who can be absent. Powered by @classytic/mongokit
Readme
@classytic/leave
Universal leave / time-off engine. One primitive for corporate PTO, university excused absences, gym membership holds, clinic appointment credits, parental leave — any scenario where a subject is temporarily unavailable.
Subject-polymorphic. ERPNext HR and Odoo HR tie leave to Employee; @classytic/leave uses subjectRef: string + subjectModel: string (SourceBridge pattern). Host-defined subject — Employee, Student, Patient, GymMember, Contractor.
Primitives-first. Uses ApprovalChain, OutboxStore, DateRange, Money, ExternalRef from @classytic/primitives/* — zero duplicated shapes.
Install
npm install @classytic/leave @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, mongoose >=9.4.1, zod >=4.
Transactions required.
approve/cancel/convert/accrue/deductcouple the request FSM, the balance snapshot, and the accrual ledger atomically — MongoDB must run as a replica set or sharded cluster.createLeave()assertscapabilities.transactionsat boot and throwsLeaveCapabilityErrorotherwise. For standalone-Mongo local dev only, passallowNonTransactional: true(partial-write risk is yours).
Core shape
LeavePolicy ─────────────┬── LeaveBalance (snapshot per subject × type)
types[]: {code, unit, │ balanceDays | balanceHours
accrualRate, │
carryoverRule, ... } │
│
└── LeaveAccrual (append-only ledger)
deltaDays | deltaHours
reason, requestId?, compensates?
LeaveRequest
subjectRef, subjectModel
typeCode
range: DateRange (from @classytic/primitives/period)
quantityDays | quantityHours
status: pending | approved | rejected | cancelled
approvals?: ApprovalChain (from @classytic/primitives/approval)Quick start
import mongoose from 'mongoose';
import { createLeave } from '@classytic/leave';
const leave = await createLeave({
connection: mongoose.connection,
tenant: { fieldType: 'objectId' }, // repo-core TenantConfig (+ mongokit plugin knobs)
});
// Production: createLeave({ ..., autoIndex: false }) and run
// `await leave.syncIndexes()` from a deploy script (rule 35).
// 1. Tenant sets up policies
const policy = await leave.repositories.policy.createPolicy({
name: 'Default staff policy',
types: [
{ code: 'annual', unit: 'days', accrualRate: { amount: 20, per: 'year' } },
{ code: 'sick', unit: 'days', accrualRate: { amount: 10, per: 'year' } },
{ code: 'personal', unit: 'hours', accrualRate: { amount: 40, per: 'year' } },
],
}, ctx);
// 2. Accrue balance (monthly cron, onboarding, etc.)
await leave.repositories.balance.accrue({
subjectRef: String(employee._id),
subjectModel: 'Employee',
typeCode: 'annual',
unit: 'days',
delta: 1.67, // monthly accrual of 20/year
reason: 'periodic_accrual',
// Natural key — a cron re-run with the same key throws
// DuplicateAccrualError (409) instead of double-crediting.
idempotencyKey: `accrual:2026-06:annual:${employee._id}`,
}, ctx);
// 3. Subject submits request
const request = await leave.repositories.request.submit({
subjectRef: String(employee._id),
subjectModel: 'Employee',
typeCode: 'annual',
range: {
start: new Date('2026-06-01T00:00:00Z'),
end: new Date('2026-06-05T00:00:00Z'),
},
reason: 'vacation',
}, ctx);
// 4. Approver decides (with optional ApprovalChain for multi-step flows)
const approved = await leave.repositories.request.approve(
request.leaveRequestNumber,
{},
reviewerCtx,
);
// On approval, `balance.deduct()` fires atomically and a new LeaveAccrual
// entry is appended to the ledger.Concurrency + error contract
Every domain error implements repo-core's HttpError — arc serializes
status + hierarchical code (leave.request.overlap,
leave.balance.insufficient, …) with zero adapter code.
- Balance overdraw is closed. The snapshot write is a version CAS;
racing approvals surface as
LeaveConcurrencyConflictError(409) orInsufficientBalanceError(409) — never a negative balance. - Overlapping requests:
submit()rejects a window intersecting an existing pending/approved request of the same subject (OverlappingRequestError, half-open[start, end)semantics; opt out per call withallowOverlap: true). Honest race note: this is a pre-persist query, not a unique index — variable-start ranges cannot be expressed as a Mongo uniqueness constraint, so two concurrent submits can both land aspending(the secondapprove()is where the balance debit hard-caps total leave). Hosts needing strict calendar exclusivity must serialize submissions externally (queue, Redis lock). - Idempotent accrual runs: pass
idempotencyKeyonaccrue/deduct/clipForCarryover; a unique partial index per tenant makes re-runs throwDuplicateAccrualError(409). - Cancellation re-credits exactly once:
cancel()of an approved request runs a compound CAS (version + status) — only one of N racing cancels reaches the compensating accrual.
Host-owned transactions
To combine leave verbs with other writes in one transaction, use the engine's unit-of-work — it threads the session AND defers in-process event publishes until after commit:
await leave.withTransaction(ctx, async (txCtx) => {
const req = await leave.repositories.request.approve(id, {}, txCtx);
await payrollRepo.create(adjustment, { session: txCtx.session });
return req;
});Passing a raw ctx.session without a managed event queue throws
UnmanagedSessionError before any write (ghost-event protection). For
manual composition, PENDING_EVENTS + flushPending are exported.
Events (primitives-compatible)
await leave.events.subscribe('leave:*', async (event) => {
// leave:policy.created leave:policy.archived
// leave:balance.accrued leave:balance.deducted
// leave:request.submitted leave:request.approved
// leave:request.rejected leave:request.cancelled
// leave:request.converted
});Host can inject any arc transport (Memory, Redis, Kafka) — primitives' EventTransport shape is fungible. When an outbox is wired, outbox.save runs first and its failures propagate (P8) — a failing outbox aborts the verb instead of silently dropping the durable event.
Event catalog (rule 18): leaveEventDefinitions[] — one Zod schema per event — is exported from the root and the @classytic/leave/events subpath:
import { leaveEventDefinitions } from '@classytic/leave/events';
for (const def of leaveEventDefinitions) registry.register(def); // arc EventRegistryIntegration with @classytic/muster
Leave doesn't import muster. When a subject is on approved leave AND has an AttendanceSession scheduled in the same window, the host decides policy: reconcile via a muster correction with outcome: 'on_leave', or auto-cancel the session. Both options are host concerns — leave only owns the balance + request FSM.
Arc integration
import { defineResource } from '@classytic/arc';
export const requestResource = defineResource({
name: 'leave-request',
prefix: '/leave/requests',
adapter: createAdapter(leave.models.LeaveRequest, leave.repositories.request),
actions: {
submit: { handler: (_id, data, req) => leave.repositories.request.submit(data, req.scope) },
approve: { handler: (id, data, req) => leave.repositories.request.approve(id, data, req.scope) },
reject: { handler: (id, data, req) => leave.repositories.request.reject(id, data, req.scope) },
cancel: { handler: (id, data, req) => leave.repositories.request.cancel(id, data, req.scope) },
},
});