feat: partly paid pos invoices (#48246)

* fix: partial payment in pos

* fix: show alerts for update failure

* fix: partial payment validation

* fix: remove setting clearance date

* fix: partly paid invoices in pos

* fix: throw error if user tries to make payment for consolidated invoice

* fix: include unpaid invoices in partly paid invoice filter

* refactor: function rename

* feat: button to open form view for partly paid invoices in pos order summary

* fix: payment menu item visible for unpaid invoices

* refactor: update_payments function

* fix: set outstanding amount for pos invoice

* test: partly paid pos invoices

* test: removed frappe.db.commit

* refactor: using before_submit to set outstanding amount
This commit is contained in:
Diptanil Saha
2025-06-28 00:48:23 +05:30
committed by GitHub
parent 1cb7d5126c
commit c742a1dbe9
14 changed files with 338 additions and 30 deletions

View File

@@ -66,6 +66,13 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
if (doc.docstatus == 1 && !doc.is_return) {
this.frm.add_custom_button(__("Return"), this.make_sales_return.bind(this), __("Create"));
if (["Partly Paid", "Overdue", "Unpaid"].includes(doc.status)) {
this.frm.add_custom_button(
__("Payment"),
this.collect_outstanding_payment.bind(this),
__("Create")
);
}
this.frm.page.set_inner_btn_group_as_primary(__("Create"));
}
@@ -210,6 +217,138 @@ erpnext.selling.POSInvoiceController = class POSInvoiceController extends erpnex
frm: this.frm,
});
}
async collect_outstanding_payment() {
const total_amount = flt(this.frm.doc.rounded_total) | flt(this.frm.doc.grand_total);
const paid_amount = flt(this.frm.doc.paid_amount);
const outstanding_amount = flt(this.frm.doc.outstanding_amount);
const me = this;
const table_fields = [
{
fieldname: "mode_of_payment",
fieldtype: "Link",
in_list_view: 1,
label: __("Mode of Payment"),
options: "Mode of Payment",
reqd: 1,
},
{
fieldname: "amount",
fieldtype: "Currency",
in_list_view: 1,
label: __("Amount"),
options: this.frm.doc.currency,
reqd: 1,
onchange: function () {
dialog.fields_dict.payments.df.data.some((d) => {
if (d.idx == this.doc.idx) {
d.amount = this.value === null ? 0 : this.value;
dialog.fields_dict.payments.grid.refresh();
return true;
}
});
let amount = 0;
for (let d of dialog.fields_dict.payments.df.data) {
amount += d.amount;
}
let change_amount = total_amount - (paid_amount + amount);
dialog.fields_dict.outstanding_amount.set_value(
outstanding_amount - amount < 0 ? 0 : outstanding_amount - amount
);
dialog.fields_dict.paid_amount.set_value(paid_amount + amount);
dialog.fields_dict.change_amount.set_value(change_amount < 0 ? change_amount * -1 : 0);
},
},
];
const payment_method_data = await this.fetch_pos_payment_methods();
const dialog = new frappe.ui.Dialog({
title: __("Collect Outstanding Amount"),
fields: [
{
fieldname: "payments",
fieldtype: "Table",
label: __("Payments"),
cannot_add_rows: false,
in_place_edit: true,
reqd: 1,
data: payment_method_data,
fields: table_fields,
},
{
fieldname: "section_break_1",
fieldtype: "Section Break",
},
{
fieldname: "outstanding_amount",
fieldtype: "Currency",
label: __("Outstanding Amount"),
read_only: 1,
default: outstanding_amount,
},
{
fieldname: "column_break_1",
fieldtype: "Column Break",
},
{
fieldname: "paid_amount",
fieldtype: "Currency",
label: __("Paid Amount"),
read_only: 1,
default: paid_amount,
},
{
fieldname: "change_amount",
fieldtype: "Currency",
label: __("Change Amount"),
read_only: 1,
default: 0,
},
],
primary_action_label: __("Submit"),
primary_action(values) {
dialog.hide();
me.frm.call({
doc: me.frm.doc,
method: "update_payments",
args: {
payments: values.payments.filter((d) => d.amount != 0),
},
freeze: true,
callback: function (r) {
if (!r.exc) {
frappe.show_alert({
message: __("Payments updated."),
indicator: "green",
});
me.frm.reload_doc();
} else {
frappe.show_alert({
message: __("Payments could not be updated."),
indicator: "red",
});
}
},
});
},
});
dialog.show();
}
async fetch_pos_payment_methods() {
const pos_profile = this.frm.doc.pos_profile;
if (!pos_profile) return;
const pos_profile_doc = await frappe.db.get_doc("POS Profile", pos_profile);
const data = [];
pos_profile_doc.payments.forEach((pay) => {
const { mode_of_payment } = pay;
data.push({ mode_of_payment, amount: 0 });
});
return data;
}
};
extend_cscript(cur_frm.cscript, new erpnext.selling.POSInvoiceController({ frm: cur_frm }));

