@@ -188,18 +175,17 @@ function clearSearch() {
v-for="report in section.reports"
:key="report.slug"
class="group relative overflow-hidden border-border/70 transition-all duration-200 hover:-translate-y-0.5 hover:shadow-lg"
- :class="toneClasses[report.tone].glow"
>
-
+
{{ report.category }}
@@ -211,54 +197,31 @@ function clearSearch() {
-
+
- {{ filter.label }}
+
+ {{ __("Locked") }}
-
- +{{ report.filters.length - 4 }}
-
-
-
-
-
- {{ report.filters.length }} {{ __("filters") }}
-
-
-
-
- {{ __("Locked") }}
-
-
- {{ __("Open") }}
-
-
-
+ {{ __("Open") }}
+
+
diff --git a/frontend/src/views/RolePermissionsView.vue b/frontend/src/views/RolePermissionsView.vue
new file mode 100755
index 0000000..c309980
--- /dev/null
+++ b/frontend/src/views/RolePermissionsView.vue
@@ -0,0 +1,218 @@
+
+
+
+
+
{{ __("Role Permissions") }}
+
+ {{ __("Configure which POS actions each role may perform.") }}
+
+
+
+
+ {{ __("Refresh") }}
+
+
+
+
+ {{ error }}
+
+
+
+ {{ __("No POS roles found. Create a POS Role first.") }}
+
+
+
+
+
+ {{ __("Role") }}
+
+
+
+ {{ enabledCount }} {{ __("of") }} {{ totalCount }} {{ __("permissions enabled") }}
+
+
+
+
+
+
+ {{ __(group.title) }}
+
+
+
+
+
+
+ {{ __("Select a role to view its permissions.") }}
+
+
+
+
+
+
diff --git a/frontend/tests/paymentDialogShortcuts.spec.ts b/frontend/tests/paymentDialogShortcuts.spec.ts
old mode 100644
new mode 100755
diff --git a/xpos/api/auth.py b/xpos/api/auth.py
index dbe624b..9fb6d5f 100755
--- a/xpos/api/auth.py
+++ b/xpos/api/auth.py
@@ -2,62 +2,200 @@
# For license information, please see license.txt
-import json
-
import frappe
from frappe import _
-from frappe.utils import cint
-
-PERMISSION_FIELDS = [
- "xpos_role",
- "xpos_pos_profile",
- "xpos_warehouse",
- "xpos_company",
- "xpos_theme",
- "xpos_close_bill",
- "xpos_allow_reprint_invoice",
- "xpos_stock_adjustment",
- "xpos_quotation",
- "xpos_apply_additional_discount",
- "xpos_apply_standard_discount",
- "xpos_allow_change_price",
- "xpos_show_edit_discount_field",
- "xpos_show_edit_item_tax_template",
- "xpos_allow_return",
- "xpos_allow_cancel_invoice",
- "xpos_allow_expense",
- "xpos_allow_bank_drop",
- "xpos_discount_limit",
- "xpos_unsettled_invoices",
- "xpos_local_purchase",
- "xpos_purchase_order",
- "xpos_purchase_invoice",
- "xpos_stock_entry",
- "xpos_near_expiry_items",
- "xpos_list_of_invoices",
- "xpos_list_of_cancelled_invoices",
- "xpos_list_of_errors",
- "xpos_list_of_purchase_invoices",
- "xpos_list_of_quotations",
- "xpos_list_of_stock_entries",
- "xpos_list_of_local_purchases",
- "xpos_list_of_stock_adjustments",
- "xpos_list_of_expense",
- "xpos_list_of_bank_drops",
- "xpos_invoice_settlement_report",
- "xpos_sales_report_by_time",
- "xpos_sales_summary_by_hour",
- "xpos_current_stock_by_brand",
- "xpos_stock_register",
- "xpos_current_stock_report",
-]
-
-
-def _get_user_fields():
- """Return the subset of PERMISSION_FIELDS that actually exist on tabUser."""
- meta = frappe.get_meta("User")
- existing = {f.fieldname for f in meta.fields}
- return [f for f in PERMISSION_FIELDS if f in existing]
+from frappe.utils import cint, flt
+
+ALL_PERMISSION_KEYS = (
+ "close_shift",
+ "allow_reprint_invoice",
+ "print_draft_invoice",
+ "shift_report",
+ "apply_additional_discount",
+ "show_edit_discount_field",
+ "allow_change_price",
+ "sale_return",
+ "expense",
+ "bank_drop",
+ "current_stock_by_brand",
+ "current_stock_report",
+ "manage_role_permissions",
+)
+
+DEFAULT_ROLE = "Cashier"
+DEFAULT_DISCOUNT_LIMIT = 100
+_ROLE_CACHE_KEY = "xpos_role_permissions"
+
+
+def _resolve_role_permissions(role_name: str) -> dict:
+ """Build the full permission map for a role from its POS Role Permission rows.
+
+ Every key in :data:`ALL_PERMISSION_KEYS` is present in the result; flags
+ without an enabled child row default to ``False``.
+ """
+ perms = {key: False for key in ALL_PERMISSION_KEYS}
+
+ if not role_name or not frappe.db.exists("POS Role", role_name):
+ return perms
+
+ rows = frappe.get_all(
+ "POS Role Permission",
+ filters={"parent": role_name, "parenttype": "POS Role"},
+ fields=["permission", "enabled"],
+ ignore_permissions=True,
+ )
+ for row in rows:
+ if row.permission in perms:
+ perms[row.permission] = bool(row.enabled)
+
+ return perms
+
+
+def _get_user_pos_role(user: str, pos_profile: str | None = None) -> str:
+ """Resolve a user's POS Role from the POS Profile User child rows.
+
+ When ``pos_profile`` is given the matching profile's row is preferred;
+ otherwise the first profile row that carries a ``pos_role`` wins. Falls
+ back to :data:`DEFAULT_ROLE` when no assignment exists.
+ """
+ filters = {"user": user, "parenttype": "POS Profile"}
+ if pos_profile:
+ filters["parent"] = pos_profile
+
+ rows = frappe.get_all(
+ "POS Profile User",
+ filters=filters,
+ fields=["pos_role"],
+ order_by="parent asc, idx asc",
+ ignore_permissions=True,
+ )
+ for row in rows:
+ if row.pos_role:
+ return row.pos_role
+
+ return DEFAULT_ROLE
+
+
+def _get_role_permissions(role_name: str) -> dict:
+ """Cached accessor for :func:`_resolve_role_permissions`.
+
+ Cached per-role in ``frappe.cache`` and invalidated by the POS Role /
+ POS Permission doc_events (see :func:`clear_role_permission_cache`).
+ """
+ role_name = role_name or DEFAULT_ROLE
+ return frappe.cache().hget(
+ _ROLE_CACHE_KEY,
+ role_name,
+ generator=lambda: _resolve_role_permissions(role_name),
+ )
+
+
+def clear_role_permission_cache(role_name: str | None = None) -> None:
+ """Invalidate the cached permission map for a role (or all roles)."""
+ if role_name:
+ frappe.cache().hdel(_ROLE_CACHE_KEY, role_name)
+ else:
+ frappe.cache().delete_key(_ROLE_CACHE_KEY)
+
+
+def _all_enabled() -> dict:
+ """Permission map with every flag granted (System Manager / Administrator)."""
+ return {key: True for key in ALL_PERMISSION_KEYS}
+
+
+def _is_superuser(user: str) -> bool:
+ return user == "Administrator" or "System Manager" in frappe.get_roles(user)
+
+
+def user_has_pos_permission(
+ key: str, user: str | None = None, pos_profile: str | None = None
+) -> bool:
+ """Whether ``user``'s POS Role grants the permission ``key``.
+
+ Single source of truth for server-side POS permission checks. Resolves the
+ role from the user's POS Profile User row (preferring ``pos_profile`` when
+ supplied). Administrators and System Managers always qualify; Guest never.
+ """
+ user = user or frappe.session.user
+ if user == "Guest":
+ return False
+ if _is_superuser(user):
+ return True
+ role_name = _get_user_pos_role(user, pos_profile)
+ return bool(_get_role_permissions(role_name).get(key))
+
+
+def can_manage_role_permissions(user: str | None = None) -> bool:
+ """Whether ``user`` may view and edit the POS role permission matrix.
+
+ Administrators and System Managers always qualify. Any other user must
+ have the ``manage_role_permissions`` flag enabled on their POS Role.
+ """
+ return user_has_pos_permission("manage_role_permissions", user)
+
+
+def _require_manage_permissions() -> None:
+ if not can_manage_role_permissions():
+ frappe.throw(
+ _("You are not permitted to manage POS role permissions."),
+ frappe.PermissionError,
+ )
+
+
+def _get_user_discount_limit(user_name: str, pos_profile: str | None) -> float:
+ """Per-user discount cap, read from the POS Profile User child row."""
+ if not pos_profile:
+ return DEFAULT_DISCOUNT_LIMIT
+
+ value = frappe.db.get_value(
+ "POS Profile User",
+ {"parent": pos_profile, "parenttype": "POS Profile", "user": user_name},
+ "discount_limit",
+ )
+ return flt(value) if value not in (None, "") else DEFAULT_DISCOUNT_LIMIT
+
+
+@frappe.whitelist()
+def get_my_pos_permissions(pos_profile: str | None = None) -> dict:
+ """Return POS permission flags for the current logged-in user (browser mode).
+
+ Resolves the user's POS Role from their POS Profile User row (preferring
+ ``pos_profile`` when supplied). System Manager / Administrator receive every
+ flag enabled.
+ """
+ user = frappe.session.user
+ if user == "Guest":
+ return {key: False for key in ALL_PERMISSION_KEYS}
+
+ if _is_superuser(user):
+ return _all_enabled()
+
+ role_name = _get_user_pos_role(user, pos_profile)
+ return _get_role_permissions(role_name)
+
+
+def get_current_user_permissions() -> dict:
+ """Return the current session user's xPOS role and permission flags.
+
+ Used by ``extend_bootinfo`` so the web app has an authoritative source of
+ truth for permissions. System Manager / Administrator resolve to all
+ permissions enabled.
+ """
+ user = frappe.session.user
+ if user == "Guest":
+ return {}
+
+ role_name = _get_user_pos_role(user)
+
+ if _is_superuser(user):
+ permissions = _all_enabled()
+ else:
+ permissions = _get_role_permissions(role_name)
+
+ return {
+ "role": role_name,
+ "permissions": permissions,
+ }
@frappe.whitelist()
@@ -77,19 +215,28 @@ def get_pos_users(
Returns:
list[dict]: One record per user with at minimum:
name, username, full_name, enabled, password_hash (empty),
- role, pos_profile, warehouse, company, theme,
- and all available xpos permission flag fields.
+ role, pos_profile, warehouse, company, theme, discount_limit,
+ and every permission flag in :data:`ALL_PERMISSION_KEYS`, resolved
+ from the POS Role assigned on the user's POS Profile User row.
"""
limit_start = cint(limit_start)
limit_page_length = cint(limit_page_length)
profile_users = frappe.db.sql(
"""
- SELECT DISTINCT pu.user
- FROM `tabPOS Profile User` pu
- INNER JOIN `tabPOS Profile` pp ON pp.name = pu.parent
- WHERE pp.disabled = 0
- ORDER BY pu.user
+ SELECT user, pos_profile, pos_role, discount_limit, warehouse, company
+ FROM (
+ SELECT pu.user, pu.parent AS pos_profile, pu.pos_role, pu.discount_limit,
+ pp.warehouse, pp.company,
+ ROW_NUMBER() OVER (
+ PARTITION BY pu.user ORDER BY pu.parent ASC, pu.idx ASC
+ ) AS rn
+ FROM `tabPOS Profile User` pu
+ INNER JOIN `tabPOS Profile` pp ON pp.name = pu.parent
+ WHERE pp.disabled = 0
+ ) ranked
+ WHERE rn = 1
+ ORDER BY user
LIMIT %(limit)s OFFSET %(offset)s
""",
{"limit": limit_page_length, "offset": limit_start},
@@ -99,21 +246,24 @@ def get_pos_users(
if not profile_users:
return []
- user_names = [r.user for r in profile_users]
-
- base_fields = ["name", "username", "full_name", "enabled", "modified"]
- available_xpos_fields = _get_user_fields()
- all_fields = base_fields + available_xpos_fields
-
- users = frappe.get_all(
- "User",
- filters={"name": ["in", user_names]},
- fields=all_fields,
- ignore_permissions=True,
- )
+ user_meta = {
+ u.name: u
+ for u in frappe.get_all(
+ "User",
+ filters={"name": ["in", [r.user for r in profile_users]]},
+ fields=["name", "username", "full_name", "enabled", "modified"],
+ ignore_permissions=True,
+ )
+ }
results = []
- for user in users:
+ for pu in profile_users:
+ user = user_meta.get(pu.user)
+ if not user:
+ continue
+ role_name = pu.pos_role or DEFAULT_ROLE
+ perms = _get_role_permissions(role_name)
+ discount_limit = flt(pu.discount_limit) if pu.discount_limit not in (None, "") else DEFAULT_DISCOUNT_LIMIT
row = {
"name": user.name,
"username": user.username or user.name,
@@ -121,26 +271,76 @@ def get_pos_users(
"enabled": cint(user.enabled),
"modified": str(user.modified) if user.modified else None,
"password_hash": "",
- "role": user.get("xpos_role") or "Cashier",
- "pos_profile": user.get("xpos_pos_profile") or "",
- "warehouse": user.get("xpos_warehouse") or "",
- "company": user.get("xpos_company") or "",
- "theme": user.get("xpos_theme") or "Default",
- "close_bill": cint(user.get("xpos_close_bill", 1)),
- "allow_reprint_invoice": cint(user.get("xpos_allow_reprint_invoice", 0)),
- "stock_adjustment": cint(user.get("xpos_stock_adjustment", 0)),
- "quotation": cint(user.get("xpos_quotation", 0)),
- "apply_additional_discount": cint(user.get("xpos_apply_additional_discount", 0)),
- "apply_standard_discount": cint(user.get("xpos_apply_standard_discount", 0)),
- "allow_change_price": cint(user.get("xpos_allow_change_price", 0)),
- "show_edit_discount_field": cint(user.get("xpos_show_edit_discount_field", 0)),
- "show_edit_item_tax_template": cint(user.get("xpos_show_edit_item_tax_template", 0)),
- "allow_return": cint(user.get("xpos_allow_return", 0)),
- "allow_cancel_invoice": cint(user.get("xpos_allow_cancel_invoice", 0)),
- "allow_expense": cint(user.get("xpos_allow_expense", 0)),
- "allow_bank_drop": cint(user.get("xpos_allow_bank_drop", 0)),
- "discount_limit": user.get("xpos_discount_limit") or 100,
+ "role": role_name,
+ "pos_profile": pu.pos_profile or "",
+ "warehouse": pu.warehouse or "",
+ "company": pu.company or "",
+ "theme": "Default",
+ "discount_limit": discount_limit,
+ **{key: cint(perms.get(key, False)) for key in ALL_PERMISSION_KEYS},
}
results.append(row)
return results
+
+
+@frappe.whitelist()
+def get_role_permission_matrix() -> dict:
+ """Return the full role/permission matrix for the admin UI.
+
+ Shape::
+
+ {
+ "roles": [{"name", "role_name"}],
+ "permissions": [{"name", "label", "group"}],
+ "matrix": {role_name: {permission_name: bool}},
+ }
+ """
+ _require_manage_permissions()
+ roles = frappe.get_all(
+ "POS Role",
+ fields=["name", "role_name"],
+ order_by="role_name asc",
+ ignore_permissions=True,
+ )
+ permissions = frappe.get_all(
+ "POS Permission",
+ fields=["name", "permission_label as label"],
+ ignore_permissions=True,
+ )
+ # Order permissions by the canonical catalog order; tag with group.
+ from xpos.install import POS_PERMISSIONS
+
+ group_of = {name: group for name, _label, group in POS_PERMISSIONS}
+ order = {name: idx for idx, (name, _l, _g) in enumerate(POS_PERMISSIONS)}
+ for perm in permissions:
+ perm["group"] = group_of.get(perm.name, "Other")
+ permissions.sort(key=lambda p: order.get(p["name"], len(order)))
+
+ matrix: dict[str, dict[str, bool]] = {}
+ for role in roles:
+ matrix[role.name] = _resolve_role_permissions(role.name)
+
+ return {"roles": roles, "permissions": permissions, "matrix": matrix}
+
+
+@frappe.whitelist()
+def set_role_permission(role: str, permission: str, enabled) -> dict:
+ """Upsert a single POS Role Permission child row and bust the cache."""
+ _require_manage_permissions()
+ if not frappe.db.exists("POS Role", role):
+ frappe.throw(_("POS Role {0} not found").format(role))
+
+ enabled = 1 if cint(enabled) else 0
+ doc = frappe.get_doc("POS Role", role)
+
+ target = next((row for row in doc.permissions if row.permission == permission), None)
+ if target:
+ target.enabled = enabled
+ else:
+ doc.append("permissions", {"permission": permission, "enabled": enabled})
+
+ doc.save(ignore_permissions=True)
+ clear_role_permission_cache(role)
+
+ return {"role": role, "permission": permission, "enabled": enabled}
diff --git a/xpos/api/invoices.py b/xpos/api/invoices.py
index 94a3c8b..613738d 100755
--- a/xpos/api/invoices.py
+++ b/xpos/api/invoices.py
@@ -314,11 +314,15 @@ def create_invoice(data: str | dict):
rate_precision = _get_item_rate_precision()
+ from xpos.api.auth import user_has_pos_permission
+
+ allow_rate_change = user_has_pos_permission("allow_change_price", pos_profile=pos.name)
+
for item_data in items:
item_rate = flt(item_data.get("rate", 0), rate_precision)
item_qty = flt(item_data.get("qty", 1), 3)
- if not cint(pos.get("allow_rate_change")):
+ if not allow_rate_change:
price_list = pos.get("selling_price_list")
if price_list:
price_list_rate = frappe.db.get_value(
diff --git a/xpos/api/print_formats.py b/xpos/api/print_formats.py
index e682b8e..4b1d9bc 100755
--- a/xpos/api/print_formats.py
+++ b/xpos/api/print_formats.py
@@ -8,6 +8,10 @@
"""
import frappe
+from frappe import _
+from frappe.utils import cint
+
+REPRINT_DOCTYPES = ("Sales Invoice", "POS Invoice")
@frappe.whitelist()
@@ -19,3 +23,47 @@ def get_print_formats(doctype: str = "Sales Invoice"):
fields=["name"],
)
return [p.name for p in print_formats]
+
+
+def _can_reprint(user: str | None = None) -> bool:
+ """Whether ``user`` may reprint a posted POS invoice (the ``allow_reprint_invoice`` right).
+
+ Resolved from the user's POS Role permission map so it stays in sync with
+ the Role Permissions admin screen. Administrators / System Managers always
+ qualify.
+ """
+ from xpos.api.auth import user_has_pos_permission
+
+ return user_has_pos_permission("allow_reprint_invoice", user)
+
+
+def invoice_has_permission(doc, ptype: str, user: str) -> bool:
+ """``has_permission`` hook restricting reprinting of POS invoices.
+
+ The first receipt (``print_count`` == 0) is always allowed so cashiers
+ can print at the point of sale; every subsequent print is a reprint and
+ requires the ``allow_reprint_invoice`` right. Non-print actions and
+ non-POS invoices defer to Frappe's normal role permissions.
+ """
+ if ptype != "print":
+ return True
+ if not getattr(doc, "is_pos", 0):
+ return True
+ if cint(getattr(doc, "print_count", 0)) < 1:
+ return True
+ return _can_reprint(user)
+
+
+@frappe.whitelist()
+def mark_invoice_printed(doctype: str, name: str) -> dict:
+ """Record that a POS receipt has printed, so later prints count as reprints.
+
+ Called by the POS frontend right after the point-of-sale receipt prints.
+ Uses a direct db write because the counter is an internal marker, not
+ user-editable content; ``update_modified=False`` avoids needless re-sync.
+ """
+ if doctype not in REPRINT_DOCTYPES:
+ frappe.throw(_("Unsupported doctype for print tracking: {0}").format(doctype))
+ current = cint(frappe.db.get_value(doctype, name, "print_count"))
+ frappe.db.set_value(doctype, name, "print_count", current + 1, update_modified=False)
+ return {"name": name, "print_count": current + 1}
diff --git a/xpos/api/reports.py b/xpos/api/reports.py
new file mode 100755
index 0000000..9c864ed
--- /dev/null
+++ b/xpos/api/reports.py
@@ -0,0 +1,209 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+"""Report metadata for the X POS frontend report viewer.
+
+The frontend renders a generic, Frappe-style report view (filter bar + data
+table). The filter configuration for each report lives here so the frontend no
+longer hardcodes it; this mirrors the filters defined in each report's desk
+``*.js`` file but is JSON-serialisable.
+
+Report data itself is still fetched through the standard
+``frappe.desk.query_report.run`` endpoint.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import add_months, today
+
+
+def _company_default() -> str:
+ """Resolve the user's default company, mirroring the desk ``*.js`` defaults."""
+ return frappe.defaults.get_user_default("Company") or frappe.defaults.get_default("company") or ""
+
+
+def _company_filter():
+ return {
+ "fieldname": "company",
+ "label": "Company",
+ "type": "link",
+ "doctype": "Company",
+ "required": True,
+ "clears": ["warehouse"],
+ }
+
+
+def _warehouse_filter(required: bool = False, multi: bool = False):
+ return {
+ "fieldname": "warehouse",
+ "label": "Warehouses" if multi else "Warehouse",
+ "type": "multi-link" if multi else "link",
+ "doctype": "Warehouse",
+ "required": required,
+ # "$company" resolves to the current value of the company filter.
+ "getQueryFilters": {"company": "$company", "is_group": 0} if not multi else {"company": "$company"},
+ }
+
+
+def _multi_link(fieldname: str, label: str, doctype: str, required: bool = False):
+ return {
+ "fieldname": fieldname,
+ "label": label,
+ "type": "multi-link",
+ "doctype": doctype,
+ "required": required,
+ }
+
+
+def _link(fieldname: str, label: str, doctype: str, required: bool = False):
+ return {
+ "fieldname": fieldname,
+ "label": label,
+ "type": "link",
+ "doctype": doctype,
+ "required": required,
+ }
+
+
+def _date(fieldname: str, label: str, required: bool = False, default=None):
+ field = {"fieldname": fieldname, "label": label, "type": "date", "required": required}
+ if default is not None:
+ field["defaultValue"] = default
+ return field
+
+
+def _filters() -> dict:
+ return {
+ "Current Stock Report": [
+ _company_filter(),
+ _warehouse_filter(required=True),
+ _link("supplier", "Supplier", "Supplier"),
+ _link("brand", "Brand", "Brand"),
+ _link("item_group", "Item Group", "Item Group"),
+ ],
+ "Current Stock By Brand": [
+ _multi_link("brand", "Brand", "Brand", required=True),
+ ],
+ "Current Stock Summary": [
+ _company_filter(),
+ _warehouse_filter(required=True),
+ ],
+ "Current Stock With Levels": [
+ _company_filter(),
+ _warehouse_filter(required=False),
+ ],
+ "Dead Stock Report": [
+ _company_filter(),
+ _warehouse_filter(required=False),
+ {"fieldname": "days", "label": "Days", "type": "integer", "required": True, "defaultValue": 30},
+ {
+ "fieldname": "min_value",
+ "label": "Minimum Value",
+ "type": "float",
+ "required": True,
+ "defaultValue": 1000,
+ },
+ _multi_link("supplier", "Supplier", "Supplier"),
+ ],
+ "Stock Value By Warehouse": [
+ _company_filter(),
+ ],
+ "Stock Value Summary By Date": [
+ _company_filter(),
+ _date("from_posting_date", "From Posting Date", required=True, default=today()),
+ ],
+ "Warehouse Stock Movement": [
+ _company_filter(),
+ _date("from_date", "From Date", required=True, default=today()),
+ _date("to_date", "To Date", required=True, default=today()),
+ ],
+ "Branch Item Summary": [
+ _company_filter(),
+ _warehouse_filter(required=True),
+ _date("from_date", "From Date", required=True, default=today()),
+ _date("to_date", "To Date", required=True, default=today()),
+ ],
+ "Branch Set Summary": [
+ _company_filter(),
+ _warehouse_filter(required=True),
+ _date("date", "Date", required=True, default=today()),
+ ],
+ "Low Qty Sales Report": [
+ _company_filter(),
+ _date("from_date", "From Date", required=True, default=add_months(today(), -1)),
+ _date("to_date", "To Date", required=True, default=today()),
+ {"fieldname": "min_qty", "label": "Min Qty", "type": "integer", "required": True, "defaultValue": 10},
+ ],
+ "Zero Qty Sales Report": [
+ _company_filter(),
+ _date("from_date", "From Date", required=True, default=today()),
+ _date("to_date", "To Date", required=True, default=today()),
+ ],
+ "Slow Fast Moving Items": [
+ _company_filter(),
+ _date("from_date", "From Date", required=True, default=add_months(today(), -1)),
+ _date("to_date", "To Date", required=True, default=today()),
+ _multi_link("supplier", "Supplier", "Supplier"),
+ ],
+ "Stock Audit Report": [
+ _company_filter(),
+ _warehouse_filter(required=False, multi=True),
+ _multi_link("item_group", "Item Group", "Item Group"),
+ _multi_link("brand", "Brand", "Brand"),
+ _multi_link("supplier", "Supplier", "Supplier"),
+ ],
+ "Purchase Order Report": [
+ _company_filter(),
+ _link("supplier", "Supplier", "Supplier", required=True),
+ _date("from_date", "From Date", required=True),
+ _date("to_date", "To Date", required=True),
+ {
+ "fieldname": "type",
+ "label": "Type",
+ "type": "select",
+ "required": True,
+ "defaultValue": "All",
+ "options": [
+ {"label": "All", "value": "All"},
+ {"label": "Based on ReOrder Level", "value": "Based on ReOrder Level"},
+ {"label": "Based on Sales", "value": "Based on Sales"},
+ ],
+ },
+ ],
+ "POS Shift Reconciliation": [
+ _link("pos_opening_shift", "Opening Shift", "POS Opening Shift"),
+ _link("company", "Company", "Company"),
+ _link("pos_profile", "POS Profile", "POS Profile"),
+ _date("from_date", "From Date"),
+ _date("to_date", "To Date"),
+ ],
+ }
+
+
+def _apply_company_defaults(filters: list) -> list:
+ """Inject the user's default company into the company filter at request time."""
+ company = _company_default()
+ if not company:
+ return filters
+ for field in filters:
+ if field.get("fieldname") == "company" and "defaultValue" not in field:
+ field["defaultValue"] = company
+ return filters
+
+
+@frappe.whitelist()
+def get_report_meta(report: str) -> dict:
+ """Return the JSON filter configuration for a report.
+
+ Columns are intentionally omitted — they arrive with the data from
+ ``frappe.desk.query_report.run`` so no report execution is needed here.
+ """
+ registry = _filters()
+ filters = registry.get(report)
+ if filters is None:
+ frappe.throw(_("Unknown report: {0}").format(report), frappe.DoesNotExistError)
+
+ if not frappe.has_permission("Report", "read"):
+ frappe.throw(_("Not permitted to view reports"), frappe.PermissionError)
+
+ return {"filters": _apply_company_defaults(filters)}
diff --git a/xpos/api/shifts.py b/xpos/api/shifts.py
index 5970a0d..006b285 100755
--- a/xpos/api/shifts.py
+++ b/xpos/api/shifts.py
@@ -6,7 +6,7 @@
import frappe
from frappe import Any, _
from frappe.utils import cint, flt, now_datetime, nowdate
-from xpos.api.utilities import get_invoice_type, is_pos_cashier
+from xpos.api.utilities import can_close_shift, get_invoice_type, is_pos_cashier
def _row_value(row: dict | object, key: str, default: Any | None = None):
@@ -142,6 +142,9 @@ def close_shift(opening_shift: str, closing_details: str | list[dict] | None):
- Tax summary per shift
- Payment reconciliation with expected vs actual amounts
"""
+ if not can_close_shift():
+ frappe.throw(_("Only a Supervisor can close a shift."), frappe.PermissionError)
+
closing_details = json.loads(closing_details) if isinstance(closing_details, str) else closing_details
opening = frappe.get_doc("POS Opening Shift", opening_shift)
@@ -424,10 +427,14 @@ def _enrich_shift_data(data: dict, pos_profile: str):
data["taxes"] = []
data["tax_inclusive"] = 0
+ from xpos.api.auth import user_has_pos_permission
+
data["print_settings"] = {
"print_format": profile.get("print_format") or "POS Invoice",
"print_format_for_online": profile.get("print_format_for_online"),
- "allow_print_before_pay": cint(profile.get("allow_print_draft_invoices")) or 0,
+ "allow_print_before_pay": 1
+ if user_has_pos_permission("print_draft_invoice", pos_profile=pos_profile)
+ else 0,
"auto_print_receipt": cint(profile.get("auto_print_receipt")) or 0,
"letter_head": profile.get("letter_head") or "",
}
@@ -515,6 +522,9 @@ def create_closing_shift(data: str | dict, local_id: str | None = None) -> dict:
Returns:
dict with 'name' key containing the server docname
"""
+ if not can_close_shift():
+ frappe.throw(_("Only a Supervisor can close a shift."), frappe.PermissionError)
+
data = json.loads(data) if isinstance(data, str) else data
if local_id:
diff --git a/xpos/api/tests/test_fbr_integration.py b/xpos/api/tests/test_fbr_integration.py
old mode 100644
new mode 100755
diff --git a/xpos/api/utilities.py b/xpos/api/utilities.py
index 03ab6da..9d984cc 100755
--- a/xpos/api/utilities.py
+++ b/xpos/api/utilities.py
@@ -202,4 +202,16 @@ def is_pos_cashier(user: str | None = None, pos_profile: str | None = None) -> b
{"parent": pos_profile, "parenttype": "POS Profile", "user": user},
"is_cashier",
)
- )
\ No newline at end of file
+ )
+
+
+def can_close_shift(user: str | None = None, pos_profile: str | None = None) -> bool:
+ """Return whether ``user`` may close a cashier/sales shift (the ``close_shift`` right).
+
+ Resolved from the user's POS Role permission map so it stays in sync with
+ the Role Permissions admin screen. Administrators / System Managers always
+ qualify.
+ """
+ from xpos.api.auth import user_has_pos_permission
+
+ return user_has_pos_permission("close_shift", user, pos_profile)
\ No newline at end of file
diff --git a/xpos/desktop_icon/x_pos.json b/xpos/desktop_icon/x_pos.json
old mode 100644
new mode 100755
diff --git a/xpos/hooks.py b/xpos/hooks.py
index 028b3ce..057bcba 100755
--- a/xpos/hooks.py
+++ b/xpos/hooks.py
@@ -93,7 +93,7 @@
# ------------
# before_install = "xpos.install.before_install"
-# after_install = "xpos.install.after_install"
+after_install = "xpos.install.after_install"
# Uninstallation
# ------------
@@ -129,10 +129,11 @@
# permission_query_conditions = {
# "Event": "frappe.desk.doctype.event.event.get_permission_query_conditions",
# }
-#
-# has_permission = {
-# "Event": "frappe.desk.doctype.event.event.has_permission",
-# }
+
+has_permission = {
+ "Sales Invoice": "xpos.api.print_formats.invoice_has_permission",
+ "POS Invoice": "xpos.api.print_formats.invoice_has_permission",
+}
# DocType Class
# ---------------
diff --git a/xpos/install.py b/xpos/install.py
new file mode 100755
index 0000000..88f8485
--- /dev/null
+++ b/xpos/install.py
@@ -0,0 +1,82 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+"""
+Install-time seeding for the xPOS role-based permission system.
+"""
+
+import frappe
+
+POS_PERMISSIONS = (
+ # Billing & Invoicing
+ ("close_shift", "Close Shift", "Billing & Invoicing"),
+ ("allow_reprint_invoice", "Reprint Invoice", "Billing & Invoicing"),
+ ("print_draft_invoice", "Print Draft Invoice", "Billing & Invoicing"),
+ ("shift_report", "Shift Report", "Billing & Invoicing"),
+ # Discounts & Pricing
+ ("apply_additional_discount", "Apply Additional Discount", "Discounts & Pricing"),
+ ("show_edit_discount_field", "Edit Discount Field", "Discounts & Pricing"),
+ ("allow_change_price", "Change Price", "Discounts & Pricing"),
+ # Sales Operations
+ ("sale_return", "Sale Return", "Sales Operations"),
+ # Cash Management
+ ("expense", "Expense", "Cash Management"),
+ ("bank_drop", "Bank Drop", "Cash Management"),
+ # Reports
+ ("current_stock_by_brand", "Current Stock by Brand", "Reports"),
+ ("current_stock_report", "Current Stock Report", "Reports"),
+ # Administration
+ ("manage_role_permissions", "Manage Role Permissions", "Administration"),
+)
+
+ALL_PERMISSION_NAMES = tuple(name for name, _label, _group in POS_PERMISSIONS)
+
+# Cashiers ring up sales out of the box; every catalog permission is an elevated
+# capability, so none are enabled by default.
+_CASHIER_ENABLED: set[str] = set()
+_MANAGER_DISABLED = {"manage_role_permissions"}
+
+DEFAULT_ROLES = (
+ ("Cashier", _CASHIER_ENABLED),
+ ("Manager", set(ALL_PERMISSION_NAMES) - _MANAGER_DISABLED),
+ ("Administrator", set(ALL_PERMISSION_NAMES)),
+)
+
+
+def after_install():
+ seed_pos_permissions()
+ seed_default_roles()
+
+
+def seed_pos_permissions():
+ """Upsert the static POS Permission catalog. Idempotent."""
+ for permission_name, permission_label, _group in POS_PERMISSIONS:
+ if frappe.db.exists("POS Permission", permission_name):
+ continue
+ frappe.get_doc(
+ {
+ "doctype": "POS Permission",
+ "permission_name": permission_name,
+ "permission_label": permission_label,
+ }
+ ).insert(ignore_permissions=True)
+
+
+def seed_default_roles():
+ """Create the default POS Role records with their child permission rows.
+
+ Skips roles that already exist so existing customisations are preserved.
+ """
+ for role_name, enabled_set in DEFAULT_ROLES:
+ if frappe.db.exists("POS Role", role_name):
+ continue
+ role = frappe.get_doc({"doctype": "POS Role", "role_name": role_name})
+ for permission_name in ALL_PERMISSION_NAMES:
+ role.append(
+ "permissions",
+ {
+ "permission": permission_name,
+ "enabled": 1 if permission_name in enabled_set else 0,
+ },
+ )
+ role.insert(ignore_permissions=True)
diff --git a/xpos/patches.txt b/xpos/patches.txt
index 9000ac8..99c2d36 100755
--- a/xpos/patches.txt
+++ b/xpos/patches.txt
@@ -7,4 +7,5 @@
xpos.patches.add_composite_indexes
xpos.patches.add_item_fulltext_index
xpos.patches.remove_unused_pos_profile_discount_fields
-execute:frappe.db.delete("Custom Field", filters={"fieldname": ["IN", ["create_pos_invoice_instead_of_sales_invoice", "auto_delete_draft_invoice", "allow_delete", "display_authorization_code", "fetch_items_directly_from_server"]]})
\ No newline at end of file
+execute:frappe.db.delete("Custom Field", filters={"fieldname": ["IN", ["create_pos_invoice_instead_of_sales_invoice", "auto_delete_draft_invoice", "allow_delete", "display_authorization_code", "fetch_items_directly_from_server"]]})
+xpos.patches.seed_pos_role_permissions
\ No newline at end of file
diff --git a/xpos/patches/remove_unused_pos_profile_discount_fields.py b/xpos/patches/remove_unused_pos_profile_discount_fields.py
old mode 100644
new mode 100755
diff --git a/xpos/patches/seed_pos_role_permissions.py b/xpos/patches/seed_pos_role_permissions.py
new file mode 100644
index 0000000..92bebac
--- /dev/null
+++ b/xpos/patches/seed_pos_role_permissions.py
@@ -0,0 +1,9 @@
+from xpos.install import seed_default_roles, seed_pos_permissions
+
+
+def execute():
+ """
+ Seed the POS Permission catalog and default POS Roles on existing sites.
+ """
+ seed_pos_permissions()
+ seed_default_roles()
diff --git a/xpos/startup/boot.py b/xpos/startup/boot.py
index 90d0dfd..1e6b9a9 100755
--- a/xpos/startup/boot.py
+++ b/xpos/startup/boot.py
@@ -3,6 +3,8 @@
import frappe
+from xpos.api.auth import can_manage_role_permissions, get_current_user_permissions
+
def extend_bootinfo(bootinfo):
"""extending boot session"""
@@ -25,3 +27,12 @@ def extend_bootinfo(bootinfo):
bootinfo.buying_settings = frappe.get_single("Buying Settings")
bootinfo.stock_settings = frappe.get_single("Stock Settings")
bootinfo.pos_settings = frappe.get_single("POS Settings")
+
+ user_rights = get_current_user_permissions()
+ bootinfo.xpos_role = user_rights.get("role")
+ bootinfo.xpos_permissions = user_rights.get("permissions")
+ bootinfo.xpos_is_system_manager = (
+ frappe.session.user == "Administrator"
+ or "System Manager" in frappe.get_roles(frappe.session.user)
+ )
+ bootinfo.xpos_can_manage_permissions = can_manage_role_permissions()
diff --git a/xpos/workspace_sidebar/x_pos.json b/xpos/workspace_sidebar/x_pos.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/api/item_processing/barcode.py b/xpos/x_pos/api/item_processing/barcode.py
index 85deba4..a243ca3 100755
--- a/xpos/x_pos/api/item_processing/barcode.py
+++ b/xpos/x_pos/api/item_processing/barcode.py
@@ -13,7 +13,7 @@ def _get_scale_barcode_settings():
except frappe.DoesNotExistError:
return None
except Exception:
- frappe.log_error("Unable to load Scale Barcode Settings", "POS Awesome")
+ frappe.log_error("Unable to load Scale Barcode Settings", "X POS")
return None
diff --git a/xpos/x_pos/api/test_invoice_delivery_charges.py b/xpos/x_pos/api/test_invoice_delivery_charges.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/custom/mode_of_payment.json b/xpos/x_pos/custom/mode_of_payment.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/custom/pos_invoice.json b/xpos/x_pos/custom/pos_invoice.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/custom/pos_invoice_item.json b/xpos/x_pos/custom/pos_invoice_item.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/custom/pos_profile.json b/xpos/x_pos/custom/pos_profile.json
index eeab5ea..4837016 100755
--- a/xpos/x_pos/custom/pos_profile.json
+++ b/xpos/x_pos/custom/pos_profile.json
@@ -166,7 +166,7 @@
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
- "insert_after": "allow_user_to_edit_additional_discount",
+ "insert_after": "auto_delete_draft_invoice",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Allow change posting date",
@@ -858,270 +858,6 @@
"unique": 0,
"width": null
},
- {
- "_assign": null,
- "_comments": null,
- "_liked_by": null,
- "_user_tags": null,
- "alignment": "",
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "button_color": "",
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "creation": "2026-03-01 19:07:26.285262",
- "default": null,
- "depends_on": null,
- "description": null,
- "docstatus": 0,
- "dt": "POS Profile",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "allow_print_draft_invoices",
- "fieldtype": "Check",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 95,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "use_server_cache",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Allow print draft invoices",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-03-01 23:50:45.216093",
- "modified_by": "Administrator",
- "module": "X POS",
- "name": "POS Profile-allow_print_draft_invoices",
- "no_copy": 0,
- "non_negative": 0,
- "options": null,
- "owner": "Administrator",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
- {
- "_assign": null,
- "_comments": null,
- "_liked_by": null,
- "_user_tags": null,
- "alignment": "",
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "button_color": "",
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "creation": "2026-05-28 00:00:00.000000",
- "default": null,
- "depends_on": null,
- "description": "When enabled, the POS terminal creates an unsettled invoice instead of collecting payment. A cashier settles it later from the Cashier screen.",
- "docstatus": 0,
- "dt": "POS Profile",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "enable_cashier_settlement",
- "fieldtype": "Check",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "allow_print_draft_invoices",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Enable cashier settlement",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-05-28 00:00:00.000000",
- "modified_by": "Administrator",
- "module": "X POS",
- "name": "POS Profile-enable_cashier_settlement",
- "no_copy": 0,
- "non_negative": 0,
- "options": null,
- "owner": "Administrator",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
- {
- "_assign": null,
- "_comments": null,
- "_liked_by": null,
- "_user_tags": null,
- "alignment": "",
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "button_color": "",
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "creation": "2026-05-28 00:00:00.000000",
- "default": null,
- "depends_on": "eval:doc.enable_cashier_settlement",
- "description": "Print a non-genuine backup receipt for the customer at the terminal. No payment is collected; the genuine invoice is printed by the cashier after settlement.",
- "docstatus": 0,
- "dt": "POS Profile",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "print_backup_receipt",
- "fieldtype": "Check",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 0,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "enable_cashier_settlement",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Print backup receipt",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-05-28 00:00:00.000000",
- "modified_by": "Administrator",
- "module": "X POS",
- "name": "POS Profile-print_backup_receipt",
- "no_copy": 0,
- "non_negative": 0,
- "options": null,
- "owner": "Administrator",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
- {
- "_assign": null,
- "_comments": null,
- "_liked_by": null,
- "_user_tags": null,
- "alignment": "",
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "button_color": "",
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "creation": "2026-03-01 19:00:55.980833",
- "default": null,
- "depends_on": null,
- "description": null,
- "docstatus": 0,
- "dt": "POS Profile",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "allow_print_last_invoice",
- "fieldtype": "Check",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 124,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "section_break_tjwtr",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Allow print last invoice",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-03-01 23:50:23.806717",
- "modified_by": "Administrator",
- "module": "X POS",
- "name": "POS Profile-allow_print_last_invoice",
- "no_copy": 0,
- "non_negative": 0,
- "options": null,
- "owner": "Administrator",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
{
"_assign": null,
"_comments": null,
@@ -1650,72 +1386,6 @@
"unique": 0,
"width": null
},
- {
- "_assign": null,
- "_comments": null,
- "_liked_by": null,
- "_user_tags": null,
- "alignment": "",
- "allow_in_quick_entry": 0,
- "allow_on_submit": 0,
- "bold": 0,
- "button_color": "",
- "collapsible": 0,
- "collapsible_depends_on": null,
- "columns": 0,
- "creation": "2026-03-01 17:44:40.742598",
- "default": null,
- "depends_on": null,
- "description": null,
- "docstatus": 0,
- "dt": "POS Profile",
- "fetch_from": null,
- "fetch_if_empty": 0,
- "fieldname": "allow_user_to_edit_additional_discount",
- "fieldtype": "Check",
- "hidden": 0,
- "hide_border": 0,
- "hide_days": 0,
- "hide_seconds": 0,
- "idx": 63,
- "ignore_user_permissions": 0,
- "ignore_xss_filter": 0,
- "in_global_search": 0,
- "in_list_view": 0,
- "in_preview": 0,
- "in_standard_filter": 0,
- "insert_after": "auto_delete_draft_invoice",
- "is_system_generated": 0,
- "is_virtual": 0,
- "label": "Allow user to edit additional discount",
- "length": 0,
- "link_filters": null,
- "mandatory_depends_on": null,
- "modified": "2026-03-01 20:27:20.213935",
- "modified_by": "Administrator",
- "module": "X POS",
- "name": "POS Profile-allow_user_to_edit_additional_discount",
- "no_copy": 0,
- "non_negative": 0,
- "options": null,
- "owner": "Administrator",
- "permlevel": 0,
- "placeholder": null,
- "precision": "",
- "print_hide": 0,
- "print_hide_if_no_value": 0,
- "print_width": null,
- "read_only": 0,
- "read_only_depends_on": null,
- "report_hide": 0,
- "reqd": 0,
- "search_index": 0,
- "show_dashboard": 0,
- "sort_options": 0,
- "translatable": 0,
- "unique": 0,
- "width": null
- },
{
"_assign": null,
"_comments": null,
@@ -3268,7 +2938,7 @@
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
- "insert_after": "allow_print_last_invoice",
+ "insert_after": "section_break_tjwtr",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Default Print Format",
@@ -3762,6 +3432,72 @@
"unique": 0,
"width": null
},
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "alignment": "",
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "button_color": "",
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "creation": "2026-05-28 00:00:00",
+ "default": null,
+ "depends_on": null,
+ "description": "When enabled, the POS terminal creates an unsettled invoice instead of collecting payment. A cashier settles it later from the Cashier screen.",
+ "docstatus": 0,
+ "dt": "POS Profile",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "enable_cashier_settlement",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "use_server_cache",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Enable cashier settlement",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-28 00:00:00",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Profile-enable_cashier_settlement",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
{
"_assign": null,
"_comments": null,
@@ -4950,6 +4686,72 @@
"unique": 0,
"width": null
},
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "alignment": "",
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "button_color": "",
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "creation": "2026-05-28 00:00:00",
+ "default": null,
+ "depends_on": "eval:doc.enable_cashier_settlement",
+ "description": "Print a non-genuine backup receipt for the customer at the terminal. No payment is collected; the genuine invoice is printed by the cashier after settlement.",
+ "docstatus": 0,
+ "dt": "POS Profile",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "print_backup_receipt",
+ "fieldtype": "Check",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 0,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "enable_cashier_settlement",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Print backup receipt",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-05-28 00:00:00",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Profile-print_backup_receipt",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": null,
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
{
"_assign": null,
"_comments": null,
@@ -6304,7 +6106,7 @@
"in_list_view": 0,
"in_preview": 0,
"in_standard_filter": 0,
- "insert_after": "allow_print_draft_invoices",
+ "insert_after": "use_server_cache",
"is_system_generated": 0,
"is_virtual": 0,
"label": "Use delivery charges",
@@ -6608,15 +6410,15 @@
"creation": "2013-05-24 12:15:51",
"custom": 0,
"docstatus": 0,
- "group": "Opening & Closing",
+ "group": "Invoices",
"hidden": 0,
- "idx": 3,
+ "idx": 2,
"is_child_table": 0,
- "link_doctype": "POS Opening Entry",
+ "link_doctype": "POS Invoice",
"link_fieldname": "pos_profile",
- "modified": "2026-03-20 02:15:46.051110",
+ "modified": "2026-06-03 21:25:18.493203",
"modified_by": "Administrator",
- "name": "h643mf6nkv",
+ "name": "lih4lco854",
"owner": "Administrator",
"parent": "POS Profile",
"parent_doctype": null,
@@ -6628,15 +6430,15 @@
"creation": "2013-05-24 12:15:51",
"custom": 0,
"docstatus": 0,
- "group": "Opening & Closing",
+ "group": "Invoices",
"hidden": 0,
- "idx": 4,
+ "idx": 1,
"is_child_table": 0,
- "link_doctype": "POS Closing Entry",
+ "link_doctype": "Sales Invoice",
"link_fieldname": "pos_profile",
- "modified": "2026-03-20 02:15:46.051110",
+ "modified": "2026-06-03 21:25:18.493203",
"modified_by": "Administrator",
- "name": "h64alb60lu",
+ "name": "liha2r2dls",
"owner": "Administrator",
"parent": "POS Profile",
"parent_doctype": null,
@@ -6648,15 +6450,15 @@
"creation": "2013-05-24 12:15:51",
"custom": 0,
"docstatus": 0,
- "group": "Invoices",
+ "group": "Opening & Closing",
"hidden": 0,
- "idx": 2,
+ "idx": 3,
"is_child_table": 0,
- "link_doctype": "POS Invoice",
+ "link_doctype": "POS Opening Entry",
"link_fieldname": "pos_profile",
- "modified": "2026-03-20 02:15:46.051110",
+ "modified": "2026-06-03 21:25:18.493203",
"modified_by": "Administrator",
- "name": "h64dmju8r8",
+ "name": "lihhdnfjiq",
"owner": "Administrator",
"parent": "POS Profile",
"parent_doctype": null,
@@ -6668,15 +6470,15 @@
"creation": "2013-05-24 12:15:51",
"custom": 0,
"docstatus": 0,
- "group": "Invoices",
+ "group": "Opening & Closing",
"hidden": 0,
- "idx": 1,
+ "idx": 4,
"is_child_table": 0,
- "link_doctype": "Sales Invoice",
+ "link_doctype": "POS Closing Entry",
"link_fieldname": "pos_profile",
- "modified": "2026-03-20 02:15:46.051110",
+ "modified": "2026-06-03 21:25:18.493203",
"modified_by": "Administrator",
- "name": "h64go76bk1",
+ "name": "lihk4qqib0",
"owner": "Administrator",
"parent": "POS Profile",
"parent_doctype": null,
@@ -6699,7 +6501,7 @@
"field_name": null,
"idx": 0,
"is_system_generated": 0,
- "modified": "2026-05-23 20:49:15.479365",
+ "modified": "2026-06-05 11:12:56.215878",
"modified_by": "Administrator",
"module": null,
"name": "POS Profile-main-field_order",
@@ -6707,7 +6509,7 @@
"property": "field_order",
"property_type": "Data",
"row_name": null,
- "value": "[\"company\", \"customer\", \"country\", \"disabled\", \"column_break_9\", \"warehouse\", \"company_address\", \"section_break_15\", \"applicable_for_users\", \"section_break_11\", \"payments\", \"section_break_14\", \"hide_images\", \"hide_unavailable_items\", \"auto_add_item_to_cart\", \"validate_stock_on_save\", \"print_receipt_on_order_complete\", \"action_on_new_invoice\", \"column_break_16\", \"update_stock\", \"ignore_pricing_rule\", \"allow_rate_change\", \"allow_discount_change\", \"set_grand_total_to_default_mop\", \"allow_partial_payment\", \"section_break_23\", \"item_groups\", \"column_break_25\", \"customer_groups\", \"section_break_16\", \"print_format\", \"letter_head\", \"column_break0\", \"tc_name\", \"select_print_heading\", \"section_break_19\", \"selling_price_list\", \"currency\", \"write_off_account\", \"write_off_cost_center\", \"write_off_limit\", \"account_for_change_amount\", \"disable_rounded_total\", \"column_break_23\", \"income_account\", \"expense_account\", \"taxes_and_charges\", \"tax_category\", \"apply_discount_on\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"utm_analytics_section\", \"utm_source\", \"column_break_tvls\", \"utm_campaign\", \"column_break_xygw\", \"utm_medium\", \"pos_settings\", \"settings_section\", \"auto_delete_draft_invoice\", \"allow_user_to_edit_additional_discount\", \"allow_change_posting_date\", \"allow_credit_sale\", \"allow_return\", \"allow_return_without_invoice\", \"allow_delete\", \"allow_sales_order\", \"allow_free_batch_return\", \"allow_multi_currency\", \"apply_customer_discount\", \"allow_duplicate_customer_names\", \"tax_inclusive\", \"allow_zero_rated_items\", \"hide_expected_amount\", \"display_additional_notes\", \"allow_write_off_change\", \"hide_variants_items\", \"force_price_from_customer_price_list\", \"column_break_zfglz\", \"use_cashback\", \"use_customer_credit\", \"hide_closing_shift\", \"display_item_code\", \"display_items_in_stock\", \"show_template_items\", \"input_qty\", \"auto_set_batch\", \"search_serial_no\", \"search_batch_no\", \"use_limit_search\", \"force_reload_items\", \"use_server_cache\", \"allow_print_draft_invoices\", \"enable_cashier_settlement\", \"print_backup_receipt\", \"use_delivery_charges\", \"auto_set_delivery_charges\", \"show_customer_balance\", \"column_break_aebm5\", \"max_discount_percentage_allowed\", \"default_view\", \"server_cache_duration\", \"item_search_limit\", \"pos_payments_section\", \"default_pos_expense_account\", \"cash_mode_of_payment\", \"back_office_cash_account\", \"default_source_account\", \"cash_movement_max_amount\", \"allowed_source_accounts\", \"allowed_expense_accounts\", \"column_break_gw92o\", \"allow_pos_expense\", \"allow_source_account_override\", \"require_cash_movement_remarks\", \"allow_delete_cancelled_cash_movement\", \"allow_cancel_submitted_cash_movement\", \"enable_cash_movement\", \"allow_make_new_payments\", \"allow_reconcile_payments\", \"use_pos_payments\", \"allow_cash_deposit\", \"section_break_tjwtr\", \"allow_print_last_invoice\", \"default_print_format\", \"print_format_rules\", \"print_discount_amount\", \"referral_settings\", \"auto_create_referral_for_new_customers\", \"auto_fetch_coupons_gifts\", \"fbr_integration_section\", \"enable_fbr_integration\", \"fbr_environment\", \"fbr_pos_id\", \"column_break_fbr\", \"fbr_bearer_token\", \"fbr_api_url\", \"fbr_skip_ssl_verification\", \"selling\", \"allowed_sales_persons\", \"column_break_pvbpj\", \"use_offline_mode\", \"fetch_items_directly_from_server\", \"block_sale_beyond_available_qty\", \"allow_submissions_in_background_job\", \"allow_delete_offline_invoice\", \"allow_delete_draft_invoices\", \"enable_return_validity\", \"return_validity_days\", \"column_break_tgaeu\", \"purchasing\", \"allow_create_purchase_items\", \"allow_create_purchase_suppliers\", \"allow_purchase_receipt\", \"allow_purchase_order\", \"update_selling_price\", \"update_buying_price\", \"default_purchase_uom\", \"purchase_taxes\", \"column_break_cnwjh\", \"display_authorization_code\"]"
+ "value": "[\"company\", \"customer\", \"country\", \"disabled\", \"column_break_9\", \"warehouse\", \"company_address\", \"section_break_15\", \"applicable_for_users\", \"section_break_11\", \"payments\", \"section_break_14\", \"hide_images\", \"hide_unavailable_items\", \"auto_add_item_to_cart\", \"validate_stock_on_save\", \"print_receipt_on_order_complete\", \"action_on_new_invoice\", \"column_break_16\", \"update_stock\", \"ignore_pricing_rule\", \"allow_rate_change\", \"allow_discount_change\", \"set_grand_total_to_default_mop\", \"allow_partial_payment\", \"section_break_23\", \"item_groups\", \"column_break_25\", \"customer_groups\", \"section_break_16\", \"print_format\", \"letter_head\", \"column_break0\", \"tc_name\", \"select_print_heading\", \"section_break_19\", \"selling_price_list\", \"currency\", \"write_off_account\", \"write_off_cost_center\", \"write_off_limit\", \"account_for_change_amount\", \"disable_rounded_total\", \"column_break_23\", \"income_account\", \"expense_account\", \"taxes_and_charges\", \"tax_category\", \"apply_discount_on\", \"accounting_dimensions_section\", \"cost_center\", \"dimension_col_break\", \"project\", \"utm_analytics_section\", \"utm_source\", \"column_break_tvls\", \"utm_campaign\", \"column_break_xygw\", \"utm_medium\", \"pos_settings\", \"settings_section\", \"auto_delete_draft_invoice\", \"allow_change_posting_date\", \"allow_credit_sale\", \"allow_return\", \"allow_return_without_invoice\", \"allow_delete\", \"allow_sales_order\", \"allow_free_batch_return\", \"allow_multi_currency\", \"apply_customer_discount\", \"allow_duplicate_customer_names\", \"tax_inclusive\", \"allow_zero_rated_items\", \"hide_expected_amount\", \"display_additional_notes\", \"allow_write_off_change\", \"hide_variants_items\", \"force_price_from_customer_price_list\", \"column_break_zfglz\", \"use_cashback\", \"use_customer_credit\", \"hide_closing_shift\", \"display_item_code\", \"display_items_in_stock\", \"show_template_items\", \"input_qty\", \"auto_set_batch\", \"search_serial_no\", \"search_batch_no\", \"use_limit_search\", \"force_reload_items\", \"use_server_cache\", \"enable_cashier_settlement\", \"print_backup_receipt\", \"use_delivery_charges\", \"auto_set_delivery_charges\", \"show_customer_balance\", \"column_break_aebm5\", \"max_discount_percentage_allowed\", \"default_view\", \"server_cache_duration\", \"item_search_limit\", \"pos_payments_section\", \"default_pos_expense_account\", \"cash_mode_of_payment\", \"back_office_cash_account\", \"default_source_account\", \"cash_movement_max_amount\", \"allowed_source_accounts\", \"allowed_expense_accounts\", \"column_break_gw92o\", \"allow_pos_expense\", \"allow_source_account_override\", \"require_cash_movement_remarks\", \"allow_delete_cancelled_cash_movement\", \"allow_cancel_submitted_cash_movement\", \"enable_cash_movement\", \"allow_make_new_payments\", \"allow_reconcile_payments\", \"use_pos_payments\", \"allow_cash_deposit\", \"section_break_tjwtr\", \"default_print_format\", \"print_format_rules\", \"print_discount_amount\", \"referral_settings\", \"auto_create_referral_for_new_customers\", \"auto_fetch_coupons_gifts\", \"fbr_integration_section\", \"enable_fbr_integration\", \"fbr_environment\", \"fbr_pos_id\", \"column_break_fbr\", \"fbr_bearer_token\", \"fbr_api_url\", \"fbr_skip_ssl_verification\", \"selling\", \"allowed_sales_persons\", \"column_break_pvbpj\", \"use_offline_mode\", \"fetch_items_directly_from_server\", \"block_sale_beyond_available_qty\", \"allow_submissions_in_background_job\", \"allow_delete_offline_invoice\", \"allow_delete_draft_invoices\", \"enable_return_validity\", \"return_validity_days\", \"column_break_tgaeu\", \"purchasing\", \"allow_create_purchase_items\", \"allow_create_purchase_suppliers\", \"allow_purchase_receipt\", \"allow_purchase_order\", \"update_selling_price\", \"update_buying_price\", \"default_purchase_uom\", \"purchase_taxes\", \"column_break_cnwjh\", \"display_authorization_code\"]"
},
{
"_assign": null,
@@ -6722,7 +6524,7 @@
"field_name": "utm_analytics_section",
"idx": 0,
"is_system_generated": 1,
- "modified": "2026-05-23 20:49:15.530433",
+ "modified": "2026-06-05 11:12:56.254429",
"modified_by": "Administrator",
"module": null,
"name": "POS Profile-utm_analytics_section-hidden",
diff --git a/xpos/x_pos/custom/pos_profile_user.json b/xpos/x_pos/custom/pos_profile_user.json
old mode 100644
new mode 100755
index e640be9..73bdd50
--- a/xpos/x_pos/custom/pos_profile_user.json
+++ b/xpos/x_pos/custom/pos_profile_user.json
@@ -5,13 +5,147 @@
"_comments": null,
"_liked_by": null,
"_user_tags": null,
+ "alignment": "",
"allow_in_quick_entry": 0,
"allow_on_submit": 0,
"bold": 0,
+ "button_color": "",
"collapsible": 0,
"collapsible_depends_on": null,
"columns": 0,
- "creation": "2026-05-29 00:00:00.000000",
+ "creation": "2026-06-04 14:50:21.803508",
+ "default": null,
+ "depends_on": null,
+ "description": null,
+ "docstatus": 0,
+ "dt": "POS Profile User",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "pos_role",
+ "fieldtype": "Link",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 4,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "discount_limit",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "POS Role",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-06-04 14:50:38.750460",
+ "modified_by": "Administrator",
+ "module": null,
+ "name": "POS Profile User-custom_pos_role",
+ "no_copy": 0,
+ "non_negative": 0,
+ "options": "POS Role",
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "alignment": "",
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "button_color": "",
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "creation": "2026-06-04 00:00:00",
+ "default": "0",
+ "depends_on": null,
+ "description": "Maximum discount percentage this user may apply at POS. Defaults to 100 (no cap).",
+ "docstatus": 0,
+ "dt": "POS Profile User",
+ "fetch_from": null,
+ "fetch_if_empty": 0,
+ "fieldname": "discount_limit",
+ "fieldtype": "Percent",
+ "hidden": 0,
+ "hide_border": 0,
+ "hide_days": 0,
+ "hide_seconds": 0,
+ "idx": 4,
+ "ignore_user_permissions": 0,
+ "ignore_xss_filter": 0,
+ "in_global_search": 0,
+ "in_list_view": 0,
+ "in_preview": 0,
+ "in_standard_filter": 0,
+ "insert_after": "is_cashier",
+ "is_system_generated": 0,
+ "is_virtual": 0,
+ "label": "Discount Limit",
+ "length": 0,
+ "link_filters": null,
+ "mandatory_depends_on": null,
+ "modified": "2026-06-04 00:00:00",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Profile User-discount_limit",
+ "no_copy": 0,
+ "non_negative": 1,
+ "options": null,
+ "owner": "Administrator",
+ "permlevel": 0,
+ "placeholder": null,
+ "precision": "",
+ "print_hide": 0,
+ "print_hide_if_no_value": 0,
+ "print_width": null,
+ "read_only": 0,
+ "read_only_depends_on": null,
+ "report_hide": 0,
+ "reqd": 0,
+ "search_index": 0,
+ "show_dashboard": 0,
+ "sort_options": 0,
+ "translatable": 0,
+ "unique": 0,
+ "width": null
+ },
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "alignment": "",
+ "allow_in_quick_entry": 0,
+ "allow_on_submit": 0,
+ "bold": 0,
+ "button_color": "",
+ "collapsible": 0,
+ "collapsible_depends_on": null,
+ "columns": 0,
+ "creation": "2026-05-29 00:00:00",
"default": "0",
"depends_on": null,
"description": "If checked, this user is a cashier and may open the Cashier screen to settle (close) bills sent from POS terminals.",
@@ -25,7 +159,7 @@
"hide_border": 0,
"hide_days": 0,
"hide_seconds": 0,
- "idx": 0,
+ "idx": 3,
"ignore_user_permissions": 0,
"ignore_xss_filter": 0,
"in_global_search": 0,
@@ -39,7 +173,7 @@
"length": 0,
"link_filters": null,
"mandatory_depends_on": null,
- "modified": "2026-05-29 00:00:00.000000",
+ "modified": "2026-05-29 00:00:00",
"modified_by": "Administrator",
"module": "X POS",
"name": "POS Profile User-is_cashier",
@@ -68,6 +202,30 @@
"custom_perms": [],
"doctype": "POS Profile User",
"links": [],
- "property_setters": [],
+ "property_setters": [
+ {
+ "_assign": null,
+ "_comments": null,
+ "_liked_by": null,
+ "_user_tags": null,
+ "creation": "2026-06-04 14:50:22.013939",
+ "default_value": null,
+ "doc_type": "POS Profile User",
+ "docstatus": 0,
+ "doctype_or_field": "DocType",
+ "field_name": null,
+ "idx": 0,
+ "is_system_generated": 0,
+ "modified": "2026-06-05 11:12:56.512056",
+ "modified_by": "Administrator",
+ "module": null,
+ "name": "POS Profile User-main-field_order",
+ "owner": "Administrator",
+ "property": "field_order",
+ "property_type": "Data",
+ "row_name": null,
+ "value": "[\"default\", \"user\", \"is_cashier\", \"discount_limit\", \"custom_pos_role\"]"
+ }
+ ],
"sync_on_migrate": 1
-}
+}
\ No newline at end of file
diff --git a/xpos/x_pos/doctype/pos_allowed_expense_account/__init__.py b/xpos/x_pos/doctype/pos_allowed_expense_account/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_allowed_expense_account/pos_allowed_expense_account.json b/xpos/x_pos/doctype/pos_allowed_expense_account/pos_allowed_expense_account.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_allowed_expense_account/pos_allowed_expense_account.py b/xpos/x_pos/doctype/pos_allowed_expense_account/pos_allowed_expense_account.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_allowed_source_account/__init__.py b/xpos/x_pos/doctype/pos_allowed_source_account/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_allowed_source_account/pos_allowed_source_account.json b/xpos/x_pos/doctype/pos_allowed_source_account/pos_allowed_source_account.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_allowed_source_account/pos_allowed_source_account.py b/xpos/x_pos/doctype/pos_allowed_source_account/pos_allowed_source_account.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_cash_movement/__init__.py b/xpos/x_pos/doctype/pos_cash_movement/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.js b/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.js
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.json b/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.py b/xpos/x_pos/doctype/pos_cash_movement/pos_cash_movement.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_cash_movement/test_pos_cash_movement.py b/xpos/x_pos/doctype/pos_cash_movement/test_pos_cash_movement.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift/__init__.py b/xpos/x_pos/doctype/pos_closing_shift/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift/pos_closing_shift.json b/xpos/x_pos/doctype/pos_closing_shift/pos_closing_shift.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift/pos_closing_shift.py b/xpos/x_pos/doctype/pos_closing_shift/pos_closing_shift.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift/test_pos_closing_shift.py b/xpos/x_pos/doctype/pos_closing_shift/test_pos_closing_shift.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_detail/__init__.py b/xpos/x_pos/doctype/pos_closing_shift_detail/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_detail/pos_closing_shift_detail.json b/xpos/x_pos/doctype/pos_closing_shift_detail/pos_closing_shift_detail.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py b/xpos/x_pos/doctype/pos_closing_shift_detail/pos_closing_shift_detail.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_taxes/__init__.py b/xpos/x_pos/doctype/pos_closing_shift_taxes/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.json b/xpos/x_pos/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py b/xpos/x_pos/doctype/pos_closing_shift_taxes/pos_closing_shift_taxes.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon/__init__.py b/xpos/x_pos/doctype/pos_coupon/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon/pos_coupon.js b/xpos/x_pos/doctype/pos_coupon/pos_coupon.js
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon/pos_coupon.json b/xpos/x_pos/doctype/pos_coupon/pos_coupon.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon/pos_coupon.py b/xpos/x_pos/doctype/pos_coupon/pos_coupon.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon/test_pos_coupon.py b/xpos/x_pos/doctype/pos_coupon/test_pos_coupon.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon_detail/__init__.py b/xpos/x_pos/doctype/pos_coupon_detail/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon_detail/pos_coupon_detail.json b/xpos/x_pos/doctype/pos_coupon_detail/pos_coupon_detail.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_coupon_detail/pos_coupon_detail.py b/xpos/x_pos/doctype/pos_coupon_detail/pos_coupon_detail.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer/__init__.py b/xpos/x_pos/doctype/pos_offer/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer/pos_offer.js b/xpos/x_pos/doctype/pos_offer/pos_offer.js
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer/pos_offer.json b/xpos/x_pos/doctype/pos_offer/pos_offer.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer/pos_offer.py b/xpos/x_pos/doctype/pos_offer/pos_offer.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer/test_pos_offer.py b/xpos/x_pos/doctype/pos_offer/test_pos_offer.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer_detail/__init__.py b/xpos/x_pos/doctype/pos_offer_detail/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer_detail/pos_offer_detail.json b/xpos/x_pos/doctype/pos_offer_detail/pos_offer_detail.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_offer_detail/pos_offer_detail.py b/xpos/x_pos/doctype/pos_offer_detail/pos_offer_detail.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift/__init__.py b/xpos/x_pos/doctype/pos_opening_shift/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.js b/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.js
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.json b/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.py b/xpos/x_pos/doctype/pos_opening_shift/pos_opening_shift.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift/test_pos_opening_shift.py b/xpos/x_pos/doctype/pos_opening_shift/test_pos_opening_shift.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift_detail/__init__.py b/xpos/x_pos/doctype/pos_opening_shift_detail/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift_detail/pos_opening_shift_detail.json b/xpos/x_pos/doctype/pos_opening_shift_detail/pos_opening_shift_detail.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py b/xpos/x_pos/doctype/pos_opening_shift_detail/pos_opening_shift_detail.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_payment_entry_reference/__init__.py b/xpos/x_pos/doctype/pos_payment_entry_reference/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_payment_entry_reference/pos_payment_entry_reference.json b/xpos/x_pos/doctype/pos_payment_entry_reference/pos_payment_entry_reference.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py b/xpos/x_pos/doctype/pos_payment_entry_reference/pos_payment_entry_reference.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_permission/__init__.py b/xpos/x_pos/doctype/pos_permission/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/xpos/x_pos/doctype/pos_permission/pos_permission.js b/xpos/x_pos/doctype/pos_permission/pos_permission.js
new file mode 100755
index 0000000..79335d4
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_permission/pos_permission.js
@@ -0,0 +1,8 @@
+// Copyright (c) 2026, Ali Raza and contributors
+// For license information, please see license.txt
+
+// frappe.ui.form.on("POS Permission", {
+// refresh(frm) {
+
+// },
+// });
diff --git a/xpos/x_pos/doctype/pos_permission/pos_permission.json b/xpos/x_pos/doctype/pos_permission/pos_permission.json
new file mode 100755
index 0000000..2eb4fe5
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_permission/pos_permission.json
@@ -0,0 +1,67 @@
+{
+ "actions": [],
+ "autoname": "field:permission_name",
+ "creation": "2026-06-04 14:56:10.555707",
+ "doctype": "DocType",
+ "document_type": "Setup",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "permission_name",
+ "column_break_acbh",
+ "permission_label"
+ ],
+ "fields": [
+ {
+ "fieldname": "permission_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Permission Name",
+ "read_only": 1,
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "column_break_acbh",
+ "fieldtype": "Column Break"
+ },
+ {
+ "fieldname": "permission_label",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Permission Label",
+ "read_only": 1,
+ "reqd": 1
+ }
+ ],
+ "in_create": 1,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-06-04 15:34:55.405210",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Permission",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "read_only": 1,
+ "row_format": "Dynamic",
+ "sort_field": "modified",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "permission_label",
+ "track_changes": 1
+}
diff --git a/xpos/x_pos/doctype/pos_permission/pos_permission.py b/xpos/x_pos/doctype/pos_permission/pos_permission.py
new file mode 100755
index 0000000..581447a
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_permission/pos_permission.py
@@ -0,0 +1,27 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+from frappe.model.document import Document
+
+from xpos.api.auth import clear_role_permission_cache
+
+
+class POSPermission(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ permission_label: DF.Data
+ permission_name: DF.Data
+ # end: auto-generated types
+
+ def on_update(self):
+ # The catalog changed — every cached role map may be stale.
+ clear_role_permission_cache()
+
+ def on_trash(self):
+ clear_role_permission_cache()
diff --git a/xpos/x_pos/doctype/pos_permission/test_pos_permission.py b/xpos/x_pos/doctype/pos_permission/test_pos_permission.py
new file mode 100755
index 0000000..c1336bb
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_permission/test_pos_permission.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2026, Ali Raza and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+
+class IntegrationTestPOSPermission(IntegrationTestCase):
+ """
+ Integration tests for POSPermission.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/xpos/x_pos/doctype/pos_print_format_rule/__init__.py b/xpos/x_pos/doctype/pos_print_format_rule/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_print_format_rule/pos_print_format_rule.json b/xpos/x_pos/doctype/pos_print_format_rule/pos_print_format_rule.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_print_format_rule/pos_print_format_rule.py b/xpos/x_pos/doctype/pos_print_format_rule/pos_print_format_rule.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_purchase_taxes/__init__.py b/xpos/x_pos/doctype/pos_purchase_taxes/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_purchase_taxes/pos_purchase_taxes.json b/xpos/x_pos/doctype/pos_purchase_taxes/pos_purchase_taxes.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_purchase_taxes/pos_purchase_taxes.py b/xpos/x_pos/doctype/pos_purchase_taxes/pos_purchase_taxes.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_role/__init__.py b/xpos/x_pos/doctype/pos_role/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/xpos/x_pos/doctype/pos_role/pos_role.js b/xpos/x_pos/doctype/pos_role/pos_role.js
new file mode 100755
index 0000000..1911826
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role/pos_role.js
@@ -0,0 +1,139 @@
+// Copyright (c) 2026, Ali Raza and contributors
+// For license information, please see license.txt
+//
+// Renders a grouped checkbox matrix into the `permissions_html` field, bound to
+// the `permissions` child table, and hides the raw child grid.
+
+const POS_PERMISSION_GROUPS = [
+ {
+ title: "Billing & Invoicing",
+ items: [
+ { key: "close_bill", label: "Close Bill" },
+ { key: "close_shift", label: "Close Shift" },
+ { key: "allow_reprint_invoice", label: "Reprint Invoice" },
+ { key: "shift_report", label: "Shift Report" },
+ { key: "allow_cancel_invoice", label: "Cancel Invoice" },
+ { key: "unsettled_invoices", label: "Unsettled Invoices" },
+ ],
+ },
+ {
+ title: "Discounts & Pricing",
+ items: [
+ { key: "apply_additional_discount", label: "Apply Additional Discount" },
+ { key: "apply_standard_discount", label: "Apply Standard Discount" },
+ { key: "show_edit_discount_field", label: "Edit Discount Field" },
+ { key: "edit_tax_template", label: "Edit Tax Template" },
+ { key: "allow_change_price", label: "Change Price" },
+ ],
+ },
+ {
+ title: "Sales Operations",
+ items: [
+ { key: "quotation", label: "Quotation" },
+ { key: "sale_return", label: "Sale Return" },
+ ],
+ },
+ {
+ title: "Purchasing & Stock",
+ items: [
+ { key: "local_purchase", label: "Local Purchase" },
+ { key: "purchase_order", label: "Purchase Order" },
+ { key: "purchase_invoice", label: "Purchase Invoice" },
+ { key: "stock_adjustment", label: "Stock Adjustment" },
+ { key: "stock_entry", label: "Stock Entry" },
+ { key: "near_expiry_items", label: "Near Expiry Items" },
+ ],
+ },
+ {
+ title: "Cash Management",
+ items: [
+ { key: "expense", label: "Expense" },
+ { key: "bank_drop", label: "Bank Drop" },
+ ],
+ },
+ {
+ title: "Lists",
+ items: [
+ { key: "list_of_invoices", label: "List of Invoices" },
+ { key: "list_of_cancelled_invoices", label: "List of Cancelled Invoices" },
+ { key: "list_of_errors", label: "List of Errors" },
+ { key: "list_of_purchase_invoices", label: "List of Purchase Invoices" },
+ { key: "list_of_quotations", label: "List of Quotations" },
+ { key: "list_of_stock_entries", label: "List of Stock Entries" },
+ { key: "list_of_local_purchases", label: "List of Local Purchases" },
+ { key: "list_of_stock_adjustments", label: "List of Stock Adjustments" },
+ { key: "list_of_expense", label: "List of Expenses" },
+ { key: "list_of_bank_drops", label: "List of Bank Drops" },
+ ],
+ },
+ {
+ title: "Reports",
+ items: [
+ { key: "invoice_settlement_report", label: "Invoice Settlement Report" },
+ { key: "sales_report_by_time", label: "Sales Report by Time" },
+ { key: "sales_summary_by_hour", label: "Sales Summary by Hour" },
+ { key: "current_stock_by_brand", label: "Current Stock by Brand" },
+ { key: "stock_register", label: "Stock Register" },
+ { key: "current_stock_report", label: "Current Stock Report" },
+ ],
+ },
+];
+
+frappe.ui.form.on("POS Role", {
+ refresh(frm) {
+ // The matrix is the editing surface — hide the raw child grid.
+ frm.set_df_property("permissions", "hidden", 1);
+ render_permission_matrix(frm);
+ },
+});
+
+function get_perm_row(frm, key) {
+ return (frm.doc.permissions || []).find((row) => row.permission === key);
+}
+
+function set_permission(frm, key, enabled) {
+ let row = get_perm_row(frm, key);
+ if (!row) {
+ row = frm.add_child("permissions", { permission: key, enabled: enabled ? 1 : 0 });
+ } else {
+ row.enabled = enabled ? 1 : 0;
+ }
+ frm.dirty();
+}
+
+function render_permission_matrix(frm) {
+ const wrapper = frm.get_field("permissions_html").$wrapper;
+ wrapper.empty();
+
+ const $container = $('
');
+
+ POS_PERMISSION_GROUPS.forEach((group) => {
+ const $group = $(`
+
+
+ ${frappe.utils.escape_html(__(group.title))}
+
+
+ `);
+
+ group.items.forEach((item) => {
+ const row = get_perm_row(frm, item.key);
+ const checked = row && cint(row.enabled) ? "checked" : "";
+ const $item = $(`
+
+
+ ${frappe.utils.escape_html(__(item.label))}
+
+ `);
+ $item.find("input").on("change", function () {
+ set_permission(frm, item.key, this.checked);
+ });
+ $group.append($item);
+ });
+
+ $container.append($group);
+ });
+
+ wrapper.append($container);
+}
diff --git a/xpos/x_pos/doctype/pos_role/pos_role.json b/xpos/x_pos/doctype/pos_role/pos_role.json
new file mode 100755
index 0000000..a392bb9
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role/pos_role.json
@@ -0,0 +1,74 @@
+{
+ "actions": [],
+ "autoname": "field:role_name",
+ "creation": "2026-06-04 15:07:55.706446",
+ "doctype": "DocType",
+ "engine": "InnoDB",
+ "field_order": [
+ "role_name",
+ "section_break_vgze",
+ "permissions_html",
+ "section_break_chcf",
+ "permissions"
+ ],
+ "fields": [
+ {
+ "fieldname": "role_name",
+ "fieldtype": "Data",
+ "in_list_view": 1,
+ "label": "Role Name",
+ "reqd": 1,
+ "unique": 1
+ },
+ {
+ "fieldname": "section_break_vgze",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "permissions_html",
+ "fieldtype": "HTML",
+ "label": "Permissions HTML"
+ },
+ {
+ "fieldname": "section_break_chcf",
+ "fieldtype": "Section Break"
+ },
+ {
+ "fieldname": "permissions",
+ "fieldtype": "Table",
+ "label": "Permissions",
+ "options": "POS Role Permission"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "links": [],
+ "modified": "2026-06-04 15:30:11.437117",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Role",
+ "naming_rule": "By fieldname",
+ "owner": "Administrator",
+ "permissions": [
+ {
+ "create": 1,
+ "delete": 1,
+ "email": 1,
+ "export": 1,
+ "print": 1,
+ "read": 1,
+ "report": 1,
+ "role": "System Manager",
+ "share": 1,
+ "write": 1
+ }
+ ],
+ "quick_entry": 1,
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": [],
+ "title_field": "role_name",
+ "track_changes": 1
+}
diff --git a/xpos/x_pos/doctype/pos_role/pos_role.py b/xpos/x_pos/doctype/pos_role/pos_role.py
new file mode 100755
index 0000000..22e8be6
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role/pos_role.py
@@ -0,0 +1,51 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+import frappe
+from frappe.model.document import Document
+
+from xpos.api.auth import clear_role_permission_cache
+
+
+class POSRole(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+ from xpos.x_pos.doctype.pos_role_permission.pos_role_permission import POSRolePermission
+
+ permissions: DF.Table[POSRolePermission]
+ role_name: DF.Data
+ # end: auto-generated types
+
+ def validate(self):
+ self._sync_permissions_with_catalog()
+
+ def on_update(self):
+ clear_role_permission_cache(self.name)
+
+ def on_trash(self):
+ clear_role_permission_cache(self.name)
+
+ def _sync_permissions_with_catalog(self):
+ """Keep the child table aligned with the POS Permission catalog.
+
+ Adds a disabled row for every catalog permission missing from this role
+ and drops rows whose permission no longer exists, preserving the enabled
+ flag of existing rows.
+ """
+ catalog = set(frappe.get_all("POS Permission", pluck="name"))
+
+ kept = []
+ seen = set()
+ for row in self.permissions:
+ if row.permission in catalog and row.permission not in seen:
+ kept.append(row)
+ seen.add(row.permission)
+ self.set("permissions", kept)
+
+ for permission in catalog - seen:
+ self.append("permissions", {"permission": permission, "enabled": 0})
diff --git a/xpos/x_pos/doctype/pos_role/test_pos_role.py b/xpos/x_pos/doctype/pos_role/test_pos_role.py
new file mode 100755
index 0000000..708d162
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role/test_pos_role.py
@@ -0,0 +1,22 @@
+# Copyright (c) 2026, Ali Raza and Contributors
+# See license.txt
+
+# import frappe
+from frappe.tests import IntegrationTestCase
+
+
+# On IntegrationTestCase, the doctype test records and all
+# link-field test record dependencies are recursively loaded
+# Use these module variables to add/remove to/from that list
+EXTRA_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+IGNORE_TEST_RECORD_DEPENDENCIES = [] # eg. ["User"]
+
+
+
+class IntegrationTestPOSRole(IntegrationTestCase):
+ """
+ Integration tests for POSRole.
+ Use this class for testing interactions between multiple components.
+ """
+
+ pass
diff --git a/xpos/x_pos/doctype/pos_role_permission/__init__.py b/xpos/x_pos/doctype/pos_role_permission/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.json b/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.json
new file mode 100755
index 0000000..0c181a5
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.json
@@ -0,0 +1,48 @@
+{
+ "actions": [],
+ "allow_rename": 1,
+ "creation": "2026-06-04 15:28:00.546961",
+ "doctype": "DocType",
+ "editable_grid": 1,
+ "engine": "InnoDB",
+ "field_order": [
+ "permission",
+ "column_break_qopc",
+ "enabled"
+ ],
+ "fields": [
+ {
+ "fieldname": "permission",
+ "fieldtype": "Link",
+ "in_list_view": 1,
+ "label": "Permission",
+ "options": "POS Permission"
+ },
+ {
+ "fieldname": "column_break_qopc",
+ "fieldtype": "Column Break"
+ },
+ {
+ "default": "0",
+ "fieldname": "enabled",
+ "fieldtype": "Check",
+ "in_list_view": 1,
+ "label": "Enabled"
+ }
+ ],
+ "grid_page_length": 50,
+ "index_web_pages_for_search": 1,
+ "istable": 1,
+ "links": [],
+ "modified": "2026-06-04 15:30:44.732969",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Role Permission",
+ "owner": "Administrator",
+ "permissions": [],
+ "row_format": "Dynamic",
+ "rows_threshold_for_grid_search": 20,
+ "sort_field": "creation",
+ "sort_order": "DESC",
+ "states": []
+}
diff --git a/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.py b/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.py
new file mode 100755
index 0000000..96a8968
--- /dev/null
+++ b/xpos/x_pos/doctype/pos_role_permission/pos_role_permission.py
@@ -0,0 +1,24 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+# import frappe
+from frappe.model.document import Document
+
+
+class POSRolePermission(Document):
+ # begin: auto-generated types
+ # This code is auto-generated. Do not modify anything in this block.
+
+ from typing import TYPE_CHECKING
+
+ if TYPE_CHECKING:
+ from frappe.types import DF
+
+ enabled: DF.Check
+ parent: DF.Data
+ parentfield: DF.Data
+ parenttype: DF.Data
+ permission: DF.Link | None
+ # end: auto-generated types
+
+ pass
diff --git a/xpos/x_pos/doctype/pos_sales_person_filter/__init__.py b/xpos/x_pos/doctype/pos_sales_person_filter/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_sales_person_filter/pos_sales_person_filter.json b/xpos/x_pos/doctype/pos_sales_person_filter/pos_sales_person_filter.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/doctype/pos_sales_person_filter/pos_sales_person_filter.py b/xpos/x_pos/doctype/pos_sales_person_filter/pos_sales_person_filter.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/integrations/__init__.py b/xpos/x_pos/integrations/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/integrations/fbr.py b/xpos/x_pos/integrations/fbr.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/report/pos_shift_reconciliation/__init__.py b/xpos/x_pos/report/pos_shift_reconciliation/__init__.py
new file mode 100755
index 0000000..e69de29
diff --git a/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.js b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.js
new file mode 100755
index 0000000..43c2b8d
--- /dev/null
+++ b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.js
@@ -0,0 +1,38 @@
+// Copyright (c) 2026, Ali Raza and contributors
+// For license information, please see license.txt
+
+frappe.query_reports["POS Shift Reconciliation"] = {
+ filters: [
+ {
+ fieldname: "pos_opening_shift",
+ label: __("Opening Shift"),
+ fieldtype: "Link",
+ options: "POS Opening Shift",
+ },
+ {
+ fieldname: "company",
+ label: __("Company"),
+ fieldtype: "Link",
+ options: "Company",
+ default: frappe.defaults.get_default("company"),
+ },
+ {
+ fieldname: "pos_profile",
+ label: __("POS Profile"),
+ fieldtype: "Link",
+ options: "POS Profile",
+ },
+ {
+ fieldname: "from_date",
+ label: __("From Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.get_today(),
+ },
+ {
+ fieldname: "to_date",
+ label: __("To Date"),
+ fieldtype: "Date",
+ default: frappe.datetime.get_today(),
+ },
+ ],
+};
diff --git a/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.json b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.json
new file mode 100755
index 0000000..fe27851
--- /dev/null
+++ b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.json
@@ -0,0 +1,34 @@
+{
+ "add_total_row": 1,
+ "add_translate_data": 0,
+ "columns": [],
+ "creation": "2026-06-03 00:00:00.000000",
+ "disabled": 0,
+ "docstatus": 0,
+ "doctype": "Report",
+ "filters": [],
+ "idx": 0,
+ "is_standard": "Yes",
+ "letter_head": null,
+ "modified": "2026-06-03 00:00:00.000000",
+ "modified_by": "Administrator",
+ "module": "X POS",
+ "name": "POS Shift Reconciliation",
+ "owner": "Administrator",
+ "prepared_report": 0,
+ "ref_doctype": "POS Opening Shift",
+ "report_name": "POS Shift Reconciliation",
+ "report_type": "Script Report",
+ "roles": [
+ {
+ "role": "System Manager"
+ },
+ {
+ "role": "Sales Manager"
+ },
+ {
+ "role": "Sales User"
+ }
+ ],
+ "timeout": 0
+}
diff --git a/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.py b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.py
new file mode 100755
index 0000000..3f83043
--- /dev/null
+++ b/xpos/x_pos/report/pos_shift_reconciliation/pos_shift_reconciliation.py
@@ -0,0 +1,140 @@
+# Copyright (c) 2026, Ali Raza and contributors
+# For license information, please see license.txt
+
+"""Per-shift reconciliation (Z-report).
+
+Returns the items sold during a POS shift (qty + amount) as the main table, and
+the totals by payment mode (plus headline figures) as the report summary, so a
+supervisor can reconcile the till at end of shift.
+"""
+
+import frappe
+from frappe import _
+from frappe.utils import flt
+
+from xpos.api.utilities import get_invoice_type
+
+
+def execute(filters=None):
+ filters = frappe.parse_json(filters) if filters else {}
+ doctype = get_invoice_type()
+ invoices = _get_invoices(filters, doctype)
+ return get_columns(), _get_items_sold(invoices, doctype), None, None, _get_report_summary(invoices, doctype)
+
+
+def _get_invoices(filters, doctype):
+ conditions = {"docstatus": 1, "is_pos": 1}
+ if doctype == "POS Invoice":
+ conditions["consolidated_invoice"] = ["in", ["", None]]
+
+ if filters.get("pos_opening_shift"):
+ conditions["pos_opening_shift"] = filters["pos_opening_shift"]
+ else:
+ if filters.get("company"):
+ conditions["company"] = filters["company"]
+ if filters.get("pos_profile"):
+ conditions["pos_profile"] = filters["pos_profile"]
+ if filters.get("from_date") and filters.get("to_date"):
+ conditions["posting_date"] = ["between", [filters["from_date"], filters["to_date"]]]
+
+ return frappe.get_all(
+ doctype,
+ filters=conditions,
+ fields=["name", "grand_total", "net_total", "change_amount", "is_return"],
+ )
+
+
+def _get_items_sold(invoices, doctype):
+ names = [inv["name"] for inv in invoices]
+ if not names:
+ return []
+
+ item_table = f"`tab{doctype} Item`"
+ return frappe.db.sql(
+ f"""
+ SELECT
+ item_code,
+ item_name,
+ uom,
+ ROUND(SUM(qty), 3) AS qty,
+ ROUND(SUM(amount), 2) AS amount
+ FROM {item_table}
+ WHERE parent IN %(names)s AND parenttype = %(dt)s
+ GROUP BY item_code
+ ORDER BY item_name ASC
+ """,
+ {"names": names, "dt": doctype},
+ as_dict=True,
+ )
+
+
+def _get_report_summary(invoices, doctype):
+ # Totals by payment mode, mirroring xpos.api.shifts.get_shift_summary so the
+ # report and the closing dialog always agree (change is distributed across
+ # the modes used on each invoice).
+ payment_summary = {}
+ for inv in invoices:
+ payments = frappe.get_all(
+ "Sales Invoice Payment",
+ filters={"parent": inv["name"], "parenttype": doctype},
+ fields=["mode_of_payment", "amount"],
+ )
+ inv_change = flt(inv.get("change_amount"))
+ inv_paid = sum(flt(p["amount"]) for p in payments)
+ for p in payments:
+ pay_amount = flt(p["amount"])
+ if inv_paid > 0 and inv_change > 0:
+ pay_amount -= pay_amount / inv_paid * inv_change
+ mode = p["mode_of_payment"]
+ payment_summary[mode] = payment_summary.get(mode, 0) + pay_amount
+
+ grand_total = sum(flt(inv.get("grand_total")) for inv in invoices)
+ returns_count = sum(1 for inv in invoices if inv.get("is_return"))
+
+ summary = [
+ {"label": _("Total Invoices"), "value": len(invoices), "indicator": "Blue"},
+ {"label": _("Returns"), "value": returns_count, "indicator": "Red" if returns_count else "Grey"},
+ {"label": _("Grand Total"), "value": flt(grand_total, 2), "datatype": "Currency", "indicator": "Green"},
+ ]
+ for mode, amount in sorted(payment_summary.items()):
+ summary.append(
+ {"label": mode, "value": flt(amount, 2), "datatype": "Currency", "indicator": "Green"}
+ )
+ return summary
+
+
+def get_columns():
+ return [
+ {
+ "label": _("Item Code"),
+ "fieldname": "item_code",
+ "fieldtype": "Link",
+ "options": "Item",
+ "width": 180,
+ },
+ {
+ "label": _("Item Name"),
+ "fieldname": "item_name",
+ "fieldtype": "Data",
+ "width": 260,
+ },
+ {
+ "label": _("UOM"),
+ "fieldname": "uom",
+ "fieldtype": "Link",
+ "options": "UOM",
+ "width": 90,
+ },
+ {
+ "label": _("Qty Sold"),
+ "fieldname": "qty",
+ "fieldtype": "Float",
+ "width": 110,
+ },
+ {
+ "label": _("Amount"),
+ "fieldname": "amount",
+ "fieldtype": "Currency",
+ "width": 140,
+ },
+ ]
diff --git a/xpos/x_pos/report/stock_audit_report/__init__.py b/xpos/x_pos/report/stock_audit_report/__init__.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/report/stock_audit_report/stock_audit_report.js b/xpos/x_pos/report/stock_audit_report/stock_audit_report.js
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/report/stock_audit_report/stock_audit_report.json b/xpos/x_pos/report/stock_audit_report/stock_audit_report.json
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/report/stock_audit_report/stock_audit_report.py b/xpos/x_pos/report/stock_audit_report/stock_audit_report.py
old mode 100644
new mode 100755
diff --git a/xpos/x_pos/workspace/x_pos/x_pos.json b/xpos/x_pos/workspace/x_pos/x_pos.json
index c9f81ad..a59eb52 100755
--- a/xpos/x_pos/workspace/x_pos/x_pos.json
+++ b/xpos/x_pos/workspace/x_pos/x_pos.json
@@ -1,6 +1,7 @@
{
+ "app": "xpos",
"charts": [],
- "content": "[{\"id\":\"xpos_header\",\"type\":\"header\",\"data\":{\"text\":\"
X POS \",\"col\":12}},{\"id\":\"xpos_shortcut\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"X POS App\",\"col\":3}},{\"id\":\"bExEIm8hyI\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Invoice\",\"col\":3}},{\"id\":\"trazJ5ah1v\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Invoice\",\"col\":3}},{\"id\":\"xpos_spacer\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"1adNPVupDN\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"kCl3eJ9h7e\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"UgL618YZqd\",\"type\":\"card\",\"data\":{\"card_name\":\"Cash Movement\",\"col\":4}}]",
+ "content": "[{\"id\":\"xpos_header\",\"type\":\"header\",\"data\":{\"text\":\"
X POS \",\"col\":12}},{\"id\":\"xpos_shortcut\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"X POS App\",\"col\":3}},{\"id\":\"bExEIm8hyI\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Sales Invoice\",\"col\":3}},{\"id\":\"trazJ5ah1v\",\"type\":\"shortcut\",\"data\":{\"shortcut_name\":\"Pos Invoice\",\"col\":3}},{\"id\":\"xpos_spacer\",\"type\":\"spacer\",\"data\":{\"col\":12}},{\"id\":\"1adNPVupDN\",\"type\":\"card\",\"data\":{\"card_name\":\"Setup\",\"col\":4}},{\"id\":\"kCl3eJ9h7e\",\"type\":\"card\",\"data\":{\"card_name\":\"Offers & Coupons\",\"col\":4}},{\"id\":\"UgL618YZqd\",\"type\":\"card\",\"data\":{\"card_name\":\"Cash Movement\",\"col\":4}},{\"id\":\"3-RdUJ4vti\",\"type\":\"card\",\"data\":{\"card_name\":\"Reports\",\"col\":4}}]",
"creation": "2026-02-27 00:00:00",
"custom_blocks": [],
"docstatus": 0,
@@ -80,11 +81,194 @@
"onboard": 0,
"type": "Link"
},
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Reports",
+ "link_count": 16,
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Card Break"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "POS Shift Reconciliation",
+ "link_count": 0,
+ "link_to": "POS Shift Reconciliation",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 1,
+ "label": "Branch Item Summary",
+ "link_count": 0,
+ "link_to": "Branch Item Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Branch Set Summary",
+ "link_count": 0,
+ "link_to": "Branch Set Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Stock Entry",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Current Stock By Brand",
+ "link_count": 0,
+ "link_to": "Current Stock By Brand",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Item",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Current Stock Report",
+ "link_count": 0,
+ "link_to": "Current Stock Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Item",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Current Stock Summary",
+ "link_count": 0,
+ "link_to": "Current Stock Summary",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Item",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Current Stock with Levels",
+ "link_count": 0,
+ "link_to": "Current Stock with Levels",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Bin",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Dead Stock Report",
+ "link_count": 0,
+ "link_to": "Dead Stock Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Stock Ledger Entry",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Low Qty Sales Report",
+ "link_count": 0,
+ "link_to": "Low Qty Sales Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Sales Invoice",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Purchase Order Report",
+ "link_count": 0,
+ "link_to": "Purchase Order Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Bin",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Slow Fast Moving Items",
+ "link_count": 0,
+ "link_to": "Slow Fast Moving Items",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Sales Invoice",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Stock Audit Report",
+ "link_count": 0,
+ "link_to": "Stock Audit Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Item",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Stock Value by Warehouse",
+ "link_count": 0,
+ "link_to": "Stock Value by Warehouse",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Bin",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Stock Value Summary by Date",
+ "link_count": 0,
+ "link_to": "Stock Value Summary by Date",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Stock Ledger Entry",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Warehouse Stock Movement",
+ "link_count": 0,
+ "link_to": "Warehouse Stock Movement",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Stock Ledger Entry",
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Zero Qty Sales Report",
+ "link_count": 0,
+ "link_to": "Zero Qty Sales Report",
+ "link_type": "Report",
+ "onboard": 0,
+ "report_ref_doctype": "Sales Invoice",
+ "type": "Link"
+ },
{
"hidden": 0,
"is_query_report": 0,
"label": "Setup",
- "link_count": 5,
+ "link_count": 6,
"link_type": "DocType",
"onboard": 0,
"type": "Card Break"
@@ -92,9 +276,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Profile",
+ "label": "Delivery Charges",
"link_count": 0,
- "link_to": "POS Profile",
+ "link_to": "Delivery Charges",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -102,9 +286,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "Scale Barcode Settings",
+ "label": "POS Closing Shift",
"link_count": 0,
- "link_to": "Scale Barcode Settings",
+ "link_to": "POS Closing Shift",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -122,9 +306,9 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "POS Closing Shift",
+ "label": "POS Profile",
"link_count": 0,
- "link_to": "POS Closing Shift",
+ "link_to": "POS Profile",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
@@ -132,15 +316,25 @@
{
"hidden": 0,
"is_query_report": 0,
- "label": "Delivery Charges",
+ "label": "POS Role",
"link_count": 0,
- "link_to": "Delivery Charges",
+ "link_to": "POS Role",
+ "link_type": "DocType",
+ "onboard": 0,
+ "type": "Link"
+ },
+ {
+ "hidden": 0,
+ "is_query_report": 0,
+ "label": "Scale Barcode Settings",
+ "link_count": 0,
+ "link_to": "Scale Barcode Settings",
"link_type": "DocType",
"onboard": 0,
"type": "Link"
}
],
- "modified": "2026-04-08 17:25:36.344674",
+ "modified": "2026-06-06 12:12:41.123126",
"modified_by": "Administrator",
"module": "X POS",
"name": "X POS",
@@ -177,5 +371,6 @@
"type": "DocType"
}
],
- "title": "X POS"
-}
\ No newline at end of file
+ "title": "X POS",
+ "type": "Workspace"
+}