Shift (v2)
1. Overview
| Property | Value |
|---|---|
| ID | FEAT-SALE-SHIFT |
| Status | Stable (v2 - fully replaces PosSession v1) |
| Owner | sale-team |
| Depends on | Device, SaleChannel, SaleOrder (shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId), FinanceVoucher (shiftId), FinanceTransaction (shiftEnrollmentId) |
| PRD | PRD-SHF-001 - Multi-employee Shifts |
A Shift is a working shift on one sale channel (merchant + saleChannel). A channel may have multiple open shifts at once - a device either creates a new one or joins an existing open shift (a device can be in only one open shift at a time). Devices with cashDrawerEnabled spawn a ShiftDrawer with an opening float. Employees acting on a device are enrolled automatically (see §4). Closing the shift freezes selling store-wide; each drawer reconciles individually; when the last drawer settles the shift finalizes (RECONCILED) with an aggregate Z report.
The legacy
/pos/sessions/*API (PosSession v1) has been removed entirely. All integrations use/shifts/*.
2. Entity Model
ShiftEnrollmentis polymorphic:principalType = 'Device' | 'User',principalId= deviceId or userId. Each principal holds at most one201_ACTIVATEDenrollment at a time (partial unique index).- Money attribution:
FinanceVoucher.shiftId(document grain - per shift) andFinanceTransaction.shiftEnrollmentId(posting grain - per drawer/device).
3. Lifecycle
| Entity | Value | Meaning |
|---|---|---|
| Shift | 100_OPENING | Open - normal selling |
| Shift | 200_CLOSED | Closed - selling LOCKED, drawers pending reconciliation |
| Shift | 300_RECONCILED | Fully settled - aggregate Z available |
| Drawer | 100_OPENING / 200_CLOSED / 300_RECONCILED | Same, per drawer |
Invariants & key behaviors:
- A channel may have multiple
OPENINGshifts at once; each device/user principal holds at most one ACTIVATED enrollment (that is the real single-open invariant). - Closing does NOT remove device enrollments - drop-out is the cashier's explicit choice at reconcile (
dropOut: true). Devices that stay are carried over to the next shift (still entering a fresh opening float). - New-shift gate: opening a new shift is never blocked by the previous shift. Unfinished orders are carried forward (
DRAFT/PROCESSING/PARTIALre-parented onto the new shift; terminal orders stay put), and the previous shift does not need to be reconciled first. Cash integrity is enforced per-till at drawer open: opening a drawer whose previous session is200_CLOSED-unreconciled is rejected withdrawer.pending_reconcileuntil that drawer is reconciled (surfaced aspendingReconcileon/drawer-devices). - A shift with no drawers finalizes to
RECONCILEDimmediately on close.
4. Employee tracking (automatic)
Beyond devices, the system enrolls employees (principalType = 'User') into the shift - fully automatic, no FE API calls required:
| Moment | Behavior |
|---|---|
| Employee opens/joins the shift | USER enrollment + CHECK_IN event (same transaction as the device enrollment) |
| A different employee logs into the device and sells (create order / checkout) | Lazy check-in: enrolled into the device's shift on their first action |
| Employee starts acting in a different shift | Auto CHECK_OUT from the old shift, CHECK_IN to the new - presence follows where they last acted |
Shift finalizes (RECONCILED) | Every still-ACTIVATED employee is checked out (leftAt = settle time) |
- The employee identity comes from the request JWT - so the single operational requirement for FE: each employee logs in with their own account on the terminal (no shared logins), or presence/handover data attributes everything to one person.
- Presence tracking is best-effort: an enrollment failure never blocks a sale.
- Presence interval =
enrolledAt→leftAton the USER enrollment; the detailed timeline lives in the event ledger.
Event ledger (ShiftEnrollmentEvent.type):
| Event | Principal | Meaning |
|---|---|---|
DROP_IN / DROP_OUT | Device | Device joined / left the shift (drop-out chosen at reconcile) |
CHECK_IN / CHECK_OUT | User | Employee joined / left the shift |
DRAWER_OPEN | Device | Drawer opened (amount = opening float) |
PAY_IN / PAY_OUT / SAFE_DROP | Device | Cash in / out / safe drop (amount required; issues a finance voucher) |
RECONCILE | Device | Drawer reconciled (amount = counted cash, note = cashier note) |
5. Authentication & Headers
| Header | Required | Notes |
|---|---|---|
Authorization: Bearer <JWT> | All endpoints | Employee identity (see §4) |
x-device-info: <deviceId> | All endpoints except GET /shifts (listing) | The backend resolves the device to derive the merchant. Do NOT send deviceId via body/query. |
x-merchant-id: <merchantId> | GET /shifts only (back-office) | Merchant scope for device-less screens. |
Prerequisites: merchant has enableShiftManagement on; device fully registered (merchantId, status = 201_ACTIVATED, correct cashDrawerEnabled); role granted the Shift-subject permissions.
Turning the feature off mid-shift
The enableShiftManagement toggle only blocks opening NEW shifts (POST /shifts / POST /shifts/{id}/join → management.disabled). An already-open shift keeps working end-to-end: selling continues (orders just stop attaching to the shift), drawer cash ops, close, reconcile and all X/Z reports work normally - disabling the feature never strands an open shift.
6. REST Endpoints
| Verb | Path | Purpose |
|---|---|---|
GET | /shifts/current | Device state - the single source for screen routing |
POST | /shifts | Create shift - { saleChannelId, name?, notes?, drawerDeviceId?, openingFloat?, startAt?, endAt? }. name/notes are i18n { default, en?, vi? } |
POST | /shifts/{id}/join | Join an open shift - { drawerDeviceId?, openingFloat?, notes? } (notes i18n) |
POST | /shifts/cash-movement | Drawer cash op - { drawerId, type: PAY_IN|PAY_OUT|SAFE_DROP, amount, reason? } |
POST | /shifts/close | Close shift - { shiftId }; freezes selling everywhere |
POST | /shifts/drawers/{id}/reconcile | Reconcile drawer - { actualCash, actualNonCash?, acknowledgeDiscrepancy?, dropOut?, notes? }. actualNonCash accepts only BANK/QR_CODE/MOBILE_POS keys (unknown keys rejected) |
GET | /shifts/{id}/report | Bundle { shift, devices[] } - whole-shift + per-device (live X while open, stored Z once reconciled; bundle.shift is the aggregate). ?deviceId= narrows to one device |
POST | /shifts/{id}/report/snapshot | Persist a point-in-time snapshot of the bundle (idempotent - 409 snapshot.unchanged); list via /report/snapshots, detail via /report/snapshots/{snapshotId} |
GET | /shifts/z-report | Device-layer Z report - the calling device's most recent Z (still viewable after drop-out / carry-over). Non-drawer devices get their Z cut at CLOSE time (their figures are frozen then; no need to wait for reconciliation) - cash-count fields are 0, carrying only the device's sales slice |
GET | /shifts?filter=... | Shift listing per the platform LIST convention (loopback filter; merchant via the x-merchant-id header; response Shift[] or {count,data} per x-request-count-data; merchant scope is always enforced server-side) |
GET /shifts/current returns null (not in a shift) or { shift, enrollment, drawer?, expectedCash?, deviceParticipation }:
current returns | Screen |
|---|---|
null | Open shift |
shift.status = 100_OPENING | Selling (+ drawer ops when drawer present) |
shift.status = 200_CLOSED and drawer.status = 200_CLOSED | Reconcile drawer |
shift.status = 200_CLOSED, no drawer | Idle until next shift |
expectedCash: present only when the merchant has blind count DISABLED.deviceParticipation = { activeDeviceCount, joinedDeviceCount, pendingDevices[] }- which of the merchant's devices haven't joined yet (withcashDrawerEnabledto tell which still owe a float input).
Selling: send x-device-info on draft-order create and checkout - the backend attaches the order to the shift (openedInShiftEnrollmentId at create; closedInShiftEnrollmentId + shiftId at payment - revenue credits the closing shift; an order opened in shift A and paid in shift B is supported) and lazily checks in the acting employee (§4).
Discrepancy flow on reconcile: |actual − expected| > merchant tolerance → close.discrepancy_recount_required (409) → recount dialog: resubmit a corrected amount or include acknowledgeDiscrepancy: true.
7. Report payload (shared by X and Z)
{
"reportMeta": { "reportType": "Z", "reportNo": 3, "code": "Z-20260611-003" }, // code = printable handover voucher code
"sessionInfo": { "id": "...", "openedAt": "ISO", "closedAt": "ISO|null", "cashierId": "...", "closerId": "..." },
"cashSummary": {
"openingFloat": 500000, "cashSales": 4200000, "cashRefunds": 0,
"payIns": 200000, "payOuts": 150000, "safeDrops": 1000000,
"expectedCash": 3750000, "actualCash": 3740000, "cashDiscrepancy": -10000
},
"salesSummary": { "grossSales": 0, "discounts": 0, "taxAmount": 0, "netSales": 0, "orderCount": 0, "itemCount": 0 },
"paymentMethods": { "CASH": 0, "CARD": 0, "TRANSFER": 0 },
"actualNonCash": { },
"sections": { // finance matrix: count = bill count, amount = total
"sale": { "CASH": { "count": 12, "amount": 4200000 }, "BANK": {}, "QR_CODE": {}, "MOBILE_POS": {} },
"receipt": { }, // other receipt vouchers (PAY_IN, manual receipts)
"payment": { }, // other payment vouchers (PAY_OUT, SAFE_DROP, manual payments)
"return": { } // refunds
},
"notes": "..." // cashier note - per-drawer Z only; null on X and aggregate Z
}- Reconciling a drawer writes one Z PER DEVICE attached to it (not a single pooled Z): each device's Z carries the drawer's pooled cash (identical across those devices → response flag
cashShared: true) plus that device's OWN sales/sections. Group bydrawerIdand never sum cash across them.cashSharedis derived at read (a drawer with >1 device), not stored. - The aggregate shift Z has drawer money fields (
openingFloat/expectedCash/actualCash/cashDiscrepancy) = 0; sales figures cover the whole shift;notesis null. It remains the cash source of truth (sums physical drawers), independent of the per-device Z rows. sectionsnever double-counts across sections: each voucher is classified into exactly ONE section (sale-originated receipts live insale, never repeated inreceipt).- Split tender: a bill paid with multiple methods counts once in each method bucket it touched -
amountis always exact; do NOT sum the fourcountcolumns to derive a section's total bill count.
8. Flows
Handover screen flow (closing from the X-report screen):
- "Refresh latest data" button →
GET /shifts/{id}/report(whole shift + per-device;?deviceId=for one device) - live X while open, no record written. - Cashier enters
actualCash+notes. - "Close shift" button:
close→reconcile(handle the discrepancy dialog if raised). - "Close & print handover voucher": as above, then render the voucher from the Z response (
reportMeta.codeis the voucher code). - Multi-drawer merchants: repeat steps 2-3 on each drawer device; the aggregate voucher =
GET /shifts/{id}/report(bundle.shift) after the last drawer settles. Each device re-views its own Z viaGET /shifts/z-report.
9. Error codes
Key (server.sale.shift.*) | HTTP | Description |
|---|---|---|
management.disabled | 409 | Merchant has shift management off |
actor.user_invalid | 400 | Invalid token |
actor.device_header_missing | 400 | Missing x-device-info |
actor.device_not_found | 404 | Unknown deviceId |
actor.device_merchant_unassigned | 400 | Device has no merchant |
actor.device_merchant_mismatch | 400 | Device belongs to another merchant |
actor.merchant_id_missing | 400 | (listing) missing x-merchant-id header |
actor.sale_channel_not_found | 404 | Unknown sale channel |
actor.sale_channel_merchant_mismatch | 400 | Channel belongs to another merchant |
open.opening_float_required | 400 | Drawer device must supply an opening float |
drawer.pending_reconcile | 409 | Opening a drawer whose previous session is CLOSED-unreconciled (uncounted cash) - reconcile it first. Opening a NEW shift is not blocked |
open.already_open | 409 | Device is enrolled in another open shift |
open.merchant_mismatch | 403 | Active enrollment belongs to another merchant |
lookup.no_active_session | 400 | Device not in a shift (order create) |
checkout.no_active_session | 409 | Device not in a shift (checkout) |
session.merchant_mismatch | 403 | Enrollment belongs to another merchant (sell guard) |
state.shift_not_open | 400 | Shift closed - refresh current, route to reconcile |
close.discrepancy_recount_required | 409 | Discrepancy over tolerance - recount / acknowledge |
drawer.not_open | 400 | Drawer not open (cash op after close) |
drawer.not_found | 404 | Drawer / Z report not found |
z_report.already_generated | 409 | Drawer already reconciled |
cash_wallet.not_found | 400 | Merchant has no default cash account |
scope.merchant_forbidden | 403 | No access to the resource's merchant |
device_context.required | 400 | Order create without device context while shifts enabled |
10. Related pages
- Sale Order - orders attach via
shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId - PRD: Multi-employee Shifts
- X/Z Shift Reports