Skip to content

Shift (v2)

1. Overview

PropertyValue
IDFEAT-SALE-SHIFT
StatusStable (v2 - fully replaces PosSession v1)
Ownersale-team
Depends onDevice, SaleChannel, SaleOrder (shiftId/openedInShiftEnrollmentId/closedInShiftEnrollmentId), FinanceVoucher (shiftId), FinanceTransaction (shiftEnrollmentId)
PRDPRD-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

  • ShiftEnrollment is polymorphic: principalType = 'Device' | 'User', principalId = deviceId or userId. Each principal holds at most one 201_ACTIVATED enrollment at a time (partial unique index).
  • Money attribution: FinanceVoucher.shiftId (document grain - per shift) and FinanceTransaction.shiftEnrollmentId (posting grain - per drawer/device).

3. Lifecycle

EntityValueMeaning
Shift100_OPENINGOpen - normal selling
Shift200_CLOSEDClosed - selling LOCKED, drawers pending reconciliation
Shift300_RECONCILEDFully settled - aggregate Z available
Drawer100_OPENING / 200_CLOSED / 300_RECONCILEDSame, per drawer

Invariants & key behaviors:

  • A channel may have multiple OPENING shifts 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/PARTIAL re-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 is 200_CLOSED-unreconciled is rejected with drawer.pending_reconcile until that drawer is reconciled (surfaced as pendingReconcile on /drawer-devices).
  • A shift with no drawers finalizes to RECONCILED immediately on close.

4. Employee tracking (automatic)

Beyond devices, the system enrolls employees (principalType = 'User') into the shift - fully automatic, no FE API calls required:

MomentBehavior
Employee opens/joins the shiftUSER 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 shiftAuto 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 = enrolledAtleftAt on the USER enrollment; the detailed timeline lives in the event ledger.

Event ledger (ShiftEnrollmentEvent.type):

EventPrincipalMeaning
DROP_IN / DROP_OUTDeviceDevice joined / left the shift (drop-out chosen at reconcile)
CHECK_IN / CHECK_OUTUserEmployee joined / left the shift
DRAWER_OPENDeviceDrawer opened (amount = opening float)
PAY_IN / PAY_OUT / SAFE_DROPDeviceCash in / out / safe drop (amount required; issues a finance voucher)
RECONCILEDeviceDrawer reconciled (amount = counted cash, note = cashier note)

5. Authentication & Headers

HeaderRequiredNotes
Authorization: Bearer <JWT>All endpointsEmployee 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}/joinmanagement.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

VerbPathPurpose
GET/shifts/currentDevice state - the single source for screen routing
POST/shiftsCreate shift - { saleChannelId, name?, notes?, drawerDeviceId?, openingFloat?, startAt?, endAt? }. name/notes are i18n { default, en?, vi? }
POST/shifts/{id}/joinJoin an open shift - { drawerDeviceId?, openingFloat?, notes? } (notes i18n)
POST/shifts/cash-movementDrawer cash op - { drawerId, type: PAY_IN|PAY_OUT|SAFE_DROP, amount, reason? }
POST/shifts/closeClose shift - { shiftId }; freezes selling everywhere
POST/shifts/drawers/{id}/reconcileReconcile drawer - { actualCash, actualNonCash?, acknowledgeDiscrepancy?, dropOut?, notes? }. actualNonCash accepts only BANK/QR_CODE/MOBILE_POS keys (unknown keys rejected)
GET/shifts/{id}/reportBundle { 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/snapshotPersist a point-in-time snapshot of the bundle (idempotent - 409 snapshot.unchanged); list via /report/snapshots, detail via /report/snapshots/{snapshotId}
GET/shifts/z-reportDevice-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 returnsScreen
nullOpen shift
shift.status = 100_OPENINGSelling (+ drawer ops when drawer present)
shift.status = 200_CLOSED and drawer.status = 200_CLOSEDReconcile drawer
shift.status = 200_CLOSED, no drawerIdle 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 (with cashDrawerEnabled to 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)

jsonc
{
  "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 by drawerId and never sum cash across them. cashShared is 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; notes is null. It remains the cash source of truth (sums physical drawers), independent of the per-device Z rows.
  • sections never double-counts across sections: each voucher is classified into exactly ONE section (sale-originated receipts live in sale, never repeated in receipt).
  • Split tender: a bill paid with multiple methods counts once in each method bucket it touched - amount is always exact; do NOT sum the four count columns to derive a section's total bill count.

8. Flows

Handover screen flow (closing from the X-report screen):

  1. "Refresh latest data" button → GET /shifts/{id}/report (whole shift + per-device; ?deviceId= for one device) - live X while open, no record written.
  2. Cashier enters actualCash + notes.
  3. "Close shift" button: closereconcile (handle the discrepancy dialog if raised).
  4. "Close & print handover voucher": as above, then render the voucher from the Z response (reportMeta.code is the voucher code).
  5. 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 via GET /shifts/z-report.

9. Error codes

Key (server.sale.shift.*)HTTPDescription
management.disabled409Merchant has shift management off
actor.user_invalid400Invalid token
actor.device_header_missing400Missing x-device-info
actor.device_not_found404Unknown deviceId
actor.device_merchant_unassigned400Device has no merchant
actor.device_merchant_mismatch400Device belongs to another merchant
actor.merchant_id_missing400(listing) missing x-merchant-id header
actor.sale_channel_not_found404Unknown sale channel
actor.sale_channel_merchant_mismatch400Channel belongs to another merchant
open.opening_float_required400Drawer device must supply an opening float
drawer.pending_reconcile409Opening a drawer whose previous session is CLOSED-unreconciled (uncounted cash) - reconcile it first. Opening a NEW shift is not blocked
open.already_open409Device is enrolled in another open shift
open.merchant_mismatch403Active enrollment belongs to another merchant
lookup.no_active_session400Device not in a shift (order create)
checkout.no_active_session409Device not in a shift (checkout)
session.merchant_mismatch403Enrollment belongs to another merchant (sell guard)
state.shift_not_open400Shift closed - refresh current, route to reconcile
close.discrepancy_recount_required409Discrepancy over tolerance - recount / acknowledge
drawer.not_open400Drawer not open (cash op after close)
drawer.not_found404Drawer / Z report not found
z_report.already_generated409Drawer already reconciled
cash_wallet.not_found400Merchant has no default cash account
scope.merchant_forbidden403No access to the resource's merchant
device_context.required400Order create without device context while shifts enabled

Proprietary and Confidential. Unauthorized copying, distribution, or use of this software is strictly prohibited.