Skip to main content

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}