Cashier · Deep dive

Kỹ thuật chi tiết — Cashier App

Đi sâu vào ứng dụng thu ngân tại quầy: Create React App + CRACO bọc trong Capacitor để ra APK Android, router bảo vệ bằng react-router-dom v6, client axios với interceptor, vòng đời ca làm việc theo status, đếm tiền theo mệnh giá, deposit/redeem credits. Đọc sau báo cáo tổng quan 06-cashier.html.

React 18 (CRA) TypeScript 4.9 CRACO Capacitor 7 (Android) react-router-dom 6 axios

Stack & tư duy tổng thể

Khác hẳn Kiosk Frontend (Next.js, state machine in-memory): đây là một Create React App cổ điển được Capacitor đóng gói thành app Android cho nhân viên quầy. Nắm 6 đặc điểm "lạ" sau trước khi đọc code.

📟

CRA + CRACO, KHÔNG phải Next.js

Dùng react-scripts 5 điều khiển qua @craco/craco. Script chạy là craco start / craco build. Không có App Router, không SSR — toàn bộ là SPA client-side.

🤖

App đích là APK Android (Capacitor 7)

Cùng codebase web nhưng triển khai thật là một app Android chạy trên tablet đặt ở quầy. Build qua npm run apk:prodcap syncgradlew assembleDebug.

🔌

Dùng axios, không phải fetch

Tất cả request qua một AxiosInstance singleton trong cashierApi.ts với interceptors tự gắn Bearer token và bắt 401.

🧭

Có routing thật (react-router 6)

Trái với kiosk frontend, app này dùng URL routes thực sự: /login, /shifts/:id, /shifts/:id/opening… và route guard CashierProtectedRoute.

🔐

Context Auth + 1 token (không refresh)

CashierAuthProvider giữ state đăng nhập. Chỉ có access token trong localStorage, không có refresh token. 401 → đăng xuất ngay (không retry).

📦

Phụ thuộc package nội bộ monorepo

