diff --git a/POS/components.d.ts b/POS/components.d.ts index 1f2e8b35f..deae0d619 100644 --- a/POS/components.d.ts +++ b/POS/components.d.ts @@ -17,7 +17,9 @@ declare module 'vue' { CouponDialog: typeof import('./src/components/sale/CouponDialog.vue')['default'] CouponManagement: typeof import('./src/components/sale/CouponManagement.vue')['default'] CreateCustomerDialog: typeof import('./src/components/sale/CreateCustomerDialog.vue')['default'] + CreditSalesSummaryDialog: typeof import('./src/components/customers/CreditSalesSummaryDialog.vue')['default'] CustomerDialog: typeof import('./src/components/sale/CustomerDialog.vue')['default'] + CustomerDuesDialog: typeof import('./src/components/customers/CustomerDuesDialog.vue')['default'] DraftInvoicesDialog: typeof import('./src/components/sale/DraftInvoicesDialog.vue')['default'] EditItemDialog: typeof import('./src/components/sale/EditItemDialog.vue')['default'] InstallAppBadge: typeof import('./src/components/common/InstallAppBadge.vue')['default'] diff --git a/POS/src/components/customers/CreditSalesSummaryDialog.vue b/POS/src/components/customers/CreditSalesSummaryDialog.vue new file mode 100644 index 000000000..fb138fde8 --- /dev/null +++ b/POS/src/components/customers/CreditSalesSummaryDialog.vue @@ -0,0 +1,238 @@ + + + + + diff --git a/POS/src/components/customers/CustomerDuesDialog.vue b/POS/src/components/customers/CustomerDuesDialog.vue new file mode 100644 index 000000000..41c8cf425 --- /dev/null +++ b/POS/src/components/customers/CustomerDuesDialog.vue @@ -0,0 +1,690 @@ + + + + + diff --git a/POS/src/components/pos/ManagementSlider.vue b/POS/src/components/pos/ManagementSlider.vue index 04896fb21..ae9379955 100644 --- a/POS/src/components/pos/ManagementSlider.vue +++ b/POS/src/components/pos/ManagementSlider.vue @@ -60,6 +60,25 @@ + + +
diff --git a/POS/src/composables/useDialogSubmit.js b/POS/src/composables/useDialogSubmit.js new file mode 100644 index 000000000..323d5890c --- /dev/null +++ b/POS/src/composables/useDialogSubmit.js @@ -0,0 +1,125 @@ +import { onBeforeUnmount, unref, watch } from "vue" + +/** + * Consistent keyboard confirm for dialogs. + * + * Wires a window-level `keydown` listener while a dialog is open and invokes the + * dialog's primary action on **Ctrl/Cmd+S** (universal) and optionally **Enter**. + * + * Why a module-level stack: dialogs can stack (CustomerDialog → CreateCustomerDialog, + * PartialPayments → PaymentDialog). Only the top-most open instance should react to a + * keypress, otherwise a parent dialog would submit at the same time as its child. + * This is intentionally independent of `useDialogState` (which has no ordering). + * + * @example + * useDialogSubmit({ + * isOpen: show, + * onSubmit: handleCreate, + * canSubmit: () => !!customerData.customer_name && hasPermission, + * enter: true, // default + * ctrlS: true, // default + * }) + */ + +// Module-level ordered stack of open dialog instance ids (last = top-most). +const openDialogStack = [] +let nextInstanceId = 0 + +// Registry of instance id → its keydown config, so the single shared listener can +// dispatch to whichever instance is currently on top of the stack. +const instances = new Map() + +let listenerAttached = false + +function handleKeydown(event) { + if (openDialogStack.length === 0) return + + const topId = openDialogStack[openDialogStack.length - 1] + const instance = instances.get(topId) + if (!instance) return + + const { onSubmit, canSubmit, enter, ctrlS } = instance + + const isCtrlS = + (event.ctrlKey || event.metaKey) && (event.key === "s" || event.key === "S") + const isEnter = event.key === "Enter" + + const allowed = () => !canSubmit || canSubmit() + + if (isCtrlS) { + // Always suppress the browser "Save page" dialog, regardless of guard. + event.preventDefault() + if (ctrlS && allowed()) { + onSubmit() + } + return + } + + if (isEnter) { + if (!enter) return + // Leave textarea newlines and any local @keydown.enter handler alone. + const target = event.target + if (target && target.tagName === "TEXTAREA") return + if (event.defaultPrevented) return + if (allowed()) { + onSubmit() + } + } +} + +function attachListener() { + if (listenerAttached) return + window.addEventListener("keydown", handleKeydown, true) + listenerAttached = true +} + +function detachListener() { + if (!listenerAttached) return + window.removeEventListener("keydown", handleKeydown, true) + listenerAttached = false +} + +export function useDialogSubmit(options) { + const { + isOpen, + onSubmit, + canSubmit = null, + enter = true, + ctrlS = true, + } = options + + const id = nextInstanceId++ + + const pushToStack = () => { + instances.set(id, { onSubmit, canSubmit, enter, ctrlS }) + // Remove any stale entry, then push to top. + const existing = openDialogStack.indexOf(id) + if (existing !== -1) openDialogStack.splice(existing, 1) + openDialogStack.push(id) + attachListener() + } + + const removeFromStack = () => { + const existing = openDialogStack.indexOf(id) + if (existing !== -1) openDialogStack.splice(existing, 1) + instances.delete(id) + if (openDialogStack.length === 0) detachListener() + } + + const stop = watch( + () => unref(isOpen), + (open) => { + if (open) { + pushToStack() + } else { + removeFromStack() + } + }, + { immediate: true }, + ) + + onBeforeUnmount(() => { + stop() + removeFromStack() + }) +} diff --git a/POS/src/pages/POSSale.vue b/POS/src/pages/POSSale.vue index 35cd5c858..a20b4d127 100644 --- a/POS/src/pages/POSSale.vue +++ b/POS/src/pages/POSSale.vue @@ -675,6 +675,26 @@ @refresh-history="loadInvoiceHistoryData" /> + + + + + + { const { isOpen: showLogoutDialog } = useDialog("logout"); const { isOpen: showItemSelectionDialog } = useDialog("itemSelection"); const { isOpen: showErrorDialog } = useDialog("invoiceError"); + const { isOpen: showCreditSalesSummary } = useDialog("creditSalesSummary"); // Global dialog state const { isAnyDialogOpen } = useDialogState(); @@ -159,6 +160,7 @@ export const usePOSUIStore = defineStore("posUI", () => { showLogoutDialog.value = false; showItemSelectionDialog.value = false; showErrorDialog.value = false; + showCreditSalesSummary.value = false; clearError(); lastOfflinePrintDoc.value = null; } @@ -183,6 +185,7 @@ export const usePOSUIStore = defineStore("posUI", () => { showLogoutDialog, showItemSelectionDialog, showErrorDialog, + showCreditSalesSummary, isAnyDialogOpen, errorDialogTitle, errorDialogMessage, diff --git a/POS/src/utils/searchText.js b/POS/src/utils/searchText.js new file mode 100644 index 000000000..7b1daaad5 --- /dev/null +++ b/POS/src/utils/searchText.js @@ -0,0 +1,98 @@ +/** + * Arabic-aware text normalization and match segmentation for instant search. + * + * Normalization makes search forgiving for Arabic input: + * - strips diacritics (تشكيل) + * - unifies alef variants (أ إ آ ٱ → ا), ة → ه, ى → ي, ؤ → و, ئ → ي + * - converts Arabic-Indic digits (٠-٩, ۰-۹) to Latin digits + * - lowercases Latin text + */ + +const ARABIC_DIACRITICS = + /[\u0610-\u061a\u064b-\u065f\u0670\u06d6-\u06dc\u06df-\u06e4\u06e7\u06e8\u06ea-\u06ed]/g + +/** + * Normalize a string for matching. Safe on empty/nullish input. + * @param {String} text + * @returns {String} normalized text + */ +export function normalizeSearchText(text) { + if (!text) return "" + return String(text) + .toLowerCase() + .replace(ARABIC_DIACRITICS, "") + .replace(/[أإآٱ]/g, "ا") + .replace(/ة/g, "ه") + .replace(/ى/g, "ي") + .replace(/ؤ/g, "و") + .replace(/ئ/g, "ي") + .replace(/[٠-٩]/g, (d) => String(d.charCodeAt(0) - 0x0660)) + .replace(/[۰-۹]/g, (d) => String(d.charCodeAt(0) - 0x06f0)) +} + +/** + * Split a raw query into normalized tokens. + * @param {String} query + * @returns {String[]} non-empty normalized tokens + */ +export function tokenizeQuery(query) { + return normalizeSearchText(query).split(/\s+/).filter(Boolean) +} + +/** + * Normalize a string while keeping a map from each normalized character + * back to its index in the original string (diacritics are dropped, so the + * mapping is not 1:1). + */ +function normalizeWithMap(text) { + const chars = [] + const map = [] + for (let i = 0; i < text.length; i++) { + const n = normalizeSearchText(text[i]) + for (const c of n) { + chars.push(c) + map.push(i) + } + } + return { norm: chars.join(""), map } +} + +/** + * Split text into segments marking the parts matched by the tokens, for + * rendering highlighted search results. Matching is normalization-aware, + * so typing "أحمد" highlights "احمد" and vice versa. + * + * @param {String} text - original display text + * @param {String[]} tokens - normalized tokens (from tokenizeQuery) + * @returns {Array<{text: String, hit: Boolean}>} ordered segments + */ +export function matchSegments(text, tokens) { + if (!text) return [] + if (!tokens || tokens.length === 0) return [{ text, hit: false }] + + const { norm, map } = normalizeWithMap(text) + const hits = new Array(text.length).fill(false) + for (const token of tokens) { + let from = 0 + // Mark every occurrence so repeated fragments all light up + for (;;) { + const idx = norm.indexOf(token, from) + if (idx === -1) break + const start = map[idx] + const end = map[idx + token.length - 1] + for (let i = start; i <= end; i++) hits[i] = true + from = idx + token.length + } + } + + const segments = [] + for (let i = 0; i < text.length; i++) { + const last = segments[segments.length - 1] + if (last && last.hit === hits[i]) { + last.text += text[i] + } else { + segments.push({ text: text[i], hit: hits[i] }) + } + } + return segments +} diff --git a/pos_next/api/customer_dues.py b/pos_next/api/customer_dues.py new file mode 100644 index 000000000..f3a2e7f10 --- /dev/null +++ b/pos_next/api/customer_dues.py @@ -0,0 +1,426 @@ +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +""" +Customer Dues API + +Provides a full account statement for a customer and FIFO lump-sum payment. +Reuses enrich_invoice_with_payment_history and create_payment_entry from +partial_payments.py and get_customer_balance from credit_sales.py. +""" + +import json + +import frappe +from frappe import _ +from frappe.utils import flt + +from pos_next.api.credit_sales import get_customer_balance +from pos_next.api.partial_payments import ( + AMOUNT_TOLERANCE, + create_payment_entry, + enrich_invoice_with_payment_history, +) + +# Fields fetched for due invoices (enriched with payment history) +_DUE_FIELDS = [ + "name", + "posting_date", + "posting_time", + "customer", + "customer_name", + "grand_total", + "outstanding_amount", + "status", + "currency", + "is_return", + "return_against", +] + +# Fields fetched for settled/return invoices (no payment-ledger enrichment) +_SETTLED_FIELDS = [ + "name", + "posting_date", + "posting_time", + "customer", + "customer_name", + "grand_total", + "outstanding_amount", + "status", + "currency", + "is_return", + "return_against", +] + + +@frappe.whitelist() +def get_customer_due_statement(customer, pos_profile=None, company=None, limit=100): + """ + Return a complete account statement for *customer*. + + Args: + customer: Customer ID + pos_profile: Optional POS Profile name — used to resolve company when company is omitted + company: Optional Company filter; resolved from pos_profile when absent + limit: Max rows for settled/return invoices (default 100) + + Returns: + { + summary: {total_outstanding, total_credit, net_balance, + due_count, settled_count}, + due_invoices: [...], # outstanding > 0, oldest first; enriched with payment history + settled_invoices: [...], # outstanding <= 0 or is_return, recent first; capped + currency: str, + } + """ + if not customer: + frappe.throw(_("Customer is required")) + + if not frappe.has_permission("Sales Invoice", "read"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + if not frappe.db.exists("Customer", customer): + frappe.throw(_("Customer {0} does not exist").format(customer)) + + # Resolve company + if not company and pos_profile: + company = frappe.db.get_value("POS Profile", pos_profile, "company") + + # ── Summary ────────────────────────────────────────────────────────────── + balance = get_customer_balance(customer, company) + summary = { + "total_outstanding": balance["total_outstanding"], + "total_credit": balance["total_credit"], + "net_balance": balance["net_balance"], + } + + # ── Due invoices (oldest first = FIFO display order) ───────────────────── + due_filters = { + "customer": customer, + "docstatus": 1, + "is_return": 0, + "outstanding_amount": [">", 0], + } + if company: + due_filters["company"] = company + + due_invoices = frappe.get_all( + "Sales Invoice", + filters=due_filters, + fields=_DUE_FIELDS, + order_by="posting_date asc, creation asc", + ) + + # Enrich with payment history (payment ledger = source of truth) + for inv in due_invoices: + enrich_invoice_with_payment_history(inv) + + # ── Settled / return invoices (recent first, capped) ───────────────────── + settled_filters = { + "customer": customer, + "docstatus": 1, + } + if company: + settled_filters["company"] = company + + settled_invoices = frappe.get_all( + "Sales Invoice", + filters=settled_filters, + fields=_SETTLED_FIELDS, + order_by="posting_date desc, creation desc", + limit=int(limit), + or_filters={ + "outstanding_amount": ["<=", 0], + "is_return": 1, + }, + ) + + # ── Batch-fetch items for all invoices (avoids N+1) ───────────────────── + all_names = [inv["name"] for inv in due_invoices] + [inv["name"] for inv in settled_invoices] + if all_names: + rows = frappe.get_all( + "Sales Invoice Item", + filters={"parent": ["in", all_names]}, + fields=["parent", "item_code", "item_name", "qty", "rate", "amount", "uom"], + ) + items_by_invoice = {} + for row in rows: + items_by_invoice.setdefault(row["parent"], []).append(row) + for inv in due_invoices + settled_invoices: + inv["items"] = items_by_invoice.get(inv["name"], []) + + # ── Currency (from first invoice or company default) ───────────────────── + currency = ( + (due_invoices or settled_invoices or [{}])[0].get("currency") + or frappe.db.get_value("Company", company, "default_currency") + or "USD" + ) + + summary["due_count"] = len(due_invoices) + summary["settled_count"] = len(settled_invoices) + + return { + "summary": summary, + "due_invoices": due_invoices, + "settled_invoices": settled_invoices, + "currency": currency, + } + + +@frappe.whitelist() +def get_credit_customers_summary(pos_profile=None, company=None): + """ + Aggregate "who owes the shop money" across all company customers. + + Mirrors the math in credit_sales.get_customer_balance but GROUP BY customer: + - regular (is_return=0) positive outstanding_amount → total_outstanding + - return (is_return=1) outstanding_amount < 0 → Abs summed as total_credit + - net_balance = total_outstanding - total_credit + + Only customers with net_balance > 0 are returned, sorted by net_balance desc. + + Args: + pos_profile: Optional POS Profile — used to resolve company when company is omitted + company: Optional Company filter; resolved from pos_profile when absent + + Returns: + { + customers: [{customer, customer_name, total_outstanding, + total_credit, net_balance, due_count}, ...], + totals: {net_balance, customer_count}, + currency: str, + } + """ + if not frappe.has_permission("Sales Invoice", "read"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + # Resolve company + if not company and pos_profile: + company = frappe.db.get_value("POS Profile", pos_profile, "company") + + try: + from frappe.query_builder import DocType + from frappe.query_builder.functions import Abs, Coalesce, Sum + from pypika import Case + + SalesInvoice = DocType("Sales Invoice") + + base_filters = SalesInvoice.docstatus == 1 + if company: + base_filters = base_filters & (SalesInvoice.company == company) + + # Regular invoices: positive outstanding (what customer owes) + due count + regular_query = ( + frappe.qb.from_(SalesInvoice) + .select( + SalesInvoice.customer, + SalesInvoice.customer_name, + Coalesce( + Sum( + Case() + .when(SalesInvoice.outstanding_amount > 0, SalesInvoice.outstanding_amount) + .else_(0) + ), + 0, + ).as_("total_outstanding"), + Coalesce( + Sum(Case().when(SalesInvoice.outstanding_amount > 0, 1).else_(0)), + 0, + ).as_("due_count"), + ) + .where(base_filters & (SalesInvoice.is_return == 0)) + .groupby(SalesInvoice.customer, SalesInvoice.customer_name) + ) + + # Return invoices: only negative outstanding counts as credit (no cash refund) + return_query = ( + frappe.qb.from_(SalesInvoice) + .select( + SalesInvoice.customer, + Coalesce(Sum(Abs(SalesInvoice.outstanding_amount)), 0).as_("total_credit"), + ) + .where( + base_filters + & (SalesInvoice.is_return == 1) + & (SalesInvoice.outstanding_amount < 0) + ) + .groupby(SalesInvoice.customer) + ) + + regular_rows = regular_query.run(as_dict=True) + return_rows = return_query.run(as_dict=True) + + credit_by_customer = {r.customer: flt(r.total_credit) for r in return_rows} + + customers = [] + for r in regular_rows: + total_outstanding = flt(r.total_outstanding) + total_credit = credit_by_customer.get(r.customer, 0.0) + net_balance = total_outstanding - total_credit + if net_balance > 0: + customers.append( + { + "customer": r.customer, + "customer_name": r.customer_name or r.customer, + "total_outstanding": total_outstanding, + "total_credit": total_credit, + "net_balance": net_balance, + "due_count": int(r.due_count or 0), + } + ) + + customers.sort(key=lambda c: c["net_balance"], reverse=True) + + net_total = sum(c["net_balance"] for c in customers) + + currency = ( + (frappe.db.get_value("Company", company, "default_currency") if company else None) + or frappe.db.get_default("currency") + or "USD" + ) + + return { + "customers": customers, + "totals": {"net_balance": net_total, "customer_count": len(customers)}, + "currency": currency, + } + + except Exception: + frappe.log_error( + title="Credit Customers Summary Error", + message=f"pos_profile: {pos_profile}, company: {company}\n{frappe.get_traceback()}", + ) + return { + "customers": [], + "totals": {"net_balance": 0.0, "customer_count": 0}, + "currency": "USD", + } + + +@frappe.whitelist() +def pay_customer_due( + customer, + payments, + pos_profile=None, + pos_opening_shift=None, + company=None, +): + """ + FIFO lump-sum payment across a customer's outstanding invoices. + + payments: JSON list [{mode_of_payment, amount, account?}] + + Returns: + { + success: bool, + payment_entries_created: int, + allocations: [{invoice, mode_of_payment, amount}], + summary: , + } + """ + if not customer: + frappe.throw(_("Customer is required")) + + if not frappe.has_permission("Sales Invoice", "write"): + frappe.throw(_("Not permitted"), frappe.PermissionError) + + # Parse payments + if isinstance(payments, str): + try: + payments = json.loads(payments) + except json.JSONDecodeError: + frappe.throw(_("Invalid payments payload: malformed JSON")) + + if not isinstance(payments, list) or not payments: + frappe.throw(_("At least one payment is required")) + + # Resolve company + if not company and pos_profile: + company = frappe.db.get_value("POS Profile", pos_profile, "company") + + # Fetch outstanding invoices oldest-first (FIFO) + due_filters = { + "customer": customer, + "docstatus": 1, + "is_return": 0, + "outstanding_amount": [">", 0], + } + if company: + due_filters["company"] = company + + due_invoices = frappe.get_all( + "Sales Invoice", + filters=due_filters, + fields=["name", "outstanding_amount"], + order_by="posting_date asc, creation asc", + ) + + if not due_invoices: + frappe.throw(_("No outstanding invoices found for customer {0}").format(customer)) + + # Total payment vs total outstanding + total_payment = sum(flt(p.get("amount", 0)) for p in payments) + total_outstanding = sum(flt(inv["outstanding_amount"]) for inv in due_invoices) + + if total_payment > total_outstanding + AMOUNT_TOLERANCE: + frappe.throw( + _("Total payment {0} exceeds total outstanding {1}").format( + frappe.format_value(total_payment, {"fieldtype": "Currency"}), + frappe.format_value(total_outstanding, {"fieldtype": "Currency"}), + ) + ) + + # Savepoint-backed batch + savepoint = "pay_customer_due_batch" + allocations = [] + payment_entries_created = 0 + + # Track remaining outstanding per invoice across payment modes + inv_remaining = {inv["name"]: flt(inv["outstanding_amount"]) for inv in due_invoices} + inv_order = [inv["name"] for inv in due_invoices] + + try: + frappe.db.savepoint(savepoint) + + for payment in payments: + mode = payment.get("mode_of_payment", "Cash") + account = payment.get("account") + remaining_mode = flt(payment.get("amount", 0)) + + if remaining_mode <= 0: + continue + + for inv_name in inv_order: + if remaining_mode <= AMOUNT_TOLERANCE: + break + inv_due = inv_remaining.get(inv_name, 0) + if inv_due <= AMOUNT_TOLERANCE: + continue + + alloc_amount = min(remaining_mode, inv_due) + pe_name = create_payment_entry( + invoice_name=inv_name, + amount=alloc_amount, + mode_of_payment=mode, + payment_account=account, + pos_opening_shift=pos_opening_shift, + ) + allocations.append( + {"invoice": inv_name, "mode_of_payment": mode, "amount": alloc_amount} + ) + payment_entries_created += 1 + inv_remaining[inv_name] = inv_due - alloc_amount + remaining_mode -= alloc_amount + + except Exception: + frappe.db.rollback(save_point=savepoint) + raise + + fresh_summary = get_customer_balance(customer, company) + + return { + "success": True, + "payment_entries_created": payment_entries_created, + "allocations": allocations, + "summary": fresh_summary, + } diff --git a/pos_next/api/test_customer_dues.py b/pos_next/api/test_customer_dues.py new file mode 100644 index 000000000..a46b42da6 --- /dev/null +++ b/pos_next/api/test_customer_dues.py @@ -0,0 +1,80 @@ +# Copyright (c) 2025, BrainWise and contributors +# For license information, please see license.txt + +import unittest +from types import SimpleNamespace +from unittest.mock import patch + +from pos_next.api import customer_dues + + +def _builder_with_result(result): + """A query-builder stub whose chained calls return the given rows from .run().""" + + class _Builder: + def select(self, *_a, **_k): + return self + + def where(self, *_a, **_k): + return self + + def groupby(self, *_a, **_k): + return self + + def run(self, *_a, **_k): + return result + + return _Builder() + + +class TestCreditCustomersSummary(unittest.TestCase): + @patch("pos_next.api.customer_dues.frappe.db.get_value", return_value="EGP") + @patch("pos_next.api.customer_dues.frappe.qb.from_") + @patch("pos_next.api.customer_dues.frappe.has_permission", return_value=True) + def test_summary_nets_returns_against_outstanding(self, _perm, mock_from, _gv): + # Regular invoices (is_return=0): positive outstanding per customer + regular_rows = [ + SimpleNamespace( + customer="CUST-A", customer_name="Mixed Customer", + total_outstanding=382, due_count=14, + ), + SimpleNamespace( + customer="CUST-B", customer_name="Owes Only", + total_outstanding=150, due_count=1, + ), + SimpleNamespace( + customer="CUST-C", customer_name="Fully Credited", + total_outstanding=50, due_count=1, + ), + ] + # Return invoices (is_return=1, outstanding<0): credit per customer + return_rows = [ + SimpleNamespace(customer="CUST-A", total_credit=133), + SimpleNamespace(customer="CUST-C", total_credit=50), + ] + mock_from.side_effect = [ + _builder_with_result(regular_rows), + _builder_with_result(return_rows), + ] + + result = customer_dues.get_credit_customers_summary(company="Sonex") + + # CUST-C is fully offset by its return credit → excluded (net_balance == 0) + names = [c["customer"] for c in result["customers"]] + self.assertEqual(names, ["CUST-A", "CUST-B"]) # sorted by net_balance desc + + mixed = result["customers"][0] + self.assertEqual(mixed["total_outstanding"], 382) + self.assertEqual(mixed["total_credit"], 133) + self.assertEqual(mixed["net_balance"], 249) + self.assertEqual(mixed["due_count"], 14) + + self.assertEqual(result["totals"]["customer_count"], 2) + self.assertEqual(result["totals"]["net_balance"], 399) # 249 + 150 + self.assertEqual(result["currency"], "EGP") + + @patch("pos_next.api.customer_dues.frappe.throw", side_effect=RuntimeError("Not permitted")) + @patch("pos_next.api.customer_dues.frappe.has_permission", return_value=False) + def test_summary_requires_read_permission(self, _perm, _throw): + with self.assertRaisesRegex(RuntimeError, "Not permitted"): + customer_dues.get_credit_customers_summary(company="Sonex") diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv index c99f44df1..f00437182 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1565,3 +1565,39 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "District","الحي","" "Select District","اختر الحي","" "Select a governorate first","اختر المحافظة أولاً","" +"All Settled","تم التسوية بالكامل","" +"Credit Sales","مبيعات آجلة","" +"Credit available: {0}","الرصيد المتاح: {0}","" +"Customers who owe money","العملاء المدينون","" +"Due: {0}","المستحق: {0}","" +"Everyone is settled up.","جميع الحسابات مسددة.","" +"Failed to load account statement","فشل تحميل كشف الحساب","" +"Failed to load account statement. Try refreshing.","فشل تحميل كشف الحساب. حاول التحديث.","" +"Failed to load credit sales","فشل تحميل المبيعات الآجلة","" +"Failed to load credit sales. Try refreshing.","فشل تحميل المبيعات الآجلة. حاول التحديث.","" +"From returns","من المرتجعات","" +"In credit","دائن","" +"Items on Credit","الأصناف الآجلة","" +"Net Balance","الرصيد الصافي","" +"No customers match your search.","لا يوجد عملاء مطابقون لبحثك.","" +"No customers owe money","لا يوجد عملاء مدينون","" +"No invoices match the selected filter.","لا توجد فواتير مطابقة للفلتر المحدد.","" +"No items on credit.","لا توجد أصناف آجلة.","" +"Owes","مدين","" +"Paid ({0})","مدفوع ({0})","" +"Paid {0} allocation(s) across {1} invoice(s)","تم سداد {0} دفعة عبر {1} فاتورة","" +"Partly Paid ({0})","مدفوع جزئياً ({0})","" +"Pay Due ({0})","سداد المستحق ({0})","" +"Payment added to {0}","تمت إضافة الدفعة إلى {0}","" +"Payment failed","فشل الدفع","" +"Return Credit","رصيد المرتجعات","" +"Statement","كشف الحساب","" +"This customer has no outstanding balance.","لا يوجد رصيد مستحق على هذا العميل.","" +"Total Due","إجمالي المستحق","" +"Total Owed","إجمالي المستحق","" +"Total owed: {0}","إجمالي المستحق: {0}","" +"You are offline. Payments are disabled.","أنت غير متصل. الدفع معطل.","" +"vs {0}","مقابل {0}","" +"{0} customer(s) owe money","{0} عميل عليهم مستحقات","" +"{0} invoice(s)","{0} فاتورة","" +"{0} took {1} item(s) on credit","{0} أخذ {1} صنف بالآجل",""