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
6 changes: 5 additions & 1 deletion docs/architecture/frontend.md
Original file line number Diff line number Diff line change
Expand Up @@ -445,7 +445,11 @@ instead of the shared one.

The renderer's print surface is a decision tree, not a single path. All of it lives in
[`usePrinterStore`](../../frontend/src/hooks/usePrinter.ts), and the shared kernel, renderers, and
transports are described in [Printing architecture](printing.md).
transports are described in [Printing architecture](printing.md). Before dispatching a bill or tax
bill, `usePrinterStore` awaits receipt settings hydration (`ensureReceiptSettingsLoaded()`, backed
by [`useReceiptSettingsSync`](../../frontend/src/hooks/useReceiptSettingsSync.ts)) so storefront
branding and receipt metadata are loaded into `pos-settings.ts` from `GET /api/settings/business`
before rendering.

For a bill or a kitchen ticket, in the order the store tries them:

Expand Down
2 changes: 1 addition & 1 deletion docs/reference/api.md
Original file line number Diff line number Diff line change
Expand Up @@ -590,7 +590,7 @@ Router: `main/routes/settings.ts`. Full path: `/api/settings`.
| --- | --- | --- | --- | --- |
| `GET` | `/charges` | `settings.view`, `orders.create`, or `bills.discount.apply` | none | `{ charges }`; fee definitions only. |
| `PUT` | `/charges` | `settings.manage` | body: `charges` or a charge-definition array | `{ charges }`; replaces the validated fee definitions. Fee amounts remain configured in Settings. |
| `GET` | `/business` | `ROLE_ACCESS.allStaff` | none | - |
| `GET` | `/business` | `settings.view`, `bills.read`, `bills.print`, or `bills.generate` | none | - |
| `PUT` | `/business` | `ROLE_ACCESS.ownerManager` | body: `business_name`, `timezone`, `business_day_start_time`, `currency`, `country`, `language`, `tax_registration_number`, `state_code`, `business_address`, `business_phone`, `instagram_handle`, `billing_type`, `tables_required`, `tax_registered`, `bill_show_name`, `bill_show_address`, `bill_show_phone`, `bill_show_tax_id`, `bill_show_tax_breakdown`, `bill_show_customer_name`, `bill_show_customer_phone`, `bill_show_table_number`, `bill_show_logo`, `receipt_logo`, `receipt_logo_raster`, `currency_display`, `number_digits`, `calendar`, `country_selected` | A `currency` that differs from the stored value returns `409` with `error: "currency_change_requires_reset"` plus `current_currency` and `requested_currency`. Currency changes go through the reset flow, not this route. |
| `GET` | `/tax` | `ROLE_ACCESS.allStaff` | none | - |
| `PUT` | `/tax` | `ROLE_ACCESS.ownerManager` | body: `tax_registered`, `tax_registration_number`, `state_code`, `tax_scheme`, `country` | - |
Expand Down
6 changes: 3 additions & 3 deletions frontend/e2e/prepaid-payment-reconciliation.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -780,8 +780,8 @@ test('payment modal hides charge controls without bill discount permission', asy
timeout: 10000,
});
markStage('payment_heading_visible', 'complete');
// Orders reads each setting once; Sidebar must not repeat denied reads after auth refresh.
expect(restrictedSettingsReads).toBeLessThanOrEqual(2);
// Orders and layout read settings for storefront/kds; Sidebar must not repeat denied reads after auth refresh.
expect(restrictedSettingsReads).toBeLessThanOrEqual(3);
});
await test.step('staff sees charge details without mutation controls', async () => {
markStage('staff_readonly_charge_assertions', 'start');
Expand All @@ -802,7 +802,7 @@ test('payment modal hides charge controls without bill discount permission', asy
expect(
(await feeDefinitions.json()).charges.map((charge: { id: string }) => charge.id),
).toContain('payment_required');
const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/business`, {
const settingsDenied = await serverPage.request.get(`${BASE}/api/settings/tax`, {
headers: limitedHeaders,
});
expect(settingsDenied.status()).toBe(403);
Expand Down
3 changes: 3 additions & 0 deletions frontend/src/hooks/usePrinter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,7 @@ import { type Language } from '@/lib/i18n/languages';
import type { ThermalPrinterCapabilities } from '@print/thermal-capabilities';
import { rasterWebUsbPathEnabled } from '@print/raster';
import { columnsForConfiguredPrinter } from '@print/width';
import { ensureReceiptSettingsLoaded } from '@/hooks/useReceiptSettingsSync';
import { getCountryByCode, getCurrencySymbol, resolveTenantCurrency } from '@/lib/countries';

type CoreBillTemplate = 'classic' | 'compact';
Expand Down Expand Up @@ -213,6 +214,7 @@ export const usePrinterStore = create<PrinterState>()(
set({ lastError: null });
let windowTransferred = false;
try {
await ensureReceiptSettingsLoaded().catch(() => {});
const {
billTemplate,
billTemplateSource,
Expand Down Expand Up @@ -482,6 +484,7 @@ export const usePrinterStore = create<PrinterState>()(
printTaxBill: async (bill, tenant, opts) => {
set({ lastError: null });
try {
await ensureReceiptSettingsLoaded().catch(() => {});
const {
printerUseUnicode,
printerArabicShaping,
Expand Down
76 changes: 67 additions & 9 deletions frontend/src/hooks/useReceiptSettingsSync.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,24 +2,82 @@ import { useEffect } from 'react';
import api from '@/lib/api';
import { useAuthStore } from '@/store/auth';
import { usePosSettingsStore, type ReceiptSettingsHydrationData } from '@/store/pos-settings';
import type { Tenant } from '@/lib/types';
import { tenantCan } from '@/lib/permissions';

export function canSyncReceiptSettings(tenant: Tenant | null | undefined): boolean {
if (!tenant) return false;
return (
tenantCan(tenant, 'settings.view') ||
tenantCan(tenant, 'bills.read') ||
tenantCan(tenant, 'bills.print') ||
tenantCan(tenant, 'bills.generate')
);
}

let syncPromise: Promise<void> | null = null;
let isHydrated = false;
let lastSyncTime = 0;
const FOCUS_SYNC_THROTTLE_MS = 60_000;

export function syncReceiptSettings(force = false): Promise<void> {
if (syncPromise) {
return syncPromise;
}

const now = Date.now();
if (!force && isHydrated && now - lastSyncTime < FOCUS_SYNC_THROTTLE_MS) {
return Promise.resolve();
}

const currentTenant = useAuthStore.getState().currentTenant;
if (!canSyncReceiptSettings(currentTenant)) {
return Promise.resolve();
}

syncPromise = api
.get<ReceiptSettingsHydrationData>('/settings/business')
.then((res) => {
if (res?.data) {
usePosSettingsStore.getState().hydrateReceiptSettings(res.data);
isHydrated = true;
lastSyncTime = Date.now();
}
})
.catch(() => {
// Graceful fallback: offline/network failure leaves existing persisted settings intact
})
.finally(() => {
syncPromise = null;
});

return syncPromise;
}

export async function ensureReceiptSettingsLoaded(): Promise<void> {
if (syncPromise) {
await syncPromise;
}
if (isHydrated) return;
await syncReceiptSettings(true);
}

export function resetReceiptSettingsSyncForTests(): void {
syncPromise = null;
isHydrated = false;
lastSyncTime = 0;
}

export function useReceiptSettingsSync(): void {
const currentTenant = useAuthStore((s) => s.currentTenant);

useEffect(() => {
if (!currentTenant) return;
if (!tenantCan(currentTenant, 'settings.view')) return;
if (!canSyncReceiptSettings(currentTenant)) return;

let active = true;
const sync = () => {
api
.get<ReceiptSettingsHydrationData>('/settings/business')
.then((res) => {
if (!active || !res?.data) return;
usePosSettingsStore.getState().hydrateReceiptSettings(res.data);
})
.catch(() => {});
if (!active) return;
syncReceiptSettings();
};

sync();
Expand Down
7 changes: 2 additions & 5 deletions frontend/src/lib/permissions.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
import type { Tenant } from '@/lib/types';
import {
permissionDefaultAllows,
type PermissionId,
} from '@shared/permissions';
import { isRole } from '@shared/role-permissions';
import { permissionDefaultAllows, type PermissionId } from '../../../shared/permissions';
import { isRole } from '../../../shared/role-permissions';

/** Client-side UX gate only; the backend always re-checks live authorization. */
export function tenantCan(tenant: Tenant | null | undefined, permissionId: PermissionId): boolean {
Expand Down
33 changes: 30 additions & 3 deletions main/routes/settings.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { Router, Request, Response } from 'express';
import { Router, Request, Response, RequestHandler } from 'express';
import expressRateLimit from 'express-rate-limit';
import { randomUUID } from 'crypto';
import { getDatabase, getSettingValue, now } from '../db';
Expand All @@ -12,7 +12,11 @@ import {
getGoogleDriveErrorCode,
googleDrive,
} from '../services/google-drive';
import { requireAnyPermission, requirePermission } from '../services/authorization';
import {
requireAnyPermission,
requirePermission,
resolveEffectivePermissions,
} from '../services/authorization';
import {
CUSTOMER_DISPLAY_KEY,
parseCustomerDisplayConfig,
Expand Down Expand Up @@ -268,7 +272,30 @@ function taxShape(s: Record<string, string>) {

// ── Specific routes (must come BEFORE /:key wildcard) ─────────────────────

router.get('/business', requirePermission('settings.view'), (req: Request, res: Response) => {
const requireBusinessSettingsRead: RequestHandler = (req, res, next) => {
const userId = String((req as Request & { user?: { userId?: string } }).user?.userId || '');
if (!userId) {
return res
.status(401)
.json({ error: 'Authentication required', code: 'authentication_required' });
}
const effective = resolveEffectivePermissions(userId);
if (
!effective ||
!['settings.view', 'bills.read', 'bills.print', 'bills.generate'].some((permissionId) =>
effective.permissionIds.has(permissionId as any),
)
) {
return res.status(403).json({
error: 'Insufficient permissions',
code: 'permission_denied',
permission: 'settings.view',
});
}
next();
};

router.get('/business', requireBusinessSettingsRead, (req: Request, res: Response) => {
try {
const s = getAllSettings(getDatabase());
res.json(businessShape(s));
Expand Down
Loading
Loading