@kioskgaming/ui@kioskgaming/page-loadingfile:../packages/*. CRACO phải vá source-map-loader cho packages/ui mới build được.

Cấu trúc thư mục src/

Cây thư mục phẳng theo chuẩn CRA: pages/ cho từng màn hình, components/, services/, hooks/, utils/. Alias @/ KHÔNG được dùng — import bằng đường dẫn tương đối.

kioskgaming_cashier/ ├── capacitor.config.ts # ★ appId + server.url remote (Android) ├── craco.config.js # vá webpack source-map-loader cho packages/ui ├── tailwind.config.js # Tailwind v3 + CSS vars hsl() ├── android/ # project Android sinh bởi Capacitor (gradlew) ├── scripts/ set-android-app-name.js # đổi tên app dev/prod └── src/ ├── index.tsx # entry: createRoot + registerServiceWorker ├── App.tsx # ★ BrowserRouter + toàn bộ Routes ├── pages/ # 1 file = 1 màn hình │ ├── CashierLoginPage.tsx │ ├── MyShiftsPage.tsx # "/" — danh sách ca │ ├── OpeningCashPage.tsx # /shifts/:id/opening │ ├── ActiveShiftPage.tsx # /shifts/:id — màn hình làm việc │ ├── CashDropPage.tsx # /shifts/:id/cash-drop │ ├── ClosingCountPage.tsx # /shifts/:id/closing (blind close) │ ├── HandoverPage.tsx # /shifts/:id/handover ├── components/ │ ├── CashierLayout.tsx # khung chung: header + Sign out │ ├── DenominationGrid.tsx # ★ lưới đếm tiền tái dùng │ └── auth/ CashierProtectedRoute.tsx ├── services/ │ └── cashierApi.ts # ★ axios client + mọi API call ├── hooks/ useCashierAuth.tsx # Context + provider auth ├── utils/ │ ├── denominations.ts # hằng số mệnh giá + tính tổng │ └── deviceFingerprint.ts # UUID thiết bị trong localStorage ├── config/ env.ts # ENV (REACT_APP_*) └── constants/ index.ts # API_BASE_URL, STORAGE_KEYS, events
💡
Đọc theo thứ tự nào?

Người mới nên mở: App.tsx (hiểu route) → hooks/useCashierAuth.tsx (hiểu auth) → services/cashierApi.ts (hiểu API) → pages/ActiveShiftPage.tsx (màn hình lõi) → components/DenominationGrid.tsx. 5 file này là ~75% hệ thống.

Màn hình khởi động: index.tsxApp.tsx

Một SPA CRA bình thường: bootstrap React vào #root, đặt tiêu đề tab, đăng ký service worker, rồi để router quyết định màn hình.

1
index.tsx khởi tạo React

createRoot(#root).render(<App/>) trong StrictMode. Đặt document.title = ENV.APP_NAME rồi gọi registerServiceWorker().

2
App.tsx dựng cây provider

<CashierAuthProvider> bọc <BrowserRouter>, bên trong là <Routes> + <Toaster/> (react-hot-toast, góc trên phải).

3
Provider khôi phục phiên

Provider đọc cashier_auth_token + cashier_user_data từ localStorage. Trong lúc đó isLoading=true → route bảo vệ hiện PageLoadingIndicator.

Router điều hướng

Có phiên → vào / (MyShiftsPage). Không có phiên → CashierProtectedRoute redirect /login.

src/index.tsxtsx
document.title = ENV.APP_NAME;

const root = ReactDOM.createRoot(document.getElementById('root') as HTMLElement);
root.render(
  <React.StrictMode><App /></React.StrictMode>
);

registerServiceWorker(); // PWA/offline shell

Router & route bảo vệ

Toàn bộ định nghĩa trong App.tsx. Mỗi màn hình nghiệp vụ được bọc <Protected> (chính là CashierProtectedRoute). Route public chỉ có /login.

URLPageBảo vệ?Mục đích
/loginCashierLoginPagePublicĐăng nhập thu ngân
/MyShiftsPage🔒 ProtectedDanh sách ca được phân công
/shifts/:id/openingOpeningCashPage🔒 ProtectedĐếm tiền mở ca → start shift
/shifts/:idActiveShiftPage🔒 ProtectedMàn hình làm việc trong ca
/shifts/:id/cash-dropCashDropPage🔒 ProtectedRút tiền khỏi két giữa ca
/shifts/:id/closingClosingCountPage🔒 ProtectedĐếm tiền đóng ca (blind)
/shifts/:id/handoverHandoverPage🔒 ProtectedBàn giao / nhận bàn giao
*Navigate to "/"Mọi URL lạ → về trang chủ
src/components/auth/CashierProtectedRoute.tsxtsx
const { isAuthenticated, isLoading } = useCashierAuth();

if (isLoading) return <PageLoadingIndicator variant="section" />;

if (!isAuthenticated)
  return <Navigate to="/login" replace state={{ from: location }} />;

return <>{children}</>;
🧭
Điều hướng theo status của ca

Nhiều page tự redirect dựa trên trạng thái ca: ActiveShiftPage thấy status==='scheduled' sẽ đẩy về /opening; OpeningCashPage thấy status==='active' sẽ đẩy về /shifts/:id. Logic guard nằm rải trong từng page, không tập trung.

Auth, token & định danh thiết bị

Auth của cashier tách biệt hoàn toàn với Shop Admin (key localStorage khác nhau). Mỗi lần đăng nhập gắn kèm một device fingerprint và phải đăng ký thiết bị để quản lý cửa hàng duyệt.

1
Đăng nhập

cashierLogin()POST /cashier-auth/login với { username, identifier, password, deviceFingerprint }. Trả về { accessToken, cashier, shop }.

2
Lưu phiên

login(cashier, token) ghi cashier_auth_token + cashier_user_data vào localStorage, bắn event cashier-auth-token-changed.

3
Đăng ký thiết bị

ensureCashierDeviceRegistered() → nếu chưa có cashier_device_id, gọi POST /cashier/devices/register và lưu lại id. Toast nhắc "device registered for approval".

!
Hết hạn / 401

Interceptor response của axios bắt 401 → bắn event cashier-auth:unauthorized → provider gọi logout() (xoá token + về login). Không có refresh token.

🔑

STORAGE_KEYS

cashier_auth_token, cashier_user_data, cashier_device_fingerprint, cashier_device_id. Lưu ý còn shop_auth_token / shop_user_data cho Shop Portal — đừng nhầm.

📱

Device fingerprint = UUID

getOrCreateDeviceFingerprint() chỉ tạo crypto.randomUUID() rồi cache trong localStorage. Không phải fingerprint phần cứng thật — xoá storage là đổi định danh.

🐛
2 điểm dễ vấp về auth

(1) Provider chỉ tin localStorage, không gọi /cashier/me để verify token còn sống khi khởi động — token hết hạn chỉ bị phát hiện ở request đầu tiên (401). (2) Thiết bị mới đăng nhập được nhưng giao dịch có thể bị chặn cho tới khi quản lý cửa hàng duyệt thiết bị trong Shop Portal (mục Devices).

cashierApi.ts — axios singleton

Một AxiosInstance tạo sẵn với baseURL, timeout 15s, và 2 interceptor. Mọi hàm API là export thuần (không phải class), đa số bọc try/catch trả về một envelope chuẩn.

PATTERNaxios.create + request/response interceptor
  • Base URL: REACT_APP_API_BASE_URL || 'http://localhost:3001/api'đã gồm /api, nên các path bắt đầu bằng /cashier...
  • Request interceptor: đọc cashier_auth_token, nếu có thì gắn Authorization: Bearer <token>
  • Response interceptor: nếu 401 → window.dispatchEvent('cashier-auth:unauthorized') rồi reject
  • Envelope: hầu hết hàm trả { success, data?, message?, code? }; lỗi đi qua asApiErrorPayload()
  • Timeout: 15000ms cho mọi request
src/services/cashierApi.ts (rút gọn)ts
const c = axios.create({ baseURL: API_BASE_URL, timeout: 15000 });

c.interceptors.request.use((config) => {
  const token = localStorage.getItem(STORAGE_KEYS.CASHIER_AUTH_TOKEN);
  if (token) config.headers.Authorization = `Bearer ${token}`;
  return config;
});

c.interceptors.response.use((r) => r, (error) => {
  if (error.response?.status === 401)
    window.dispatchEvent(new Event('cashier-auth:unauthorized'));
  return Promise.reject(error);
});

Bảng endpoint backend (sau baseURL đã gồm /api)

HàmMethod + PathGhi chú
cashierLoginPOST /cashier-auth/logingửi cả username & identifier
fetchCashierMeGET /cashier/mekhai báo sẵn (ít dùng)
cashierListAssignedShiftsGET /cashier/shifts/assignednguồn của MyShiftsPage
cashierGetShiftGET /cashier/shifts/:idkèm transactions + cashDrops
cashierStartShiftPOST /cashier/shifts/:id/startmở ca với denominations
cashierCloseShiftBlindPOST /cashier/shifts/:id/closeblind close → trả discrepancy
cashierInitiateHandoverPOST /cashier/shifts/:id/handoverbàn giao đi
cashierConfirmHandoverPOST /cashier/shifts/:id/handover/confirmnhận bàn giao
cashierCreateCashDropPOST /cashier/cash-dropsrút tiền giữa ca
cashierListCashDropsGET /cashier/cash-drops?shiftId=
cashierListTransactionsGET /cashier/transactions?shiftId=danh sách giao dịch trong ca
cashierRegisterDevicePOST /cashier/devices/registerđăng ký tablet
cashierFetchPlayersGET /cashier/players?search=&page=&limit=tra cứu người chơi
cashierCreatePlayerPOST /cashier/playerstạo người chơi mới
cashierDepositCreditsPOST /cashier/players/:id/deposit{ amount, payment_method }
cashierRedeemCreditsPOST /cashier/players/:id/redeem{ amount } (không có method)
📍
Đừng thêm /api hai lần

API_BASE_URL đã có hậu tố /api (http://localhost:3001/api). Các path trong service vì thế bắt đầu thẳng bằng /cashier.... Khi thêm endpoint mới, không prefix /api nữa.

Denomination engine

Trái tim của mọi thao tác quỹ. utils/denominations.ts giữ hằng số + công thức; components/DenominationGrid.tsx là UI tái dùng ở Opening, Cash drop, Closing và Handover.

Hằng số & hàm
  • BILL_DENOMINATIONS = [1,2,5,10,20,50,100]
  • COIN_DENOMINATIONS = [0.01,0.05,0.1,0.25,0.5,1]
  • emptyDenominationGrid() → mảng {denomination, quantity:0}
  • computeDenominationTotal() → làm tròn 2 chữ số
  • formatCurrency() dùng Intl.NumberFormat (USD)
Hợp đồng dữ liệu
  • Type gửi lên backend: DenominationItem[]
  • Các page lọc bỏ mục quantity === 0 trước khi gửi
  • Grid là controlled: cha giữ state, truyền value + onChange
  • Tổng tính ở client tức thì; backend tự kiểm lại
src/utils/denominations.tsts
export function computeDenominationTotal(items: DenominationItem[]): number {
  const total = items.reduce(
    (sum, i) => sum + i.denomination * i.quantity, 0);
  return Math.round(total * 100) / 100; // tránh lỗi float
}
🧮
Vì sao tách hằng số ra util?

Vì mệnh giá xuất hiện ở ≥4 màn hình. Đặt chung một nguồn đảm bảo Opening và Closing dùng đúng cùng bộ mệnh giá — điều kiện tiên quyết để backend so khớp và phát hiện chênh lệch (discrepancy) chính xác.

Vòng đời ca & các status

App không có một state machine tập trung như kiosk frontend; thay vào đó "máy trạng thái" nằm ở backend qua trường shift.status, còn frontend phản ứng bằng cách redirect và bật/tắt nút.

scheduled active (closing) pending_reconciliation pending_manager_review

Trạng thái khác xuất hiện trong UI: auto_locked, suspended, no_show, cancelled. Trong code, "ca thao tác được" = ['active','auto_locked','suspended'].

statusHành vi UI
scheduledMyShifts hiện nút "Start shift" → /opening. Vào /shifts/:id sẽ bị redirect tới opening.
activeMở /opening bị đẩy về /shifts/:id. Cho phép deposit/redeem/cash-drop/close/handover.
auto_locked / suspendedVẫn nằm trong nhóm "operate được" theo code — nút giao dịch vẫn bật.
no_show / cancelledChỉ có badge màu; ActiveShiftPage hiện cảnh báo "không mở cho giao dịch", chỉ cho tra cứu/tạo player.
pending_*Badge đỏ (chờ đối soát / chờ quản lý duyệt). Sau khi đóng ca.

Đóng ca kiểu "blind close"

src/pages/ClosingCountPage.tsxtsx
const res = await cashierCloseShiftBlind(id, { denominations: items });
// Số kỳ vọng KHÔNG hiển thị cho tới khi submit xong:
res.data = { actualClosingCash, expectedClosingCash,
            discrepancyAmount, discrepancyFlagged, status };
🙈
"Blind" là có chủ đích

Thu ngân đếm két mà không thấy số kỳ vọng; backend so sánh và trả về discrepancyAmount + discrepancyFlagged sau khi submit. Điều này chống việc "đếm cho khớp" — một biện pháp kiểm soát nội bộ chuẩn ngành.

Deposit / Redeem trong ActiveShiftPage

Màn hình làm việc lõi: tra cứu người chơi (debounce 250ms), nạp credits, đổi credits, tạo người chơi mới qua modal. Tất cả nút giao dịch chỉ bật khi canOperate === true.

💰 Deposit (nạp)

doDeposit(playerId) → kiểm amount > 0cashierDepositCredits(id, { amount, payment_method }). payment_method là một trong 'cash' | 'card' | 'bank transfer' (mặc định cash). Thành công → reload players + shift.

💸 Redeem (đổi)

doRedeem(playerId)cashierRedeemCredits(id, { amount }). Không có trường phương thức thanh toán (thu ngân chi tiền mặt). Cùng dùng chung ô amount với deposit.

src/pages/ActiveShiftPage.tsx (tra cứu có debounce)tsx
useEffect(() => {
  const t = setTimeout(() => void loadPlayers(), 250);
  return () => clearTimeout(t);
}, [search]); // gõ phím → chờ 250ms → GET /cashier/players
⚠️
Tạo player: PhoneInput từ package nội bộ

Modal "Create player" dùng <PhoneInput> từ @kioskgaming/ui (mặc định country US). Cần phone HOẶC email (không bắt buộc cả hai). Lưu ý dự án có cài react-hook-form nhưng form này lại quản state thủ công bằng useState.

Handover & Cash drop

Hai thao tác quỹ phụ, đều xoay quanh DenominationGrid. HandoverPage đặc biệt vì có 2 chế độ tự suy từ status của ca.

A
Cash drop (giữa ca)

CashDropPage → đếm tiền rút khỏi két → cashierCreateCashDrop({ shiftId, denominations, deviceId }). Quản lý xác nhận nhận tiền ở Shop Portal.

B
Handover — mode initiate

Khi ca đang active/auto_locked/suspended: chọn ca scheduled kế tiếp + đếm tiền → cashierInitiateHandover(id, { toShiftId, denominations }).

C
Handover — mode confirm

Khi ca đang scheduled (người nhận): đếm tiền nhận được → cashierConfirmHandover(id, { denominations }) → tự sang /opening để mở ca.

src/pages/HandoverPage.tsx (chọn mode theo status)tsx
if (['active','auto_locked','suspended'].includes(current.status))
  setMode('initiate');
else if (current.status === 'scheduled')
  setMode('confirm');
else setMode('none'); // không cho bàn giao

Capacitor / Android — điểm khác biệt lớn nhất

Đây là thứ khiến repo này khác mọi app web khác trong hệ thống: nó được đóng gói thành APK Android chạy trên tablet quầy. Nhưng có một bất ngờ quan trọng về cách app nạp nội dung.

capacitor.config.tsts
const remoteUrl = process.env.CAP_SERVER_URL?.trim()
  || 'https://cashier.kioskservice.club/';

const config: CapacitorConfig = {
  appId: 'club.kioskservice.kioskgaming.cashier',
  appName, // '[Dev] Cashier...' hoặc 'Cashier - Kiosk Gaming'
  webDir: 'build',
  server: {
    url: remoteUrl, // ★ nạp từ web LIVE, không từ build local!
    allowNavigation: ['dev-cashier.kioskservice.club', 'cashier.kioskservice.club'],
  },
};
🚨
APK là "vỏ", nội dung tải từ server từ xa

Có khai báo webDir: 'build' nhưng server.url ghi đè: WebView Android sẽ tải thẳng https://cashier.kioskservice.club/ (hoặc dev). Nghĩa là cập nhật web = cập nhật app ngay, không cần build lại APK — nhưng cũng nghĩa là app KHÔNG chạy offline; mất mạng là trắng màn hình. (Khác với mô tả "chạy offline" ở báo cáo tổng quan.)

Build flow thực tế (script trong package.json)

Build APKbash
# DEV: trỏ WebView về dev-cashier + đổi tên "[Dev] ..."
npm run apk:dev
#  └→ CAP_SERVER_URL=https://dev-cashier... cap sync android
#     && node scripts/set-android-app-name.js dev
#     && cd android && ./gradlew assembleDebug

# PROD: trỏ về cashier.kioskservice.club
npm run apk:prod   # = apk (mặc định)

appId

club.kioskservice.kioskgaming.cashier — đây mới là package id thật (không phải com.kioskgaming.cashier).

Service worker

registerServiceWorker() chạy ở web; trong WebrView remote nó hỗ trợ cache/PWA cơ bản, nhưng không bù được việc server.url cần mạng.

gradlew assembleDebug

Cả dev lẫn prod đều build debug APK (chưa thấy assembleRelease/ký số trong script).

Tailwind v3 & CRACO

Khác kiosk frontend (Tailwind v4 không config): đây là Tailwind v3tailwind.config.js + postcss.config.js, dùng design token kiểu hsl(var(--…)). Build qua CRACO để vá webpack.

craco.config.jsjs
const patch = require('../packages/ui/scripts/craco-patch-source-map-loader-ui.cjs');

module.exports = {
  webpack: {
    configure: (cfg) => { patch(cfg); return cfg; },
  },
};
🎨

Token màu qua CSS var

tailwind.config.js map background, primary, accent… sang hsl(var(--…)). Content quét cả ../packages/ui/dist/**/*.js để giữ class của UI package.

