Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
1226691
feat(auth): PIN quick user switch and picker
khaira777 Oct 28, 2026
858afd5
no-mistakes(review): Fix E2E cashier setup and item void approver ID
khaira777 Oct 28, 2026
3436e9a
no-mistakes(document): Document PIN quick switch routes and format ch…
khaira777 Oct 28, 2026
eb852bb
fix(auth): address PIN quick switch review items
khaira777 Oct 28, 2026
f1469aa
no-mistakes(document): Update PIN login and auto-lock documentation c…
khaira777 Oct 28, 2026
91478eb
no-mistakes(ci): Updated `frontend/e2e/approval-manager-selection.spe…
khaira777 Oct 28, 2026
05da388
feat(billing): correct paid bill payment method
khaira777 Oct 28, 2026
3db8960
no-mistakes(review): fix(billing): enforce PIN rate limits and shared…
khaira777 Oct 28, 2026
f6c5bae
no-mistakes(document): Refine payment method correction docs and fix …
khaira777 Oct 28, 2026
881426d
no-mistakes(ci): Fixed the failing `Core Test Suite (Shard 3/4)` CI c…
khaira777 Oct 28, 2026
ee3ee3e
fix(billing): address Qodo review findings on payment method correction
khaira777 Oct 28, 2026
e1e8890
no-mistakes(document): Update tenantTimezone docs and remove unused i…
khaira777 Oct 28, 2026
41c2595
feat(cash): opt-in one cash shift per staff
khaira777 Oct 28, 2026
b82e6d6
no-mistakes(review): fix(cash): reset shift selection state and updat…
khaira777 Oct 28, 2026
f830d01
no-mistakes(document): Consolidate per-staff shift docs and fix lint …
khaira777 Oct 28, 2026
b4757ae
no-mistakes(ci): ### Root Cause & Invariant - **Invariant:** When `on…
khaira777 Oct 28, 2026
0f750f4
fix(cash): harden per-staff shift X-report, movements, corrections, a…
khaira777 Oct 28, 2026
ff6b5e5
no-mistakes(review): fix(cash): scope shift x-report closure metadata…
khaira777 Oct 28, 2026
d0224b9
no-mistakes(review): fix(cash): scope shift x-report closure metadata…
khaira777 Oct 28, 2026
9403685
no-mistakes(document): Update shift and PIN lockout docs and fix lint…
khaira777 Oct 28, 2026
fe3eb5d
fix(billing): restrict payment method correction to owner/manager and…
khaira777 Oct 28, 2026
82e861e
chore: merge main into fm/flocafe-1339-per-staff-shifts
khaira777 Oct 29, 2026
26817b7
no-mistakes(document): Clarify caller shift requirements in API refer…
khaira777 Oct 29, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions docs/architecture/business-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -124,6 +124,17 @@ business-day cutoff therefore stays filed under the business date on which it op
reporting and day-close aggregates continue to bound sales by each business day's `[start, end)`
window.

