Skip to content
Open
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
19 changes: 14 additions & 5 deletions .github/workflows/linter.yml
Original file line number Diff line number Diff line change
Expand Up @@ -55,6 +55,7 @@ jobs:
${{ runner.os }}-

- name: Install and run pip-audit
timeout-minutes: 10
run: |
python -m venv .audit-venv
. .audit-venv/bin/activate
Expand All @@ -70,8 +71,16 @@ jobs:
# this is an upstream-frappe issue, not a pos_next one. Ignore the
# specific IDs (NOT the package) so the gate still fails on any NEW
# vulnerability; revisit when frappe relaxes its PyJWT pin.
pip-audit --desc on \
--ignore-vuln PYSEC-2026-175 \
--ignore-vuln PYSEC-2026-177 \
--ignore-vuln PYSEC-2026-178 \
--ignore-vuln PYSEC-2026-179
audit_ok=1
for i in {1..3}; do
if pip-audit --desc on \
--ignore-vuln PYSEC-2026-175 \
--ignore-vuln PYSEC-2026-177 \
--ignore-vuln PYSEC-2026-178 \
--ignore-vuln PYSEC-2026-179; then audit_ok=0; break; fi
if [ "$i" -lt 3 ]; then sleep $((2**i)); fi
done
if [ "$audit_ok" -ne 0 ]; then
echo "Vulnerability check failed - PyPI service may be unavailable"
exit 1
fi
3 changes: 3 additions & 0 deletions POS/src/pages/Login.vue
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,7 @@ import { useSessionLock } from "../composables/useSessionLock"
import { cleanupUserSession } from "../utils/sessionCleanup"
import { ensureCSRFToken } from "../utils/csrf"
import { offlineWorker } from "../utils/offline/workerClient"
import { useCustomerSearchStore } from "@/stores/customerSearch"
import { logger } from "@/utils/logger"

