Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
13 changes: 12 additions & 1 deletion docs/architecture/printing.md
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,11 @@ total with zero due, or the selected bill balance(s) as amount due. Refund statu
does not hide a refund-marked bill's positive stored balance; when no bill exists, the slip falls
back to the order total. An unpaid balance also names the order's `expected_payment_method`: Cash on
Delivery for cash, otherwise the method, Pending, or Unknown when none was recorded. It is an
expectation, never a payment, and a paid slip shows the captured method instead. The notes block
expectation, never a payment, and a paid slip shows the captured method instead. When the order
carries an `expected_payment_method_id`, the stored name is the historical snapshot of a configured
method and prints literally instead of resolving through a builtin or sentinel label, so a configured
method named Pending or Unknown keeps its own meaning on the slip even when the method is later
renamed or deactivated. The notes block
carries the courier-only `delivery_note` beside the order note; neither the delivery note nor the
expected method is printed on a kitchen ticket or receipt. It is a **separate kind, not a receipt
template**, and that is load-bearing. It carries the delivery address and the full customer number
Expand Down Expand Up @@ -265,6 +269,13 @@ KOT path applies it, so an item that has left the kitchen does not reappear on a

## Menu catalog printing

A product with active variants prints one sale row per active variant, in the catalog's variant
order, named `Parent (Variant)` at the variant's own price, so the menu never advertises the
parent's unsellable price. A product with no active variants keeps its single parent row. A
variant that links to a recipe ingredient is not gated by its own pool, and `includeOutOfStock`
decides whether a sold-out variant row is emitted; the printed item count counts emitted rows on
every destination.

Products and POS expose a filtered menu print dialog with optional descriptions and effective
add-on groups. The dialog can send ESC/POS to a selected receipt printer and roll width, open the
system print dialog for A4 or Letter paper, or save a structured PDF. In Electron, the PDF uses the
Expand Down
4 changes: 2 additions & 2 deletions docs/architecture/runtime-and-lifecycle.md
Original file line number Diff line number Diff line change
Expand Up @@ -95,8 +95,8 @@ Playwright and CI runs survive the restart.
## Window load failures

A window whose document URL has become `chrome-error://` is treated as a failed load.
`recoverFailedWindow` rebuilds the window once, guarded by `windowLoadRecoveryAttempted`. A second
failure, or a runtime that is not healthy, requests a relaunch rather than looping.
`recoverFailedWindow` allows one in-place rebuild while the runtime is healthy. A second failure, or
a runtime that is not healthy, requests a relaunch rather than looping.

## Shutdown order

