From 118ceda46f1a77542fee90642587a007aee658b4 Mon Sep 17 00:00:00 2001 From: Ben Cornwell-Mott Date: Fri, 14 Jul 2017 17:34:45 -0700 Subject: [PATCH] Add Payment Entry for Expenses --- .../doctype/payment_entry/payment_entry.js | 32 +++- .../doctype/payment_entry/payment_entry.py | 145 +++++++++++------- .../payment_entry/test_payment_entry.py | 23 +++ erpnext/accounts/utils.py | 2 +- .../expense_claim/test_expense_claim.py | 65 ++++---- 5 files changed, 166 insertions(+), 101 deletions(-) diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.js b/erpnext/accounts/doctype/payment_entry/payment_entry.js index 4a15f39e12..fbbfd28d9a 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.js +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.js @@ -28,7 +28,7 @@ frappe.ui.form.on('Payment Entry', { frm.set_query("party_type", function() { return{ "filters": { - "name": ["in",["Customer","Supplier"]], + "name": ["in",["Customer","Supplier", "Employee"]], } } }); @@ -70,6 +70,8 @@ frappe.ui.form.on('Payment Entry', { var doctypes = ["Sales Order", "Sales Invoice", "Journal Entry"]; } else if (frm.doc.party_type=="Supplier") { var doctypes = ["Purchase Order", "Purchase Invoice", "Journal Entry"]; + } else if (frm.doc.party_type=="Employee") { + var doctypes = ["Expense Claim"]; } else { var doctypes = ["Journal Entry"]; } @@ -82,7 +84,7 @@ frappe.ui.form.on('Payment Entry', { frm.set_query("reference_name", "references", function(doc, cdt, cdn) { child = locals[cdt][cdn]; filters = {"docstatus": 1, "company": doc.company}; - party_type_doctypes = ['Sales Invoice', 'Sales Order', 'Purchase Invoice', 'Purchase Order']; + party_type_doctypes = ['Sales Invoice', 'Sales Order', 'Purchase Invoice', 'Purchase Order', 'Expense Claim']; if (in_list(party_type_doctypes, child.reference_doctype)) { filters[doc.party_type.toLowerCase()] = doc.party; @@ -200,9 +202,15 @@ frappe.ui.form.on('Payment Entry', { }); } else { if(!frm.doc.party) - frm.set_value("party_type", frm.doc.payment_type=="Receive" ? "Customer" : "Supplier"); + { + if (frm.doc.payment_type=="Receive"){ + frm.set_value("party_type", "Customer"); + } + } else + { frm.events.party(frm); + } if(frm.doc.mode_of_payment) frm.events.mode_of_payment(frm); @@ -487,7 +495,7 @@ frappe.ui.form.on('Payment Entry', { c.reference_name = d.voucher_no; c.total_amount = d.invoice_amount; c.outstanding_amount = d.outstanding_amount; - if(!in_list(["Sales Order", "Purchase Order"], d.voucher_type)) { + if(!in_list(["Sales Order", "Purchase Order", "Expense Claim"], d.voucher_type)) { if(flt(d.outstanding_amount) > 0) total_positive_outstanding += flt(d.outstanding_amount); else @@ -502,14 +510,15 @@ frappe.ui.form.on('Payment Entry', { } else { c.exchange_rate = 1; } - if (in_list(['Sales Invoice', 'Purchase Invoice'], d.reference_doctype)){ + if (in_list(['Sales Invoice', 'Purchase Invoice', "Expense Claim"], d.reference_doctype)){ c.due_date = d.due_date; } }); if( (frm.doc.payment_type=="Receive" && frm.doc.party_type=="Customer") || - (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier") + (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier") || + (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Employee") ) { if(total_positive_outstanding > total_negative_outstanding) frm.set_value("paid_amount", @@ -554,7 +563,8 @@ frappe.ui.form.on('Payment Entry', { var allocated_negative_outstanding = 0; if((frm.doc.payment_type=="Receive" && frm.doc.party_type=="Customer") || - (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier")) { + (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Supplier") || + (frm.doc.payment_type=="Pay" && frm.doc.party_type=="Employee")) { if(total_positive_outstanding_including_order > paid_amount) { var remaining_outstanding = total_positive_outstanding_including_order - paid_amount; allocated_negative_outstanding = total_negative_outstanding < remaining_outstanding ? @@ -695,6 +705,14 @@ frappe.ui.form.on('Payment Entry', { frappe.msgprint(__("Row #{0}: Reference Document Type must be one of Purchase Order, Purchase Invoice or Journal Entry", [row.idx])); return false; } + + if(frm.doc.party_type=="Employee" && + !in_list(["Expense Claim", "Journal Entry"], row.reference_doctype) + ) { + frappe.model.set_value(row.doctype, row.name, "against_voucher_type", null); + frappe.msgprint(__("Row #{0}: Reference Document Type must be one of Expense Claim or Journal Entry", [row.idx])); + return false; + } } if (row) { diff --git a/erpnext/accounts/doctype/payment_entry/payment_entry.py b/erpnext/accounts/doctype/payment_entry/payment_entry.py index e9471b7eff..59a899b70d 100644 --- a/erpnext/accounts/doctype/payment_entry/payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/payment_entry.py @@ -12,7 +12,7 @@ from erpnext.accounts.doctype.journal_entry.journal_entry \ import get_average_exchange_rate, get_default_bank_cash_account from erpnext.setup.utils import get_exchange_rate from erpnext.accounts.general_ledger import make_gl_entries - +from erpnext.hr.doctype.expense_claim.expense_claim import update_reimbursed_amount from erpnext.controllers.accounts_controller import AccountsController class InvalidPaymentEntry(ValidationError): pass @@ -57,11 +57,13 @@ class PaymentEntry(AccountsController): frappe.throw(_("Difference Amount must be zero")) self.make_gl_entries() self.update_advance_paid() + self.update_expense_claim() def on_cancel(self): self.setup_party_account_field() self.make_gl_entries(cancel=1) self.update_advance_paid() + self.update_expense_claim() self.delink_advance_entry_references() def validate_duplicate_entry(self): @@ -182,9 +184,11 @@ class PaymentEntry(AccountsController): def validate_reference_documents(self): if self.party_type == "Customer": valid_reference_doctypes = ("Sales Order", "Sales Invoice", "Journal Entry") - else: + elif self.party_type == "Supplier": valid_reference_doctypes = ("Purchase Order", "Purchase Invoice", "Journal Entry") - + elif self.party_type == "Employee": + valid_reference_doctypes = ("Expense Claim", "Journal Entry") + for d in self.get("references"): if not d.allocated_amount: continue @@ -205,12 +209,17 @@ class PaymentEntry(AccountsController): else: self.validate_journal_entry() - if d.reference_doctype in ("Sales Invoice", "Purchase Invoice"): - ref_party_account = ref_doc.debit_to \ - if self.party_type=="Customer" else ref_doc.credit_to + if d.reference_doctype in ("Sales Invoice", "Purchase Invoice", "Expense Claim"): + if self.party_type=="Customer": + ref_party_account = ref_doc.debit_to + elif self.party_type=="Supplier": + ref_party_account = ref_doc.credit_to + elif self.party_type=="Employee": + ref_party_account = ref_doc.payable_account + if ref_party_account != self.party_account: - frappe.throw(_("{0} {1} does not associated with Party Account {2}") - .format(d.reference_doctype, d.reference_name, self.party_account)) + frappe.throw(_("{0} {1} is associated with {2}, but Party Account is {3}") + .format(d.reference_doctype, d.reference_name, ref_party_account, self.party_account)) if ref_doc.docstatus != 1: frappe.throw(_("{0} {1} must be submitted") @@ -473,6 +482,13 @@ class PaymentEntry(AccountsController): if d.allocated_amount and d.reference_doctype in ("Sales Order", "Purchase Order"): frappe.get_doc(d.reference_doctype, d.reference_name).set_total_advance_paid() + def update_expense_claim(self): + if self.payment_type in ("Pay") and self.party: + for d in self.get("references"): + if d.reference_doctype=="Expense Claim" and d.reference_name: + doc = frappe.get_doc("Expense Claim", d.reference_name) + update_reimbursed_amount(doc) + @frappe.whitelist() def get_outstanding_reference_documents(args): args = json.loads(args) @@ -493,7 +509,7 @@ def get_outstanding_reference_documents(args): for d in outstanding_invoices: d["exchange_rate"] = 1 if party_account_currency != company_currency: - if d.voucher_type in ("Sales Invoice", "Purchase Invoice"): + if d.voucher_type in ("Sales Invoice", "Purchase Invoice", "Expense Claim"): d["exchange_rate"] = frappe.db.get_value(d.voucher_type, d.voucher_no, "conversion_rate") elif d.voucher_type == "Journal Entry": d["exchange_rate"] = get_exchange_rate( @@ -507,31 +523,38 @@ def get_outstanding_reference_documents(args): return negative_outstanding_invoices + outstanding_invoices + orders_to_be_billed def get_orders_to_be_billed(posting_date, party_type, party, party_account_currency, company_currency): - voucher_type = 'Sales Order' if party_type == "Customer" else 'Purchase Order' + if party_type == "Customer": + voucher_type = 'Sales Order' + elif party_type == "Supplier": + voucher_type = 'Purchase Order' + elif party_type == "Employee": + voucher_type = None + + orders = [] + if voucher_type: + ref_field = "base_grand_total" if party_account_currency == company_currency else "grand_total" - ref_field = "base_grand_total" if party_account_currency == company_currency else "grand_total" - - orders = frappe.db.sql(""" - select - name as voucher_no, - {ref_field} as invoice_amount, - ({ref_field} - advance_paid) as outstanding_amount, - transaction_date as posting_date - from - `tab{voucher_type}` - where - {party_type} = %s - and docstatus = 1 - and ifnull(status, "") != "Closed" - and {ref_field} > advance_paid - and abs(100 - per_billed) > 0.01 - order by - transaction_date, name - """.format(**{ - "ref_field": ref_field, - "voucher_type": voucher_type, - "party_type": scrub(party_type) - }), party, as_dict = True) + orders = frappe.db.sql(""" + select + name as voucher_no, + {ref_field} as invoice_amount, + ({ref_field} - advance_paid) as outstanding_amount, + transaction_date as posting_date + from + `tab{voucher_type}` + where + {party_type} = %s + and docstatus = 1 + and ifnull(status, "") != "Closed" + and {ref_field} > advance_paid + and abs(100 - per_billed) > 0.01 + order by + transaction_date, name + """.format(**{ + "ref_field": ref_field, + "voucher_type": voucher_type, + "party_type": scrub(party_type) + }), party, as_dict = True) order_list = [] for d in orders: @@ -544,24 +567,27 @@ def get_orders_to_be_billed(posting_date, party_type, party, party_account_curre return order_list def get_negative_outstanding_invoices(party_type, party, party_account, total_field): - voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" - return frappe.db.sql(""" - select - "{voucher_type}" as voucher_type, name as voucher_no, - {total_field} as invoice_amount, outstanding_amount, posting_date, - due_date, conversion_rate as exchange_rate - from - `tab{voucher_type}` - where - {party_type} = %s and {party_account} = %s and docstatus = 1 and outstanding_amount < 0 - order by - posting_date, name - """.format(**{ - "total_field": total_field, - "voucher_type": voucher_type, - "party_type": scrub(party_type), - "party_account": "debit_to" if party_type=="Customer" else "credit_to" - }), (party, party_account), as_dict = True) + if party_type != "Employee": + voucher_type = "Sales Invoice" if party_type == "Customer" else "Purchase Invoice" + return frappe.db.sql(""" + select + "{voucher_type}" as voucher_type, name as voucher_no, + {total_field} as invoice_amount, outstanding_amount, posting_date, + due_date, conversion_rate as exchange_rate + from + `tab{voucher_type}` + where + {party_type} = %s and {party_account} = %s and docstatus = 1 and outstanding_amount < 0 + order by + posting_date, name + """.format(**{ + "total_field": total_field, + "voucher_type": voucher_type, + "party_type": scrub(party_type), + "party_account": "debit_to" if party_type=="Customer" else "credit_to" + }), (party, party_account), as_dict = True) + else: + return [] @frappe.whitelist() def get_party_details(company, party_type, party, date): @@ -609,7 +635,10 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre if reference_doctype != "Journal Entry": if party_account_currency == ref_doc.company_currency: - total_amount = ref_doc.base_grand_total + if ref_doc.doctype == "Expense Claim": + total_amount = ref_doc.total_sanctioned_amount + else: + total_amount = ref_doc.base_grand_total exchange_rate = 1 else: total_amount = ref_doc.grand_total @@ -620,7 +649,7 @@ def get_reference_details(reference_doctype, reference_name, party_account_curre get_exchange_rate(party_account_currency, ref_doc.company_currency, ref_doc.posting_date) outstanding_amount = ref_doc.get("outstanding_amount") \ - if reference_doctype in ("Sales Invoice", "Purchase Invoice") \ + if reference_doctype in ("Sales Invoice", "Purchase Invoice", "Expense Claim") \ else flt(total_amount) - flt(ref_doc.advance_paid) else: # Get the exchange rate based on the posting date of the ref doc @@ -641,7 +670,12 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= if dt in ("Sales Order", "Purchase Order") and flt(doc.per_billed, 2) > 0: frappe.throw(_("Can only make payment against unbilled {0}").format(dt)) - party_type = "Customer" if dt in ("Sales Invoice", "Sales Order") else "Supplier" + if dt in ("Sales Invoice", "Sales Order"): + party_type = "Customer" + elif dt in ("Purchase Invoice", "Purchase Order"): + party_type = "Supplier" + elif dt in ("Expense Claim"): + party_type = "Employee" # party account if dt == "Sales Invoice": @@ -667,6 +701,9 @@ def get_payment_entry(dt, dn, party_amount=None, bank_account=None, bank_amount= elif dt in ("Sales Invoice", "Purchase Invoice"): grand_total = doc.base_grand_total if party_account_currency == doc.company_currency else doc.grand_total outstanding_amount = doc.outstanding_amount + elif dt in ("Expense Claim"): + grand_total = doc.total_sanctioned_amount + outstanding_amount = doc.total_sanctioned_amount - doc.total_amount_reimbursed else: total_field = "base_grand_total" if party_account_currency == doc.company_currency else "grand_total" grand_total = flt(doc.get(total_field)) diff --git a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py index ccf114fa73..9d6b75b1a7 100644 --- a/erpnext/accounts/doctype/payment_entry/test_payment_entry.py +++ b/erpnext/accounts/doctype/payment_entry/test_payment_entry.py @@ -10,6 +10,7 @@ from erpnext.selling.doctype.sales_order.test_sales_order import make_sales_orde from erpnext.accounts.doctype.payment_entry.payment_entry import get_payment_entry, InvalidPaymentEntry from erpnext.accounts.doctype.sales_invoice.test_sales_invoice import create_sales_invoice from erpnext.accounts.doctype.purchase_invoice.test_purchase_invoice import make_purchase_invoice +from erpnext.hr.doctype.expense_claim.test_expense_claim import make_expense_claim test_dependencies = ["Item"] @@ -83,6 +84,28 @@ class TestPaymentEntry(unittest.TestCase): outstanding_amount = flt(frappe.db.get_value("Sales Invoice", pi.name, "outstanding_amount")) self.assertEqual(outstanding_amount, 0) + + def test_payment_entry_against_ec(self): + + payable = frappe.db.get_value('Company', "_Test Company", 'default_payable_account') + ec = make_expense_claim(payable, 300, 300, "_Test Company","Travel Expenses - _TC") + pe = get_payment_entry("Expense Claim", ec.name, bank_account="_Test Bank USD - _TC", bank_amount=300) + pe.reference_no = "1" + pe.reference_date = "2016-01-01" + pe.source_exchange_rate = 1 + pe.insert() + pe.submit() + + expected_gle = dict((d[0], d) for d in [ + [payable, 300, 0, ec.name], + ["_Test Bank USD - _TC", 0, 300, None] + ]) + + self.validate_gl_entries(pe.name, expected_gle) + + outstanding_amount = flt(frappe.db.get_value("Expense Claim", ec.name, "total_sanctioned_amount")) - \ + flt(frappe.db.get_value("Expense Claim", ec.name, "total_amount_reimbursed")) + self.assertEqual(outstanding_amount, 0) def test_payment_entry_against_si_usd_to_inr(self): si = create_sales_invoice(customer="_Test Customer USD", debit_to="_Test Receivable USD - _TC", diff --git a/erpnext/accounts/utils.py b/erpnext/accounts/utils.py index cce1e6fff9..9ed8674df0 100644 --- a/erpnext/accounts/utils.py +++ b/erpnext/accounts/utils.py @@ -621,7 +621,7 @@ def get_outstanding_invoices(party_type, party, account, condition=None): 'invoice_amount': flt(d.invoice_amount), 'payment_amount': flt(d.payment_amount), 'outstanding_amount': flt(d.invoice_amount - d.payment_amount, precision), - 'due_date': frappe.db.get_value(d.voucher_type, d.voucher_no, "due_date"), + 'due_date': frappe.db.get_value(d.voucher_type, d.voucher_no, "posting_date") if party_type=="Employee" else frappe.db.get_value(d.voucher_type, d.voucher_no, "due_date"), })) outstanding_invoices = sorted(outstanding_invoices, key=lambda k: k['due_date'] or getdate(nowdate())) diff --git a/erpnext/hr/doctype/expense_claim/test_expense_claim.py b/erpnext/hr/doctype/expense_claim/test_expense_claim.py index e8c24bbba5..016e8fa550 100644 --- a/erpnext/hr/doctype/expense_claim/test_expense_claim.py +++ b/erpnext/hr/doctype/expense_claim/test_expense_claim.py @@ -14,6 +14,7 @@ class TestExpenseClaim(unittest.TestCase): frappe.db.sql("""delete from `tabTask` where project = "_Test Project 1" """) frappe.db.sql("""delete from `tabProject` where name = "_Test Project 1" """) + frappe.get_doc({ "project_name": "_Test Project 1", "doctype": "Project", @@ -24,52 +25,25 @@ class TestExpenseClaim(unittest.TestCase): task_name = frappe.db.get_value("Task", {"project": "_Test Project 1"}) payable_account = get_payable_account("Wind Power LLC") - expense_claim = frappe.get_doc({ - "doctype": "Expense Claim", - "employee": "_T-Employee-0001", - "payable_account": payable_account, - "approval_status": "Approved", - "project": "_Test Project 1", - "task": task_name, - "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - WP", "claim_amount": 300, "sanctioned_amount": 200 }] - }) - expense_claim.submit() + expense_claim = make_expense_claim(payable_account, 300, 200, "Wind Power LLC","Travel Expenses - WP", "_Test Project 1", task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200) self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 200) - expense_claim2 = frappe.get_doc({ - "doctype": "Expense Claim", - "employee": "_T-Employee-0001", - "approval_status": "Approved", - "project": "_Test Project 1", - "task": task_name, - "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - WP", "claim_amount": 600, "sanctioned_amount": 500 }] - }) - expense_claim2.submit() + expense_claim2 = make_expense_claim(payable_account, 600, 500, "Wind Power LLC", "Travel Expenses - WP","_Test Project 1", task_name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 700) self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 700) expense_claim2.cancel() - frappe.delete_doc("Expenses Claim", expense_claim2.name) + frappe.delete_doc("Expense Claim", expense_claim2.name) self.assertEqual(frappe.db.get_value("Task", task_name, "total_expense_claim"), 200) self.assertEqual(frappe.db.get_value("Project", "_Test Project 1", "total_expense_claim"), 200) def test_expense_claim_status(self): payable_account = get_payable_account("Wind Power LLC") - expense_claim = frappe.get_doc({ - "doctype": "Expense Claim", - "employee": "_T-Employee-0001", - "payable_account": payable_account, - "approval_status": "Approved", - "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - WP", "claim_amount": 300, "sanctioned_amount": 200 }] - }) - expense_claim.submit() + expense_claim = make_expense_claim(payable_account, 300, 200, "Wind Power LLC", "Travel Expenses - WP") je_dict = make_bank_entry(expense_claim.name) je = frappe.get_doc(je_dict) @@ -87,14 +61,7 @@ class TestExpenseClaim(unittest.TestCase): def test_expense_claim_gl_entry(self): payable_account = get_payable_account("Wind Power LLC") - expense_claim = frappe.get_doc({ - "doctype": "Expense Claim", - "employee": "_T-Employee-0001", - "payable_account": payable_account, - "approval_status": "Approved", - "expenses": - [{ "expense_type": "Travel", "default_account": "Travel Expenses - WP", "claim_amount": 300, "sanctioned_amount": 200 }] - }) + expense_claim = make_expense_claim(payable_account, 300, 200, "Wind Power LLC", "Travel Expenses - WP") expense_claim.submit() gl_entries = frappe.db.sql("""select account, debit, credit @@ -133,3 +100,23 @@ class TestExpenseClaim(unittest.TestCase): def get_payable_account(company): return frappe.db.get_value('Company', company, 'default_payable_account') + +def make_expense_claim(payable_account,claim_amount, sanctioned_amount, company, account, project=None, task_name=None): + expense_claim = frappe.get_doc({ + "doctype": "Expense Claim", + "employee": "_T-Employee-0001", + "payable_account": payable_account, + "approval_status": "Approved", + "company": company, + "expenses": + [{ "expense_type": "Travel", "default_account": account, "claim_amount": claim_amount, "sanctioned_amount": sanctioned_amount }] + }) + if project: + expense_claim.project = project + if task_name: + expense_claim.task = task_name + + expense_claim.submit() + return expense_claim + +