Payroll Engine
Every payroll run in Interwise resolves to one call: PayrollCalculatorService.calculate().
Given an employee’s attendance, compensation items, overtime entries, tax configuration,
and this year’s prior runs, it produces a PayrollMember — the full breakdown behind one
line in a payroll run, and the source of one payslip. This page walks through that
calculation in the exact order it runs, with the exact rates and formulas Interwise uses.
This is the deep technical reference. If you want the role model or the product-level tour first, start with Overview or Roles & Access — come back here when you need to know precisely how a number on a payslip was produced.
Calculation Order at a Glance
The engine runs as one pipeline, in six phases. Nothing here is optional or reordered per employee — every payroll member goes through all six phases every period; what varies is the inputs (attendance, compensation items, tax mode) and, in December or an employee’s final month, which tax formula phase five uses.
The twelve numbered steps below map onto these six phases exactly as labeled in the diagram — phases 2 and 5 each bundle several steps because earnings and tax are where most of the real logic lives.
Phase 1 — Prorate
1. Working Ratio
The very first thing the engine computes is a single proration factor, the working ratio:
ratio = min((workingDays - unpaidLeaveDays) / workingDays, 1)
This ratio is applied to every compensation item flagged isAttendance: true — typically
basic salary and any attendance-linked allowance. Items not flagged for attendance (fixed
allowances, benefits-in-kind, etc.) pass through at their full configured amount
regardless of attendance. The ratio is capped at 1 so a partial period can never inflate
pay above the full-period amount.
Phase 2 — Earnings
2. Compensation Items
The engine walks every compensation item for the employee and, for each one:
- Applies the working ratio if
isAttendanceis true; otherwise uses the item’s full amount. - Adds it to
totalEarnings,totalDeductions, ortotalBenefitsdepending on the item’stype(EARNING,DEDUCTION, or benefit). - If
isTaxis true, adds it to the taxable gross used later for PPh 21. - If
isBpjsKesehatanorisBpjsKetenagakerjaanis true, adds it to the respective BPJS contribution base. - If the item’s code is
BASICorGAJI_POKOK, records its (post-ratio) amount as the employee’s basic salary — this is what overtime’s hourly rate is derived from in the next step.
3. Overtime
Overtime entries are filtered to only those that are isApproved && isPaid, then grouped
by ISO week (Monday–Sunday). Within each week, hours are bucketed by the Indonesian
labor-law multiplier rules:
- Regular overtime: the first hour worked in the week is paid at 1.5×; every hour after that in the same week is paid at 2×.
- Public-holiday overtime: the first 7 hours are paid at 2×, the 8th hour at 3×, and every hour beyond 8 at 4×.
The hourly rate used for all of these multipliers is a single constant derived from basic salary:
hourlyRate = basicSalary / 173
173 is the standard monthly working-hours constant used across Indonesian payroll
practice. The total overtime amount is the sum of each bucket’s hours × multiplier ×
hourly rate, and it’s added to totalEarnings as a generated OVERTIME line item — it
also counts toward taxable gross.
Phase 3 — BPJS
4. BPJS Contributions
BPJS is computed from the two bases accumulated in step 2 (bpjsKesBase,
bpjsTkBase), split into employee and employer shares:
| Component | Employee | Employer | Ceiling |
|---|---|---|---|
| Kesehatan | 1% | 4% | 12,000,000 (applies to both shares) |
| JKK (Kecelakaan Kerja) | — | per employee’s industry risk rate | none |
| JKM (Kematian) | — | 0.3% (default) | none |
| JHT (Hari Tua) | 2% | 3.7% | none |
| JP (Pensiun) | 1% | 2% | 11,086,300 (as of March 2026) |
Only the employee shares (Kesehatan-EE + JHT-EE + JP-EE) are deducted from pay, as a
single generated BPJS_EMPLOYEE line item. JKK and JKM are employer-only — the employee
never sees them as a deduction.
Employer contributions don’t just disappear into overhead, though: per PMK 168/2023, the
Kesehatan, JKK, and JKM employer shares (but not JHT-ER or JP-ER) are added back
into the employee’s taxable gross for the PPh 21 calculation in phase 5, since they count
as employer-provided income in kind. JHT-EE and JP-EE, meanwhile, become the employee’s
tax deduction (currentTaxDeduction) — they reduce taxable income the way a pension
contribution would.
Phase 4 — Annualize
5. Accumulated History
Before tax can be computed, the engine looks back at every payroll run already recorded for this employee this calendar year, up to (not including) the current month, and sums:
accumulatedGross— prior months’ gross salaryaccumulatedTaxDeduction— prior months’ JHT + JP employee contributionsaccumulatedMonthlyTax— prior months’ tax already withheldaccumulatedThr— prior months’ THR (religious holiday allowance) paid this year
It also determines earliestMonth (the earliest payroll month recorded this year,
defaulting to the current month for a first-time run) and monthsWorked (current month −
earliest month + 1). These feed directly into the annualization math in the next phase —
they’re what let a mid-year joiner’s tax be computed correctly against a partial year
rather than a full twelve months.
Phase 5 — Tax
Indonesia’s default PPh 21 method since PP 58/2023 is TER (Tarif Efektif Rata-rata —
“average effective rate”): a flat rate looked up from a PTKP-category table and applied
directly to gross, month by month, with a full bracket-progressive settlement once a year.
Interwise implements TER as the default computationMethod, with the older
bracket-every-month method available as a fallback configuration.
6. Tax — Non-December (TER)
For every month except an employee’s final month, the engine computes this month’s taxable gross — salary plus the taxable employer BPJS from phase 3 — and looks up a TER rate for it against the employee’s PTKP category (the category is derived from the employee’s PTKP code; the full PTKP table lives in the Compliance Reference). That rate is applied straight to the same monthly gross:
terGross = currentMonthGross + taxableEmployerBpjs
rate = lookupTerRate(ptkpCategory, terGross)
tax = round(terGross * rate / 100 * penalty)
No annualization, no PKP, no position allowance runs in this path — TER is deliberately a single lookup-and-multiply per month, which is what makes it fast to compute and easy to audit against DJP’s published tables.
7. Tax — December / Last Month (Bracket Settlement)
An employee’s final payroll month — December, or their last active month if they leave
mid-year (isLastMonth) — is different: instead of another TER lookup, the engine
settles the entire year’s tax liability using the progressive bracket table, then
nets it against everything already withheld via TER:
annualizedGross = accumulatedGross + currentMonthGross + taxableEmployerBpjs + thrThisYear
annualizedNett = annualizedGross - (accumulatedTaxDeduction + currentTaxDeduction)
yearlyTax = progressiveTax(pkp, taxBrackets) * penalty
monthlyTax = yearlyTax - accumulatedMonthlyTax // this month's settlement
progressiveTax walks the bracket table applying each band’s rate only to the slice of
PKP that falls in it:
| PKP band (IDR/year) | Rate |
|---|---|
| 0 – 60,000,000 | 5% |
| 60,000,000 – 250,000,000 | 15% |
| 250,000,000 – 500,000,000 | 25% |
| 500,000,000 – 5,000,000,000 | 30% |
| above 5,000,000,000 | 35% |
Because monthlyTax here is a settlement against everything already withheld, it can go
negative — if TER over-withheld during the year (a common case when a large THR
pushes one month’s TER band higher than the annual bracket rate would justify), the
employee is owed a refund rather than another deduction. See step 11 for how that surfaces
on the payslip.
8. Taxable Income (PKP)
PKP (Penghasilan Kena Pajak — taxable income) only comes into play in the bracket
settlement path above (step 7, and every month if an employee is configured for the
BRACKET method instead of TER). It’s derived from annualized net income, the position
allowance (step 9), and PTKP:
pkpRaw = max(annualizedNett - positionAllowance - ptkp, 0)
pkp = floor(pkpRaw / 1000) * 1000
PTKP (Penghasilan Tidak Kena Pajak — the non-taxable income threshold) depends on marital status and dependents; the full PTKP table by code is in the Compliance Reference.
9. Position Allowance
The position allowance (biaya jabatan) is a standard deduction meant to represent job-related costs, computed before PKP since PKP depends on it:
positionAllowance = min(annualizedBase * 5%, 6,000,000)
annualizedBase is normally annualizedGross; for an employee’s very first month
(January, with no prior runs this year), it’s annualizedNett instead, per DJP’s
specification for a first-month calculation with no accumulated history to annualize
from.
10. No-Tax-ID Penalty
A flat 20% surcharge applies to the computed tax — in both the TER monthly path and the bracket settlement path — whenever the employee has no NIK on file:
function hasNoTaxIdPenalty(config) {
return !config.nik?.trim();
}
This is intentionally a single, shared function (hasNoTaxIdPenalty() in
tax-config.vo.ts) used by every tax path, so the rule can never drift between TER and
bracket calculations. Since PMK 112/2022 (effective July 2024), the NIK is the sole
tax ID Interwise tracks — there is no separate NPWP field — so the surcharge fires
whenever the NIK is empty.
11. Tax Mode
Every employee has a TaxMode — GROSS, NETT, or GROSS_UP — that determines who
actually bears the computed tax and how it shows up on the payslip:
| Mode | Who bears the tax | Effect on take-home pay | Payslip line item |
|---|---|---|---|
| GROSS | Employee | Reduced by the tax amount | PPH21_DEDUCTION — or PPH21_REFUND (an earning) if TER over-withheld during the year and December settles negative |
| NETT | Company | Unchanged — company absorbs the cost | None on the payslip; the cost is tracked internally as taxPaidByCompany |
| GROSS_UP | Company, via an added allowance | Unchanged — the allowance exactly offsets the tax | PPH21_ALLOWANCE (an earning) |
GROSS_UP can’t compute its allowance in one pass, because adding the allowance to gross
changes the tax owed on that gross, which changes the allowance needed, and so on. The
engine resolves this by iterating: recompute tax with the current allowance folded
into gross, compare to the previous iteration, and stop once the change is under 1 IDR
(capped at 20 iterations as a safety bound). In practice this converges in only a few
passes.
Phase 6 — Take-Home
12. Take-Home Pay
The last step is arithmetic, once every earning and deduction line item above has been appended:
THP = totalEarnings - totalDeductions
Everything upstream — proration, compensation items, overtime, the employee’s BPJS share,
and (for GROSS mode) the tax deduction, or (for GROSS_UP) the offsetting tax allowance
— has already been folded into totalEarnings and totalDeductions as individual line
items by the time this runs. This is the number that lands on the payslip as take-home
pay, and it’s what gets paid out through the bank disbursement file.
Where to Go Next
- Compliance Reference — the full PTKP table by code, TER category mapping, and other statutory reference values used throughout this calculation.
- Overview — how payroll fits into the rest of Interwise, from hiring through payslip generation.
- Roles & Access — who can configure tax modes and run payroll for a company.