Currently, ErpNext has no native function to record employer contributions. This is a must have in U.S accounting since every business is required to contribute taxes based on their employee’s pay.
This guide integrates employer contributions based on salary slips in ErpNext using custom scripts and modified doctypes. Contributions will be automatically imported into salary slips and journal\bank entries are automatically created with payroll entries. The formulas are based off U.S Federal and California State contributions for monthly pay, but aren’t fully fledged out to calculate all wage amounts. Please don’t use the formulas without looking them over, you should edit them to fit your use case. This was done over about a week of trail and error so it isn’t exactly perfect, but it does work flawlessly for my purposes. If anyone has a more elegant way of doing this that doesn’t included the caveat I mention below, I’d love to here it.
1. Create Salary Components to reflect your tax contributions
Create the Salary Components as earning types and name them accordingly. Include those components in your salary structure and assign them to your employees. Optionally, create liability\expense accounts for the components if you’d like to keep track of them in your ledgers.
Caveat: I originally wanted to have the components pulled dynamically with their formulas so the server script doesn’t need to be edited to modify the formulas. Unfortunately, Python and JavaScript don’t share the same syntax for formulas so I wasn’t able to do this. I ended up pulling the salary components, but manually set the formulas in the server script.
2. Customize the “Salary Slip” doctype.
Under the “Earnings & Deductions” tab, duplicate the “earnings” field and rename it to “contributions”. Update the doctype, the field name should now be named “custom_contributions”
3. Enable server scripts and create the two scripts below.
Contributions Salary Slip Script:
This script pulls the specified salary components assigned in the employee’s salary structure and imports them into the contributions field on the salary slip before submission. Please edit the script to reflect your accounts and components
Script Type: DocType Event
Reference Document Type: Salary Slip
DocType Event: Before Submit
Code:
if not doc.employee:
frappe.throw("Employee is required to calculate contributions.")
# Fetch Salary Structure Assignment for the Employee
salary_structure_assignment = frappe.db.get_value(
"Salary Structure Assignment",
{"employee": doc.employee},
"salary_structure"
)
if not salary_structure_assignment:
frappe.throw(f"No Salary Structure Assignment found for Employee {doc.employee}")
# Fetch Salary Structure
salary_structure = frappe.get_doc("Salary Structure", salary_structure_assignment)
# Ensure custom contributions exist
if not salary_structure.custom_contributions:
doc.custom_contributions = []
doc.custom_total_contributions = 0
else:
# Clear existing contributions
doc.custom_contributions = []
total_contributions = 0 # Initialize total contributions sum
for contribution in salary_structure.custom_contributions:
amount = 0
# Formula for Fed Medicare Tax
if contribution.salary_component == "Fed Medicare Tax":
if doc.gross_pay <= 16666.67:
amount = doc.gross_pay * 0.0145
else:
amount = (doc.gross_pay * 0.0145) + ((doc.gross_pay - 16666.67) * 0.009)
# Formula for Fed Social Security
if contribution.salary_component == "Fed Social Security":
if doc.gross_pay > 13350:
amount = 0
else:
amount = doc.gross_pay * 0.062
# Formula for California UI
if contribution.salary_component == "California UI":
amount = doc.gross_pay * 0.034
# Formula for California ETT
if contribution.salary_component == "California ETT":
amount = doc.gross_pay * 0.001
# Formula for FUTA
if contribution.salary_component == "FUTA":
amount = doc.gross_pay * 0.006
# Round the amount to two decimal places
amount = round(amount, 2)
# Append the contribution
doc.append("custom_contributions", {
"salary_component": contribution.salary_component,
"amount": amount,
"type": "Earning"
})
total_contributions = total_contributions + amount # Explicit assignment
# Set total contributions in the salary slip (rounded)
doc.custom_total_contributions = round(total_contributions, 2) # Explicit assignment
Contributions Journal Entry Script:
This script creates a journal entry for the employer contributions and submits it. It also creates two bank entries as drafts, one for payroll payable and the other for employer contributions and withheld employee deductions. Please edit the script to reflect your accounts and components
Script Type: DocType Event
Reference Document Type: Salary Slip
DocType Event: After Submit
Code:
if not doc.custom_contributions:
frappe.msgprint("No employer contributions found, skipping Journal Entry.")
else:
journal_entry = frappe.new_doc("Journal Entry")
journal_entry.voucher_type = "Journal Entry"
journal_entry.posting_date = doc.posting_date
journal_entry.company = doc.company
journal_entry.user_remark = f"Employer Contributions for Salary Slip {doc.name}"
accounts_list = []
total_debit = 0.0
total_credit = 0.0
for contribution in doc.custom_contributions:
salary_component = contribution.salary_component
amount = float(contribution.amount or 0)
if amount == 0:
continue # Skip zero amounts
# Determine debit account
if salary_component.startswith("F"):
debit_account = "FED Employer Tax - CompName"
elif salary_component.startswith("C"):
debit_account = "CAL Employer Tax - CompName"
else:
frappe.msgprint(f"Skipping unknown contribution: {salary_component}")
continue # Ignore unknown contributions
# Add debit entry
debit_entry = {
"account": debit_account,
"debit_in_account_currency": amount,
"credit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
}
accounts_list.append(debit_entry)
total_debit = total_debit + amount # Explicit assignment
# Add credit entry
credit_entry = {
"account": "Withheld Employer Tax - CompName",
"credit_in_account_currency": amount,
"debit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
}
accounts_list.append(credit_entry)
total_credit = total_credit + amount # Explicit assignment
# Ensure balanced Journal Entry
if abs(total_debit - total_credit) > 0.01:
frappe.throw("Debit and credit amounts do not match!")
# Append accounts to journal entry
for account in accounts_list:
journal_entry.append("accounts", account)
journal_entry.insert()
journal_entry.submit()
frappe.msgprint(f"Journal Entry {journal_entry.name} created successfully!")
# ======================== BANK ENTRY (DRAFT) ========================
# Get values from Salary Slip
withheld_employee_tax = float(doc.total_deduction or 0)
withheld_employer_tax = float(doc.custom_total_contributions or 0)
total_payment = withheld_employee_tax + withheld_employer_tax
if total_payment > 0:
draft_journal_entry = frappe.new_doc("Journal Entry")
draft_journal_entry.voucher_type = "Bank Entry"
draft_journal_entry.posting_date = doc.posting_date
draft_journal_entry.company = doc.company
draft_journal_entry.user_remark = f"Tax Payment for Salary Slip {doc.name}"
draft_journal_entry.docstatus = 0 # Ensure Draft status
draft_accounts_list = []
if withheld_employee_tax > 0:
draft_accounts_list.append({
"account": "Withheld Employee Tax - CompName",
"debit_in_account_currency": withheld_employee_tax,
"credit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
})
if withheld_employer_tax > 0:
draft_accounts_list.append({
"account": "Withheld Employer Tax - CompName",
"debit_in_account_currency": withheld_employer_tax,
"credit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
})
# Credit Entry (Bank Account)
bank_entry = {
"account": "Bank Account - CompName",
"credit_in_account_currency": total_payment,
"debit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
}
draft_accounts_list.append(bank_entry)
# Append accounts to draft journal entry
for account in draft_accounts_list:
draft_journal_entry.append("accounts", account)
draft_journal_entry.insert() # Save as Draft
frappe.msgprint(f"Draft Journal Entry {draft_journal_entry.name} created successfully!")
# ======================== SECOND BANK ENTRY (DRAFT) ========================
net_pay = float(doc.net_pay or 0)
if net_pay > 0:
payroll_payment_entry = frappe.new_doc("Journal Entry")
payroll_payment_entry.voucher_type = "Bank Entry"
payroll_payment_entry.posting_date = doc.posting_date
payroll_payment_entry.company = doc.company
payroll_payment_entry.user_remark = f"Payroll Payment for Salary Slip {doc.name}"
payroll_payment_entry.docstatus = 0 # Ensure Draft status
payroll_accounts_list = [
{
"account": "Payroll Payable - CompName",
"debit_in_account_currency": net_pay,
"credit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
},
{
"account": "Bank Account - CompName",
"credit_in_account_currency": net_pay,
"debit_in_account_currency": 0,
"reference_type": "Payroll Entry",
"reference_name": doc.payroll_entry or None
}
]
# Append accounts to the payroll payment entry
for account in payroll_accounts_list:
payroll_payment_entry.append("accounts", account)
payroll_payment_entry.insert() # Save as Draft
frappe.msgprint(f"Draft Payroll Payment Journal Entry {payroll_payment_entry.name} created successfully!")
5. Save and test your server scripts.
Submit a test payroll entry and ensure the scripts are functioning as expected. You can submit a salary slip manually and check if the contributions are imported, but the journal entries are only created when submitting the salary slip from a payroll entry.
Optional:
If you want to hide the extra fields in the contributions table on the Salary Slip, you can create the below client script:
DocType: Salary Slip
Apply To: Form
Code:
frappe.ui.form.on('Salary Slip', {
refresh: function(frm) {
removeColumns(frm, ["formula", "abbr", "depends_on_payment_days", "is_tax_applicable", "amount_based_on_formula", "type"], "custom_contributions");
}
});
function removeColumns(frm, fields, table) {
let grid = frm.get_field(table).grid;
if (!grid) return;
for (let field of fields) {
if (grid.fields_map[field]) {
grid.fields_map[field].hidden = 1;
}
}
grid.visible_columns = undefined;
grid.setup_visible_columns();
if (grid.header_row) {
grid.header_row.wrapper.remove();
delete grid.header_row;
grid.make_head();
}
for (let row of grid.grid_rows) {
if (row.open_form_button) {
row.open_form_button.parent().remove();
delete row.open_form_button;
}
for (let field in row.columns) {
if (row.columns[field] !== undefined) {
row.columns[field].remove();
}
}
delete row.columns;
row.columns = [];
row.render_row();
}
}
You should now have employer contributions integrated into ErpNext!