Cash Drawer Module
Technical reference for apps/backend/src/cash-drawer/.
Shipped with 053-mobile-vendor-printing. This module authorises and audits
standalone "no sale" drawer opens. A drawer that opens with a receipt is
not recorded here — the sale already authorises it and the print job
already attributes it.
Hexagonal map
| Layer | Codepaths | Responsibility |
|---|---|---|
| Domain | domain/cash-drawer-repository.domain.ts | Port for cash_drawer_event rows |
| Application | application/cash-drawer.service.ts | Authorise-then-record; close out outcome |
| Infrastructure | infrastructure/cash-drawer.repository.ts | Kysely adapter |
| Interfaces | interfaces/cash-drawer.controller.ts, dtos/cash-drawer.dto.ts | HTTP + RolesGuard |
CashDrawerService goes through PrinterConfigManagementService (application)
to prove the printer belongs to the tenant. It does not import the printer
repository.
Two triggers, two authorities
| Trigger | Permission | Audit |
|---|---|---|
| Open with a document configured to kick the drawer | Whatever is required to change that document (taking the payment) | Print job / document — no cash_drawer_event |
| Standalone "no sale" | PolicyResource.CashDrawer + Create | Row written before the pulse |
CashDrawer is a third resource alongside Printer and
PrinterStationAssignment. No existing role bundle grants it. Whoever
may open a till with nothing to reconcile must be given that authority
deliberately.
The mobile container never checks this. It holds no credential (051 FR-012). Enforcement is the controller gate; SC-010 is verified by calling the endpoint directly, not by watching a hidden button.
Standalone open flow
1. POST /cash-drawer/open?locationId=
→ authorize, insert row (outcome=failed, reason=not_attempted)
→ return { authorizationId, eventId } (same id)
2. Container pulses PrinterSession.openCashDrawer()
carrying that authorizationId
3. PATCH /cash-drawer/open/:authorizationId?locationId=
→ outcome sent | failed
A report that never arrives leaves the row failed / not_attempted. That is
the honest reading: someone with till access asked, and no open was confirmed.
businessId is derived from the session and locationId. It is not accepted
on the body.
POST /cash-drawer/open
Body: { printerId, correlationId? }.
Attribution is employeeId (via resolveEmployeeId), not user.id. One user
may hold employee rows in several businesses.
Rejected when:
- the printer is missing in this tenant (not-found, not forbidden — does not leak existence)
supportsCashDraweris false on the row (no drawer attached)- the printer is inactive
PATCH /cash-drawer/open/:authorizationId
Body: { outcome: "sent" | "failed", failureReason? }.
failureReason values: not_attempted, printer_unreachable,
printer_rejected, drawer_not_supported.
A sent outcome clears the reason. A failure without a reason keeps the
initial one.
Drawer outcome is independent of any document print (FR-040). A receipt that printed while the drawer stayed shut is not a failed print.
GET /cash-drawer/events?locationId=&limit=
Tenant-scoped audit list. Limit clamped to 1–500 (default 100).
How the pulse is delivered
PrinterSession.openCashDrawer() is its own call, not bytes appended to a
slip. Generic adapters emit raw ESC p. Star's adapter uses the SDK kick,
which works on models where the bare pulse does not. A transport that cannot
drive a drawer throws PrinterUnsupportedOperationError.