View File

@@ -1330,7 +1330,7 @@
"in_standard_filter": 1,
"label": "Status",
"no_copy": 1,
"options": "\nDraft\nReturn\nCredit Note Issued\nConsolidated\nSubmitted\nPaid\nUnpaid\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled",
"options": "\nDraft\nReturn\nCredit Note Issued\nConsolidated\nSubmitted\nPaid\nPartly Paid\nUnpaid\nPartly Paid and Discounted\nUnpaid and Discounted\nOverdue and Discounted\nOverdue\nCancelled",
"print_hide": 1,
"read_only": 1
},
@@ -1573,7 +1573,7 @@
"icon": "fa fa-file-text",
"is_submittable": 1,
"links": [],
"modified": "2025-01-06 15:03:19.957277",
"modified": "2025-06-24 12:13:28.242649",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Invoice",
@@ -1618,6 +1618,7 @@
"role": "All"
}
],
"row_format": "Dynamic",
"search_fields": "posting_date, due_date, customer, base_grand_total, outstanding_amount",
"show_name_in_global_search": 1,
"sort_field": "creation",

View File

@@ -149,7 +149,9 @@ class POSInvoice(SalesInvoice):
"Consolidated",
"Submitted",
"Paid",
"Partly Paid",
"Unpaid",
"Partly Paid and Discounted",
"Unpaid and Discounted",
"Overdue and Discounted",
"Overdue",
@@ -220,6 +222,9 @@ class POSInvoice(SalesInvoice):
validate_coupon_code(self.coupon_code)
def before_submit(self):
self.set_outstanding_amount()
def on_submit(self):
# create the loyalty point ledger entry if the customer is enrolled in any loyalty program
if not self.is_return and self.loyalty_program:
@@ -525,6 +530,10 @@ class POSInvoice(SalesInvoice):
)
)
def set_outstanding_amount(self):
total = flt(self.rounded_total) or flt(self.grand_total)
self.outstanding_amount = total - flt(self.paid_amount) if total > flt(self.paid_amount) else 0
def validate_loyalty_transaction(self):
if self.redeem_loyalty_points and (
not self.loyalty_redemption_account or not self.loyalty_redemption_cost_center
@@ -546,6 +555,8 @@ class POSInvoice(SalesInvoice):
self.status = "Draft"
return
total = flt(self.rounded_total) or flt(self.grand_total)
if not status:
if self.docstatus == 2:
status = "Cancelled"
@@ -561,6 +572,14 @@ class POSInvoice(SalesInvoice):
self.status = "Overdue and Discounted"
elif flt(self.outstanding_amount) > 0 and getdate(self.due_date) < getdate(nowdate()):
self.status = "Overdue"
elif (
0 < flt(self.outstanding_amount) < total
and self.is_discounted
and self.get_discounting_status() == "Disbursed"
):
self.status = "Partly Paid and Discounted"
elif 0 < flt(self.outstanding_amount) < total:
self.status = "Partly Paid"
elif (
flt(self.outstanding_amount) > 0
and getdate(self.due_date) >= getdate(nowdate())
@@ -781,6 +800,48 @@ class POSInvoice(SalesInvoice):
if pr:
return frappe.get_doc("Payment Request", pr)
@frappe.whitelist()
def update_payments(self, payments):
if self.status == "Consolidated":
frappe.throw(_("Create Payment Entry for Consolidated POS Invoices."))
paid_amount = flt(self.paid_amount)
total = flt(self.rounded_total) or flt(self.grand_total)
if paid_amount >= total:
frappe.throw(title=_("Invoice Paid"), msg=_("This invoice has already been paid."))
idx = self.payments[-1].idx if self.payments else -1
for d in payments:
idx += 1
payment = create_payments_on_invoice(self, idx, frappe._dict(d))
paid_amount += flt(payment.amount)
payment.submit()
paid_amount = flt(flt(paid_amount), self.precision("paid_amount"))
base_paid_amount = flt(flt(paid_amount * self.conversion_rate), self.precision("base_paid_amount"))
outstanding_amount = (
flt(flt(total - paid_amount), self.precision("outstanding_amount")) if total > paid_amount else 0
)
change_amount = (
flt(flt(paid_amount - total), self.precision("change_amount")) if paid_amount > total else 0
)
pi = frappe.qb.DocType("POS Invoice")
query = (
frappe.qb.update(pi)
.set(pi.paid_amount, paid_amount)
.set(pi.base_paid_amount, base_paid_amount)
.set(pi.outstanding_amount, outstanding_amount)
.set(pi.change_amount, change_amount)
.where(pi.name == self.name)
)
query.run()
self.reload()
self.set_status(update=True)
@frappe.whitelist()
def get_stock_availability(item_code, warehouse):
@@ -932,3 +993,19 @@ def get_item_group(pos_profile):
item_groups.extend(get_descendants_of("Item Group", row.item_group))
return list(set(item_groups))
def create_payments_on_invoice(doc, idx, payment_details):
from erpnext.accounts.doctype.sales_invoice.sales_invoice import get_bank_cash_account
payment = frappe.new_doc("Sales Invoice Payment")
payment.idx = idx
payment.mode_of_payment = payment_details.mode_of_payment
payment.amount = payment_details.amount
payment.base_amount = payment.amount * doc.conversion_rate
payment.parent = doc.name
payment.parentfield = "payments"
payment.parenttype = doc.doctype
payment.account = get_bank_cash_account(payment.mode_of_payment, doc.company).get("account")
return payment

View File

@@ -18,11 +18,13 @@ frappe.listview_settings["POS Invoice"] = {
Draft: "red",
Unpaid: "orange",
Paid: "green",
"Partly Paid": "yellow",
Submitted: "blue",
Consolidated: "green",
Return: "darkgrey",
"Unpaid and Discounted": "orange",
"Overdue and Discounted": "red",
"Partly Paid and Discounted": "yellow",
Overdue: "red",
};
return [__(doc.status), status_color[doc.status], "status,=," + doc.status];

View File

@@ -401,6 +401,50 @@ class TestPOSInvoice(IntegrationTestCase):
pos_inv.insert()
self.assertRaises(PartialPaymentValidationError, pos_inv.submit)
def test_partly_paid_invoices(self):
set_allow_partial_payment(self.pos_profile, 1)
pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "amount": 90},
)
pos_inv.save()
pos_inv.submit()
self.assertEqual(pos_inv.paid_amount, 90)
self.assertEqual(pos_inv.status, "Partly Paid")
pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 10}])
self.assertEqual(pos_inv.paid_amount, 100)
self.assertEqual(pos_inv.status, "Paid")
set_allow_partial_payment(self.pos_profile, 0)
def test_multi_payment_for_partly_paid_invoices(self):
set_allow_partial_payment(self.pos_profile, 1)
pos_inv = create_pos_invoice(pos_profile=self.pos_profile.name, rate=100, do_not_save=1)
pos_inv.append(
"payments",
{"mode_of_payment": "Cash", "amount": 90},
)
pos_inv.save()
pos_inv.submit()
self.assertEqual(pos_inv.paid_amount, 90)
self.assertEqual(pos_inv.status, "Partly Paid")
pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}])
self.assertEqual(pos_inv.paid_amount, 95)
self.assertEqual(pos_inv.status, "Partly Paid")
pos_inv.update_payments(payments=[{"mode_of_payment": "Cash", "amount": 5}])
self.assertEqual(pos_inv.paid_amount, 100)
self.assertEqual(pos_inv.status, "Paid")
set_allow_partial_payment(self.pos_profile, 0)
def test_serialized_item_transaction(self):
from erpnext.stock.doctype.stock_entry.test_stock_entry import make_serialized_item
@@ -1094,3 +1138,9 @@ def make_batch_item(item_name):
if not frappe.db.exists(item_name):
return make_item(item_name, dict(has_batch_no=1, create_new_batch=1, is_stock_item=1))
def set_allow_partial_payment(pos_profile, value):
pos_profile.reload()
pos_profile.allow_partial_payment = value
pos_profile.save()

