Skip to content

Repository files navigation

crmleaf/payroll-core

CI Packagist PHP Licence

Indian payroll and statutory calculations in plain PHP. No framework, no dependencies beyond ext-json, and every figure comes back with the working and the statute behind it.

composer require crmleaf/payroll-core

This is the engine the CRMLeaf payroll tools are built on. Each tool is a thin package over one calculator here; this is where the arithmetic and the rate data live.

Why it exists

Indian payroll arithmetic is not difficult, but it is fiddly, and it is wrong in a lot of software:

  • The pension share is capped at the wage ceiling even when provident fund is not, so 8.33% of a ₹30,000 basic is ₹1,250, not ₹2,499.
  • State insurance contributions continue to the end of a contribution period after wages cross the limit, so applicability cannot be read off the wage.
  • Gratuity rounds a part year up only past six months. Seven years and six months is seven; seven and seven is eight.
  • The section 87A rebate has a marginal relief band. At ₹12,10,000 of taxable income the slabs give ₹61,500 and the payable figure is ₹10,000.

Each of those is a solved problem that gets re-solved badly in private. This solves them once, in the open, with the citation next to the number.

Important

This is a calculation library, not tax advice. It implements our reading of the applicable statutes and is provided without warranty. Verify against your own compliance obligations before relying on it for a filing.

Use it

use Crmleaf\Payroll\Calculators\GratuityCalculator;
use Crmleaf\Payroll\Money;

$result = (new GratuityCalculator())->calculate(
    lastDrawnSalary: Money::fromRupees(45_000),   // basic + DA
    yearsOfService: 7,
    monthsOfService: 8,                           // over six months, so eight years
);

$result->gratuity->format();      // '₹2,07,692.31'
$result->completedYears;          // 8
$result->explain();               // '(15 × 45,000.00 × 8) ÷ 26 = ₹2,07,692.31'
$result->taxExempt->toRupees();   // 207692.31

Every calculator is a final class, constructible with new and no arguments, with a single calculate() taking named parameters.

Every result shows its working

foreach ($result->steps() as $step) {
    echo $step->label, ': ', $step->formula, '', $step->amount?->format(), "\n";
    echo '  ', $step->citation, "\n";
}

A payroll figure has to be defensible. An employee asking why ₹1,250 went to the pension fund rather than ₹2,499 is entitled to the reasoning, and so is an auditor, so the reasoning is part of the return value rather than something you reconstruct afterwards.

toArray() gives the same thing as snake_case scalars, which is what the JSON API and the Blade components consume.

What is in it

Provident fund Employee, employer, pension and insurance shares with the wage ceiling
State insurance Both shares, the wage limit, and the contribution-period rule
Income tax Both regimes, slabs, 87A with marginal relief, surcharge, cess
Tax deducted at source Monthly instalments, mid-year joiners, regime comparison
Professional tax Every state that levies it, at its own frequency
Gratuity Covered and uncovered establishments, six-month rounding, exemption
Bonus Eligibility limit and calculation ceiling, kept distinct
Leave encashment The section 10(10AA) least-of-four test
Cost to company A full structure, with the components reconciling to the total
Full and final settlement Dues, gratuity, leave, notice recovery, deductions
Provident fund penalties Section 7Q interest and 14B damages, on both bases
Compliance calendar Statutory due dates, as an iCalendar feed
Salary templates Structure, register, slip and master, as column schemas

Two more answer a commercial question rather than a statutory one, so no rate table governs them and neither takes a date:

Return on investment A payroll cycle run by hand against the same cycle automated
Savings Two per-employee costs compared across the length of a contract

Money is an integer of paise

Money::fromRupees(15_000)->percentage(8.33)->paise;   // 124950, exactly
Money::fromRupees(1_234_567.89)->format();            // '₹12,34,567.89'

Payroll divides by 26 for gratuity, takes 8.33% for the pension share, and spreads an annual tax figure over twelve months. In floats that accumulates error which eventually surfaces as a one-rupee mismatch on a challan, so every amount is an integer number of paise and every division rounds explicitly.

The statutes disagree about rounding, so there are three named methods rather than one: roundUpToRupee() for state insurance, roundToRupee() for tax deducted at source, and roundToTenRupees() for section 288B. Each is applied where the law says so.

Rates are dated data, not code

Nothing here is "the current rate". Everything is "the rate on this date", because payroll routinely recomputes the past: a revised settlement six months after separation, an arrear paid in a later year, an audit of last year's challans.

(new PfCalculator())->calculate(
    basicSalary: Money::fromRupees(30_000),
    asOf: '2013-06-01',
)->wageCeiling->toRupees();     // 6500 - the ceiling before it was raised in 2014

Ten tables live in resources/rates/, each version carrying its own effective_from, effective_to and a cited source. Changing a rate means adding a dated version, never editing a past one.

asOf accepts a DateTimeImmutable, a YYYY-MM-DD string, or a financial-year label such as '2025-26'.

When a year's rates have not been published yet

A Finance Act arrives after the financial year it governs has begun, so for part of every year the newest income tax table names the previous year. Payroll cannot stop and wait for a gazette, so the table is carried forward - and the result says so rather than letting you assume otherwise:

$result = (new IncomeTaxCalculator())->calculate(
    grossSalary: Money::fromRupees(12_00_000),
);

$result->notes;
// ['Rates for FY 2025-26 are being applied to a date in FY 2026-27, because no
//   version has been published for FY 2026-27 yet. Verify against the current
//   Finance Act before relying on this figure for filing.']

The same warning is reachable directly with RateTable::isProvisionalFor() and provisionalWarning(). Only the income tax table is dated by financial year; the others change by notification and carry no such assumption.

Ineligibility is a result, not an error

Three years of service is a valid answer of nil gratuity, with a reason. A negative salary is a bug in the caller and throws.

$result = (new GratuityCalculator())->calculate(
    lastDrawnSalary: Money::fromRupees(45_000),
    yearsOfService: 3,
);

$result->eligible;              // false
$result->gratuity->isZero();    // true
$result->ineligibilityReason;   // 'Gratuity needs 5 years of continuous service. …'

Also available

  • crmleaf/laravel-payroll - service provider, publishable config, an opt-in JSON API, Blade components and PDF documents.
  • @crmleaf/payroll-js - the same calculations in TypeScript, reading the same rate tables, agreeing to the paisa. A CI job fails the build if the two ever drift.
  • One package per tool - crmleaf/gratuity-calculator and the rest, if you want a single calculator with its own routes and views.

Contributing

Issues and pull requests both belong here. See CONTRIBUTING.md for how a rate change is dated and cited.

A wrong figure is the most valuable kind of report: give the inputs, the figure you got, the figure you expected, and the rule that says so. The first three make it reproducible; the fourth makes it a test case.

Licence

MIT © CRMLeaf.

About

Indian payroll and statutory calculations in plain PHP. Dated rate tables, explicit rounding, and a citation behind every figure.

Topics

Resources

Code of conduct

Contributing

Security policy

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages