From 32fad19074819260de20eb262b80dc22879ab51c Mon Sep 17 00:00:00 2001 From: NotAbdelrahmanelsayed Date: Fri, 12 Jun 2026 21:09:25 +0200 Subject: [PATCH] fix: shift closing total reflects money collected, not invoiced MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Credit (Pay-on-Account) sales inflated the POS shift closing total. `_process_invoice()` added the full `base_grand_total` of every invoice to the sales summary (`grand_total`/`net_total`/`sales_total`) and the per-row amount, regardless of how much was actually paid. The cash Payment Reconciliation, however, is built only from real payment rows — so a pure credit sale pushed Net Sales up by the full amount while contributing 0 to the drawer, leaving the two figures inconsistent within a single shift. For non-return invoices the money summaries and the per-row `grand_total` now use the amount actually collected (`base_paid_amount`): a pure credit sale contributes 0, a partial sale contributes only its down-payment, and `net_total` is scaled by the paid ratio. The full invoice value is preserved in `transaction_amount`; display-only `invoice_total` and `outstanding_amount` are added for the dialog badge and stripped before the child-table set. Returns, quantities and tax accrual are unchanged. The Close Shift dialog now shows an "On Account" / "Partially Paid" badge and an "Unpaid: {amount}" sub-line on rows collected for less than their invoice value. Adds unit tests for the collected-money totals and an Arabic string. Co-Authored-By: Claude Opus 4.8 --- POS/src/components/ShiftClosingDialog.vue | 12 ++ .../pos_closing_shift/pos_closing_shift.py | 33 ++++- .../test_pos_closing_shift.py | 119 +++++++++++++++++- pos_next/translations/ar.csv | 3 +- 4 files changed, 159 insertions(+), 8 deletions(-) diff --git a/POS/src/components/ShiftClosingDialog.vue b/POS/src/components/ShiftClosingDialog.vue index 8f110ed2b..818dd02fe 100644 --- a/POS/src/components/ShiftClosingDialog.vue +++ b/POS/src/components/ShiftClosingDialog.vue @@ -115,11 +115,17 @@ {{ __('Return') }} + + {{ Number(invoice.grand_total) > 0 ? __('Partially Paid') : __('On Account') }} + {{ formatCurrency(invoice.grand_total) }} +
+ {{ __('Unpaid: {0}', [formatCurrency(invoice.outstanding_amount)]) }} +
{{ invoice.customer }} {{ formatTime(invoice.posting_date) }} @@ -159,6 +165,9 @@ {{ __('Return') }} + + {{ Number(invoice.grand_total) > 0 ? __('Partially Paid') : __('On Account') }} + {{ __('Sale') }} @@ -173,6 +182,9 @@ {{ 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 f45c56d10..50b63adac 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 @@ -460,30 +460,50 @@ 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 @@ -590,7 +610,10 @@ def make_closing_shift_from_opening(opening_shift): # Set child tables (without return info - that's for display only) 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 ]) closing_shift.set("payment_reconciliation", payments) 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 d0c2a2e1d..5f2d55b47 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 @@ -3,9 +3,124 @@ # See license.txt from __future__ import unicode_literals -# 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 f89e6390a..a356aa75d 100644 --- a/pos_next/translations/ar.csv +++ b/pos_next/translations/ar.csv @@ -1559,4 +1559,5 @@ Points applied: {0}. Please pay remaining {1} with {2},تم خصم النقاط: "This offline receipt is no longer in browser storage. Sync the invoice, then print from history.","هذا الإيصال المحفوظ دون اتصال لم يعد موجودًا في تخزين المتصفح. قم بمزامنة الفاتورة أولًا، ثم اطبعها من السجل.","" "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.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" \ No newline at end of file +"Popup blocked — check your browser settings.","تم حظر النافذة المنبثقة — يُرجى التحقق من إعدادات المتصفح.","" +"Unpaid: {0}","غير مدفوع: {0}",""