From c1ccb606dcbc27c20dcf8cde5077bdbf81fa20b4 Mon Sep 17 00:00:00 2001 From: sayanthns Date: Wed, 14 Oct 2026 18:39:02 +0300 Subject: [PATCH] feat(report): Employee Payable (opening / accrued / paid / balance) Script report on the payroll payable account's employee-party GL entries: per employee Opening, Accrued (credit), Paid (debit), Balance. Filters: company, dates, account (defaults to company payroll payable), employee, hide settled. Tests compare against the General Ledger. Co-Authored-By: Claude Sonnet 5.5 --- .../report/employee_payable/__init__.py | 0 .../employee_payable/employee_payable.js | 60 ++++++++ .../employee_payable/employee_payable.json | 34 +++++ .../employee_payable/employee_payable.py | 130 ++++++++++++++++++ ram_custom/tests/test_employee_payable.py | 76 ++++++++++ 5 files changed, 300 insertions(+) create mode 100644 ram_custom/ram_custom/report/employee_payable/__init__.py create mode 100644 ram_custom/ram_custom/report/employee_payable/employee_payable.js create mode 100644 ram_custom/ram_custom/report/employee_payable/employee_payable.json create mode 100644 ram_custom/ram_custom/report/employee_payable/employee_payable.py create mode 100644 ram_custom/tests/test_employee_payable.py diff --git a/ram_custom/ram_custom/report/employee_payable/__init__.py b/ram_custom/ram_custom/report/employee_payable/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/ram_custom/ram_custom/report/employee_payable/employee_payable.js b/ram_custom/ram_custom/report/employee_payable/employee_payable.js new file mode 100644 index 0000000..fec45ee --- /dev/null +++ b/ram_custom/ram_custom/report/employee_payable/employee_payable.js @@ -0,0 +1,60 @@ +// Copyright (c) 2026, Enfono Technologies and contributors +// For license information, please see license.txt + +frappe.query_reports["Employee Payable"] = { + filters: [ + { + fieldname: "company", + label: __("Company"), + fieldtype: "Link", + options: "Company", + default: frappe.defaults.get_user_default("Company"), + reqd: 1, + on_change: function () { + frappe.query_report.set_filter_value("account", ""); + }, + }, + { + fieldname: "from_date", + label: __("From Date"), + fieldtype: "Date", + default: frappe.datetime.add_months(frappe.datetime.get_today(), -1), + reqd: 1, + }, + { + fieldname: "to_date", + label: __("To Date"), + fieldtype: "Date", + default: frappe.datetime.get_today(), + reqd: 1, + }, + { + fieldname: "account", + label: __("Payroll Payable Account"), + fieldtype: "Link", + options: "Account", + description: __("Leave empty to use the company's default payroll payable account"), + get_query: function () { + return { + filters: { + company: frappe.query_report.get_filter_value("company"), + is_group: 0, + root_type: "Liability", + }, + }; + }, + }, + { + fieldname: "employee", + label: __("Employee"), + fieldtype: "Link", + options: "Employee", + }, + { + fieldname: "hide_settled", + label: __("Hide Settled (zero balance)"), + fieldtype: "Check", + default: 0, + }, + ], +}; diff --git a/ram_custom/ram_custom/report/employee_payable/employee_payable.json b/ram_custom/ram_custom/report/employee_payable/employee_payable.json new file mode 100644 index 0000000..adf81e9 --- /dev/null +++ b/ram_custom/ram_custom/report/employee_payable/employee_payable.json @@ -0,0 +1,34 @@ +{ + "add_total_row": 1, + "columns": [], + "disable_prepared_report": 0, + "disabled": 0, + "docstatus": 0, + "doctype": "Report", + "filters": [], + "idx": 0, + "is_standard": "Yes", + "letterhead": null, + "modified": "2026-10-14 12:00:00.000000", + "module": "Ram Custom", + "name": "Employee Payable", + "owner": "Administrator", + "prepared_report": 0, + "ref_doctype": "GL Entry", + "report_name": "Employee Payable", + "report_type": "Script Report", + "roles": [ + { + "role": "Accounts User" + }, + { + "role": "Accounts Manager" + }, + { + "role": "Auditor" + }, + { + "role": "HR Manager" + } + ] +} diff --git a/ram_custom/ram_custom/report/employee_payable/employee_payable.py b/ram_custom/ram_custom/report/employee_payable/employee_payable.py new file mode 100644 index 0000000..f47354c --- /dev/null +++ b/ram_custom/ram_custom/report/employee_payable/employee_payable.py @@ -0,0 +1,130 @@ +# Copyright (c) 2026, Enfono Technologies and contributors +# For license information, please see license.txt + +"""Employee Payable: what was accrued, what was paid, what is still owed. + +Payroll accrual entries post to the company's payroll payable account with the +Employee as party (HR Settings: process payroll accounting entry based on +employee). Salary payments post a debit to the same account and party. So per +employee, over a period: + + Opening = credit - debit before From Date + Accrued = credit in the period + Paid = debit in the period + Balance = Opening + Accrued - Paid (positive = still to be paid) + +Only entries carrying an Employee party on the chosen account are read. Salary +posted straight to a separate per-staff ledger (no party) is not part of this +report. +""" + +import frappe +from frappe import _ +from frappe.query_builder import Case +from frappe.query_builder.functions import Sum +from frappe.utils import flt, getdate + +PRECISION = 2 + + +def execute(filters=None): + filters = frappe._dict(filters or {}) + validate_filters(filters) + account = get_account(filters) + currency = frappe.get_cached_value("Company", filters.company, "default_currency") + return get_columns(currency), get_data(filters, account) + + +def validate_filters(filters): + if not filters.company: + frappe.throw(_("Please select a Company")) + if not filters.from_date or not filters.to_date: + frappe.throw(_("From Date and To Date are mandatory")) + if getdate(filters.from_date) > getdate(filters.to_date): + frappe.throw(_("From Date cannot be after To Date")) + + +def get_account(filters): + account = filters.get("account") or frappe.get_cached_value( + "Company", filters.company, "default_payroll_payable_account" + ) + if not account: + frappe.throw( + _("Set a Payroll Payable Account in the filters, or a Default Payroll Payable Account on {0}").format( + frappe.bold(filters.company) + ) + ) + if frappe.get_cached_value("Account", account, "company") != filters.company: + frappe.throw(_("Account {0} does not belong to {1}").format(frappe.bold(account), frappe.bold(filters.company))) + return account + + +def get_columns(currency): + return [ + {"label": _("Employee"), "fieldname": "employee", "fieldtype": "Link", "options": "Employee", "width": 130}, + {"label": _("Employee Name"), "fieldname": "employee_name", "fieldtype": "Data", "width": 220}, + {"label": _("Opening"), "fieldname": "opening", "fieldtype": "Currency", "options": currency, "width": 120}, + {"label": _("Accrued"), "fieldname": "accrued", "fieldtype": "Currency", "options": currency, "width": 120}, + {"label": _("Paid"), "fieldname": "paid", "fieldtype": "Currency", "options": currency, "width": 120}, + {"label": _("Balance"), "fieldname": "balance", "fieldtype": "Currency", "options": currency, "width": 130}, + ] + + +def get_data(filters, account): + gle = frappe.qb.DocType("GL Entry") + in_period = gle.posting_date >= getdate(filters.from_date) + before = gle.posting_date < getdate(filters.from_date) + + query = ( + frappe.qb.from_(gle) + .select( + gle.party.as_("employee"), + Sum(Case().when(before, gle.credit - gle.debit).else_(0)).as_("opening"), + Sum(Case().when(in_period, gle.credit).else_(0)).as_("accrued"), + Sum(Case().when(in_period, gle.debit).else_(0)).as_("paid"), + ) + .where(gle.company == filters.company) + .where(gle.account == account) + .where(gle.party_type == "Employee") + .where(gle.is_cancelled == 0) + .where(gle.posting_date <= getdate(filters.to_date)) + .groupby(gle.party) + .orderby(gle.party) + ) + if filters.get("employee"): + query = query.where(gle.party == filters.employee) + + rows = query.run(as_dict=True) + names = get_employee_names([r.employee for r in rows]) + + data = [] + for r in rows: + opening, accrued, paid = (flt(r[k], PRECISION) for k in ("opening", "accrued", "paid")) + balance = flt(opening + accrued - paid, PRECISION) + if not (opening or accrued or paid or balance): + continue + if filters.get("hide_settled") and not balance: + continue + data.append( + { + "employee": r.employee, + "employee_name": names.get(r.employee) or "", + "opening": opening, + "accrued": accrued, + "paid": paid, + "balance": balance, + } + ) + return data + + +def get_employee_names(employees): + employees = [e for e in set(employees) if e] + if not employees: + return {} + return { + d.name: d.employee_name + for d in frappe.get_all( + "Employee", filters={"name": ["in", employees]}, fields=["name", "employee_name"] + ) + } diff --git a/ram_custom/tests/test_employee_payable.py b/ram_custom/tests/test_employee_payable.py new file mode 100644 index 0000000..f612fca --- /dev/null +++ b/ram_custom/tests/test_employee_payable.py @@ -0,0 +1,76 @@ +# Copyright (c) 2026, Enfono Technologies and contributors +# For license information, please see license.txt + +import frappe +from frappe.tests.utils import FrappeTestCase +from frappe.utils import flt, today + +from ram_custom.ram_custom.report.employee_payable.employee_payable import execute + + +class TestEmployeePayable(FrappeTestCase): + """The report must agree with the General Ledger, employee by employee.""" + + def _account_with_employee_entries(self): + row = frappe.db.sql( + """ + select company, account from `tabGL Entry` + where party_type = 'Employee' and is_cancelled = 0 + group by company, account order by count(*) desc limit 1 + """, + as_dict=True, + ) + return row[0] if row else None + + def _filters(self, ctx, **extra): + return {"company": ctx.company, "account": ctx.account, "from_date": "2000-01-01", "to_date": today(), **extra} + + def test_balance_matches_general_ledger(self): + ctx = self._account_with_employee_entries() + if not ctx: + self.skipTest("no employee-party GL entries on this site") + _cols, data = execute(self._filters(ctx)) + expected = { + d.party: flt(d.bal, 2) + for d in frappe.db.sql( + """ + select party, sum(credit - debit) bal from `tabGL Entry` + where company = %s and account = %s and party_type = 'Employee' and is_cancelled = 0 + group by party + """, + (ctx.company, ctx.account), + as_dict=True, + ) + } + for row in data: + self.assertAlmostEqual(row["balance"], expected[row["employee"]], places=2) + self.assertAlmostEqual(row["opening"] + row["accrued"] - row["paid"], row["balance"], places=2) + + def test_opening_moves_with_from_date(self): + ctx = self._account_with_employee_entries() + if not ctx: + self.skipTest("no employee-party GL entries on this site") + _c, all_time = execute(self._filters(ctx)) + _c, from_today = execute(self._filters(ctx, from_date=today())) + by_emp = {r["employee"]: r for r in all_time} + for r in from_today: + self.assertAlmostEqual(r["opening"], by_emp[r["employee"]]["balance"] - r["accrued"] + r["paid"], places=2) + + def test_hide_settled_drops_only_zero_balances(self): + ctx = self._account_with_employee_entries() + if not ctx: + self.skipTest("no employee-party GL entries on this site") + _c, shown = execute(self._filters(ctx, hide_settled=1)) + self.assertTrue(all(r["balance"] for r in shown)) + + def test_wrong_company_account_is_rejected(self): + ctx = self._account_with_employee_entries() + other = frappe.db.get_value("Company", {"name": ["!=", ctx.company if ctx else ""]}, "name") + if not ctx or not other: + self.skipTest("needs two companies") + with self.assertRaises(frappe.ValidationError): + execute({"company": other, "account": ctx.account, "from_date": "2026-01-01", "to_date": today()}) + + def test_dates_are_validated(self): + with self.assertRaises(frappe.ValidationError): + execute({"company": "X", "from_date": "2026-02-01", "to_date": "2026-01-01"})