Expand Down
6 changes: 3 additions & 3 deletions docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -266,7 +266,7 @@ Router: `main/routes/orders.ts`. Full path: `/api/orders`.
| --- | --- | --- | --- | --- |
| `GET` | `/` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | query: `?status`, `?type`, `?today`, `?start_date`, `?end_date`, `?table_id`, `?before_id`, `?per_page`, `?search` | `{ orders, nextCursor? }`, newest page first. `?before_id` pages backwards. `?search` matches order number, customer name, or phone before pagination. |
| `GET` | `/:id` | `orders.read` (default roles: `ROLE_ACCESS.sales`) + order read limiter | path: `id` | `{ ...order, items, table }`. |
| `POST` | `/` | `ROLE_ACCESS.sales` + order write limiter | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Honours an `Idempotency-Key` header; reusing a key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: the method is `unknown` (also the default), `pending`, `cash`, `card`, or an active custom payment method name, and is never recorded as a payment; `400` for any other method or a note over 200 characters. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. |
| `POST` | `/` | `ROLE_ACCESS.sales` + order write limiter | body: `items`, `table_id`, `customer_id`, `type`, `guest_count`, `special_instructions`, `packaging_charge`, `delivery_charge`, `service_charge`, `online_platform`, `external_order_id`, `delivery_address`, `expected_payment_method`, `expected_payment_method_id`, `delivery_note`; header: `Idempotency-Key` | `201` with the created order. Honours an `Idempotency-Key` header; reusing a key with a different body returns `409`. `expected_payment_method` and `delivery_note` are stored only for `type: 'delivery'`: without an id, the method keeps its legacy string/sentinel resolution (`unknown` by default, `pending`, `cash`, `card`, or an active custom method name), and is never recorded as a payment; `400` for any other method or a note over 200 characters. A custom method whose name matches a built-in or sentinel needs its id to be identified as custom. An optional `expected_payment_method_id` must be a positive safe integer naming an **active** configured method; the server stores its canonical name and requires any supplied non-empty name to match case-insensitively. Invalid, unknown, or inactive ids return `400`. The id is a historical marker, not a foreign key, so renaming or deleting the method later never rewrites an order; only `orders.expected_payment_method_id` decides that the stored name prints literally on a delivery slip. Non-delivery orders persist both expected-method fields as `null`. An item's `variant_id` is required when the product has active variants, and `400` when it names an unknown, foreign, or inactive variant; `unit_price` is never taken from the client. |
| `POST` | `/:id/items` | `ROLE_ACCESS.sales` + order write limiter | path: `id`; body: `items`, `special_instructions`; header: `Idempotency-Key` | Appends items and returns the recomputed order. Honours `Idempotency-Key`. Items resolve a `variant_id` under the same rules as order creation, priced with `variant.online_price` when the order carries an `online_platform`. |
| `PATCH` | `/:id/status` | `ROLE_ACCESS.orderStatus` + order write limiter | path: `id`; body: `status`, `reason`, `override_pin`, `free_table` | Order-level transition. Allowed targets: `pending` to `preparing`, `ready`, `served`, `completed`, `cancelled`; `preparing` to `ready`, `served`, `completed`, `cancelled`; `ready` to `served`, `completed`, `cancelled`; `served` to `completed`, `cancelled`. `completed` and `cancelled` are terminal. Repeating the current status is a no-op. |
| `PATCH` | `/:id/customer` | `ROLE_ACCESS.ownerManager` + order write limiter | path: `id`; body: `customer_id` | - |
Expand Down Expand Up @@ -313,8 +313,8 @@ Router: `main/routes/held-orders.ts`. Full path: `/api/held-orders`.

| Method | Path | Authorization | Parameters | Response |
| --- | --- | --- | --- | --- |
| `GET` | `/` | `ROLE_ACCESS.sales` + held-order read limiter | none | - |
| `POST` | `/` | `ROLE_ACCESS.sales` + held-order write limiter | none | - |
| `GET` | `/` | `ROLE_ACCESS.sales` + held-order read limiter | none | Held carts in `updated_at` order, each with `waivedChargeIds` and `optedInChargeIds`; a hold saved without them reads back as an empty list. A row whose stored selection JSON is damaged is still returned with empty selections. |
| `POST` | `/` | `ROLE_ACCESS.sales` + held-order write limiter | body: held-cart fields plus optional `waivedChargeIds`, `optedInChargeIds` | `{ success, id }`. Selections are bounded charge ids (`CHARGE_ID_PATTERN`, `MAX_CHARGE_ID_LENGTH`) and are deduplicated; an omitted, null, or empty list means no choices, and a malformed list is a `400` that leaves the stored cart untouched. Unknown but well-formed ids persist, because the charges engine decides applicability when the order is created. |
| `DELETE` | `/:tableId` | `ROLE_ACCESS.sales` + held-order write limiter | path: `tableId`; query: `?heldOrderId` | `?heldOrderId` selects one of several holds on the table. |