When the opt-in setting `one_shift_per_staff` is `'false'` (the default), at most one shift may be
open across the store at a time. When `one_shift_per_staff` is `'true'`, each staff member can open
and close at most one shift of their own (`cash_sessions_one_open_per_user` on `(opened_by) WHERE status = 'open'`).
Cash payments, cash refunds, drawer movements, and cash expense entries attach to the authenticated
actor's open shift (`getOpenSession(db, userId)`). Day close (`POST /api/cash-closures`) is blocked
(`409 open_shift_blocks_day_close`) until all shifts opened on or before that business date are
closed, and reconciles the combined day totals across all shifts (summing per-staff shift opening
floats into the day's `opening_float` drawer movement). Two concurrent shifts sharing a single
physical cash drawer are permitted, though individual shift drawer counts cannot be physically
isolated in a shared drawer.

### Refunds

`main/services/refund.ts` allows a refund freely within `REFUND_WINDOW_MS` of order creation, which
Expand Down
7 changes: 4 additions & 3 deletions docs/architecture/order-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -214,9 +214,10 @@ payments.
Two gates sit between a valid request and the write:

- **Shift gate.** When `require_open_shift` is on, a cash tender (including a custom method that
resolves to cash) needs an open shift, or the request is a `409`. The check runs after replay
detection, so a committed payment can be re-read with the same key after its shift closed. A cash
ledger line records the open `cash_session_id`.
resolves to cash) needs an open shift for the actor (see [business time](business-time.md#cash-closures)),
or the request is a `409`. The check runs after replay detection, so a committed payment can be
re-read with the same key after its shift closed. A cash ledger line records the open
`cash_session_id`.
- **Kitchen-delivery gate.** When `require_kitchen_delivered_before_settlement` is on, `kds_enabled`
is not `false`, and `billing_type` is not `prepaid`, paying while kitchen items are undelivered
is a `409` with code `KITCHEN_ITEMS_UNDELIVERED` unless a manager PIN is supplied as `override_pin`.
Expand Down
23 changes: 13 additions & 10 deletions docs/reference/api.md

Large diffs are not rendered by default.

16 changes: 11 additions & 5 deletions docs/reference/roles-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,11 +154,17 @@ retains that user's exceptions; the editor shows their source (shipped default,
user override) so they can be reviewed or cleared. **Staff > Permission change history** lists
every save.

**Shift close.** Cashiers close only the shift they opened. An owner or manager can close any
session, and the closing actor is recorded on the closure row. The route is
`POST /api/cash-sessions/:id/close`; the own-session rule is enforced inside the close transaction,
where the stored session and the actor are read together. This is a context policy, not a
permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md).
**Cash shifts.** Opening, viewing, and closing shifts are gated by `cash.shifts.open`,
`cash.shifts.view`, and `cash.shifts.close`. Non-`owner`/`manager` roles (such as `cashier`) may
only view, preview, close, attach explicit drawer movements to, or read the shift X-report for the
shift they opened (`403 session_owner_required` on shift/movement routes and `403
shift_access_forbidden` on `GET /api/reports/x-report?session_id=` when targeting another user's
shift), whereas an `owner` or `manager` can inspect and close any staff member's shift, with the
closing actor recorded on the closure row. On `POST /api/cash-sessions/:id/close` and
`POST /api/cash-closures/movements`, the own-session rule is enforced inside the write transaction,
where the stored session and the actor are read together. This is a context policy, not
a permission — see [the authentication and authorization page](../architecture/authentication-and-authorization.md)
and [business time](../architecture/business-time.md#cash-closures).

**Order cancellation and completion overrides.** Staff holding `orders.status.update` can cancel a
whole order without a PIN only while the order and all of its items are `pending` and no bill holds
Expand Down
2 changes: 1 addition & 1 deletion frontend/e2e/database-export-import.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -211,7 +211,7 @@ test.describe('Database Settings JSON Export and Import', () => {

const contaminatedPayload = {
app: 'FloDesktop',
schema_version: '119',
schema_version: '120',
data: {
settings: [],
categories: [],
Expand Down
13 changes: 13 additions & 0 deletions frontend/src/app/(dashboard)/pos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2641,6 +2641,19 @@ export default function POSPage() {
if (result.session) shift.setCloseModalOpen(true);
else shift.setOpenModalOpen(true);
}}
onShowCloseStaffShift={
!shift.session && shift.openSessions.length > 0
? async () => {
const result = await shift.refresh();
if (result.status !== 'ok') return;
if (result.error) {
toast.error(result.error || shift.shiftLoadFailedMessage);
return;
}
if (result.openSessions.length > 0) shift.setCloseModalOpen(true);
}
: undefined
}
shiftHasOpenSession={!!shift.session}
shiftLoading={shift.loading}
shiftError={shift.error}
Expand Down
38 changes: 38 additions & 0 deletions frontend/src/components/dashboard/CashCloseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,8 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) {
closeAnotherDay,
businessDate,
setBusinessDate,
selectedSessionId,
setSelectedSessionId,
xReport,
xLoading,
xError,
Expand Down Expand Up @@ -239,6 +241,7 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) {
const next = e.target.value;
if (!/^\d{4}-\d{2}-\d{2}$/.test(next)) return;
if (next > todayLocal) return;
setSelectedSessionId(null);
setBusinessDate(next);
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background text-foreground outline-none focus:ring-2 focus:ring-brand/30"
Expand All @@ -251,6 +254,32 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) {
</div>
{xReport && (
<>
{(xReport.openShifts?.length ?? 0) > 0 && (
<div>
<label
htmlFor="x-report-shift-filter"
className="block text-sm text-muted-foreground mb-1"
>
{tPos('xReportShiftFilterLabel')}
</label>
<select
id="x-report-shift-filter"
value={selectedSessionId ?? ''}
onChange={(e) => {
const val = e.target.value;
setSelectedSessionId(val ? Number(val) : null);
}}
className="w-full px-3 py-2 text-sm border border-border rounded-lg bg-background text-foreground"
>
<option value="">{tPos('xReportAllShifts')}</option>
{xReport.openShifts?.map((s) => (
<option key={s.id} value={s.id}>
#{s.id} - {s.opened_by_name}
</option>
))}
</select>
</div>
)}
<div className="flex justify-end">{exportMenu('x')}</div>
{(xReport.alreadyClosed || alreadyClosedOverride) && (
<div className="flex items-start gap-2 rounded-lg border border-amber-300 bg-amber-50 p-3 text-sm text-amber-800">
Expand All @@ -273,6 +302,15 @@ export function CashCloseModal({ model }: { model: CashCloseModel }) {
name: blockingOpenShift.opened_by_name,
})}
</p>
{(xReport.openShifts?.length ?? 0) > 1 && (
<ul className="list-disc ps-4 pt-1 text-xs text-amber-800">
{xReport.openShifts?.map((s) => (
<li key={s.id}>
#{s.id} - {s.opened_by_name}
</li>
))}
</ul>
)}
</div>
</div>
)}
Expand Down
43 changes: 40 additions & 3 deletions frontend/src/components/dashboard/ShiftCloseModal.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,10 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
closeModalOpen,
setCloseModalOpen,
session,
activeSession,
openSessions = [],
selectedSessionId,
setSelectedSessionId,
countedInput,
setCountedInput,
submitting,
Expand All @@ -38,7 +42,9 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
printClosure,
setSubmitError,
closePreview,
setClosePreview,
} = model;
const targetSession = activeSession ?? session;
const tPos = useTranslations('pos');
const unpaidInShiftCount =
closePreview?.unpaid_in_shift.count ?? closePreview?.unpaid_in_shift.bill_count ?? 0;
Expand All @@ -54,6 +60,8 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
setClosedResult(null);
setSubmitError(null);
setCountedInput('');
setSelectedSessionId(null);
setClosePreview(null);
setHasPrinted(false);
}
setCloseModalOpen(next);
Expand All @@ -78,8 +86,37 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
<span>{submitError}</span>
</div>
)}
{!closedResult && session && (
{!closedResult && targetSession && (
<>
{openSessions.length > 1 && (
<div>
<label
htmlFor="shift-close-target-session"
className="block text-sm text-muted-foreground mb-1"
>
{tPos('selectOpenShift')}
</label>
<select
id="shift-close-target-session"
value={selectedSessionId ?? targetSession.id}
onChange={(e) => {
const nextId = Number(e.target.value);
if (Number.isInteger(nextId) && nextId > 0) {
setSelectedSessionId(nextId);
setSubmitError(null);
setClosePreview(null);
}
}}
className="w-full rounded-lg border border-border bg-card px-3 py-2 text-sm text-foreground"
>
{openSessions.map((openShift) => (
<option key={openShift.id} value={openShift.id}>
#{openShift.id} - {openShift.opened_by_name}
</option>
))}
</select>
</div>
)}
{unpaidInShiftCount > 0 && (
<div
role="status"
Expand Down Expand Up @@ -124,7 +161,7 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{t('expectedCash')}</span>
<span className="font-semibold text-foreground">
<Ltr>{fmt(session.expected_cash_cents / minorFactor)}</Ltr>
<Ltr>{fmt(targetSession.expected_cash_cents / minorFactor)}</Ltr>
</span>
</div>
<div>
Expand Down Expand Up @@ -201,7 +238,7 @@ export function ShiftCloseModal({ model }: { model: CashSessionModel }) {
<Button variant="outline" onClick={() => closeModal(false)} disabled={submitting}>
{tCommon('cancel')}
</Button>
<Button onClick={closeShift} disabled={submitting || !session}>
<Button onClick={closeShift} disabled={submitting || !targetSession}>
{submitting && <Loader2 size={16} className="animate-spin" />}
{t('closeShiftSession')}
</Button>
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/components/layout/StatusBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -123,7 +123,7 @@ export default function StatusBar({ showUpdateBadge = true }: { showUpdateBadge?
clearInterval(interval);
window.removeEventListener('flo-shift-changed', handleShiftChange);
};
}, []);
}, [user?.id]);

useEffect(() => {
let cancelled = false;
Expand Down
12 changes: 12 additions & 0 deletions frontend/src/components/pos/PosTopbar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ interface Props {
onActionsMenuOpenChange: (open: boolean) => void;
onShowCashMovement: () => void;
onShowShift: () => void;
onShowCloseStaffShift?: () => void;
shiftHasOpenSession: boolean;
shiftLoading: boolean;
shiftError: string | null;
Expand All @@ -51,6 +52,7 @@ export default function PosTopbar({
onActionsMenuOpenChange,
onShowCashMovement,
onShowShift,
onShowCloseStaffShift,
shiftHasOpenSession,
shiftLoading,
shiftError,
Expand Down Expand Up @@ -126,6 +128,16 @@ export default function PosTopbar({
{shiftHasOpenSession ? tDashboard('closeShiftSession') : tDashboard('openShift')}
</DropdownMenuItem>
)}
{canUseShift && !shiftHasOpenSession && onShowCloseStaffShift && (
<DropdownMenuItem
onSelect={onShowCloseStaffShift}
disabled={shiftLoading}
title={shiftError ?? undefined}
>
<Lock size={16} />
{tDashboard('closeShiftSession')}
</DropdownMenuItem>
)}
<DropdownMenuItem onSelect={onShowPrintMenu}>
<Printer size={16} />
{tProducts('printMenu')}
Expand Down
Loading
Loading