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
16 changes: 10 additions & 6 deletions docs/architecture/authentication-and-authorization.md
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,7 @@ permission check inside the `withTxn` callback, after the transaction opens, usi
`.../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
(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)`)
which also requires the authenticated caller itself to satisfy `hasRole(role, ROLE_ACCESS.ownerManager)` and hold `payments.take`)
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
Expand Down Expand Up @@ -214,11 +214,15 @@ terminals, while approval authority stays restricted to active `owner` and `mana
`'success'`, `'failure'`, or `'rate_limited'`).
3. **Terminal lock and session boundary:** Only one user is signed in on a device at a time. The
manual **Lock** action in the POS topbar/sidebar (and the optional `auto_lock_minutes` idle
setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes)
revokes the active token without closing the open cash shift. Unfinished POS cart items remain on
the terminal across lock and user switch, while privileged cart adjustments (line price overrides
and charge waivers), cached override PINs, and open privileged modals are cleared and stale
asynchronous responses from the previous session are ignored.
setting in **Settings > POS**, off by default with choices `1`, `5`, `15`, or `30` minutes,
readable by all authenticated staff independently of feature permissions) revokes the active token
without closing the open cash shift. Unfinished POS cart items remain on the terminal across lock
and user switch, while privileged cart adjustments (line price overrides and charge waivers),
cached override PINs, and open privileged modals are cleared. Pending asynchronous checkout
continuations are guarded by session epoch and originating tokens so a user switch halts downstream
billing and payment transitions under the new session, clears any untouched matching cart to
prevent duplicate ordering, and preserves committed-order recovery state under the originating
user's attempt record.
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,
charge waivers, and paid-bill payment-method corrections) enforce active `owner` or `manager`
Expand Down
9 changes: 5 additions & 4 deletions docs/reference/api.md

Large diffs are not rendered by default.

604 changes: 415 additions & 189 deletions frontend/src/app/(dashboard)/pos/page.tsx

Large diffs are not rendered by default.

10 changes: 9 additions & 1 deletion frontend/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -107,7 +107,15 @@ const api = axios.create({
api.interceptors.request.use((config) => {
if (typeof window !== 'undefined') {
const token = localStorage.getItem('token');
if (token) {
const headers = config.headers;
const existingAuth =
headers &&
(('Authorization' in headers && headers.Authorization) ||
('authorization' in headers && headers.authorization) ||
(typeof (headers as { get?: (name: string) => unknown }).get === 'function'
? (headers as { get: (name: string) => unknown }).get('Authorization')
: null));
if (token && !existingAuth) {
config.headers.Authorization = `Bearer ${token}`;
}
}
Expand Down
4 changes: 2 additions & 2 deletions frontend/src/lib/cart-order-item.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,8 @@
* resolves and prices the variant from the database; only the id is sent.
*/

import type { CartItem } from '@/lib/types';
import { normalizeOverridePrice } from '@/lib/cart-price';
import type { CartItem } from './types';
import { normalizeOverridePrice } from './cart-price';

export interface OrderItemAddonPayload {
id: string;
Expand Down
2 changes: 1 addition & 1 deletion frontend/src/lib/cart-price.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
* counter order. Kept dependency-free so the cart store stays loadable in tests.
*/

import type { ProductVariant } from '@/lib/types';
import type { ProductVariant } from './types';

const NON_TWO_DECIMAL_COUNTRY_CURRENCIES: Record<string, string> = {
AF: 'AFN',
Expand Down
55 changes: 55 additions & 0 deletions frontend/src/lib/order-attempt.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,9 @@
import type { AppendAttemptStorage } from './append-attempt';
import {
cartItemToOrderItem,
type OrderItemPayload,
type OrderItemSource,
} from './cart-order-item';

/** Global key used by builds before prepaid attempts became user scoped. */
export const PREPAID_ATTEMPT_STORAGE_KEY = 'flo.prepaid.checkout.attempt';
Expand Down Expand Up @@ -30,6 +35,56 @@ export function getPrepaidOrderAttemptStorageKey(userId: string): string {
return `${PREPAID_ATTEMPT_STORAGE_KEY}${ORDER_ATTEMPT_USER_SUFFIX}${encodeURIComponent(userId)}`;
}

export interface CartStateForFingerprint {
tableId: string | null;
customerId: number | string | null;
orderType: string;
guestCount: number;
orderNotes?: string | null;
scheduledFor?: string | null;
onlinePlatform?: string | null;
externalOrderId?: string | null;
deliveryAddress?: string | null;
expectedPaymentMethod?: string | null;
expectedPaymentMethodId?: number | string | null;
deliveryNote?: string | null;
items: OrderItemSource[];
waivedChargeIds: Set<string>;
optedInChargeIds: Set<string>;
}

export function buildCartFingerprintFromState(
cartState: CartStateForFingerprint,
orderItems: OrderItemPayload[] = cartState.items.map(cartItemToOrderItem),
): string {
return JSON.stringify({
table_id: cartState.tableId,
customer_id: cartState.customerId,
type: cartState.orderType,
guest_count: cartState.guestCount,
special_instructions: cartState.orderNotes,
scheduled_for:
cartState.orderType === 'takeaway' || cartState.orderType === 'delivery'
? cartState.scheduledFor?.trim() || undefined
: undefined,
online_platform: cartState.orderType === 'online' ? cartState.onlinePlatform : undefined,
external_order_id: cartState.orderType === 'online' ? cartState.externalOrderId : undefined,
delivery_address:
cartState.orderType === 'delivery' ? cartState.deliveryAddress || undefined : undefined,
expected_payment_method:
cartState.orderType === 'delivery' ? cartState.expectedPaymentMethod || undefined : undefined,
expected_payment_method_id:
cartState.orderType === 'delivery'
? (cartState.expectedPaymentMethodId ?? undefined)
: undefined,
delivery_note:
cartState.orderType === 'delivery' ? cartState.deliveryNote || undefined : undefined,
items: orderItems,
waived_charge_ids: Array.from(cartState.waivedChargeIds),
opted_in_charge_ids: Array.from(cartState.optedInChargeIds),
});
}

function isCompletedAttempt(value: unknown): value is CompletedOrderAttempt {
return (
!!value && typeof value === 'object' && (value as { completed?: unknown }).completed === true
Expand Down
3 changes: 2 additions & 1 deletion main/routes/bills.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5846,7 +5846,8 @@ router.patch(
if (
!liveCaller ||
liveCaller.is_active !== 1 ||
!hasRole(liveCaller.role, ROLE_ACCESS.ownerManager)
!hasRole(liveCaller.role, ROLE_ACCESS.ownerManager) ||
!hasPermission(liveCaller.id, 'payments.take')
) {
throw paymentValidationError(
'Only owners and managers can correct payment methods',
Expand Down
33 changes: 22 additions & 11 deletions main/routes/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1752,20 +1752,31 @@ router.get(
},
);

const requireAuthenticatedStaff: RequestHandler = (req, res, next) => {
const userId = String(
(req as Request & { user?: { userId?: string; id?: string } }).user?.userId ||
(req as any).user?.id ||
'',
);
if (!userId) {
return res
.status(401)
.json({ error: 'Authentication required', code: 'authentication_required' });
}
const db = getDatabase();
const user = db.prepare('SELECT id, is_active FROM users WHERE id = ?').get(userId) as
| { id: string; is_active: number }
| undefined;
if (!user || user.is_active !== 1) {
return res.status(401).json({ error: 'Invalid or expired token', code: 'invalid_token' });
}
next();
};

router.get(
'/auto_lock_minutes',
settingsReadRateLimit,
requireAnyPermission(
'settings.view',
'pos.use',
'orders.read',
'orders.create',
'orders.status.update',
'tables.view',
'catalog.view',
'kitchen.use',
'support.use',
),
requireAuthenticatedStaff,
(_req: Request, res: Response) => {
try {
const setting = getDatabase()
Expand Down
36 changes: 33 additions & 3 deletions main/services/daily-invoices-export.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,7 @@ import {
type DailySalesExportQueryInput,
} from './daily-sales-export';
import { isExcludedFromBillReceipt } from '../../shared/order-item-status';
import { receiptChargeLines } from '../../shared/charges';

export const DAILY_INVOICES_EXPORT_MAX_RANGE_DAYS = 366;
export const MAX_DAILY_INVOICES_COUNT = 5000;
Expand Down Expand Up @@ -118,6 +119,7 @@ export type DailyInvoiceCard = {
service_charge: number;
packaging_charge: number;
delivery_charge: number;
charges_breakdown: string | null;
tax_amount: number;
tax_components: DisplayTaxComponent[];
tip_amount: number;
Expand Down Expand Up @@ -599,6 +601,7 @@ export function buildDailyInvoicesExportDataset(
delivery_charge: number;
packaging_charge: number;
service_charge: number;
charges_breakdown: string | null;
round_off: number;
total: number;
paid_amount: number;
Expand Down Expand Up @@ -632,6 +635,7 @@ export function buildDailyInvoicesExportDataset(
COALESCE(b.delivery_charge, 0) AS delivery_charge,
COALESCE(b.packaging_charge, 0) AS packaging_charge,
COALESCE(b.service_charge, 0) AS service_charge,
b.charges_breakdown AS charges_breakdown,
COALESCE(b.round_off, 0) AS round_off,
COALESCE(b.total, 0) AS total,
COALESCE(b.paid_amount, 0) AS paid_amount,
Expand Down Expand Up @@ -944,6 +948,7 @@ export function buildDailyInvoicesExportDataset(
service_charge: Number(bill.service_charge || 0),
packaging_charge: Number(bill.packaging_charge || 0),
delivery_charge: Number(bill.delivery_charge || 0),
charges_breakdown: bill.charges_breakdown ? String(bill.charges_breakdown) : null,
tax_amount: Number(bill.tax_amount || 0),
tax_components: taxComponents,
tip_amount: tipAmount,
Expand Down Expand Up @@ -1220,6 +1225,33 @@ export function serializeDailyInvoicesExportHtml(
})
.join('\n');

const itemisedCharges = receiptChargeLines(inv.charges_breakdown);
const itemisedIds = new Set(itemisedCharges.map((charge) => charge.id));
const chargeLines: string[] = [];
if (inv.service_charge > 0 && !itemisedIds.has('service_charge')) {
chargeLines.push(
`<div class="totals-line"><span>Service Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.service_charge))}</span></div>`,
);
}
if (inv.packaging_charge > 0 && !itemisedIds.has('packaging_charge')) {
chargeLines.push(
`<div class="totals-line"><span>Packaging Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.packaging_charge))}</span></div>`,
);
}
if (inv.delivery_charge > 0 && !itemisedIds.has('delivery_charge')) {
chargeLines.push(
`<div class="totals-line"><span>Delivery Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.delivery_charge))}</span></div>`,
);
}
for (const charge of itemisedCharges) {
const label = (charge as any).label || charge.name || charge.id;
chargeLines.push(
`<div class="totals-line"><span>${escapeHtml(label)}</span><span class="mono">${escapeHtml(fmtMoney(charge.amount))}</span></div>`,
);
}
const chargesHtml =
chargeLines.length > 0 ? chargeLines.join('\n ') : '';

const taxComponentLines =
inv.tax_components.length > 0
? inv.tax_components
Expand Down Expand Up @@ -1328,9 +1360,7 @@ export function serializeDailyInvoicesExportHtml(
<div class="invoice-totals">
<div class="totals-line"><span>Subtotal</span><span class="mono">${escapeHtml(fmtMoney(inv.subtotal))}</span></div>
${inv.discount_amount > 0 ? `<div class="totals-line"><span>Discount</span><span class="mono">-${escapeHtml(fmtMoney(inv.discount_amount))}</span></div>` : ''}
${inv.service_charge > 0 ? `<div class="totals-line"><span>Service Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.service_charge))}</span></div>` : ''}
${inv.packaging_charge > 0 ? `<div class="totals-line"><span>Packaging Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.packaging_charge))}</span></div>` : ''}
${inv.delivery_charge > 0 ? `<div class="totals-line"><span>Delivery Charge</span><span class="mono">${escapeHtml(fmtMoney(inv.delivery_charge))}</span></div>` : ''}
${chargesHtml}
${taxComponentLines}
${inv.tip_amount > 0 ? `<div class="totals-line"><span>Tip</span><span class="mono">${escapeHtml(fmtMoney(inv.tip_amount))}</span></div>` : ''}
${inv.round_off !== 0 ? `<div class="totals-line"><span>Round-off</span><span class="mono">${escapeHtml(fmtMoney(inv.round_off))}</span></div>` : ''}
Expand Down
3 changes: 2 additions & 1 deletion shared/whatsapp-automation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -565,6 +565,7 @@ export function isAutoOptOutText(body: string): boolean {
/**
* Evaluates whether `currentHhMm` ('HH:MM') falls inside `[startHhMm, endHhMm)`.
* Supports overnight windows (e.g. '22:00' to '09:00') and same-day windows.
* When `endHhMm` is '23:59', the window includes the final minute of the day.
*/
export function isTimeWithinQuietHours(
currentHhMm: string,
Expand All @@ -576,7 +577,7 @@ export function isTimeWithinQuietHours(
const end = normalizeTimeHhMm(endHhMm, '09:00');
if (start === end) return false;
if (start < end) {
return currentHhMm >= start && currentHhMm < end;
return currentHhMm >= start && (end === '23:59' || currentHhMm < end);
}
// Overnight window (e.g. 22:00 -> 09:00)
return currentHhMm >= start || currentHhMm < end;
Expand Down
Loading
Loading