const log = logger.create("Login")
Expand Down Expand Up @@ -157,6 +158,8 @@ watch(
() => session.isLoggedIn,
async (isLoggedIn) => {
if (isLoggedIn) {
customerSearchStore.resetState()

// Initialize CSRF token after successful login
try {
log.info("User logged in, initializing CSRF token...")
Expand Down
2 changes: 2 additions & 0 deletions POS/src/pages/POSSale.vue
Original file line number Diff line number Diff line change
Expand Up @@ -1450,6 +1450,7 @@ onMounted(async () => {

const backgroundOps = Promise.allSettled([
cartStore.setDefaultCustomer(),
customerSearchStore.loadAllCustomers(shiftStore.profileName, true),
offlineStore.isOffline
? offlineStore.checkOfflineCacheAvailability()
: offlineStore.preloadDataForOffline(shiftStore.currentProfile),
Expand Down Expand Up @@ -1786,6 +1787,7 @@ async function handleShiftOpened() {

const backgroundOps = Promise.allSettled([
cartStore.setDefaultCustomer(),
customerSearchStore.loadAllCustomers(shiftStore.profileName, true),
offlineStore.isOffline
? offlineStore.checkOfflineCacheAvailability()
: offlineStore.preloadDataForOffline(shiftStore.currentProfile),
Expand Down
48 changes: 31 additions & 17 deletions POS/src/stores/customerSearch.js
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,15 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {
return recs
})

function resetState() {
allCustomers.value = []
searchTerm.value = ""
selectedIndex.value = -1
serverDataFresh = false
searchIndex.value.clear()
resultCache.value.clear()
}

// Actions
async function loadAllCustomers(posProfile, forceReload = false) {
if (!posProfile) {
Expand All @@ -209,20 +218,23 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {

loading.value = true
try {
// Step 1: Load from IndexedDB cache (instant display)
const cachedCustomers = await offlineWorker.searchCachedCustomers(
"",
0,
)
const isFullSync = forceReload || !localStorage.getItem(CUSTOMERS_SYNC_KEY)

if (cachedCustomers && cachedCustomers.length > 0) {
allCustomers.value = cachedCustomers
log.debug(`Loaded ${cachedCustomers.length} customers from cache`)
if (isFullSync) {
await offlineWorker.clearCustomersCache()
allCustomers.value = []
} else if (!forceReload) {
// Delta sync: show cached list while fetching updates
const cachedCustomers = await offlineWorker.searchCachedCustomers("", 0)
if (cachedCustomers?.length > 0) {
allCustomers.value = cachedCustomers
log.debug(`Loaded ${cachedCustomers.length} customers from cache`)
}
}

// Step 2: If online, fetch delta from server
// Fetch from server (full list or delta)
if (!isOffline()) {
const lastSync = forceReload ? null : localStorage.getItem(CUSTOMERS_SYNC_KEY)
const lastSync = isFullSync ? null : localStorage.getItem(CUSTOMERS_SYNC_KEY)

const response = await call("pos_next.api.customers.get_customers", {
pos_profile: posProfile,
Expand All @@ -233,26 +245,29 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {
})
const delta = response?.message || response || []

if (delta.length > 0) {
if (isFullSync) {
const active = delta.filter((c) => !c.disabled)
allCustomers.value = active
if (active.length) {
await offlineWorker.cacheCustomers(active)
}
log.debug(`Full sync: ${active.length} customers from server`)
} else if (delta.length > 0) {
const active = delta.filter((c) => !c.disabled)
const disabled = delta.filter((c) => c.disabled)

// Merge active customers into memory
const existingMap = new Map(allCustomers.value.map((c) => [c.name, c]))
for (const c of active) {
existingMap.set(c.name, c)
}
// Remove disabled customers from memory
for (const c of disabled) {
existingMap.delete(c.name)
}
allCustomers.value = Array.from(existingMap.values())

// Persist active to IndexedDB
if (active.length) {
await offlineWorker.cacheCustomers(active)
}
// Remove disabled from IndexedDB
if (disabled.length) {
await offlineWorker.deleteCustomers(disabled.map((c) => c.name))
}
Expand All @@ -263,12 +278,10 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {
serverDataFresh = true
localStorage.setItem(CUSTOMERS_SYNC_KEY, new Date().toISOString())
} else if (allCustomers.value.length === 0) {
// Offline and cache is empty
log.warn("Offline mode: No cached customers available")
allCustomers.value = []
}

// Clear caches when new data is loaded
searchIndex.value.clear()
resultCache.value.clear()
} catch (error) {
Expand Down Expand Up @@ -410,6 +423,7 @@ export const useCustomerSearchStore = defineStore("customerSearch", () => {
recommendations,

// Actions
resetState,
loadAllCustomers,
addCustomerToCache,
setSearchTerm,
Expand Down
11 changes: 11 additions & 0 deletions POS/src/utils/sessionCleanup.js
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
import { clearAllDrafts } from "@/utils/draftManager"
import { clearAllOfflineReceiptPayloads } from "@/utils/offline/offlineReceiptCache"
import { offlineWorker } from "@/utils/offline/workerClient"
import { usePOSCartStore } from "@/stores/posCart"
import { usePOSUIStore } from "@/stores/posUI"
import { useCustomerSearchStore } from "@/stores/customerSearch"
import { useSessionLock } from "@/composables/useSessionLock"
import { shiftState } from "@/composables/useShift"

Expand Down Expand Up @@ -67,4 +69,13 @@ export async function cleanupUserSession() {
} catch (error) {
console.error("Failed to clear draft invoices:", error)
}

// 5. Clear cached customers so the next user never sees the previous user's list
try {
await offlineWorker.clearCustomersCache()
} catch (error) {
console.error("Failed to clear customers cache:", error)
}

useCustomerSearchStore().resetState()
}
77 changes: 76 additions & 1 deletion pos_next/api/customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,66 @@
import frappe
from frappe import _

CASHIER_ROLE = "POSNext Cashier"
UNRESTRICTED_CUSTOMER_ROLES = frozenset(
{
"Nexus POS Manager",
"Sales Manager",
"Sales Master Manager",
"System Manager",
}
)


def should_restrict_customers_for_user(user=None):
"""Cashiers only see the POS profile default customer and customers they created."""
user = user or frappe.session.user
if not user or user in frappe.STANDARD_USERS:
return False

roles = set(frappe.get_roles(user))
if roles & UNRESTRICTED_CUSTOMER_ROLES:
return False

return CASHIER_ROLE in roles


def get_allowed_customer_names(pos_profile=None, user=None):
"""Default POS profile customer plus customers owned by the current user."""
user = user or frappe.session.user
allowed = set()

if pos_profile:
default_customer = frappe.db.get_value("POS Profile", pos_profile, "customer")
if default_customer:
allowed.add(default_customer)

owned = frappe.get_all("Customer", filters={"owner": user}, pluck="name")
allowed.update(owned or [])

return allowed


def _apply_cashier_customer_scope(filters, pos_profile=None):
if not should_restrict_customers_for_user():
return filters

allowed = get_allowed_customer_names(pos_profile)
if not allowed:
filters["name"] = ["in", ["__no_customer__"]]
else:
filters["name"] = ["in", list(allowed)]

return filters


def _assert_customer_access(customer, pos_profile=None):
if not customer or not should_restrict_customers_for_user():
return

if customer not in get_allowed_customer_names(pos_profile):
frappe.throw(_("You do not have permission to access this customer"), frappe.PermissionError)


@frappe.whitelist()
def get_customers(search_term="", pos_profile=None, limit=20, modified_since=None):
Expand Down Expand Up @@ -45,6 +105,8 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non
# Full fetch: only active customers
filters["disabled"] = 0

filters = _apply_cashier_customer_scope(filters, pos_profile)

search_term = (search_term or "").strip()
if search_term:
like_term = f"%{search_term}%"
Expand All @@ -55,7 +117,13 @@ def get_customers(search_term="", pos_profile=None, limit=20, modified_since=Non
["Customer", "email_id", "like", like_term],
]

customer_limit = limit if limit not in (None, 0) else frappe.db.count("Customer", filters)
if limit not in (None, 0):
customer_limit = limit
elif should_restrict_customers_for_user():
customer_limit = len(get_allowed_customer_names(pos_profile))
else:
customer_limit = frappe.db.count("Customer", filters)

result = frappe.get_all(
"Customer",
filters=filters,
Expand Down Expand Up @@ -267,4 +335,11 @@ def get_customer_details(customer):
if not customer:
frappe.throw(_("Customer is required"))

pos_profile = None
form_dict = getattr(frappe.local, "form_dict", None)
if form_dict:
pos_profile = form_dict.get("pos_profile")

_assert_customer_access(customer, pos_profile)

return frappe.get_cached_doc("Customer", customer).as_dict()
60 changes: 59 additions & 1 deletion pos_next/api/test_customers.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,16 +7,21 @@
from pos_next.api.customers import (
_get_customer_assignment_context,
create_customer,
get_allowed_customer_names,
get_customers,
get_default_loyalty_program_from_settings,
should_restrict_customers_for_user,
)


class TestCustomersAPI(unittest.TestCase):
@patch("pos_next.api.customers.should_restrict_customers_for_user", return_value=False)
@patch("pos_next.api.customers.frappe.logger")
@patch("pos_next.api.customers.frappe.get_all")
@patch("pos_next.api.customers.frappe.db")
def test_get_customers_applies_search_term_filters(self, mock_db, mock_get_all, mock_logger):
def test_get_customers_applies_search_term_filters(
self, mock_db, mock_get_all, mock_logger, _mock_restrict
):
mock_logger.return_value = Mock()
mock_get_all.return_value = []

Expand Down Expand Up @@ -100,3 +105,56 @@ def test_create_customer_uses_pos_profile_for_loyalty_assignment(
mock_get_loyalty.assert_called_once_with(company=None, pos_profile="POS-A")
customer_doc.insert.assert_called_once_with()
self.assertEqual(result["loyalty_program"], "LOYALTY-A")

@patch("pos_next.api.customers.frappe.get_roles", return_value=["POSNext Cashier"])
def test_should_restrict_customers_for_cashier(self, _mock_roles):
self.assertTrue(should_restrict_customers_for_user("cashier@example.com"))

@patch("pos_next.api.customers.frappe.get_roles", return_value=["Nexus POS Manager"])
def test_should_not_restrict_customers_for_manager(self, _mock_roles):
self.assertFalse(should_restrict_customers_for_user("manager@example.com"))

@patch("pos_next.api.customers.frappe.get_all", return_value=["CUST-OWNED"])
@patch(
"pos_next.api.customers.frappe.db.get_value",
return_value="CUST-DEFAULT",
)
def test_get_allowed_customer_names_includes_profile_default_and_owned(
self, mock_get_value, mock_get_all
):
allowed = get_allowed_customer_names("POS-A", user="cashier@example.com")

self.assertEqual(allowed, {"CUST-DEFAULT", "CUST-OWNED"})
mock_get_value.assert_called_once_with("POS Profile", "POS-A", "customer")
mock_get_all.assert_called_once_with(
"Customer", filters={"owner": "cashier@example.com"}, pluck="name"
)

@patch("pos_next.api.customers.should_restrict_customers_for_user", return_value=True)
@patch(
"pos_next.api.customers.get_allowed_customer_names",
return_value={"CUST-DEFAULT", "CUST-OWNED"},
)
@patch("pos_next.api.customers.frappe.logger")
@patch("pos_next.api.customers.frappe.get_all")
@patch("pos_next.api.customers.frappe.db")
def test_get_customers_scopes_cashier_to_allowed_names(
self,
mock_db,
mock_get_all,
mock_logger,
_mock_allowed,
_mock_restrict,
):
mock_logger.return_value = Mock()
mock_get_all.return_value = []

get_customers(pos_profile="POS-A", limit=10)

kwargs = mock_get_all.call_args.kwargs
self.assertEqual(kwargs["filters"]["disabled"], 0)
self.assertEqual(kwargs["filters"]["name"][0], "in")
self.assertCountEqual(
kwargs["filters"]["name"][1],
["CUST-DEFAULT", "CUST-OWNED"],
)
Loading