Guide: Integrating Employer Contributions in ErpNext

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!

5 Likes

@j0no
Great job! Thank you for sharing.

Thank you for sharing.

I’ve been struggling with this and other limitations of HRMS.

For USA and possibly other jurisdictions, I think the payroll structure needs a significant enhancement. I think we need additional types of Salary Components like: (Employer Contribution, Employer Tax, maybe more).
Additionally, assigning a Tax Slab against a salary component would be much more useful compared to assigning it to an Employee.

Statistical Component is useful but it’s handicapped (it can’t be used in the salary slip).

Ironically I’ve been struggling over the past few hours trying to get ChatGPT to help with creating a Journal Entry from a single Salary Slip (mostly for the fact that Employer Taxes added to deductions messes up the standard Payroll Entry function).

Currently I’m handling Empoyer Taxes using standard functions. With my method, it requires a custom Journal Entry though.

For employer taxes, I add them to the Deductions with similar names to the Empoyee portion like “Social Security - Employer”, “Medicare - Employer”. I tick the box “Don’t include in Total”. This keeps the net_pay accurate and allows me to assign the Accounts as Expense accounts for these employer taxes.
@j0no Maybe you can use this method (allowing you to keep the formulas in the salary component). Modify your Journal Entry script to manipulate via the Salary Component name???

I’m just struggling with getting a custom script/button added to salary slip (then eventually to the salary slip list so I can select multiple salary slips) to initiate/create the Journal entry.

Interesting discussion! Thanks for sharing your approach, @j0no

I’ve always handled employer contributions by just adding to earnings then subtracting from deductions. Since they’re just regular components, formulas/etc. work like normal. What is the advantage here?

(I gave up using salary structures to do income tax for US payroll because of the 1-employee-1-tax-slab limitation, so it’s been a while since I tried to implement US regulations. I’m sure there’s something I’m not understanding.)

@peterg Thanks for the reminder. I’ve been working diligently on getting Salary Structures to work with Federal and NYS payroll.

I finally have a working model (without anything like 401K though). Your suggestion to use Earning + Deductions works perfectly for the Employer’s portion of taxes.

For the earning component I reference the Expense accounts (Social Security, Medicare, and I even do FUTA, and SUTA). I use the same formulas for the deduction component and set the account as payable (SS payable, Med Payable, FUTA payable, SUTA payable). This balances the transactions and when combined with the employee’s portion, the payable accounts have the correct balance.

It does make for a busy Salary Structure, but I’m happy to report I can use the built in functions (Payroll Entry > Submit Salary Slips which creates Journal Entry, and I can even use the Create Bank entry). Yahoo!!!

And what a thing of beauty when your debits == credits!

@j0no I think your scripts are unnecessary if you follow the procedure above. Let me know what you think.

1 Like

Please DM me, I’d like to help but payroll information shouldn’t be public and I’m happy to help.