Skip to main content

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​

LayerCodepathsResponsibility
Domaindomain/cash-drawer-repository.domain.tsPort for cash_drawer_event rows
Applicationapplication/cash-drawer.service.tsAuthorise-then-record; close out outcome
Infrastructureinfrastructure/cash-drawer.repository.tsKysely adapter
Interfacesinterfaces/cash-drawer.controller.ts, dtos/cash-drawer.dto.tsHTTP + 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​

TriggerPermissionAudit
Open with a document configured to kick the drawerWhatever is required to change that document (taking the payment)Print job / document — no cash_drawer_event
Standalone "no sale"PolicyResource.CashDrawer + CreateRow 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)
  • supportsCashDrawer is 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.