🩹

Vì sao cần CRACO?

react-scripts không cho sửa webpack. CRACO chèn bản vá source-map-loader để packages/ui (dạng file:) không làm build vỡ vì thiếu source map.

🎨
Phong cách màu UI

Trang login dùng gradient hổ phách–emerald (from-amber-50 … to-emerald-50), nút chính màu amber-600; màn hình trong ca dùng tông stone trung tính + nút emerald-600 cho hành động tích cực và rose-600 cho đóng ca. Icon lấy từ lucide-react.

Config & biến môi trường

Tập trung ở src/config/env.ts. Theo chuẩn CRA, biến phải có tiền tố REACT_APP_ mới lọt vào bundle (khác Next.js dùng NEXT_PUBLIC_).

BiếnMặc địnhÝ nghĩa
REACT_APP_API_BASE_URLhttp://localhost:3001/apiBase URL backend (đã gồm /api)
REACT_APP_APP_NAMECashier — Kiosk GamingTiêu đề tab (đặt ở index.tsx)
REACT_APP_TURNSTILE_ENABLEDfalseBật Cloudflare Turnstile (=== 'true')
REACT_APP_TURNSTILE_SITE_KEY''Site key Turnstile
CAP_SERVER_URLhttps://cashier.kioskservice.club/URL WebView Android (lúc cap sync)
🧩
Turnstile khai báo nhưng chưa nối vào login

