@classytic/pos
v0.2.1
Published
Multi-vertical POS / register-session kernel for MongoDB — shift lifecycle, cash-drawer reconciliation, payment-method breakdown, variance detection, FSM. Works for retail, restaurant, clinic, service-booking, car-wash and any vertical with a 'shift opens
Readme
@classytic/pos
Multi-vertical POS / register-session kernel for MongoDB. Shift lifecycle, cash-drawer reconciliation, payment-method breakdown, variance detection, FSM. Works for retail, restaurant, clinic, service-booking, car-wash and any vertical with a "shift opens, shift closes, money is reconciled" workflow.
Built on @classytic/mongokit and @classytic/primitives. Plugs into @classytic/arc for HTTP — but works standalone with any framework.
Why
Date-based aggregation ("close the day") is a pre-2010 retail pattern that hides per-shift accountability. Industry standard (Odoo, Square, Shopify POS, Lightspeed, Microsoft Dynamics 365 Retail, ARTS) is shift-driven: a register opens, accumulates orders, closes with a count, posts a single journal entry. Multi-cashier same-day = parallel shifts, summed by the ledger.
This package is that primitive. It has no opinion about your chart of accounts, payment methods, or accounting framework — those plug in via bridges.
Verticals
| Vertical | Maps to | |---|---| | Retail (grocery, boutique, hypermarket) | One shift per register per cashier-day | | Restaurant | One shift per server section, or per register; kitchen routing in extra fields | | Clinic / doctor booking | One shift per front desk per day; copays + appointment fees | | Service booking (salon, spa) | One shift per chair / station | | Car wash | One shift per bay |
The package's only assumption: "a session opens, accumulates payments, reconciles cash, closes." How you slice that across cashiers / registers / stations is your host's choice.
Install
npm install @classytic/posPeer deps: mongoose >=9.4.1, @classytic/mongokit >=3.16.0, @classytic/primitives >=0.7.2, @classytic/repo-core >=0.6.0, zod >=4.0.0.
Transactions required. The close pipeline is CAS-driven and hosts routinely wrap the shift + outbox writes in a transaction, so
createPosEngineasserts the backend declares thetransactionscapability at boot (MongoDB replica set). PassallowNonTransactional: truefor standalone-Mongo local dev.
Quick start
import mongoose from 'mongoose';
import { createPosEngine } from '@classytic/pos';
const pos = createPosEngine({
connection: mongoose.connection,
defaultPolicy: {
requiredOpeningFloat: null,
blindCloseRequired: false,
varianceThresholdAbs: 100, // BDT 100
varianceThresholdPct: 0.5, // 0.5% of expected
managerOverrideRequired: true,
allowHandover: true,
requireReasonCode: true,
allowedReasonCodes: ['safe_drop', 'petty_cash', 'bank_deposit', 'till_top_up', 'other'],
allowedPaymentMethods: ['cash', 'card', 'mfs', 'bank_transfer'],
},
bridges: {
ledger: {
async onShiftClosed(shift, ctx) {
// Build journal entry from shift.paymentBreakdown, post via your ledger
// engine, return { journalEntryId }.
const je = await myLedger.post(buildShiftJE(shift));
return { journalEntryId: je._id.toString() };
},
},
},
});
const ctx = { organizationId: 'branch-1', actorId: 'user-1' };
// Open a shift
const shift = await pos.repositories.shift.open({
registerId: 'register-1',
businessDate: new Date(),
openingCashierId: 'cashier-1',
openingCashierName: 'Alice',
openingCash: 1000,
}, ctx);
// As orders post in your host, increment the breakdown:
await pos.repositories.shift.incrementSales({
shiftId: shift._id.toString(),
method: 'cash',
amount: 250,
}, ctx);
// At end of shift — variance check + ledger bridge + state write
await pos.repositories.shift.close({
shiftId: shift._id.toString(),
countedByMethod: { cash: 1245 },
closedBy: 'cashier',
}, ctx);State machine
open ─► paused ─► open ─► blind_closed ─► closed
│ │ │
└─► forceClose ◄──┴────────────┴────► orphaned_closed| State | Meaning |
|---|---|
| open | Accepting sales; drawer live |
| paused | Toast-style handover; drawer rejects new sales until resume |
| blind_closed | Lightspeed pattern; cashier counted, awaits manager reconcile |
| closed | Reconciled, immutable |
| orphaned_closed | EOD cron auto-closed; counts default to expected; flagged for review |
Bridges
All optional. The package degrades gracefully without any.
ledger—onShiftClosed(shift, ctx) → { journalEntryId }. Host emits the JE. Throw to roll back the close.policy—resolvePolicy(ctx) → ShiftPolicy. Per-branch policy override at open time. Without it,defaultPolicyis used.notification—onVarianceOverride,onOrphanedClose. Best-effort; failures logged not raised.
Arc 2.11 wiring
import { defineResource, createMongooseAdapter } from '@classytic/arc';
import { openShiftBody, blindCloseBody, closeBody } from '@classytic/pos/schemas';
export default defineResource({
name: 'shift',
prefix: '/pos/shifts',
adapter: createMongooseAdapter(pos.models.Shift, pos.repositories.shift),
customSchemas: {
create: { body: openShiftBody },
},
actions: {
pause: { handler: (id, _, req) => pos.repositories.shift.pause(id, ctxFrom(req)) },
resume: { handler: (id, _, req) => pos.repositories.shift.resume(id, ctxFrom(req)) },
blind_close: { handler: (id, data, req) => pos.repositories.shift.blindClose({ shiftId: id, ...data }, ctxFrom(req)), schema: blindCloseBody },
close: { handler: (id, data, req) => pos.repositories.shift.close({ shiftId: id, ...data }, ctxFrom(req)), schema: closeBody },
force_close: { handler: (id, _, req) => pos.repositories.shift.forceClose(id, ctxFrom(req)) },
},
});Auto-CRUD (list/get/create/update/delete) comes from arc + mongokit. State transitions go through declarative actions. Custom routes only when you genuinely need them.
Events & transactional outbox
Every domain verb emits a pos:* event. Inject any arc EventTransport
(Memory / Redis / Kafka) — the package falls back to an in-process bus.
For at-least-once delivery, wire a host-owned OutboxStore. Each event is then
saved to the outbox under ctx.session before the transport publish, so the
event row commits atomically with the shift write. Save failures propagate (the
host's transaction rolls back); publish failures are swallowed (the relay
re-delivers from the durable row).
import { createPosEngine } from '@classytic/pos';
import { MongoOutboxStore } from '@classytic/arc'; // or any OutboxStore impl
const pos = createPosEngine({
connection,
defaultPolicy,
eventTransport: redisTransport,
outbox: new MongoOutboxStore({ connection, name: 'outbox' }),
});Hosts wanting publish-time validation / OpenAPI introspection register the Zod
event catalog with arc's EventRegistry:
import { posEventDefinitions } from '@classytic/pos/events';
for (const def of posEventDefinitions) registry.register(def);Subpath exports
import { createPosEngine } from '@classytic/pos'; // engine + types
import { ShiftRepository, type IShift } from '@classytic/pos'; // repo + model types
import {
POS_EVENTS, InProcessPosBus,
posEventDefinitions, MemoryOutboxStore, type OutboxStore,
} from '@classytic/pos/events';
import { canTransition, ShiftPolicy } from '@classytic/pos/domain';
import { openShiftBody, closeBody } from '@classytic/pos/schemas';
import { makePolicy, makeOpenShiftInput } from '@classytic/pos/testing';License
MIT — see LICENSE.
