Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions POS/src/components/ShiftClosingDialog.vue
Original file line number Diff line number Diff line change
Expand Up @@ -247,6 +247,16 @@
>
{{ __("Return") }}
</span>
<span
v-else-if="invoice.outstanding_amount > 0"
class="px-1.5 py-0.5 text-xs font-medium bg-amber-100 text-amber-800 rounded"
>
{{
Number(invoice.grand_total) > 0
? __("Partially Paid")
: __("On Account")
}}
</span>
</div>
<span
:class="[
Expand All @@ -259,6 +269,16 @@
{{ formatCurrency(invoice.grand_total) }}
</span>
</div>
<div
v-if="invoice.outstanding_amount > 0"
class="text-xs text-amber-700 mb-1"
>
{{
__("Unpaid: {0}", [
formatCurrency(invoice.outstanding_amount),
])
}}
</div>
<div
class="flex justify-between items-center text-xs text-gray-600"
>
Expand Down Expand Up @@ -345,6 +365,16 @@
>
{{ __("Return") }}
</span>
<span
v-else-if="invoice.outstanding_amount > 0"
class="px-2 py-1 text-xs font-medium bg-amber-100 text-amber-800 rounded"
>
{{
Number(invoice.grand_total) > 0
? __("Partially Paid")
: __("On Account")
}}
</span>
<span
v-else
class="px-2 py-1 text-xs font-medium bg-green-100 text-green-800 rounded"
Expand Down Expand Up @@ -373,6 +403,18 @@
>
{{ formatCurrency(invoice.grand_total) }}
</span>
<div
v-if="invoice.outstanding_amount > 0"
class="text-xs text-amber-700"
>
{{
__("Unpaid: {0}", [
formatCurrency(
invoice.outstanding_amount
),
])
}}
</div>
</td>
</tr>
</tbody>
Expand Down
34 changes: 29 additions & 5 deletions pos_next/pos_next/doctype/pos_closing_shift/pos_closing_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
],
)
Expand Down
119 changes: 117 additions & 2 deletions pos_next/pos_next/doctype/pos_closing_shift/test_pos_closing_shift.py
Original file line number Diff line number Diff line change
@@ -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)
1 change: 1 addition & 0 deletions pos_next/translations/ar.csv
Original file line number Diff line number Diff line change
Expand Up @@ -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","الحي",""
Expand Down
Loading