backbone_payroll/application/service/statutory_calcs.rs
1//! Indonesian statutory payroll calculations — pure, config-driven functions.
2//!
3//! These are the deductions/contributions that turn gross pay into Indonesian net pay:
4//! - **PPh 21** — progressive personal income tax (UU HPP brackets, PTKP relief, NPWP surtax).
5//! - **BPJS Kesehatan** — national health insurance (employee 1% / employer 4%, salary-capped).
6//! - **BPJS Ketenagakerjaan** — employment insurance (JHT + JP + JKK + JKM).
7//! - **THR** — holiday allowance, pro-rated by tenure.
8//!
9//! Design rules (read me):
10//! - **Pure math only.** No DB, no ports, no async. Every rate/bracket/PTKP value/cap lives in
11//! [`StatutoryConfig`] (loaded from `config/application.yml` or built via [`StatutoryConfig::default`],
12//! which bakes the current-law values). The calc bodies hardcode *the formula structure*, never the
13//! numeric rates — BPJS caps move yearly and PTKP/brackets move by law, so config is the single
14//! source of truth.
15//! - **No Cargo edge to `backbone-employee`.** The shipped library has zero normal dependency on the
16//! employee module (it is a dev-only path dep used by integration tests). [`PtkpTier`] is therefore
17//! mirrored locally here — same 8 variants, same `snake_case` serde, same lowercase `Display` /
18//! `FromStr` as `backbone_employee::domain::entity::PtkpTier`. The slip-assembly layer (which does
19//! hold the employee edge) maps one-to-one via the string round-trip
20//! `employee_tier.to_string().parse::<PtkpTier>()` — both speak `"tk0".."k3"`.
21//! - **Rounding.** Every monetary output is rounded to 2 dp (rupiah + sen) with
22//! `RoundingStrategy::HalfUp`, applied once at the end of each function so intermediate precision
23//! is preserved. IDR has no sen in cash practice, but payroll ledgers keep 2 dp for the deduction
24//! totals to remain reconcilable; the composing slip may `.round_dp(0)` if whole-rupiah posting is
25//! desired.
26//! - **Fallibility.** Lookups keyed by config (PTKP tier, JKK risk class) can miss if a config is
27//! malformed; those functions return `Result<_, StatutoryError>`. The Default config is always
28//! complete, so well-formed deployments never see the error variants.
29
30use rust_decimal::{Decimal, RoundingStrategy};
31use rust_decimal::prelude::ToPrimitive;
32use serde::{Deserialize, Serialize};
33use std::collections::HashMap;
34use std::path::Path;
35
36/// Two-decimal rounding used for every statutory money output.
37const MONEY_DP: u32 = 2;
38/// Round a Decimal to ledger precision (2 dp, half-up).
39fn money(d: Decimal) -> Decimal {
40 d.round_dp_with_strategy(MONEY_DP, RoundingStrategy::MidpointAwayFromZero)
41}
42
43// ============================================================================
44// PtkpTier — local mirror of backbone_employee::domain::entity::PtkpTier
45// ============================================================================
46
47/// Pengurang Tanggungan Pajak (PTKP) tier — personal income-tax relief category.
48///
49/// Mirrors `backbone_employee::domain::entity::PtkpTier` 1:1 (same variants, same wire encoding) so
50/// the slip-assembly layer can convert with `employee_tier.to_string().parse::<PtkpTier>()`. Duplicated
51/// deliberately: payroll's shipped library has no Cargo edge to the employee module, and a local enum
52/// is both type-safe and self-documenting where a `&str` key would not be.
53///
54/// Variants: `Tk0..Tk3` (unmarried, 0–3 dependants) · `K0..K3` (married, 0–3 dependants).
55#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
56#[serde(rename_all = "snake_case")]
57pub enum PtkpTier {
58 #[default]
59 Tk0,
60 Tk1,
61 Tk2,
62 Tk3,
63 K0,
64 K1,
65 K2,
66 K3,
67}
68
69impl PtkpTier {
70 /// Stable lowercase key used to index [`Pph21Config::ptkp_map`] — matches the YAML map keys
71 /// and `backbone_employee::PtkpTier`'s `Display` output.
72 pub fn key(self) -> &'static str {
73 match self {
74 PtkpTier::Tk0 => "tk0",
75 PtkpTier::Tk1 => "tk1",
76 PtkpTier::Tk2 => "tk2",
77 PtkpTier::Tk3 => "tk3",
78 PtkpTier::K0 => "k0",
79 PtkpTier::K1 => "k1",
80 PtkpTier::K2 => "k2",
81 PtkpTier::K3 => "k3",
82 }
83 }
84}
85
86impl std::fmt::Display for PtkpTier {
87 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
88 f.write_str(self.key())
89 }
90}
91
92impl std::str::FromStr for PtkpTier {
93 type Err = StatutoryError;
94
95 fn from_str(s: &str) -> Result<Self, Self::Err> {
96 match s.to_lowercase().as_str() {
97 "tk0" => Ok(Self::Tk0),
98 "tk1" => Ok(Self::Tk1),
99 "tk2" => Ok(Self::Tk2),
100 "tk3" => Ok(Self::Tk3),
101 "k0" => Ok(Self::K0),
102 "k1" => Ok(Self::K1),
103 "k2" => Ok(Self::K2),
104 "k3" => Ok(Self::K3),
105 other => Err(StatutoryError::UnknownPtkpTier(other.to_string())),
106 }
107 }
108}
109
110impl Default for OvertimeConfig {
111 fn default() -> Self {
112 StatutoryConfig::default().overtime
113 }
114}
115
116// ============================================================================
117// TerCategory + Pph21Method — local mirrors of backbone_employee's tax axis
118// ============================================================================
119
120/// PPh-21 average-effective-rate (TER) category — which TER rate table the monthly withholding
121/// dispatches to.
122///
123/// Mirrors `backbone_employee::domain::entity::TerCategory` 1:1 (same variants, same wire encoding),
124/// for the same reason [`PtkpTier`] is mirrored: the shipped library has no Cargo edge to the
125/// employee module, and the slip-assembly layer maps via the string round-trip.
126#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
127#[serde(rename_all = "snake_case")]
128pub enum TerCategory {
129 #[default]
130 TerA,
131 TerB,
132 TerC,
133}
134
135impl TerCategory {
136 /// Stable lowercase key indexing [`Pph21Config::ter`] — matches the DB `category` values and
137 /// `backbone_employee::TerCategory`'s `Display` output.
138 pub fn key(self) -> &'static str {
139 match self {
140 TerCategory::TerA => "ter_a",
141 TerCategory::TerB => "ter_b",
142 TerCategory::TerC => "ter_c",
143 }
144 }
145}
146
147impl std::fmt::Display for TerCategory {
148 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
149 f.write_str(self.key())
150 }
151}
152
153impl std::str::FromStr for TerCategory {
154 type Err = StatutoryError;
155
156 fn from_str(s: &str) -> Result<Self, Self::Err> {
157 match s.to_lowercase().as_str() {
158 "ter_a" => Ok(Self::TerA),
159 "ter_b" => Ok(Self::TerB),
160 "ter_c" => Ok(Self::TerC),
161 other => Err(StatutoryError::UnknownTerCategory(other.to_string())),
162 }
163 }
164}
165
166/// Which PPh-21 path a slip dispatches to. `NpwpBrackets` is the annualized progressive-bracket
167/// computation ([`pph21`]); `Ter(category)` is the monthly average-effective-rate table lookup
168/// ([`pph21_ter`]). The employee's tax row picks: `ter_category` NULL → brackets, else that TER
169/// category. `tax_method` (gross/gross_up/netto) is a DIFFERENT axis (gross-up treatment) and does
170/// not appear here.
171#[derive(Debug, Clone, Copy, PartialEq, Eq)]
172pub enum Pph21Method {
173 NpwpBrackets,
174 Ter(TerCategory),
175}
176
177impl Pph21Method {
178 /// The audit label stamped on the slip (`npwp_brackets | ter_a | ter_b | ter_c`).
179 pub fn label(&self) -> &'static str {
180 match self {
181 Pph21Method::NpwpBrackets => "npwp_brackets",
182 Pph21Method::Ter(c) => c.key(),
183 }
184 }
185}
186
187impl std::fmt::Display for Pph21Method {
188 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
189 f.write_str(self.label())
190 }
191}
192
193// ============================================================================
194// Errors
195// ============================================================================
196
197/// Errors raised by the statutory calculators. Only the config-keyed lookups (PTKP tier, JKK risk
198/// class) and the YAML loader are fallible; the math itself is total.
199#[derive(Debug, thiserror::Error)]
200pub enum StatutoryError {
201 /// The PTKP tier is not present in `pph21.ptkp_map` — the config is incomplete.
202 #[error("unknown PTKP tier '{0}' — add it to pph21.ptkp_map in config/application.yml")]
203 UnknownPtkpTier(String),
204 /// The BPJS JKK risk class is not present in `bpjs.ketenagakerjaan.jkk_rates_by_risk_class`.
205 #[error("unknown BPJS JKK risk class {0} — add it to bpjs.ketenagakerjaan.jkk_rates_by_risk_class")]
206 UnknownRiskClass(u8),
207 /// A statutory config YAML could not be parsed.
208 #[error("invalid statutory config YAML: {0}")]
209 Yaml(#[from] serde_yaml::Error),
210 /// A config file could not be read.
211 #[error("statutory config I/O error: {0}")]
212 Io(#[from] std::io::Error),
213 /// The TER category is not one of `ter_a|ter_b|ter_c` (interop failure with the employee axis).
214 #[error("unknown TER category '{0}' — expected ter_a | ter_b | ter_c")]
215 UnknownTerCategory(String),
216 /// No TER bands are effective for the category — TER must fail closed, never zero-tax.
217 #[error("no TER rates configured for category '{0}' — seed effective-dated ter_rates rows for the period")]
218 NoTerRates(String),
219 /// No overtime multiplier bands are configured — overtime pay must fail closed, never zero-pay.
220 #[error("no overtime multiplier bands configured — seed effective-dated overtime rows for the period")]
221 MissingOvertimeBands,
222 /// A parameter table has no rows effective at the requested date (as-of loader only).
223 #[error("no statutory parameters effective for {1} in country '{0}' — seed the parameter tables for that period")]
224 NoParamsForPeriod(String, String),
225 /// A parameter-table read failed at the database (as-of loader only). Infrastructure failure —
226 /// distinct from `NoParamsForPeriod`, which is a data-presence failure the caller maps to 422.
227 #[error("statutory parameter read failed: {0}")]
228 Db(#[from] sqlx::Error),
229}
230
231// ============================================================================
232// Config
233// ============================================================================
234
235/// Top-level statutory configuration — the `statutory:` block of `config/application.yml`.
236///
237/// Construct with [`StatutoryConfig::default`] (current-law values baked in) or load from YAML via
238/// [`StatutoryConfig::from_yaml_str`] / [`StatutoryConfig::load_from_config_dir`].
239#[derive(Debug, Clone, Serialize, Deserialize)]
240pub struct StatutoryConfig {
241 #[serde(default)]
242 pub pph21: Pph21Config,
243 #[serde(default)]
244 pub bpjs: BpjsConfig,
245 /// Overtime multiplier bands. Distinct from the other two blocks in that it is pay math, not a
246 /// withholding — but it shares the same as-of/effective-dated lifecycle, so it lives here.
247 #[serde(default)]
248 pub overtime: OvertimeConfig,
249}
250
251/// PPh 21 configuration: progressive tax brackets + PTKP relief map + NPWP surtax multiplier.
252#[derive(Debug, Clone, Serialize, Deserialize)]
253pub struct Pph21Config {
254 /// Progressive brackets, sorted by `lower_bound` ascending. Each bracket taxes the slice of
255 /// taxable income **above** its `lower_bound` (exclusive) up to its `upper_bound` (inclusive) at
256 /// `rate`. The final bracket has `upper_bound: null` (unbounded).
257 pub brackets: Vec<Pph21Bracket>,
258 /// Annual PTKP relief (IDR) keyed by lowercase tier label (`"tk0".."k3"`).
259 #[serde(default)]
260 pub ptkp_map: HashMap<String, Decimal>,
261 /// Multiplier applied to computed tax when the taxpayer has no NPWP (default `1.2` = 20% surtax).
262 #[serde(default = "default_npwp_surtax")]
263 pub npwp_surtax_multiplier: Decimal,
264 /// TER (average effective rate) bands keyed by category (`"ter_a".."ter_c"`), each list sorted by
265 /// `lower_bound` ascending. A category with no entry (or an empty list) fails closed — TER never
266 /// silently degrades to zero tax. Empty by default in serde (the DB loader fills it from the
267 /// effective-dated rows); [`StatutoryConfig::default`] seeds the starter bands.
268 #[serde(default)]
269 pub ter: HashMap<String, Vec<TerRateBand>>,
270}
271
272/// One TER band: monthly bases from `lower_bound` (inclusive) up to the next band's `lower_bound`
273/// are withheld at `rate`. The first band's `lower_bound` is normally zero.
274#[derive(Debug, Clone, Serialize, Deserialize)]
275pub struct TerRateBand {
276 pub lower_bound: Decimal,
277 pub rate: Decimal,
278}
279
280/// Overtime configuration: the monthly-hours divisor plus the multiplier bands over the hour
281/// sequence, per day kind. Rest-day bands are carried for completeness (the regulation defines
282/// them) but the workday schedule is the only one the pay calc dispatches to today.
283#[derive(Debug, Clone, Serialize, Deserialize)]
284pub struct OvertimeConfig {
285 /// Hours in a normal month — the divisor that maps monthly salary to one hour's pay (173 by
286 /// regulation).
287 #[serde(default = "default_overtime_hours_per_month")]
288 pub hours_per_month: Decimal,
289 /// Workday multiplier bands sorted by `hour_from` ascending (hour 1 → 1.5×, hours 2+ → 2×).
290 #[serde(default = "default_overtime_workday_bands")]
291 pub workday: Vec<OvertimeBand>,
292 /// Rest-day bands (first 8 hours → 2×, 9th+ → 3×). Seeded, not dispatched by the workday pay
293 /// path — kept so a rest-day-aware caller can resolve them without a second config source.
294 #[serde(default = "default_overtime_restday_bands")]
295 pub rest_day: Vec<OvertimeBand>,
296}
297
298/// One multiplier band over the overtime hour sequence: hours `hour_from..=hour_to` (1-based, from
299/// the start of the overtime stretch) are paid at `multiplier` × the hourly rate. `hour_to: None`
300/// is open-ended.
301#[derive(Debug, Clone, Serialize, Deserialize)]
302pub struct OvertimeBand {
303 pub hour_from: i32,
304 pub hour_to: Option<i32>,
305 pub multiplier: Decimal,
306}
307
308/// One progressive-tax bracket. `upper_bound = None` means unbounded (the top bracket).
309#[derive(Debug, Clone, Serialize, Deserialize)]
310pub struct Pph21Bracket {
311 /// Inclusive lower bound of the bracket (IDR/yr). Income at exactly this bound contributes 0 to
312 /// this bracket — it was already exhausted by the bracket below.
313 pub lower_bound: Decimal,
314 /// Inclusive upper bound (IDR/yr), or `None` for the unbounded top bracket.
315 pub upper_bound: Option<Decimal>,
316 /// Marginal rate for this bracket (e.g. `Decimal::new(5, 2)` == 5%).
317 pub rate: Decimal,
318}
319
320/// BPJS configuration — Kesehatan (health) + Ketenagakerjaan (employment) insurance.
321#[derive(Debug, Clone, Serialize, Deserialize)]
322pub struct BpjsConfig {
323 #[serde(default)]
324 pub kesehatan: BpjsKesehatanConfig,
325 #[serde(default)]
326 pub ketenagakerjaan: BpjsTkConfig,
327}
328
329/// BPJS Kesehatan — health insurance. Employee and employer rates are both applied to the salary
330/// capped at `salary_cap`.
331#[derive(Debug, Clone, Serialize, Deserialize)]
332pub struct BpjsKesehatanConfig {
333 #[serde(default = "default_kesehatan_employee")]
334 pub employee_rate: Decimal,
335 #[serde(default = "default_kesehatan_employer")]
336 pub employer_rate: Decimal,
337 #[serde(default = "default_kesehatan_cap")]
338 pub salary_cap: Decimal,
339}
340
341/// BPJS Ketenagakerjaan — employment insurance (JHT, JP, JKK, JKM).
342#[derive(Debug, Clone, Serialize, Deserialize)]
343pub struct BpjsTkConfig {
344 /// JHT — employee share (uncapped).
345 #[serde(default = "default_jht_employee")]
346 pub jht_employee_rate: Decimal,
347 /// JHT — employer share (uncapped).
348 #[serde(default = "default_jht_employer")]
349 pub jht_employer_rate: Decimal,
350 /// JP — employee share, applied to salary capped at `jp_salary_cap`.
351 #[serde(default = "default_jp_employee")]
352 pub jp_employee_rate: Decimal,
353 /// JP — employer share, applied to salary capped at `jp_salary_cap`.
354 #[serde(default = "default_jp_employer")]
355 pub jp_employer_rate: Decimal,
356 /// JP salary cap (IDR/month).
357 #[serde(default = "default_jp_cap")]
358 pub jp_salary_cap: Decimal,
359 /// JKK — employer-only rate keyed by risk class (1–5) as a **string** key
360 /// (`"1".."5"`). serde_yaml round-trips numeric YAML keys unreliably across versions, so string
361 /// keys are used; the calc looks up `risk_class.to_string()`.
362 #[serde(default)]
363 pub jkk_rates_by_risk_class: HashMap<String, Decimal>,
364 /// JKM — employer-only flat rate.
365 #[serde(default = "default_jkm")]
366 pub jkm_rate: Decimal,
367}
368
369fn default_npwp_surtax() -> Decimal {
370 Decimal::new(12, 1) // 1.2
371}
372fn default_kesehatan_employee() -> Decimal {
373 Decimal::new(1, 2) // 0.01
374}
375fn default_kesehatan_employer() -> Decimal {
376 Decimal::new(4, 2) // 0.04
377}
378fn default_kesehatan_cap() -> Decimal {
379 Decimal::new(12_000_000, 0)
380}
381fn default_jht_employee() -> Decimal {
382 Decimal::new(2, 2) // 0.02
383}
384fn default_jht_employer() -> Decimal {
385 Decimal::new(37, 3) // 0.037
386}
387fn default_jp_employee() -> Decimal {
388 Decimal::new(1, 2) // 0.01
389}
390fn default_jp_employer() -> Decimal {
391 Decimal::new(2, 2) // 0.02
392}
393fn default_jp_cap() -> Decimal {
394 Decimal::new(10_547_400, 0)
395}
396fn default_jkm() -> Decimal {
397 Decimal::new(3, 3) // 0.003
398}
399fn default_overtime_hours_per_month() -> Decimal {
400 Decimal::new(173, 0)
401}
402fn default_overtime_workday_bands() -> Vec<OvertimeBand> {
403 vec![
404 OvertimeBand { hour_from: 1, hour_to: Some(1), multiplier: Decimal::new(15, 1) }, // 1.5×
405 OvertimeBand { hour_from: 2, hour_to: None, multiplier: Decimal::new(2, 0) }, // 2×
406 ]
407}
408fn default_overtime_restday_bands() -> Vec<OvertimeBand> {
409 vec![
410 OvertimeBand { hour_from: 1, hour_to: Some(8), multiplier: Decimal::new(2, 0) }, // 2×
411 OvertimeBand { hour_from: 9, hour_to: None, multiplier: Decimal::new(3, 0) }, // 3×
412 ]
413}
414
415impl Default for StatutoryConfig {
416 /// Current-law (UU HPP / BPJS 2024) statutory values. Kept in sync with the `statutory:` block
417 /// of `config/application.yml`; when the law changes, update both (or just the YAML).
418 fn default() -> Self {
419 let ptkp_map = [
420 ("tk0", Decimal::new(54_000_000, 0)),
421 ("tk1", Decimal::new(58_500_000, 0)),
422 ("tk2", Decimal::new(63_000_000, 0)),
423 ("tk3", Decimal::new(67_500_000, 0)),
424 ("k0", Decimal::new(58_500_000, 0)),
425 ("k1", Decimal::new(63_000_000, 0)),
426 ("k2", Decimal::new(67_500_000, 0)),
427 ("k3", Decimal::new(72_000_000, 0)),
428 ]
429 .into_iter()
430 .map(|(k, v)| (k.to_string(), v))
431 .collect();
432
433 let brackets = vec![
434 Pph21Bracket {
435 lower_bound: Decimal::ZERO,
436 upper_bound: Some(Decimal::new(60_000_000, 0)),
437 rate: Decimal::new(5, 2), // 5%
438 },
439 Pph21Bracket {
440 lower_bound: Decimal::new(60_000_000, 0),
441 upper_bound: Some(Decimal::new(250_000_000, 0)),
442 rate: Decimal::new(15, 2), // 15%
443 },
444 Pph21Bracket {
445 lower_bound: Decimal::new(250_000_000, 0),
446 upper_bound: Some(Decimal::new(500_000_000, 0)),
447 rate: Decimal::new(25, 2), // 25%
448 },
449 Pph21Bracket {
450 lower_bound: Decimal::new(500_000_000, 0),
451 upper_bound: Some(Decimal::new(5_000_000_000, 0)),
452 rate: Decimal::new(30, 2), // 30%
453 },
454 Pph21Bracket {
455 lower_bound: Decimal::new(5_000_000_000, 0),
456 upper_bound: None,
457 rate: Decimal::new(35, 2), // 35%
458 },
459 ];
460
461 let jkk_rates_by_risk_class = [
462 (1u8, Decimal::new(24, 4)), // 0.24%
463 (2u8, Decimal::new(54, 4)), // 0.54%
464 (3u8, Decimal::new(89, 4)), // 0.89%
465 (4u8, Decimal::new(127, 4)), // 1.27%
466 (5u8, Decimal::new(174, 4)), // 1.74%
467 ]
468 .into_iter()
469 .map(|(c, r)| (c.to_string(), r))
470 .collect();
471
472 StatutoryConfig {
473 pph21: Pph21Config {
474 brackets,
475 ptkp_map,
476 npwp_surtax_multiplier: default_npwp_surtax(),
477 ter: default_ter_rates(),
478 },
479 bpjs: BpjsConfig {
480 kesehatan: BpjsKesehatanConfig {
481 employee_rate: default_kesehatan_employee(),
482 employer_rate: default_kesehatan_employer(),
483 salary_cap: default_kesehatan_cap(),
484 },
485 ketenagakerjaan: BpjsTkConfig {
486 jht_employee_rate: default_jht_employee(),
487 jht_employer_rate: default_jht_employer(),
488 jp_employee_rate: default_jp_employee(),
489 jp_employer_rate: default_jp_employer(),
490 jp_salary_cap: default_jp_cap(),
491 jkk_rates_by_risk_class,
492 jkm_rate: default_jkm(),
493 },
494 },
495 overtime: OvertimeConfig {
496 hours_per_month: default_overtime_hours_per_month(),
497 workday: default_overtime_workday_bands(),
498 rest_day: default_overtime_restday_bands(),
499 },
500 }
501 }
502}
503
504/// The starter TER bands — a reduced, coarse-grained representation of the regulation's published
505/// table. These are SEED-SOURCE material: the authoritative copy lives in the effective-dated
506/// parameter tables seeded by migration and resolved as-of each payroll period; this Default exists
507/// so config-only deployments and tests have a complete, honest config. Corrections land as new
508/// effective-dated rows, never as edits here.
509fn default_ter_rates() -> HashMap<String, Vec<TerRateBand>> {
510 // One band row: lower bound (whole rupiah) + rate as a (mantissa, scale) pair.
511 fn band(lb: i64, rate_mantissa: i64, rate_scale: u32) -> TerRateBand {
512 TerRateBand { lower_bound: Decimal::new(lb, 0), rate: Decimal::new(rate_mantissa, rate_scale) }
513 }
514 // Rows per category, sorted ascending — identical to the seeded rows.
515 let bands: [(&str, Vec<TerRateBand>); 3] = [
516 (
517 "ter_a",
518 vec![
519 band(0, 0, 0),
520 band(5_400_000, 25, 4), // 0.25%
521 band(6_600_000, 5, 3), // 0.5%
522 band(7_800_000, 1, 2), // 1%
523 band(8_900_000, 15, 3), // 1.5%
524 band(9_650_000, 25, 3), // 2.5%
525 band(10_350_000, 3, 2), // 3%
526 band(12_100_000, 5, 2), // 5%
527 band(15_400_000, 8, 2), // 8%
528 band(19_500_000, 12, 2), // 12%
529 band(33_700_000, 17, 2), // 17%
530 band(45_500_000, 20, 2), // 20%
531 ],
532 ),
533 (
534 "ter_b",
535 vec![
536 band(0, 0, 0),
537 band(5_400_000, 5, 3), // 0.5%
538 band(6_600_000, 1, 2), // 1%
539 band(7_800_000, 2, 2), // 2%
540 band(8_900_000, 35, 3), // 3.5%
541 band(9_650_000, 45, 3), // 4.5%
542 band(10_350_000, 65, 3), // 6.5%
543 band(12_100_000, 9, 2), // 9%
544 band(15_400_000, 13, 2), // 13%
545 band(19_500_000, 17, 2), // 17%
546 band(33_700_000, 23, 2), // 23%
547 band(45_500_000, 27, 2), // 27%
548 ],
549 ),
550 (
551 "ter_c",
552 vec![
553 band(0, 0, 0),
554 band(5_400_000, 1, 2), // 1%
555 band(6_600_000, 2, 2), // 2%
556 band(7_800_000, 35, 3), // 3.5%
557 band(8_900_000, 5, 2), // 5%
558 band(9_650_000, 6, 2), // 6%
559 band(10_350_000, 10, 2), // 10%
560 band(12_100_000, 13, 2), // 13%
561 band(15_400_000, 17, 2), // 17%
562 band(19_500_000, 21, 2), // 21%
563 band(33_700_000, 28, 2), // 28%
564 band(45_500_000, 32, 2), // 32%
565 ],
566 ),
567 ];
568 bands
569 .into_iter()
570 .map(|(cat, list)| (cat.to_string(), list))
571 .collect()
572}
573
574impl Default for Pph21Config {
575 fn default() -> Self {
576 StatutoryConfig::default().pph21
577 }
578}
579impl Default for BpjsConfig {
580 fn default() -> Self {
581 StatutoryConfig::default().bpjs
582 }
583}
584impl Default for BpjsKesehatanConfig {
585 fn default() -> Self {
586 StatutoryConfig::default().bpjs.kesehatan
587 }
588}
589impl Default for BpjsTkConfig {
590 fn default() -> Self {
591 StatutoryConfig::default().bpjs.ketenagakerjaan
592 }
593}
594
595impl Pph21Config {
596 /// Annual PTKP relief for `tier`, or an error if the tier is absent from `ptkp_map`.
597 pub fn ptkp_relief(&self, tier: PtkpTier) -> Result<Decimal, StatutoryError> {
598 self.ptkp_map
599 .get(tier.key())
600 .copied()
601 .ok_or_else(|| StatutoryError::UnknownPtkpTier(tier.key().to_string()))
602 }
603}
604
605impl StatutoryConfig {
606 /// Parse the `statutory:` block out of a full `application.yml` document string. If the block is
607 /// absent, the current-law [`StatutoryConfig::default`] is returned so a payroll node that has not
608 /// yet added the block still boots with correct statutory values.
609 pub fn from_yaml_str(application_yml: &str) -> Result<Self, StatutoryError> {
610 let root: serde_yaml::Value = serde_yaml::from_str(application_yml)?;
611 match root.get("statutory") {
612 Some(block) => Ok(serde_yaml::from_value(block.clone())?),
613 None => Ok(Self::default()),
614 }
615 }
616
617 /// Load from a config directory containing `application.yml` (+ optional
618 /// `application-{env}.yml` override). The env file's **whole** `statutory:` block replaces the
619 /// base block when present (shallow override — you tune rates by overriding the entire section).
620 pub fn load_from_config_dir(dir: &Path, environment: &str) -> Result<Self, StatutoryError> {
621 let base = std::fs::read_to_string(dir.join("application.yml"))?;
622 let mut root: serde_yaml::Value = serde_yaml::from_str(&base)?;
623
624 let env_path = dir.join(format!("application-{}.yml", environment));
625 if env_path.exists() {
626 let env_str = std::fs::read_to_string(&env_path)?;
627 if let Ok(env_root) = serde_yaml::from_str::<serde_yaml::Value>(&env_str) {
628 if let Some(env_stat) = env_root.get("statutory") {
629 // Replace the whole statutory subtree (shallow override by design).
630 match root.get_mut("statutory") {
631 Some(slot) => *slot = env_stat.clone(),
632 None => {
633 if let serde_yaml::Value::Mapping(ref mut m) = root {
634 m.insert(
635 serde_yaml::Value::String("statutory".into()),
636 env_stat.clone(),
637 );
638 }
639 }
640 }
641 }
642 }
643 }
644
645 match root.get("statutory") {
646 Some(block) => Ok(serde_yaml::from_value(block.clone())?),
647 None => Ok(Self::default()),
648 }
649 }
650}
651
652// ============================================================================
653// Calculations
654// ============================================================================
655
656/// Apply a sorted-ascending progressive bracket schedule to `taxable`. Each bracket taxes the slice
657/// strictly above its `lower_bound` up to (and including) its `upper_bound`. Internal helper.
658fn progressive_tax(taxable: Decimal, brackets: &[Pph21Bracket]) -> Decimal {
659 let mut tax = Decimal::ZERO;
660 for b in brackets {
661 // No income reaches this or any higher bracket once taxable <= this bracket's lower bound.
662 if taxable <= b.lower_bound {
663 break;
664 }
665 let slice = match &b.upper_bound {
666 Some(upper) => {
667 let top = if taxable < *upper {
668 taxable
669 } else {
670 *upper
671 };
672 top - b.lower_bound
673 }
674 None => taxable - b.lower_bound,
675 };
676 tax += slice * b.rate;
677 }
678 tax
679}
680
681/// **PPh 21** — monthly personal income tax (progressive, PTKP-relieved, NPWP-surtaxed).
682///
683/// Formula: gross_annual = `gross_monthly × 12`; `annual_taxable = max(0, gross_annual − ptkp_relief)`;
684/// apply progressive brackets; multiply by `npwp_surtax_multiplier` (1.2×) when `has_npwp == false`;
685/// `monthly_tax = annual_tax / 12`, rounded to 2 dp.
686///
687/// Returns the **monthly** PPh 21 withholding (IDR, 2 dp).
688pub fn pph21(
689 ptkp: PtkpTier,
690 has_npwp: bool,
691 gross_monthly: Decimal,
692 cfg: &Pph21Config,
693) -> Result<Decimal, StatutoryError> {
694 let twelve = Decimal::new(12, 0);
695 let gross_annual = gross_monthly * twelve;
696 let relief = cfg.ptkp_relief(ptkp)?;
697 let annual_taxable = if gross_annual > relief {
698 gross_annual - relief
699 } else {
700 Decimal::ZERO
701 };
702 let mut annual_tax = progressive_tax(annual_taxable, &cfg.brackets);
703 if !has_npwp {
704 annual_tax *= cfg.npwp_surtax_multiplier;
705 }
706 let monthly_tax = annual_tax / twelve;
707 Ok(money(monthly_tax))
708}
709
710/// **PPh 21 TER** — monthly withholding via the average-effective-rate table (the no-annualization
711/// path for employees whose tax row names a TER category).
712///
713/// Formula (PMK 168/2023): the TER base is the monthly gross minus the employment-insurance
714/// **employee** share only — `jht_employee + jp_employee`, i.e. JHT on uncapped salary + JP on
715/// JP-capped salary; the health-insurance employee share is NOT subtracted. The rate is the band of
716/// the category whose `lower_bound` is the greatest one `<= ter_base`; tax = `money(ter_base × rate)`,
717/// × `npwp_surtax_multiplier` (1.2) when the taxpayer has no NPWP.
718///
719/// The JHT/JP products enter the base **unrounded** (before the per-component 2-dp rounding the
720/// breakdown applies) so the base is the exact product difference — rounding here would drift the
721/// band edge for salaries sitting within a fraction of a sen of a boundary.
722///
723/// Fails closed: a category with no bands (or an empty list) is [`StatutoryError::NoTerRates`] —
724/// a missing table must never silently yield zero tax.
725pub fn pph21_ter(
726 category: TerCategory,
727 has_npwp: bool,
728 gross_monthly: Decimal,
729 cfg: &StatutoryConfig,
730) -> Result<Decimal, StatutoryError> {
731 let bands = cfg
732 .pph21
733 .ter
734 .get(category.key())
735 .filter(|list| !list.is_empty())
736 .ok_or_else(|| StatutoryError::NoTerRates(category.key().to_string()))?;
737
738 // Unrounded employment-insurance employee share (JHT uncapped, JP on its cap).
739 let tk = &cfg.bpjs.ketenagakerjaan;
740 let jp_base = if gross_monthly > tk.jp_salary_cap { tk.jp_salary_cap } else { gross_monthly };
741 let insurance_share = gross_monthly * tk.jht_employee_rate + jp_base * tk.jp_employee_rate;
742 let ter_base = (gross_monthly - insurance_share).max(Decimal::ZERO);
743
744 // Band = the greatest lower_bound <= ter_base (bands sorted ascending; base lands in the last
745 // band it reaches). A base below the first band's lower_bound (possible when the table starts
746 // above zero) matches no band → zero-rate band semantics do not apply; fail closed instead.
747 let mut rate: Option<Decimal> = None;
748 for b in bands {
749 if ter_base >= b.lower_bound {
750 rate = Some(b.rate);
751 } else {
752 break;
753 }
754 }
755 let rate = rate.ok_or_else(|| StatutoryError::NoTerRates(category.key().to_string()))?;
756
757 let mut tax = ter_base * rate;
758 if !has_npwp {
759 tax *= cfg.pph21.npwp_surtax_multiplier;
760 }
761 Ok(money(tax))
762}
763
764/// **BPJS Kesehatan** — health insurance (employee 1%, employer 4%, on salary capped at the cap).
765///
766/// Returns `(employee, employer)` monthly contributions (IDR, 2 dp).
767pub fn bpjs_kesehatan(gross_monthly: Decimal, cfg: &BpjsConfig) -> (Decimal, Decimal) {
768 let k = &cfg.kesehatan;
769 let capped = if gross_monthly > k.salary_cap {
770 k.salary_cap
771 } else {
772 gross_monthly
773 };
774 let employee = money(capped * k.employee_rate);
775 let employer = money(capped * k.employer_rate);
776 (employee, employer)
777}
778
779/// Full per-component BPJS Ketenagakerjaan breakdown. All amounts monthly (IDR, 2 dp).
780#[derive(Debug, Clone, PartialEq)]
781pub struct BpjsTkBreakdown {
782 /// JHT — employee share (2%, uncapped).
783 pub jht_employee: Decimal,
784 /// JHT — employer share (3.7%, uncapped).
785 pub jht_employer: Decimal,
786 /// JP — employee share (1%, JP-capped).
787 pub jp_employee: Decimal,
788 /// JP — employer share (2%, JP-capped).
789 pub jp_employer: Decimal,
790 /// JKK — employer-only (rate by `risk_class`).
791 pub jkk_employer: Decimal,
792 /// JKM — employer-only (0.3%).
793 pub jkm_employer: Decimal,
794 /// Sum of all employee-paid components (JHT + JP).
795 pub employee_total: Decimal,
796 /// Sum of all employer-paid components (JHT + JP + JKK + JKM).
797 pub employer_total: Decimal,
798}
799
800/// **BPJS Ketenagakerjaan** — employment insurance (JHT + JP + JKK + JKM).
801///
802/// - JHT: employee `jht_employee_rate`, employer `jht_employer_rate`, both on **uncapped** salary.
803/// - JP: employee `jp_employee_rate`, employer `jp_employer_rate`, both on `min(salary, jp_salary_cap)`.
804/// - JKK: employer-only, rate selected by `risk_class` (1–5).
805/// - JKM: employer-only, flat `jkm_rate` on uncapped salary.
806///
807/// Returns the full [`BpjsTkBreakdown`] with per-component and total employee/employer amounts.
808pub fn bpjs_ketenagakerjaan(
809 gross_monthly: Decimal,
810 risk_class: u8,
811 cfg: &BpjsConfig,
812) -> Result<BpjsTkBreakdown, StatutoryError> {
813 let tk = &cfg.ketenagakerjaan;
814
815 let jht_employee = money(gross_monthly * tk.jht_employee_rate);
816 let jht_employer = money(gross_monthly * tk.jht_employer_rate);
817
818 let jp_capped = if gross_monthly > tk.jp_salary_cap {
819 tk.jp_salary_cap
820 } else {
821 gross_monthly
822 };
823 let jp_employee = money(jp_capped * tk.jp_employee_rate);
824 let jp_employer = money(jp_capped * tk.jp_employer_rate);
825
826 let jkk_rate = tk
827 .jkk_rates_by_risk_class
828 .get(&risk_class.to_string())
829 .copied()
830 .ok_or(StatutoryError::UnknownRiskClass(risk_class))?;
831 let jkk_employer = money(gross_monthly * jkk_rate);
832
833 let jkm_employer = money(gross_monthly * tk.jkm_rate);
834
835 let employee_total = money(jht_employee + jp_employee);
836 let employer_total = money(jht_employer + jp_employer + jkk_employer + jkm_employer);
837
838 Ok(BpjsTkBreakdown {
839 jht_employee,
840 jht_employer,
841 jp_employee,
842 jp_employer,
843 jkk_employer,
844 jkm_employer,
845 employee_total,
846 employer_total,
847 })
848}
849
850/// **THR** — holiday allowance: 1× monthly salary pro-rated by tenure.
851///
852/// `amount = monthly_salary × min(tenure_months / 12, 1)` — employees with ≥ 12 months tenure get the
853/// full month; shorter tenures are pro-rated. `tenure_months` is a `Decimal` so fractional months
854/// (e.g. computed from day-level `join_date` math) are honoured. Clamped to a non-negative fraction.
855pub fn thr(monthly_salary: Decimal, tenure_months: Decimal) -> Decimal {
856 let twelve = Decimal::new(12, 0);
857 let fraction = if tenure_months >= twelve {
858 Decimal::new(1, 0)
859 } else if tenure_months > Decimal::ZERO {
860 tenure_months / twelve
861 } else {
862 Decimal::ZERO
863 };
864 money(monthly_salary * fraction)
865}
866
867/// **Overtime pay** — workday schedule (the only day kind dispatched today).
868///
869/// Hourly rate = `monthly_base / hours_per_month` (173 by regulation). `hours` is ONE day's
870/// overtime stretch: it walks the workday band sequence hour by hour — each full hour at its
871/// band's multiplier, a fractional final hour at its hour's multiplier pro-rata (e.g. 3.5h =
872/// h1×1.5 + h2×2 + h3×2 + h4×2×0.5). The band schedule — including the 1.5× first hour — resets
873/// with each day, so a period's overtime pay is the SUM of this function over its days; pricing
874/// a window-aggregated hour count would over-pay every one-hour-per-day pattern. The sum is
875/// rounded once per day. Rest-day/holiday schedules are seeded in config but intentionally not
876/// dispatched here; a rest-day-aware caller resolves them explicitly when that policy lands.
877///
878/// Fails closed on an empty/unstartable band table ([`StatutoryError::MissingOvertimeBands`]) —
879/// missing bands must never silently yield zero pay for real worked hours.
880pub fn overtime_pay(hours: Decimal, monthly_base: Decimal, cfg: &OvertimeConfig) -> Result<Decimal, StatutoryError> {
881 if hours <= Decimal::ZERO || monthly_base <= Decimal::ZERO {
882 return Ok(Decimal::ZERO);
883 }
884 if cfg.workday.is_empty() || cfg.hours_per_month <= Decimal::ZERO {
885 return Err(StatutoryError::MissingOvertimeBands);
886 }
887
888 let hourly = monthly_base / cfg.hours_per_month;
889 let whole = hours.floor();
890 let frac = hours - whole;
891 let mut total = Decimal::ZERO;
892
893 // Multiplier for the nth hour of the overtime stretch: the band whose [hour_from, hour_to]
894 // range contains n. An hour in a gap between bands or past a capped final band matches
895 // nothing — the fail-closed error below, never a neighboring band's multiplier.
896 let multiplier_for = |hour: i64| -> Option<Decimal> {
897 cfg.workday
898 .iter()
899 .find(|b| {
900 hour >= b.hour_from as i64
901 && b.hour_to.map_or(true, |to| hour <= to as i64)
902 })
903 .map(|b| b.multiplier)
904 };
905
906 for h in 1..=(whole.to_i64().unwrap_or(i64::MAX)) {
907 total += hourly * multiplier_for(h).ok_or(StatutoryError::MissingOvertimeBands)?;
908 }
909 if frac > Decimal::ZERO {
910 let next = whole.to_i64().unwrap_or(i64::MAX) + 1;
911 total += hourly * multiplier_for(next).ok_or(StatutoryError::MissingOvertimeBands)? * frac;
912 }
913 Ok(money(total))
914}
915
916// ============================================================================
917// compute_statutory — the slip-assembly entry point
918// ============================================================================
919
920/// One computed statutory component ready to attach to a salary slip. The neutral output of
921/// [`compute_statutory`]: payroll's slip-assembly attaches a GL account (the payable for a deduction,
922/// the expense for the THR earning) to turn this into a [`StatutoryLine`](super::payroll_write_service::StatutoryLine).
923///
924/// `component_type` mirrors the slip-line vocabulary: `"earning"` (THR) or `"deduction"` (PPh 21,
925/// BPJS Kesehatan, BPJS Ketenagakerjaan employee share).
926#[derive(Debug, Clone)]
927pub struct StatutoryComponent {
928 pub name: String,
929 pub component_type: String, // "earning" | "deduction"
930 pub amount: Decimal,
931}
932
933impl StatutoryComponent {
934 fn earning(name: impl Into<String>, amount: Decimal) -> Self {
935 Self { name: name.into(), component_type: "earning".into(), amount }
936 }
937 fn deduction(name: impl Into<String>, amount: Decimal) -> Self {
938 Self { name: name.into(), component_type: "deduction".into(), amount }
939 }
940}
941
942/// Compose every Indonesia statutory component for one employee's monthly pay into a slip-ready list.
943///
944/// This is the seam between the (pure, employee-edge-free) calcs above and the slip-assembly layer:
945/// it takes the employee's statutory inputs as primitives (PTKP tier, NPWP presence, the PPh-21
946/// dispatch `method`) plus the gross monthly salary, the BPJS JKK `risk_class`, the THR tenure, and
947/// the [`StatutoryConfig`], and calls [`pph21`] / [`pph21_ter`] / [`bpjs_kesehatan`] /
948/// [`bpjs_ketenagakerjaan`] / [`thr`] to produce:
949///
950/// - **THR** earning (tenure-pro-rated; omitted when tenure is zero → no THR), using the monthly gross
951/// as the THR base (1× monthly salary).
952/// - **PPh 21** deduction (monthly withholding; brackets or TER per `method`).
953/// - **BPJS Kesehatan** employee deduction (1% of capped salary).
954/// - **BPJS Ketenagakerjaan** employee deduction (JHT 2% + JP 1% of capped/uncapped salary).
955///
956/// Evaluation order is **BPJS first, PPh 21 last**: the TER base subtracts the employment-insurance
957/// employee share, so the insurance products must exist before the tax path runs. The output order
958/// is unchanged (THR, PPh 21, Kesehatan, Ketenagakerjaan).
959///
960/// Only **employee-paid** deductions are emitted — employer shares (BPJS Kesehatan 4%, JKK, JKM, JP
961/// employer 2%, JHT employer 3.7%) are real costs but they are NOT withheld from the slip's net pay;
962/// they hit a separate employer-cost accrual that a different process books. Zero-amount components
963/// are dropped so the slip is not cluttered with no-op lines.
964///
965/// Returns `Err` on an unknown `risk_class`, a PTKP tier absent from the config, or (TER path) a
966/// missing band table — all fail closed (a malformed config should never silently produce a wrong
967/// net pay).
968pub fn compute_statutory(
969 method: Pph21Method,
970 ptkp: PtkpTier,
971 has_npwp: bool,
972 gross_monthly: Decimal,
973 risk_class: u8,
974 thr_tenure_months: Decimal,
975 cfg: &StatutoryConfig,
976) -> Result<Vec<StatutoryComponent>, StatutoryError> {
977 let mut out = Vec::new();
978
979 // Insurance components FIRST — the TER tax base subtracts the employment-insurance employee
980 // share, so these must be resolved (and their config validated) before dispatching the tax path.
981 let (kes_employee, _kes_employer) = bpjs_kesehatan(gross_monthly, &cfg.bpjs);
982 let tk = bpjs_ketenagakerjaan(gross_monthly, risk_class, &cfg.bpjs)?;
983
984 // THR earning first in the OUTPUT (it raises gross; the deductions below are not THR-taxable
985 // here — Indonesia taxes THR separately at year-end / on payment under a different scheme, so
986 // the monthly PPh 21 base stays the ordinary gross).
987 let thr_amt = thr(gross_monthly, thr_tenure_months);
988 if thr_amt > Decimal::ZERO {
989 out.push(StatutoryComponent::earning("THR", thr_amt));
990 }
991
992 // PPh 21 monthly withholding on the ordinary gross — dispatched by the employee's tax profile.
993 let pph = match method {
994 Pph21Method::NpwpBrackets => pph21(ptkp, has_npwp, gross_monthly, &cfg.pph21)?,
995 Pph21Method::Ter(category) => pph21_ter(category, has_npwp, gross_monthly, cfg)?,
996 };
997 if pph > Decimal::ZERO {
998 out.push(StatutoryComponent::deduction("PPh 21", pph));
999 }
1000
1001 // BPJS Kesehatan — employee share only (1% of capped salary).
1002 if kes_employee > Decimal::ZERO {
1003 out.push(StatutoryComponent::deduction("BPJS Kesehatan", kes_employee));
1004 }
1005
1006 // BPJS Ketenagakerjaan — employee share only (JHT + JP). Employer components (JHT-er, JP-er, JKK,
1007 // JKM) are an employer cost, not a slip deduction.
1008 if tk.employee_total > Decimal::ZERO {
1009 out.push(StatutoryComponent::deduction("BPJS Ketenagakerjaan", tk.employee_total));
1010 }
1011
1012 Ok(out)
1013}
1014
1015// ============================================================================
1016// Tests — the gate. Hand-computed expected values.
1017// ============================================================================
1018#[cfg(test)]
1019mod tests {
1020 use super::*;
1021 use std::str::FromStr;
1022
1023 /// Convenience: the default (current-law) config.
1024 fn cfg() -> StatutoryConfig {
1025 StatutoryConfig::default()
1026 }
1027
1028 // ---- PPh 21 -------------------------------------------------------------
1029
1030 #[test]
1031 fn pph21_tk0_npwp_12m_is_625000() {
1032 // TK0, has_npwp, gross 12,000,000/mo → annual 144M − PTKP 54M = 90M taxable
1033 // → 5%×60M + 15%×30M = 3M + 4.5M = 7.5M annual → /12 = 625,000/mo.
1034 let monthly = pph21(
1035 PtkpTier::Tk0,
1036 true,
1037 Decimal::new(12_000_000, 0),
1038 &cfg().pph21,
1039 )
1040 .expect("tk0 is in the default ptkp_map");
1041 assert_eq!(monthly, Decimal::new(625_000, 0));
1042 }
1043
1044 #[test]
1045 fn pph21_no_npwp_surtax_is_120x() {
1046 // Same case, no NPWP → 625,000 × 1.2 = 750,000.
1047 let monthly = pph21(
1048 PtkpTier::Tk0,
1049 false,
1050 Decimal::new(12_000_000, 0),
1051 &cfg().pph21,
1052 )
1053 .expect("tk0 is in the default ptkp_map");
1054 assert_eq!(monthly, Decimal::new(750_000, 0));
1055 }
1056
1057 #[test]
1058 fn pph21_k3_high_income_hits_four_brackets() {
1059 // K3 (PTKP 72M), has_npwp, gross 50M/mo → annual 600M − 72M = 528M taxable.
1060 // 5%×60M = 3,000,000
1061 // 15%×(250M-60M) = 28,500,000 (190M slice)
1062 // 25%×(500M-250M)= 62,500,000 (250M slice)
1063 // 30%×(528M-500M)= 8,400,000 (28M slice)
1064 // annual = 102,400,000 → /12 = 8,533,333.33 (2 dp, half-up).
1065 let monthly = pph21(
1066 PtkpTier::K3,
1067 true,
1068 Decimal::new(50_000_000, 0),
1069 &cfg().pph21,
1070 )
1071 .expect("k3 is in the default ptkp_map");
1072 assert_eq!(monthly, Decimal::from_str("8533333.33").unwrap());
1073 }
1074
1075 #[test]
1076 fn pph21_salary_below_ptkp_is_zero() {
1077 // Gross below the PTKP relief → no tax. TK3 relief 67.5M; gross 5M/mo = 60M annual < 67.5M.
1078 let monthly = pph21(
1079 PtkpTier::Tk3,
1080 true,
1081 Decimal::new(5_000_000, 0),
1082 &cfg().pph21,
1083 )
1084 .unwrap();
1085 assert_eq!(monthly, Decimal::ZERO);
1086 }
1087
1088 // ---- BPJS Kesehatan -----------------------------------------------------
1089
1090 #[test]
1091 fn bpjs_kesehatan_at_cap_is_120k_480k() {
1092 // salary 12M, cap 12M → employee 1%×12M = 120,000; employer 4%×12M = 480,000.
1093 let (emp, er) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &cfg().bpjs);
1094 assert_eq!(emp, Decimal::new(120_000, 0));
1095 assert_eq!(er, Decimal::new(480_000, 0));
1096 }
1097
1098 #[test]
1099 fn bpjs_kesehatan_above_cap_clamps() {
1100 // salary 20M > cap 12M → contributions computed on the 12M cap, not 20M.
1101 let (emp, er) = bpjs_kesehatan(Decimal::new(20_000_000, 0), &cfg().bpjs);
1102 assert_eq!(emp, Decimal::new(120_000, 0));
1103 assert_eq!(er, Decimal::new(480_000, 0));
1104 }
1105
1106 #[test]
1107 fn bpjs_kesehatan_below_cap_pro_rata() {
1108 // salary 7.5M < cap → 1%×7.5M = 75,000; 4%×7.5M = 300,000.
1109 let (emp, er) = bpjs_kesehatan(Decimal::new(7_500_000, 0), &cfg().bpjs);
1110 assert_eq!(emp, Decimal::new(75_000, 0));
1111 assert_eq!(er, Decimal::new(300_000, 0));
1112 }
1113
1114 // ---- BPJS Ketenagakerjaan ----------------------------------------------
1115
1116 #[test]
1117 fn bpjs_tk_risk_class_3_at_10m() {
1118 // gross 10M, risk class 3 (JKK 0.89%).
1119 // JHT emp 2%×10M = 200,000 JHT er 3.7%×10M = 370,000
1120 // JP emp 1%×10M = 100,000 (10M < JP cap 10,547,400)
1121 // JP er 2%×10M = 200,000
1122 // JKK er 0.89%×10M = 89,000
1123 // JKM er 0.3%×10M = 30,000
1124 // emp total = 300,000 ; er total = 370k+200k+89k+30k = 689,000.
1125 let b = bpjs_ketenagakerjaan(Decimal::new(10_000_000, 0), 3, &cfg().bpjs).unwrap();
1126 assert_eq!(b.jht_employee, Decimal::new(200_000, 0));
1127 assert_eq!(b.jht_employer, Decimal::new(370_000, 0));
1128 assert_eq!(b.jp_employee, Decimal::new(100_000, 0));
1129 assert_eq!(b.jp_employer, Decimal::new(200_000, 0));
1130 assert_eq!(b.jkk_employer, Decimal::new(89_000, 0));
1131 assert_eq!(b.jkm_employer, Decimal::new(30_000, 0));
1132 assert_eq!(b.employee_total, Decimal::new(300_000, 0));
1133 assert_eq!(b.employer_total, Decimal::new(689_000, 0));
1134 }
1135
1136 #[test]
1137 fn bpjs_tk_jp_cap_kicks_in_above_cap() {
1138 // gross 12M > JP cap 10,547,400 → JP computed on the cap.
1139 // JP emp 1%×10,547,400 = 105,474 ; JP er 2%×10,547,400 = 210,948.
1140 // JHT/JKK/JKM are uncapped → JHT emp 2%×12M = 240,000.
1141 let b = bpjs_ketenagakerjaan(Decimal::new(12_000_000, 0), 1, &cfg().bpjs).unwrap();
1142 assert_eq!(b.jp_employee, Decimal::new(105_474, 0));
1143 assert_eq!(b.jp_employer, Decimal::new(210_948, 0));
1144 assert_eq!(b.jht_employee, Decimal::new(240_000, 0));
1145 // JKK class 1 = 0.24% × 12M = 28,800.
1146 assert_eq!(b.jkk_employer, Decimal::new(28_800, 0));
1147 // JKM 0.3% × 12M = 36,000.
1148 assert_eq!(b.jkm_employer, Decimal::new(36_000, 0));
1149 }
1150
1151 #[test]
1152 fn bpjs_tk_unknown_risk_class_errors() {
1153 // Class 9 is not configured → fail closed with UnknownRiskClass.
1154 let err = bpjs_ketenagakerjaan(Decimal::new(10_000_000, 0), 9, &cfg().bpjs)
1155 .expect_err("class 9 should be unknown");
1156 assert!(matches!(err, StatutoryError::UnknownRiskClass(9)));
1157 }
1158
1159 // ---- THR ----------------------------------------------------------------
1160
1161 #[test]
1162 fn thr_prorated_6_months_is_half() {
1163 // 12M salary, 6 months tenure → 12M × (6/12) = 6,000,000.
1164 let amount = thr(Decimal::new(12_000_000, 0), Decimal::new(6, 0));
1165 assert_eq!(amount, Decimal::new(6_000_000, 0));
1166 }
1167
1168 #[test]
1169 fn thr_full_at_12_months() {
1170 // ≥ 12 months → full 1× month, capped.
1171 let amount = thr(Decimal::new(15_000_000, 0), Decimal::new(12, 0));
1172 assert_eq!(amount, Decimal::new(15_000_000, 0));
1173 }
1174
1175 #[test]
1176 fn thr_capped_above_12_months() {
1177 // 24 months tenure → still exactly 1× month (no double-THR).
1178 let amount = thr(Decimal::new(15_000_000, 0), Decimal::new(24, 0));
1179 assert_eq!(amount, Decimal::new(15_000_000, 0));
1180 }
1181
1182 #[test]
1183 fn thr_zero_tenure_is_zero() {
1184 let amount = thr(Decimal::new(12_000_000, 0), Decimal::ZERO);
1185 assert_eq!(amount, Decimal::ZERO);
1186 }
1187
1188 // ---- Config loading & PtkpTier interop ----------------------------------
1189
1190 #[test]
1191 fn ptkp_tier_roundtrips_as_snake_case() {
1192 // Mirrors backbone_employee::PtkpTier's lowercase Display/FromStr exactly.
1193 for tier in [
1194 PtkpTier::Tk0,
1195 PtkpTier::Tk1,
1196 PtkpTier::Tk2,
1197 PtkpTier::Tk3,
1198 PtkpTier::K0,
1199 PtkpTier::K1,
1200 PtkpTier::K2,
1201 PtkpTier::K3,
1202 ] {
1203 let s = tier.to_string();
1204 assert_eq!(PtkpTier::from_str(&s).unwrap(), tier);
1205 }
1206 // An unknown label is rejected (interop safety).
1207 assert!(PtkpTier::from_str("tk9").is_err());
1208 }
1209
1210 #[test]
1211 fn config_from_yaml_drives_same_calc_as_default() {
1212 // The shipped config/application.yml `statutory:` block must produce identical results to the
1213 // baked-in Default — proving the calcs are config-driven, not hardcoded.
1214 let yaml = include_str!("../../../config/application.yml");
1215 let loaded = StatutoryConfig::from_yaml_str(yaml).expect("application.yml parses");
1216
1217 let via_default =
1218 pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &cfg().pph21).unwrap();
1219 let via_loaded =
1220 pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &loaded.pph21).unwrap();
1221 assert_eq!(via_default, via_loaded);
1222 assert_eq!(via_loaded, Decimal::new(625_000, 0));
1223
1224 let (emp_default, _) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &cfg().bpjs);
1225 let (emp_loaded, _) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &loaded.bpjs);
1226 assert_eq!(emp_default, emp_loaded);
1227 }
1228
1229 #[test]
1230 fn config_missing_statutory_block_falls_back_to_default() {
1231 // A YAML with no `statutory:` key still boots with the current-law defaults.
1232 let yaml = "server:\n port: 8080\n";
1233 let loaded = StatutoryConfig::from_yaml_str(yaml).unwrap();
1234 let monthly = pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &loaded.pph21).unwrap();
1235 assert_eq!(monthly, Decimal::new(625_000, 0));
1236 }
1237
1238 // ---- compute_statutory (the slip-assembly entry point) ------------------
1239
1240 #[test]
1241 fn compute_statutory_tk0_npwp_12m_full_tenure_emits_four_components() {
1242 // TK0, has_npwp, gross 12M, risk class 3, full (12-month) tenure, default config. Hand-computed:
1243 // THR earning = thr(12M, 12) = 12,000,000 (1× month, full tenure)
1244 // PPh 21 deduction = 625,000 (verified above)
1245 // BPJS Kesehatan emp = 1% × min(12M, 12M cap) = 120,000
1246 // BPJS TK emp (JHT+JP) = 2%×12M + 1%×10,547,400 = 240,000 + 105,474 = 345,474
1247 // total deductions = 625,000 + 120,000 + 345,474 = 1,090,474.
1248 let comps = compute_statutory(
1249 Pph21Method::NpwpBrackets,
1250 PtkpTier::Tk0,
1251 true,
1252 Decimal::new(12_000_000, 0),
1253 3,
1254 Decimal::new(12, 0),
1255 &cfg(),
1256 )
1257 .expect("tk0 + risk class 3 are in the default config");
1258
1259 let by_name: std::collections::HashMap<String, (String, Decimal)> = comps
1260 .iter()
1261 .map(|c| (c.name.clone(), (c.component_type.clone(), c.amount)))
1262 .collect();
1263 assert_eq!(by_name["THR"], ("earning".into(), Decimal::new(12_000_000, 0)));
1264 assert_eq!(by_name["PPh 21"], ("deduction".into(), Decimal::new(625_000, 0)));
1265 assert_eq!(by_name["BPJS Kesehatan"], ("deduction".into(), Decimal::new(120_000, 0)));
1266 assert_eq!(by_name["BPJS Ketenagakerjaan"], ("deduction".into(), Decimal::new(345_474, 0)));
1267 assert_eq!(comps.len(), 4, "exactly four components");
1268
1269 let total_deductions: Decimal = comps
1270 .iter()
1271 .filter(|c| c.component_type == "deduction")
1272 .map(|c| c.amount)
1273 .sum();
1274 assert_eq!(total_deductions, Decimal::new(1_090_474, 0));
1275 }
1276
1277 #[test]
1278 fn compute_statutory_zero_tenure_drops_thr() {
1279 // Tenure 0 → THR is 0 → omitted. The three deductions remain (they don't depend on tenure).
1280 let comps = compute_statutory(
1281 Pph21Method::NpwpBrackets,
1282 PtkpTier::Tk0,
1283 true,
1284 Decimal::new(12_000_000, 0),
1285 1,
1286 Decimal::ZERO,
1287 &cfg(),
1288 )
1289 .unwrap();
1290 assert!(!comps.iter().any(|c| c.name == "THR"), "zero-tenure THR must be dropped");
1291 assert_eq!(comps.len(), 3, "PPh21 + BPJS Kesehatan + BPJS TK only");
1292 }
1293
1294 // ---- PPh 21 TER (starter bands — values documented as non-authoritative seed data) ------
1295
1296 #[test]
1297 fn pph21_ter_a_10m_base_9_7m_is_242500() {
1298 // TER A, gross 10M: JHT emp 2%×10M = 200,000 + JP emp 1%×10M = 100,000 → base 9,700,000.
1299 // Band [9,650,000, 10,350,000) → 2.5% → 9.7M × 0.025 = 242,500.
1300 let tax = pph21_ter(
1301 TerCategory::TerA,
1302 true,
1303 Decimal::new(10_000_000, 0),
1304 &cfg(),
1305 )
1306 .expect("ter_a bands are seeded");
1307 assert_eq!(tax, Decimal::new(242_500, 0));
1308 }
1309
1310 #[test]
1311 fn pph21_ter_a_no_npwp_is_291000() {
1312 // Same case without NPWP → 242,500 × 1.2 = 291,000.
1313 let tax = pph21_ter(
1314 TerCategory::TerA,
1315 false,
1316 Decimal::new(10_000_000, 0),
1317 &cfg(),
1318 )
1319 .unwrap();
1320 assert_eq!(tax, Decimal::new(291_000, 0));
1321 }
1322
1323 #[test]
1324 fn pph21_ter_base_uses_unrounded_insurance_products() {
1325 // A gross whose JHT+JP products are fractional in sen: gross 9,999,999.99 →
1326 // share = 0.03 × gross (both components uncapped at this salary) = 299,999.9997 →
1327 // base = 9,699,999.9903 (NOT 9,699,999.99 — the rounded per-component breakdown would
1328 // subtract a different number). Base lands mid-band [9.65M, 10.35M) → 2.5% →
1329 // 242,499.9998… → 242,500.00. The mid-band rounding agrees either way; the band EDGE is
1330 // where the unrounded base is load-bearing, pinned by the edge test below.
1331 let tax = pph21_ter(
1332 TerCategory::TerA,
1333 true,
1334 Decimal::from_str("9999999.99").unwrap(),
1335 &cfg(),
1336 )
1337 .unwrap();
1338 assert_eq!(tax, Decimal::from_str("242500.00").unwrap());
1339 }
1340
1341 #[test]
1342 fn pph21_ter_band_edge_is_lower_bound_inclusive() {
1343 // Band selection is "greatest lower_bound <= base" — the bound itself belongs to the band
1344 // that opens there. Pinned with a synthetic config (zero insurance rates, so base == gross
1345 // exactly) whose ter_a bands open at a clean 1,000,000: a base exactly AT the bound takes
1346 // the higher band; one sen below takes the lower one.
1347 let mut c = cfg();
1348 c.pph21.ter.insert(
1349 "ter_a".into(),
1350 vec![
1351 TerRateBand { lower_bound: Decimal::ZERO, rate: Decimal::new(0, 0) },
1352 TerRateBand { lower_bound: Decimal::new(1_000_000, 0), rate: Decimal::new(10, 2) },
1353 ],
1354 );
1355 c.bpjs.ketenagakerjaan.jht_employee_rate = Decimal::ZERO;
1356 c.bpjs.ketenagakerjaan.jp_employee_rate = Decimal::ZERO;
1357
1358 let at = pph21_ter(TerCategory::TerA, true, Decimal::new(1_000_000, 0), &c).unwrap();
1359 assert_eq!(at, Decimal::new(100_000, 0), "base exactly at the bound is IN the band above");
1360
1361 let below = pph21_ter(TerCategory::TerA, true, Decimal::from_str("999999.99").unwrap(), &c).unwrap();
1362 assert_eq!(below, Decimal::ZERO, "one sen below the bound is the lower band (0%)");
1363 }
1364
1365 #[test]
1366 fn pph21_ter_empty_table_fails_closed() {
1367 // A config with no TER bands for the category must error, never zero-tax.
1368 let mut c = cfg();
1369 c.pph21.ter.remove("ter_a");
1370 let err = pph21_ter(TerCategory::TerA, true, Decimal::new(10_000_000, 0), &c)
1371 .expect_err("missing ter_a bands must fail");
1372 assert!(matches!(err, StatutoryError::NoTerRates(cat) if cat == "ter_a"));
1373
1374 // An empty list is the same failure.
1375 c.pph21.ter.insert("ter_a".into(), vec![]);
1376 assert!(matches!(
1377 pph21_ter(TerCategory::TerA, true, Decimal::new(10_000_000, 0), &c),
1378 Err(StatutoryError::NoTerRates(_))
1379 ));
1380 }
1381
1382 #[test]
1383 fn pph21_ter_high_base_uses_top_band() {
1384 // TER C, gross 50M: JHT emp 1M + JP emp 105,474 (capped) → base 48,894,526 → top band
1385 // [45.5M, ∞) → 32% → 48,894,526 × 0.32 = 15,646,248.32.
1386 let tax = pph21_ter(
1387 TerCategory::TerC,
1388 true,
1389 Decimal::new(50_000_000, 0),
1390 &cfg(),
1391 )
1392 .unwrap();
1393 assert_eq!(tax, Decimal::from_str("15646248.32").unwrap());
1394 }
1395
1396 #[test]
1397 fn pph21_ter_interops_with_compute_statutory_dispatch() {
1398 // The dispatch honors the method: same employee inputs, TER A path replaces the brackets
1399 // PPh 21 with the TER amount (242,500 at 10M gross) while BPJS lines stay identical.
1400 let brackets = compute_statutory(
1401 Pph21Method::NpwpBrackets,
1402 PtkpTier::Tk0,
1403 true,
1404 Decimal::new(10_000_000, 0),
1405 3,
1406 Decimal::ZERO,
1407 &cfg(),
1408 )
1409 .unwrap();
1410 let ter = compute_statutory(
1411 Pph21Method::Ter(TerCategory::TerA),
1412 PtkpTier::Tk0,
1413 true,
1414 Decimal::new(10_000_000, 0),
1415 3,
1416 Decimal::ZERO,
1417 &cfg(),
1418 )
1419 .unwrap();
1420 let find = |v: &Vec<StatutoryComponent>, n: &str| {
1421 v.iter().find(|c| c.name == n).map(|c| c.amount).unwrap()
1422 };
1423 // Brackets path at 10M gross TK0: annual 120M − 54M = 66M → 5%×60M + 15%×6M = 3.9M → /12 = 325,000.
1424 assert_eq!(find(&brackets, "PPh 21"), Decimal::from_str("325000.00").unwrap());
1425 assert_eq!(find(&ter, "PPh 21"), Decimal::new(242_500, 0));
1426 // Insurance lines identical across the two paths.
1427 assert_eq!(find(&brackets, "BPJS Kesehatan"), find(&ter, "BPJS Kesehatan"));
1428 assert_eq!(find(&brackets, "BPJS Ketenagakerjaan"), find(&ter, "BPJS Ketenagakerjaan"));
1429 }
1430
1431 // ---- overtime ----------------------------------------------------------------
1432
1433 #[test]
1434 fn overtime_10h_at_8_7m_base_is_980635_84() {
1435 // ONE day's 10h stretch: h1 → 1.5×, h2..h10 → 2× ⇒ 19.5 multiplier-hours; hourly =
1436 // 8,700,000/173. 19.5 × 50,289.017341… = 980,635.8381… → 980,635.84. A period's pay is
1437 // the sum of this over its days (each day restarts the 1.5× first hour).
1438 let pay = overtime_pay(
1439 Decimal::new(10, 0),
1440 Decimal::new(8_700_000, 0),
1441 &cfg().overtime,
1442 )
1443 .unwrap();
1444 assert_eq!(pay, Decimal::from_str("980635.84").unwrap());
1445 }
1446
1447 #[test]
1448 fn overtime_hour_beyond_the_last_band_fails_closed() {
1449 // A band table whose coverage ends (hour_to set, or a gap between bands) has NO multiplier
1450 // for hours past its end — that must error, never borrow a neighbour band's rate.
1451 let mut c = cfg();
1452 c.overtime.workday = vec![
1453 OvertimeBand { hour_from: 1, hour_to: Some(1), multiplier: Decimal::from_str("1.5").unwrap() },
1454 OvertimeBand { hour_from: 2, hour_to: Some(3), multiplier: Decimal::new(2, 0) },
1455 ];
1456 // hour 4 is past the covered range → MissingOvertimeBands
1457 assert!(matches!(
1458 overtime_pay(Decimal::new(4, 0), Decimal::new(10_000_000, 0), &c.overtime),
1459 Err(StatutoryError::MissingOvertimeBands)
1460 ));
1461 // hours inside the covered range still price normally (3h = 1.5 + 2 + 2 = 5.5 × base/173).
1462 let pay = overtime_pay(Decimal::new(3, 0), Decimal::new(10_000_000, 0), &c.overtime).unwrap();
1463 let hourly = Decimal::new(10_000_000, 0) / Decimal::new(173, 0);
1464 assert_eq!(pay, (Decimal::from_str("5.5").unwrap() * hourly).round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero));
1465 }
1466
1467 #[test]
1468 fn overtime_first_hour_only_is_1_5x() {
1469 // 1h → 1.5 × (10M/173) = 1.5 × 57,803.468… = 86,705.202… → 86,705.20.
1470 let pay = overtime_pay(
1471 Decimal::new(1, 0),
1472 Decimal::new(10_000_000, 0),
1473 &cfg().overtime,
1474 )
1475 .unwrap();
1476 assert_eq!(pay, Decimal::from_str("86705.20").unwrap());
1477 }
1478
1479 #[test]
1480 fn overtime_fractional_hour_prorates_the_band() {
1481 // 2.5h → 1.5×1 + 2×1 + 2×0.5 = 4.5 multiplier-hours at base 8.7M:
1482 // 4.5 × 50,289.017341… = 226,300.578… → 226,300.58.
1483 let pay = overtime_pay(
1484 Decimal::from_str("2.5").unwrap(),
1485 Decimal::new(8_700_000, 0),
1486 &cfg().overtime,
1487 )
1488 .unwrap();
1489 assert_eq!(pay, Decimal::from_str("226300.58").unwrap());
1490 }
1491
1492 #[test]
1493 fn overtime_zero_hours_or_base_is_zero() {
1494 assert_eq!(overtime_pay(Decimal::ZERO, Decimal::new(10_000_000, 0), &cfg().overtime).unwrap(), Decimal::ZERO);
1495 assert_eq!(overtime_pay(Decimal::new(3, 0), Decimal::ZERO, &cfg().overtime).unwrap(), Decimal::ZERO);
1496 }
1497
1498 #[test]
1499 fn overtime_missing_bands_fail_closed() {
1500 let mut c = cfg();
1501 c.overtime.workday = vec![];
1502 assert!(matches!(
1503 overtime_pay(Decimal::new(2, 0), Decimal::new(10_000_000, 0), &c.overtime),
1504 Err(StatutoryError::MissingOvertimeBands)
1505 ));
1506 }
1507
1508 #[test]
1509 fn pph21_method_labels_are_the_audit_stamp() {
1510 assert_eq!(Pph21Method::NpwpBrackets.label(), "npwp_brackets");
1511 assert_eq!(Pph21Method::Ter(TerCategory::TerB).label(), "ter_b");
1512 assert_eq!(Pph21Method::Ter(TerCategory::TerC).to_string(), "ter_c");
1513 }
1514}