View File

@@ -26,13 +26,14 @@
"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",
"action_on_new_invoice",
"allow_partial_payment",
"section_break_23",
"item_groups",
"column_break_25",
@@ -423,6 +424,12 @@
"fieldtype": "Select",
"label": "Action on New Invoice",
"options": "Always Ask\nSave Changes and Load New Invoice\nDiscard Changes and Load New Invoice"
},
{
"default": "0",
"fieldname": "allow_partial_payment",
"fieldtype": "Check",
"label": "Allow Partial Payment"
}
],
"grid_page_length": 50,
@@ -451,7 +458,7 @@
"link_fieldname": "pos_profile"
}
],
"modified": "2025-05-23 12:12:32.247652",
"modified": "2025-06-24 11:19:19.834905",
"modified_by": "Administrator",
"module": "Accounts",
"name": "POS Profile",

View File

@@ -32,6 +32,7 @@ class POSProfile(Document):
"Always Ask", "Save Changes and Load New Invoice", "Discard Changes and Load New Invoice"
]
allow_discount_change: DF.Check
allow_partial_payment: DF.Check
allow_rate_change: DF.Check
applicable_for_users: DF.Table[POSProfileUser]
apply_discount_on: DF.Literal["Grand Total", "Net Total"]

View File

@@ -1104,20 +1104,19 @@ class SalesInvoice(SellingController):
self.validate_pos_opening_entry()
def validate_full_payment(self):
allow_partial_payment = frappe.db.get_value("POS Profile", self.pos_profile, "allow_partial_payment")
invoice_total = flt(self.rounded_total) or flt(self.grand_total)
if self.docstatus == 1:
if self.is_return and self.paid_amount != invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
if self.paid_amount < invoice_total:
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
if (
self.docstatus == 1
and not self.is_return
and not allow_partial_payment
and self.paid_amount < invoice_total
):
frappe.throw(
msg=_("Partial Payment in POS Transactions are not allowed."),
exc=PartialPaymentValidationError,
)
def validate_pos_opening_entry(self):
opening_entries = frappe.get_all(

View File

@@ -477,22 +477,28 @@ def get_invoice_filters(doctype, status, name=None, customer=None):
if doctype == "POS Invoice":
filters["status"] = status
if status == "Partly Paid":
filters["status"] = ["in", ["Partly Paid", "Overdue", "Unpaid"]]
return filters
if doctype == "Sales Invoice":
filters["is_created_using_pos"] = 1
filters["is_consolidated"] = 0
if status == "Draft":
filters["docstatus"] = 0
if status == "Consolidated":
filters["pos_closing_entry"] = ["is", "set"]
else:
filters["docstatus"] = 1
if status == "Paid":
filters["is_return"] = 0
if status == "Return":
filters["is_return"] = 1
filters["pos_closing_entry"] = ["is", "set"] if status == "Consolidated" else ["is", "not set"]
filters["pos_closing_entry"] = ["is", "not set"]
if status == "Draft":
filters["docstatus"] = 0
elif status == "Partly Paid":
filters["status"] = ["in", ["Partly Paid", "Overdue", "Unpaid"]]
else:
filters["docstatus"] = 1
if status == "Paid":
filters["is_return"] = 0
if status == "Return":
filters["is_return"] = 1
return filters

View File

@@ -561,6 +561,13 @@ erpnext.PointOfSale.Controller = class {
() => frappe.dom.unfreeze(),
]);
},
open_in_form_view: (doctype, name) => {
frappe.run_serially([
() => frappe.dom.freeze(),
() => frappe.set_route("Form", doctype, name),
() => frappe.dom.unfreeze(),
]);
},
},
});
}

