Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
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
16 changes: 9 additions & 7 deletions docs/architecture/authentication-and-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,7 @@ list. `requireRole` (in `main/middleware/security.ts`) still exists, but a stati
(`tests/authorization-static-audit.test.ts`) fails the build if a new call site under
`main/routes/` uses it; the few remaining direct role checks are reviewed context policy, covered
in their own sections below (KDS station/category scope, refund approver identity, the
Staff Approval PIN holder for order-status, kitchen-delivery, charge-waiver, discount, and item cancel/void overrides, and the
last-active-owner/staff-target rule).
Staff Approval PIN holder, and the last-active-owner/staff-target rule).

**Resolution.** For every permission id, `resolveEffectivePermissions(userId)` evaluates, in
order: the protected-owner rule (for `authorization.manage` and `staff.privileged.manage` only,
Expand Down Expand Up @@ -137,7 +136,10 @@ permission check inside the `withTxn` callback, after the transaction opens, usi
`hasPermission(actorId, id)` against state read in the same transaction.
`PATCH /api/orders/:id/status`, `PATCH /api/orders/:orderId/items/:itemId/cancel`, the matching
`.../restore` endpoint in `main/routes/orders.ts`, and the bill payment kitchen-delivery override
and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths additionally require the presenting
and charge waiver override in `main/routes/bills.ts` do this, and their override-PIN paths
(along with paid-bill payment-method correction on `PATCH /api/bills/:id/payments/:index/method`,
which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)`)
additionally require the presenting
user to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` - a deliberate context-policy role check,
not a permission, because the Staff Approval PIN belongs to a
person acting in an owner/manager capacity regardless of what their individual permission grants
Expand Down Expand Up @@ -216,10 +218,10 @@ terminals, while approval authority stays restricted to active `owner` and `mana
and charge waivers), cached override PINs, and open privileged modals are cleared and stale
asynchronous responses from the previous session are ignored.
4. **Approval boundary:** `GET /api/staff/approvers` and every Staff Approval PIN check (refunds,
item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides, and
charge waivers) enforce active `owner` or `manager` role eligibility both outside and inside
write transactions. A valid `cashier`, `server`, or `chef` PIN is rejected with `403` on every
approval path.
item voids, order/item/bill discounts, order-status overrides, kitchen-delivery overrides,
charge waivers, and paid-bill payment-method corrections) enforce active `owner` or `manager`
role eligibility both outside and inside write transactions. A valid `cashier`, `server`, or
`chef` PIN is rejected with `403` on every approval path.

## Password policy

Expand Down
7 changes: 5 additions & 2 deletions docs/architecture/business-time.md
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,11 @@ function tenantTimezone(): string {

- [`main/routes/reports.ts`](../../main/routes/reports.ts), `tenantTimezone`, guards
`bucketByLocalHourAndWeekday` and `dayBoundsInTimezone` in reporting.
- [`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts) exports `tenantTimezone` so
that `cash-sessions.ts` resolves the same window rather than a second, divergent one.
- [`main/services/cash-drawer-movements.ts`](../../main/services/cash-drawer-movements.ts) defines
`tenantTimezone` (re-exported by [`main/routes/cash-closures.ts`](../../main/routes/cash-closures.ts)
for `cash-sessions.ts` and imported by [`main/routes/bills.ts`](../../main/routes/bills.ts)) so that
cash closures, shift sessions, and bill business-day checks resolve the same window rather than a
second, divergent one.
- [`main/services/refund.ts`](../../main/services/refund.ts) resolves inline, because a wrong
business-day end there can wrongly accept or reject a late refund.