Repo có cài @marsidev/react-turnstile và đọc TURNSTILE_* trong env.ts, nhưng CashierLoginPage hiện chưa render widget Turnstile. Nếu cần bật chống bot ở đăng nhập, đây là điểm phải nối thêm. (Dependency qrcode cũng có sẵn cho nhu cầu tương lai.)

Thiết lập kiểm thử

Repo có bộ @testing-library/* qua CRA/Jest nhưng hiện chưa có test file thực tế trong codebase.

🧪

Có gì

Chạy bằng craco test (Jest của CRA). setupTests.ts nạp @testing-library/jest-dom. Sẵn sàng viết test nhưng chưa có file *.test.tsx trong repo.

⚠️

Khoảng trống

Chưa có test cho DenominationGrid, deposit/redeem, hay router guard. Phần lõi nghiệp vụ tiền hiện chưa được phủ test.

Scriptsbash
npm start          # craco start → http://localhost:3000
npm run build      # craco build → thư mục build/
npm test           # craco test (Jest watch)
npm run apk:prod   # cap sync + gradlew assembleDebug

Cookbook cho dev mới

Các tác vụ phổ biến theo đúng quy ước của project.

HOW-TOThêm một endpoint API mới
  1. Viết hàm export trong src/services/cashierApi.ts, dùng cashierClient.get/post (đã có token).
  2. Path bắt đầu bằng /cashier...không thêm /api (đã nằm trong baseURL).
  3. Bọc try/catch + asApiErrorPayload(e) để trả envelope { success, data?, message? }.
  4. Khai báo type response ngay trong service.
HOW-TOThêm một trang được bảo vệ
  1. Tạo src/pages/XxxPage.tsx, bọc nội dung bằng <CashierLayout title=… backTo=…>.
  2. Khai báo <Route> trong App.tsx và bọc bằng <Protected>.
  3. Lấy id ca bằng useParams; nếu thiếu thì <Navigate to="/" />.
  4. Hiển thị lỗi bằng toast (react-hot-toast) thay vì alert.
HOW-TOTái dùng lưới đếm tiền
  1. const [denoms, setDenoms] = useState(emptyDenominationGrid()).
  2. Render <DenominationGrid value={denoms} onChange={setDenoms} />.
  3. Trước khi gửi backend, lọc denoms.filter(d => d.quantity > 0).
  4. Hiển thị tổng bằng computeDenominationTotal() / formatCurrency().
HOW-TOBuild & cài APK lên tablet quầy
  1. npm run build (CRA sinh build/ — vẫn cần cho cap sync dù WebView dùng remote).
  2. npm run apk:dev hoặc apk:prod để set CAP_SERVER_URL + sync + gradlew assembleDebug.
  3. APK nằm trong android/app/build/outputs/apk/debug/; cài lên thiết bị.
  4. Đăng nhập lần đầu sẽ đăng ký thiết bị → nhờ quản lý cửa hàng duyệt thiết bị.

Bẫy thường gặp (đọc trước khi sửa)

BẫyChi tiết & cách xử lý
Tưởng app chạy offlineserver.url trong capacitor.config.ts trỏ web live → mất mạng là trắng màn hình. webDir: 'build' bị ghi đè.
Thêm /api hai lầnAPI_BASE_URL đã gồm /api. Path service phải bắt đầu bằng /cashier....
Không có refresh tokenChỉ access token. 401 → đăng xuất ngay (event cashier-auth:unauthorized). Không retry.
Token không được verify lúc khởi độngProvider tin localStorage, không gọi /cashier/me. Token chết chỉ lộ ở request đầu.
Thiết bị chưa được duyệtĐăng nhập OK nhưng giao dịch có thể bị chặn tới khi quản lý duyệt thiết bị ở Shop Portal.
Quên lọc quantity > 0Mọi page đếm tiền lọc bỏ mục số lượng 0 trước khi gửi. Bỏ bước này có thể gây lỗi/dữ liệu rác.
Nhầm key localStorageCó cả shop_*cashier_*. Auth cashier hoàn toàn tách biệt Shop Admin.
Turnstile/qrcode cài nhưng chưa nốiDependency tồn tại, chưa render. Đừng tưởng đăng nhập đã có chống bot.
appId thật khác báo cáo cũclub.kioskservice.kioskgaming.cashier, không phải com.kioskgaming.cashier.