diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue index ad02948af..ef986750d 100644 --- a/POS/src/components/ShiftClosingDialog.vue +++ b/POS/src/components/ShiftClosingDialog.vue @@ -247,6 +247,16 @@ > {{ __("Return") }} + + {{ + Number(invoice.grand_total) > 0 + ? __("Partially Paid") + : __("On Account") + }} + 0" + class="text-xs text-amber-700 mb-1" + > + {{ + __("Unpaid: {0}", [ + formatCurrency(invoice.outstanding_amount), + ]) + }} +
@@ -345,6 +365,16 @@ > {{ __("Return") }} + + {{ + Number(invoice.grand_total) > 0 + ? __("Partially Paid") + : __("On Account") + }} + {{ formatCurrency(invoice.grand_total) }} +
+ {{ + __("Unpaid: {0}", [ + formatCurrency( + invoice.outstanding_amount + ), + ]) + }} +
diff --git a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py index 7166d7c94..ce58266a7 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py +++ b/pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py @@ -456,33 +456,53 @@ def _process_invoice(invoice, invoice_field, company_currency, cash_mode, paymen "customer": invoice.customer, "is_return": is_return, "return_against": invoice.get("return_against"), + "invoice_total": base_grand_total, + "outstanding_amount": 0, } ) + # Money actually collected on this sale, in company currency. A pure + # Pay-on-Account credit sale has paid_amount == 0; a partial sale carries + # only its cash/card down-payment. Returns keep the full (signed) amount — + # the return branch already reflects real refunds via payment rows. + base_paid = get_base_value(invoice, "paid_amount", "base_paid_amount", conversion_rate) + paid_ratio = (base_paid / base_grand_total) if base_grand_total else 0 + + # Collected amount drives the sales summary / per-row totals; the full + # invoice value is preserved separately for display and reconciliation. + collected = base_grand_total if is_return else base_paid + # Build transaction record transaction = frappe._dict( { invoice_field: invoice.name, "posting_date": invoice.posting_date, - "grand_total": base_grand_total, + "grand_total": collected, "transaction_currency": invoice.get("currency") or company_currency, "transaction_amount": flt(invoice.get("grand_total")), "customer": invoice.customer, "is_return": is_return, "return_against": invoice.get("return_against") if is_return else None, + # Display-only (stripped before the child table set): full invoice + # value and the unpaid remainder, used by the closing dialog badge. + "invoice_total": base_grand_total, + "outstanding_amount": 0 if is_return else (base_grand_total - base_paid), } ) # Update summary totals - summary["grand_total"] += base_grand_total - summary["net_total"] += base_net_total summary["total_quantity"] += flt(invoice.total_qty) if is_return: + summary["grand_total"] += base_grand_total + summary["net_total"] += base_net_total summary["returns_total"] += abs(base_grand_total) summary["returns_count"] += 1 else: - summary["sales_total"] += base_grand_total + # Net Sales == money collected, keeping net proportional to what was paid. + summary["grand_total"] += base_paid + summary["net_total"] += base_net_total * paid_ratio + summary["sales_total"] += base_paid summary["sales_count"] += 1 # Process taxes @@ -605,7 +625,11 @@ def make_closing_shift_from_opening(opening_shift): closing_shift.set( "pos_transactions", [ - {k: v for k, v in txn.items() if k not in ("is_return", "return_against")} + { + k: v + for k, v in txn.items() + if k not in ("is_return", "return_against", "invoice_total", "outstanding_amount") + } for txn in pos_transactions ], ) diff --git a/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py b/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py index 13007cfee..a1d926c68 100644 --- a/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py +++ b/pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py @@ -1,9 +1,124 @@ # Copyright (c) 2020, Youssef Restom and Contributors # See license.txt -# import frappe import unittest +import frappe + +from pos_next.pos_next.doctype.pos_closing_shift.pos_closing_shift import _process_invoice + + +def _invoice(name, grand_total, paid_amount, net_total=None, qty=1, is_return=0, payments=None): + """Build a minimal Sales Invoice as_dict() shape for _process_invoice().""" + return frappe._dict({ + "name": name, + "posting_date": "2026-06-12", + "customer": "Test Customer", + "currency": "USD", + "conversion_rate": 1, + "grand_total": grand_total, + "base_grand_total": grand_total, + "net_total": net_total if net_total is not None else grand_total, + "base_net_total": net_total if net_total is not None else grand_total, + "paid_amount": paid_amount, + "base_paid_amount": paid_amount, + "total_qty": qty, + "is_return": is_return, + "change_amount": 0, + "base_change_amount": 0, + "taxes": [], + "payments": payments or [], + }) + + +def _empty_summary(): + return { + "grand_total": 0, "net_total": 0, "total_quantity": 0, + "returns_total": 0, "returns_count": 0, + "sales_total": 0, "sales_count": 0, + } + class TestPOSClosingShift(unittest.TestCase): - pass + def test_closing_total_reflects_collected_money(self): + """Net Sales must equal money actually collected, not the full invoice value.""" + summary = _empty_summary() + payments, taxes = [], [] + + # (a) fully-paid sale: 100 collected + full = _invoice( + "INV-FULL", grand_total=100, paid_amount=100, + payments=[frappe._dict({"mode_of_payment": "Cash", "amount": 100, "base_amount": 100})], + ) + # (b) pure credit sale (Pay-on-Account): nothing collected + credit = _invoice("INV-CREDIT", grand_total=200, paid_amount=0, payments=[]) + # (c) partial sale: 120 down-payment on a 300 invoice + partial = _invoice( + "INV-PARTIAL", grand_total=300, paid_amount=120, + payments=[frappe._dict({"mode_of_payment": "Cash", "amount": 120, "base_amount": 120})], + ) + + txn_full = _process_invoice(full, "sales_invoice", "USD", "Cash", payments, taxes, summary) + txn_credit = _process_invoice(credit, "sales_invoice", "USD", "Cash", payments, taxes, summary) + txn_partial = _process_invoice(partial, "sales_invoice", "USD", "Cash", payments, taxes, summary) + + # Net Sales == collected (100 + 0 + 120), NOT the full 600. + self.assertEqual(summary["grand_total"], 220) + self.assertEqual(summary["sales_total"], 220) + self.assertEqual(summary["sales_count"], 3) + # Quantity is still the full goods sold (3 lines x 1). + self.assertEqual(summary["total_quantity"], 3) + + # Cash reconciliation only reflects real payment rows (credit has none). + cash = next(p for p in payments if p.mode_of_payment == "Cash") + self.assertEqual(cash.expected_amount, 220) + + # Per-row transaction: grand_total = collected, full value + unpaid preserved. + self.assertEqual(txn_full["grand_total"], 100) + self.assertEqual(txn_full["outstanding_amount"], 0) + + self.assertEqual(txn_credit["grand_total"], 0) + self.assertEqual(txn_credit["invoice_total"], 200) + self.assertEqual(txn_credit["outstanding_amount"], 200) + self.assertEqual(txn_credit["transaction_amount"], 200) + + self.assertEqual(txn_partial["grand_total"], 120) + self.assertEqual(txn_partial["outstanding_amount"], 180) + self.assertEqual(txn_partial["invoice_total"], 300) + + # Per-row collected amounts sum to the header total (EOD print stays consistent). + self.assertEqual( + txn_full["grand_total"] + txn_credit["grand_total"] + txn_partial["grand_total"], + summary["grand_total"], + ) + + def test_net_total_stays_proportional_to_collected(self): + """net_total tracks the paid ratio so it never exceeds collected takings.""" + summary = _empty_summary() + payments, taxes = [], [] + + # Half paid on a sale whose net_total differs from grand_total (tax inclusive). + partial = _invoice( + "INV-HALF", grand_total=100, paid_amount=50, net_total=90, + payments=[frappe._dict({"mode_of_payment": "Cash", "amount": 50, "base_amount": 50})], + ) + _process_invoice(partial, "sales_invoice", "USD", "Cash", payments, taxes, summary) + + self.assertEqual(summary["grand_total"], 50) + self.assertEqual(summary["net_total"], 45) # 90 * (50/100) + + def test_credit_return_is_unaffected(self): + """Credit returns with no payment rows still contribute nothing and skip early.""" + summary = _empty_summary() + payments, taxes = [], [] + + credit_return = _invoice( + "INV-RET", grand_total=-100, paid_amount=0, is_return=1, payments=[], + ) + credit_return["return_against"] = "INV-FULL" + + txn = _process_invoice(credit_return, "sales_invoice", "USD", "Cash", payments, taxes, summary) + + self.assertEqual(txn["grand_total"], 0) + self.assertEqual(summary["grand_total"], 0) + self.assertEqual(summary["returns_total"], 0) diff --git a/pos_next/translations/ar.csv b/pos_next/translations/ar.csv index c99f44df1..cb2cc644c 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1560,6 +1560,7 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "Invoice {0} saved offline and sent to printer — will sync when online","تم حفظ الفاتورة {0} دون اتصال وإرسالها إلى الطابعة — وستتم مزامنتها عند عودة الاتصال","" "Invoice {0} saved offline but print failed — open Print from the success dialog","تم حفظ الفاتورة {0} دون اتصال، لكن تعذّرت الطباعة — افتح خيار الطباعة من نافذة النجاح","" "Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" +"Unpaid: {0}","غير مدفوع: {0}","" "Governorate","المحافظة","" "Select Governorate","اختر المحافظة","" "District","الحي",""