View File

@@ -1042,6 +1042,7 @@ erpnext.PointOfSale.ItemCart = class {
"Credit Note Issued": "gray",
"Partly Paid": "yellow",
Overdue: "yellow",
Unpaid: "red",
};
transaction_container.append(

View File

@@ -66,7 +66,7 @@ erpnext.PointOfSale.PastOrderList = class {
df: {
label: __("Invoice Status"),
fieldtype: "Select",
options: `Draft\nPaid\nConsolidated\nReturn`,
options: ["Draft", "Paid", "Consolidated", "Return", "Partly Paid"].join("\n"),
placeholder: __("Filter by invoice status"),
onchange: function () {
if (me.$component.is(":visible")) me.refresh_list();

View File

@@ -75,8 +75,9 @@ erpnext.PointOfSale.PastOrderSummary = class {
let indicator_color = "";
["Paid", "Consolidated"].includes(status) && (indicator_color = "green");
status === "Draft" && (indicator_color = "red");
status === "Return" && (indicator_color = "grey");
["Partly Paid", "Overdue"].includes(status) && (indicator_color = "yellow");
["Draft", "Unpaid"].includes(status) && (indicator_color = "red");
["Credit Note Issued", "Return"].includes(status) && (indicator_color = "grey");
return `<div class="left-section">
<div class="customer-name">${doc.customer}</div>
@@ -243,6 +244,10 @@ erpnext.PointOfSale.PastOrderSummary = class {
this.$summary_container.on("click", ".print-btn", () => {
this.print_receipt();
});
this.$summary_container.on("click", ".open-btn", () => {
this.events.open_in_form_view(this.doc.doctype, this.doc.name);
});
}
print_receipt() {
@@ -361,7 +366,14 @@ erpnext.PointOfSale.PastOrderSummary = class {
return [
{ condition: this.doc.docstatus === 0, visible_btns: ["Edit Order", "Delete Order"] },
{
condition: !this.doc.is_return && this.doc.docstatus === 1,
condition: ["Partly Paid", "Overdue", "Unpaid"].includes(this.doc.status),
visible_btns: ["Print Receipt", "Email Receipt", "Open in Form View"],
},
{
condition:
!this.doc.is_return &&
this.doc.docstatus === 1 &&
!["Partly Paid", "Overdue", "Unpaid"].includes(this.doc.status),
visible_btns: ["Print Receipt", "Email Receipt", "Return"],
},
{

View File

@@ -5,6 +5,7 @@ erpnext.PointOfSale.Payment = class {
this.events = events;
this.set_gt_to_default_mop = settings.set_grand_total_to_default_mop;
this.invoice_fields = settings.invoice_fields;
this.allow_partial_payment = settings.allow_partial_payment;
this.init_component();
}
@@ -224,7 +225,12 @@ erpnext.PointOfSale.Payment = class {
const paid_amount = doc.paid_amount;
const items = doc.items;
if (!items.length || (paid_amount == 0 && doc.additional_discount_percentage != 100)) {
if (
!items.length ||
(paid_amount == 0 &&
doc.additional_discount_percentage != 100 &&
this.allow_partial_payment === 0)
) {
const message = items.length
? __("You cannot submit the order without payment.")
: __("You cannot submit empty order.");