Skip to content
Merged
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
Empty file.
60 changes: 60 additions & 0 deletions ram_custom/ram_custom/report/employee_payable/employee_payable.js
Original file line number Diff line number Diff line change
@@ -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,
},
],
};
Original file line number Diff line number Diff line change
@@ -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"
}
]
}
130 changes: 130 additions & 0 deletions ram_custom/ram_custom/report/employee_payable/employee_payable.py
Original file line number Diff line number Diff line change
@@ -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"]
)
}
76 changes: 76 additions & 0 deletions ram_custom/tests/test_employee_payable.py
Original file line number Diff line number Diff line change
@@ -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"})
Loading