### Bills
Expand Down
7 changes: 5 additions & 2 deletions frontend/e2e/prepaid-payment-reconciliation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -581,8 +581,11 @@ test('payment modal hides charge controls without bill discount permission', asy
await serverEmail.fill('[email protected]');
await serverPage!.locator('#password').fill(E2E_PASSWORD);
await serverPage!.locator('button[type="submit"]').click();
await serverPage!.waitForURL((url) => url.pathname !== '/auth/login' && url.pathname !== '/auth/login/');
await serverPage!.goto(`${BASE}/orders`);
// Sign-in already lands this restricted staff member on Orders, so wait for
// the intended page instead of navigating again: a second full navigation
// mounts the page twice and doubles its per-mount settings reads.
await serverPage!.waitForURL((url) => url.pathname.replace(/\/+$/, '') === '/orders');
await expect(serverPage!.getByPlaceholder(/search/i).first()).toBeVisible();
await serverPage!.getByPlaceholder(/search/i).first().fill(order.order_number);
await expect(serverPage!.getByText(`#${order.order_number}`)).toBeVisible();
markStage('staff_login_order_search', 'complete');
Expand Down
24 changes: 20 additions & 4 deletions frontend/src/app/(dashboard)/orders/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -444,7 +444,9 @@ export default function OrdersPage() {
try {
const data = JSON.parse(event.data);
if (data.type === 'order_updated' || data.type === 'orders' || data.type === 'initial_data') {
fetchOrders(undefined, { rateLimitedRefresh: true });
// Another terminal just changed an order this page has loaded, so
// the already-visible pages are refreshed, not only page one.
fetchOrders(undefined, { rateLimitedRefresh: true, refreshLoadedPages: true });
}
} catch {
// Ignore parse errors
Expand Down Expand Up @@ -525,8 +527,13 @@ export default function OrdersPage() {

initPage();

// 10-second backup polling interval (WebSocket handles real-time updates)
const interval = setInterval(() => fetchOrders(undefined, { rateLimitedRefresh: true }), 10000);
// 10-second backup polling interval (WebSocket handles real-time updates).
// It is also the only trigger when the socket is unavailable, so it carries
// the same loaded-page refresh as the live push.
const interval = setInterval(
() => fetchOrders(undefined, { rateLimitedRefresh: true, refreshLoadedPages: true }),
10000,
);

return () => {
clearInterval(interval);
Expand Down Expand Up @@ -1193,7 +1200,16 @@ export default function OrdersPage() {
try {
const held = await heldOrdersStore.restoreOrder(heldOrder.tableId);
if (held) {
cartStore.loadItems(held.items, heldOrder.tableId, held.customerId, held.guestCount, held.orderNotes, held.id);
cartStore.loadItems(
held.items,
heldOrder.tableId,
held.customerId,
held.guestCount,
held.orderNotes,
held.id,
held.waivedChargeIds,
held.optedInChargeIds,
);
cartStore.setOrderType('dine_in');
router.push('/pos');
} else {
Expand Down
24 changes: 22 additions & 2 deletions frontend/src/app/(dashboard)/pos/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -676,6 +676,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId || undefined : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
waived_charge_ids: Array.from(cart.waivedChargeIds),
opted_in_charge_ids: Array.from(cart.optedInChargeIds),
Expand Down Expand Up @@ -746,6 +747,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
items: orderItems,
waived_charge_ids: Array.from(cart.waivedChargeIds),
Expand Down Expand Up @@ -835,6 +837,7 @@ export default function POSPage() {
external_order_id: cart.orderType === 'online' ? cart.externalOrderId || undefined : undefined,
delivery_address: cart.orderType === 'delivery' ? cart.deliveryAddress || undefined : undefined,
expected_payment_method: cart.orderType === 'delivery' ? cart.expectedPaymentMethod || undefined : undefined,
expected_payment_method_id: cart.orderType === 'delivery' ? cart.expectedPaymentMethodId ?? undefined : undefined,
delivery_note: cart.orderType === 'delivery' ? cart.deliveryNote || undefined : undefined,
waived_charge_ids: Array.from(cart.waivedChargeIds),
opted_in_charge_ids: Array.from(cart.optedInChargeIds),
Expand Down Expand Up @@ -975,7 +978,16 @@ export default function POSPage() {
try {
const held = await heldOrders.restoreOrder(tableId);
if (held) {
cart.loadItems(held.items, tableId, held.customerId, held.guestCount, held.orderNotes, held.id);
cart.loadItems(
held.items,
tableId,
held.customerId,
held.guestCount,
held.orderNotes,
held.id,
held.waivedChargeIds,
held.optedInChargeIds,
);
cart.setOrderType('dine_in');
} else {
await heldOrders.fetchHeldOrders();
Expand All @@ -996,7 +1008,15 @@ export default function POSPage() {
}
const tableName = tables.find((t) => t.id === tableId)?.name || tableId;
try {
await heldOrders.holdOrder(tableId, cart.items, cart.customerId, cart.guestCount, cart.orderNotes);
await heldOrders.holdOrder(
tableId,
cart.items,
cart.customerId,
cart.guestCount,
cart.orderNotes,
[...cart.waivedChargeIds],
[...cart.optedInChargeIds],
);
cart.clearCart();
setShowTablePicker(false);
toast.success(t('orderHeld', { tableName }));
Expand Down
47 changes: 41 additions & 6 deletions frontend/src/components/pos/CartPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -144,6 +144,7 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
const t = useTranslations('pos');
const tCommon = useTranslations('common');
const tOrders = useTranslations('orders');
const tSettings = useTranslations('settings');
const isRestaurant = (currentTenant?.business_type ?? 'restaurant') === 'restaurant';
const fmt = useFormatCurrency();
const canHold = isRestaurant && cart.orderType === 'dine_in' && cart.tableId && cart.items.length > 0 && billingType === 'postpaid';
Expand Down Expand Up @@ -195,7 +196,15 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
}
const tableName = tables.find((t) => t.id === cart.tableId)?.name || cart.tableId;
try {
await heldOrders.holdOrder(cart.tableId, cart.items, cart.customerId, cart.guestCount, cart.orderNotes);
await heldOrders.holdOrder(
cart.tableId,
cart.items,
cart.customerId,
cart.guestCount,
cart.orderNotes,
[...cart.waivedChargeIds],
[...cart.optedInChargeIds],
);
cart.clearCart();
toast.success(t('orderHeldFor', { table: tableName }));
} catch {
Expand Down Expand Up @@ -269,17 +278,43 @@ export default function CartPanel({ tables, submitting, onPlaceOrder, onEditItem
<Wallet size={14} className="text-muted-foreground shrink-0" />
<span className="text-sm text-muted-foreground">{t('expectedPayment')}</span>
<select
value={cart.expectedPaymentMethod}
onChange={(e) => cart.setExpectedPaymentMethod(e.target.value)}
value={cart.expectedPaymentMethodId !== null
? `custom:${cart.expectedPaymentMethodId}`
: cart.expectedPaymentMethod}
Comment thread
khaira777 marked this conversation as resolved.
onChange={(e) => {
const selected = e.target.value;
// A configured method travels by identity, so a method named
// "Pending" or "Unknown" cannot be read back as a sentinel.
const customId = selected.startsWith('custom:')
? Number(selected.slice('custom:'.length))
: null;
if (customId !== null) {
const method = customPaymentMethods.find((candidate) => candidate.id === customId);
if (method) {
cart.setExpectedPaymentMethod(method.name, method.id);
return;
}
if (customId === cart.expectedPaymentMethodId) return;
}
cart.setExpectedPaymentMethod(selected, null);
}}
className="flex-1 min-w-0 min-h-11 px-3 py-2 text-sm border border-border bg-card rounded-lg focus:ring-2 focus:ring-brand focus:border-brand outline-none"
>
<option value="">{tCommon('unknown')}</option>
<option value="pending">{tOrders('pending')}</option>
<option value="cash">{t('methodCash')}</option>
<option value="card">{t('methodCard')}</option>
{customPaymentMethods.map((method) => (
<option key={method.id} value={method.name}>{method.name}</option>
))}
{customPaymentMethods.length > 0 && (
<optgroup label={tSettings('paymentMethods')}>
{customPaymentMethods.map((method) => (
<option key={method.id} value={`custom:${method.id}`}>{method.name}</option>
))}
</optgroup>
)}
{cart.expectedPaymentMethodId !== null
&& !customPaymentMethods.some((method) => method.id === cart.expectedPaymentMethodId) && (
<option value={`custom:${cart.expectedPaymentMethodId}`}>{cart.expectedPaymentMethod || tCommon('unknown')}</option>
)}
</select>
</label>
<div className="flex items-center gap-2">
Expand Down
Loading
Loading