Expand Down
4 changes: 3 additions & 1 deletion docs/architecture/order-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -230,7 +230,9 @@ payment with `409`. A zero-balance payment retry on an already-settled bill retu
idempotently without rejecting. The response carries the updated bill plus the wallet and loyalty
effects; `bill.payment_details` is the
settlement ledger, one entry per line with the applied amount, the requested amount, cash tendered
and change, the `cash_session_id`, and the timestamp.
and change, the `cash_session_id`, and the timestamp. While a `paid` bill's cash shift and business
day remain open, the tender method on a recorded non-wallet line can be corrected without changing
its amount via `PATCH /api/bills/:id/payments/:index/method` (see [the Bills API reference](../reference/api.md#bills)).

## Settlement and completion

Expand Down
1 change: 1 addition & 0 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -405,6 +405,7 @@ Bill generation, settlement, and completion are described end to end in
| `POST` | `/:id/split-check` | `ROLE_ACCESS.ownerManagerCashier` | body: `checks` | Body `checks` must hold between 2 and 20 entries and the resulting split group is capped at 20 checks. An unpaid untouched dine-in check is divided by allocating every active order item quantity; a split-group child may be divided again while it stays unpaid with no payment recorded, keeping its bill id, bill number and group, while paid siblings keep their own rows and payments. `400` when a request does not allocate exactly the source's own quantities. `403` when `split_checks_enabled` is off. `409` when the source has a recorded payment, a legacy already-split bill is re-split, the source has no divisible allocation, or the resulting split group would exceed 20 checks. |
| `POST` | `/:id/payment` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: payment line object (accepts optional `override_pin`, `approver_id`, `manager_id`, `user_id`), `customer_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
| `POST` | `/:id/payments` | `ROLE_ACCESS.ownerManagerCashier` | path: `id`; body: `payments`, `customer_id`, optional `override_pin`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | See [Order lifecycle](../architecture/order-lifecycle.md#payments) for input, replay, gate, and settlement behavior. |
| `PATCH` | `/:id/payments/:index/method` | `ROLE_ACCESS.ownerManager` + `payments.take` + active `owner` or `manager` Staff Approval PIN | path: `id`, `index`; body: `method`, `override_pin`, optional `payment_method_id`, `transaction_id`, `reason`, `approver_id`, `manager_id`, `user_id`; header: `Idempotency-Key` | `{ bill, audit }` (with `idempotentReplay: true` on `Idempotency-Key` or natural replay). Corrects the tender method on payment line `:index` of a `paid` bill without changing the amount (`400` `amount_immutable` if a differing `amount` is supplied, `400` `wallet_method_immutable` if changing to `wallet`, `400` `card_transaction_id_required` when required for `card`, `400` `payment_method_unavailable` for unknown methods). Caller must be an authenticated active `owner` or `manager` (`403` `insufficient_role_permissions` for `cashier`, `server`, or `chef` callers) and must provide an active `owner` or `manager` Staff Approval PIN (`403` when missing, invalid, or belonging to a `cashier`, `server`, or `chef`; `429` when rate-limited). Returns `404` when the bill or payment line is not found, and `409` when the bill is not `paid` (`bill_not_paid`), the existing line is `wallet` (`wallet_method_immutable`), the payment's cash shift is closed, missing/unverifiable, or was recorded without an open shift (`shift_closed`), the store business day is closed (`day_closed`), the bill, line, or a split sibling has been refunded (`bill_refunded`, `payment_line_refunded`, `split_sibling_refunded`) or settled in a closed or different shift (`split_sibling_shift_closed`), the target custom payment method is inactive (`payment_method_inactive`), `transaction_id` is already used (`transaction_reference_used`, `transaction_reference_reused`), or `Idempotency-Key` conflicts (`idempotency_key_conflict`). Audited in `order_audit_log` as `bill.payment_method.corrected`. |
| `POST` | `/:id/applyDiscount` | `ROLE_ACCESS.ownerManager` | path: `id`; body: `type`, `value`, `reason`, `override_pin`, `approver_id`, `manager_id`, `user_id` | `400` when the bill is paid (`paid`). `409` when the order is completed or cancelled (`order_not_discountable`), the bill is split (`split_check`) or refunded (`refunded`), or the discount would reduce the bill total below collected payments (`below_collected_funds`). Auto-settles the bill to `paid`, completes the order, and frees the table when remaining balance reaches zero on a partially paid bill (gated by kitchen delivery; see [Settlement and completion](../architecture/order-lifecycle.md#settlement-and-completion)). Audited as `order_discount_applied`. |
| `PATCH` | `/:id/charges` | `bills.discount.apply` | path: `id`; body: `charge_id`, `waived` or `applied`, optional `override_pin`, `approver_id`, `manager_id`, `user_id` | `{ bill }` with updated charge breakdown and totals. Only for unpaid, unsplit bills with no payments; the charge must be active and apply to the order type. Waiving a charge when the caller is outside `ROLE_ACCESS.ownerManager` (or when `override_pin` is supplied) requires an active `owner` or `manager` Staff Approval PIN holding `bills.discount.apply` (`403` with `requiresApproval: true` when missing, `403` on invalid PIN, `429` when rate-limited, `400` on conflicting approver ids). `409` when the order is cancelled. |
| `POST` | `/:id/markPrinted` | `bills.print` | path: `id` | Stamps `bills.printed_at`. |
Expand Down
9 changes: 9 additions & 0 deletions docs/reference/roles-and-permissions.md
Original file line number Diff line number Diff line change
Expand Up @@ -171,6 +171,15 @@ manager PIN (with `payments.take` when overriding unsettled payment), even when
is already an owner or manager. Full safeguard, restock, and audit rules are in
[the Orders API reference](api.md#orders).

**Paid bill payment method correction.** Only an active `owner` or `manager` holding `payments.take`
can correct the tender method on a recorded payment line of a `paid` bill
(`PATCH /api/bills/:id/payments/:index/method`) while its cash shift and store business day remain
open (`Yes (with Staff Approval PIN)` for Owner and Manager; `No` for Cashier, Server, and Chef).
Every correction requires an active `owner` or `manager` Staff Approval PIN (`override_pin`,
`approver_id`); a `cashier`, `server`, or `chef` caller or PIN never authorizes this action (`403`).
Full shift, refund, split-sibling, amount-immutability, and audit rules are in
[the Bills API reference](api.md#bills).

**Owner visibility.** The permission editor itself is rendered only for an authenticated user
holding `authorization.manage` — an active owner, always. The API enforces this independently;
hiding a control in the interface is not a security boundary.
Expand Down
24 changes: 24 additions & 0 deletions frontend/src/app/(dashboard)/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@ import { classifyOrderRequestFailure } from '@/lib/order-attempt';
import { OrderCard } from '@/components/orders/OrderCard';
import { OrdersMasterList } from '@/components/orders/OrdersMasterList';
import { OrderDetailPanel } from '@/components/orders/OrderDetailPanel';
import CorrectPaymentMethodModal from '@/components/orders/CorrectPaymentMethodModal';
import EditOrderItemModal from '@/components/orders/EditOrderItemModal';
import { PriceOverrideModal } from '@/components/orders/PriceOverrideModal';
import { useOrdersLayoutPreference } from '@/hooks/useOrdersLayout';
Expand Down Expand Up @@ -189,6 +190,10 @@ export default function OrdersPage() {
const [tabFilter, setTabFilter] = useState<FilterType>('active');
const [paymentBill, setPaymentBill] = useState<Bill | null>(null);
const [refundModal, setRefundModal] = useState<{ order: Order; bills: Bill[] } | null>(null);
const [correctPaymentModal, setCorrectPaymentModal] = useState<{
order: Order;
bills: Bill[];
} | null>(null);
const [tables, setTables] = useState<Table[]>([]);
// null until /settings/kds_enabled resolves; the WebSocket stays closed
// until the feature is confirmed on, because a disabled KDS refuses the
Expand Down Expand Up @@ -1876,6 +1881,12 @@ export default function OrdersPage() {
onPayBill={handlePayBill}
onAddItems={openAddItemsModal}
onRefund={(ord, bills) => setRefundModal({ order: ord, bills })}
onCorrectPaymentMethod={
(currentTenant?.role === 'owner' || currentTenant?.role === 'manager') &&
tenantCan(currentTenant, 'payments.take')
? (ord, bills) => setCorrectPaymentModal({ order: ord, bills })
: undefined
}
onConvertToTakeaway={handleConvertToTakeaway}
onCancelOrder={(ord) =>
setCancelModal({
Expand Down Expand Up @@ -2048,6 +2059,19 @@ export default function OrdersPage() {
/>
)}

{/* Correct Payment Method Modal */}
{correctPaymentModal && (
<CorrectPaymentMethodModal
order={correctPaymentModal.order}
bills={correctPaymentModal.bills}
onClose={() => setCorrectPaymentModal(null)}
onCorrected={() => {
setCorrectPaymentModal(null);
fetchOrders(undefined, { refreshLoadedPages: true });
}}
/>
)}

{/* Print Confirmation Modal */}
{confirmPrintBillId !== null && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/50">
Expand Down
Loading
Loading