use rust_decimal::{Decimal, RoundingStrategy};
use rust_decimal::prelude::ToPrimitive;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
const MONEY_DP: u32 = 2;
fn money(d: Decimal) -> Decimal {
d.round_dp_with_strategy(MONEY_DP, RoundingStrategy::MidpointAwayFromZero)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum PtkpTier {
#[default]
Tk0,
Tk1,
Tk2,
Tk3,
K0,
K1,
K2,
K3,
}
impl PtkpTier {
pub fn key(self) -> &'static str {
match self {
PtkpTier::Tk0 => "tk0",
PtkpTier::Tk1 => "tk1",
PtkpTier::Tk2 => "tk2",
PtkpTier::Tk3 => "tk3",
PtkpTier::K0 => "k0",
PtkpTier::K1 => "k1",
PtkpTier::K2 => "k2",
PtkpTier::K3 => "k3",
}
}
}
impl std::fmt::Display for PtkpTier {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.key())
}
}
impl std::str::FromStr for PtkpTier {
type Err = StatutoryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"tk0" => Ok(Self::Tk0),
"tk1" => Ok(Self::Tk1),
"tk2" => Ok(Self::Tk2),
"tk3" => Ok(Self::Tk3),
"k0" => Ok(Self::K0),
"k1" => Ok(Self::K1),
"k2" => Ok(Self::K2),
"k3" => Ok(Self::K3),
other => Err(StatutoryError::UnknownPtkpTier(other.to_string())),
}
}
}
impl Default for OvertimeConfig {
fn default() -> Self {
StatutoryConfig::default().overtime
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TerCategory {
#[default]
TerA,
TerB,
TerC,
}
impl TerCategory {
pub fn key(self) -> &'static str {
match self {
TerCategory::TerA => "ter_a",
TerCategory::TerB => "ter_b",
TerCategory::TerC => "ter_c",
}
}
}
impl std::fmt::Display for TerCategory {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.key())
}
}
impl std::str::FromStr for TerCategory {
type Err = StatutoryError;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_lowercase().as_str() {
"ter_a" => Ok(Self::TerA),
"ter_b" => Ok(Self::TerB),
"ter_c" => Ok(Self::TerC),
other => Err(StatutoryError::UnknownTerCategory(other.to_string())),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Pph21Method {
NpwpBrackets,
Ter(TerCategory),
}
impl Pph21Method {
pub fn label(&self) -> &'static str {
match self {
Pph21Method::NpwpBrackets => "npwp_brackets",
Pph21Method::Ter(c) => c.key(),
}
}
}
impl std::fmt::Display for Pph21Method {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(self.label())
}
}
#[derive(Debug, thiserror::Error)]
pub enum StatutoryError {
#[error("unknown PTKP tier '{0}' — add it to pph21.ptkp_map in config/application.yml")]
UnknownPtkpTier(String),
#[error("unknown BPJS JKK risk class {0} — add it to bpjs.ketenagakerjaan.jkk_rates_by_risk_class")]
UnknownRiskClass(u8),
#[error("invalid statutory config YAML: {0}")]
Yaml(#[from] serde_yaml::Error),
#[error("statutory config I/O error: {0}")]
Io(#[from] std::io::Error),
#[error("unknown TER category '{0}' — expected ter_a | ter_b | ter_c")]
UnknownTerCategory(String),
#[error("no TER rates configured for category '{0}' — seed effective-dated ter_rates rows for the period")]
NoTerRates(String),
#[error("no overtime multiplier bands configured — seed effective-dated overtime rows for the period")]
MissingOvertimeBands,
#[error("no statutory parameters effective for {1} in country '{0}' — seed the parameter tables for that period")]
NoParamsForPeriod(String, String),
#[error("statutory parameter read failed: {0}")]
Db(#[from] sqlx::Error),
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct StatutoryConfig {
#[serde(default)]
pub pph21: Pph21Config,
#[serde(default)]
pub bpjs: BpjsConfig,
#[serde(default)]
pub overtime: OvertimeConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pph21Config {
pub brackets: Vec<Pph21Bracket>,
#[serde(default)]
pub ptkp_map: HashMap<String, Decimal>,
#[serde(default = "default_npwp_surtax")]
pub npwp_surtax_multiplier: Decimal,
#[serde(default)]
pub ter: HashMap<String, Vec<TerRateBand>>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TerRateBand {
pub lower_bound: Decimal,
pub rate: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OvertimeConfig {
#[serde(default = "default_overtime_hours_per_month")]
pub hours_per_month: Decimal,
#[serde(default = "default_overtime_workday_bands")]
pub workday: Vec<OvertimeBand>,
#[serde(default = "default_overtime_restday_bands")]
pub rest_day: Vec<OvertimeBand>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OvertimeBand {
pub hour_from: i32,
pub hour_to: Option<i32>,
pub multiplier: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Pph21Bracket {
pub lower_bound: Decimal,
pub upper_bound: Option<Decimal>,
pub rate: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BpjsConfig {
#[serde(default)]
pub kesehatan: BpjsKesehatanConfig,
#[serde(default)]
pub ketenagakerjaan: BpjsTkConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BpjsKesehatanConfig {
#[serde(default = "default_kesehatan_employee")]
pub employee_rate: Decimal,
#[serde(default = "default_kesehatan_employer")]
pub employer_rate: Decimal,
#[serde(default = "default_kesehatan_cap")]
pub salary_cap: Decimal,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct BpjsTkConfig {
#[serde(default = "default_jht_employee")]
pub jht_employee_rate: Decimal,
#[serde(default = "default_jht_employer")]
pub jht_employer_rate: Decimal,
#[serde(default = "default_jp_employee")]
pub jp_employee_rate: Decimal,
#[serde(default = "default_jp_employer")]
pub jp_employer_rate: Decimal,
#[serde(default = "default_jp_cap")]
pub jp_salary_cap: Decimal,
#[serde(default)]
pub jkk_rates_by_risk_class: HashMap<String, Decimal>,
#[serde(default = "default_jkm")]
pub jkm_rate: Decimal,
}
fn default_npwp_surtax() -> Decimal {
Decimal::new(12, 1) }
fn default_kesehatan_employee() -> Decimal {
Decimal::new(1, 2) }
fn default_kesehatan_employer() -> Decimal {
Decimal::new(4, 2) }
fn default_kesehatan_cap() -> Decimal {
Decimal::new(12_000_000, 0)
}
fn default_jht_employee() -> Decimal {
Decimal::new(2, 2) }
fn default_jht_employer() -> Decimal {
Decimal::new(37, 3) }
fn default_jp_employee() -> Decimal {
Decimal::new(1, 2) }
fn default_jp_employer() -> Decimal {
Decimal::new(2, 2) }
fn default_jp_cap() -> Decimal {
Decimal::new(10_547_400, 0)
}
fn default_jkm() -> Decimal {
Decimal::new(3, 3) }
fn default_overtime_hours_per_month() -> Decimal {
Decimal::new(173, 0)
}
fn default_overtime_workday_bands() -> Vec<OvertimeBand> {
vec![
OvertimeBand { hour_from: 1, hour_to: Some(1), multiplier: Decimal::new(15, 1) }, OvertimeBand { hour_from: 2, hour_to: None, multiplier: Decimal::new(2, 0) }, ]
}
fn default_overtime_restday_bands() -> Vec<OvertimeBand> {
vec![
OvertimeBand { hour_from: 1, hour_to: Some(8), multiplier: Decimal::new(2, 0) }, OvertimeBand { hour_from: 9, hour_to: None, multiplier: Decimal::new(3, 0) }, ]
}
impl Default for StatutoryConfig {
fn default() -> Self {
let ptkp_map = [
("tk0", Decimal::new(54_000_000, 0)),
("tk1", Decimal::new(58_500_000, 0)),
("tk2", Decimal::new(63_000_000, 0)),
("tk3", Decimal::new(67_500_000, 0)),
("k0", Decimal::new(58_500_000, 0)),
("k1", Decimal::new(63_000_000, 0)),
("k2", Decimal::new(67_500_000, 0)),
("k3", Decimal::new(72_000_000, 0)),
]
.into_iter()
.map(|(k, v)| (k.to_string(), v))
.collect();
let brackets = vec![
Pph21Bracket {
lower_bound: Decimal::ZERO,
upper_bound: Some(Decimal::new(60_000_000, 0)),
rate: Decimal::new(5, 2), },
Pph21Bracket {
lower_bound: Decimal::new(60_000_000, 0),
upper_bound: Some(Decimal::new(250_000_000, 0)),
rate: Decimal::new(15, 2), },
Pph21Bracket {
lower_bound: Decimal::new(250_000_000, 0),
upper_bound: Some(Decimal::new(500_000_000, 0)),
rate: Decimal::new(25, 2), },
Pph21Bracket {
lower_bound: Decimal::new(500_000_000, 0),
upper_bound: Some(Decimal::new(5_000_000_000, 0)),
rate: Decimal::new(30, 2), },
Pph21Bracket {
lower_bound: Decimal::new(5_000_000_000, 0),
upper_bound: None,
rate: Decimal::new(35, 2), },
];
let jkk_rates_by_risk_class = [
(1u8, Decimal::new(24, 4)), (2u8, Decimal::new(54, 4)), (3u8, Decimal::new(89, 4)), (4u8, Decimal::new(127, 4)), (5u8, Decimal::new(174, 4)), ]
.into_iter()
.map(|(c, r)| (c.to_string(), r))
.collect();
StatutoryConfig {
pph21: Pph21Config {
brackets,
ptkp_map,
npwp_surtax_multiplier: default_npwp_surtax(),
ter: default_ter_rates(),
},
bpjs: BpjsConfig {
kesehatan: BpjsKesehatanConfig {
employee_rate: default_kesehatan_employee(),
employer_rate: default_kesehatan_employer(),
salary_cap: default_kesehatan_cap(),
},
ketenagakerjaan: BpjsTkConfig {
jht_employee_rate: default_jht_employee(),
jht_employer_rate: default_jht_employer(),
jp_employee_rate: default_jp_employee(),
jp_employer_rate: default_jp_employer(),
jp_salary_cap: default_jp_cap(),
jkk_rates_by_risk_class,
jkm_rate: default_jkm(),
},
},
overtime: OvertimeConfig {
hours_per_month: default_overtime_hours_per_month(),
workday: default_overtime_workday_bands(),
rest_day: default_overtime_restday_bands(),
},
}
}
}
fn default_ter_rates() -> HashMap<String, Vec<TerRateBand>> {
fn band(lb: i64, rate_mantissa: i64, rate_scale: u32) -> TerRateBand {
TerRateBand { lower_bound: Decimal::new(lb, 0), rate: Decimal::new(rate_mantissa, rate_scale) }
}
let bands: [(&str, Vec<TerRateBand>); 3] = [
(
"ter_a",
vec![
band(0, 0, 0),
band(5_400_000, 25, 4), band(6_600_000, 5, 3), band(7_800_000, 1, 2), band(8_900_000, 15, 3), band(9_650_000, 25, 3), band(10_350_000, 3, 2), band(12_100_000, 5, 2), band(15_400_000, 8, 2), band(19_500_000, 12, 2), band(33_700_000, 17, 2), band(45_500_000, 20, 2), ],
),
(
"ter_b",
vec![
band(0, 0, 0),
band(5_400_000, 5, 3), band(6_600_000, 1, 2), band(7_800_000, 2, 2), band(8_900_000, 35, 3), band(9_650_000, 45, 3), band(10_350_000, 65, 3), band(12_100_000, 9, 2), band(15_400_000, 13, 2), band(19_500_000, 17, 2), band(33_700_000, 23, 2), band(45_500_000, 27, 2), ],
),
(
"ter_c",
vec![
band(0, 0, 0),
band(5_400_000, 1, 2), band(6_600_000, 2, 2), band(7_800_000, 35, 3), band(8_900_000, 5, 2), band(9_650_000, 6, 2), band(10_350_000, 10, 2), band(12_100_000, 13, 2), band(15_400_000, 17, 2), band(19_500_000, 21, 2), band(33_700_000, 28, 2), band(45_500_000, 32, 2), ],
),
];
bands
.into_iter()
.map(|(cat, list)| (cat.to_string(), list))
.collect()
}
impl Default for Pph21Config {
fn default() -> Self {
StatutoryConfig::default().pph21
}
}
impl Default for BpjsConfig {
fn default() -> Self {
StatutoryConfig::default().bpjs
}
}
impl Default for BpjsKesehatanConfig {
fn default() -> Self {
StatutoryConfig::default().bpjs.kesehatan
}
}
impl Default for BpjsTkConfig {
fn default() -> Self {
StatutoryConfig::default().bpjs.ketenagakerjaan
}
}
impl Pph21Config {
pub fn ptkp_relief(&self, tier: PtkpTier) -> Result<Decimal, StatutoryError> {
self.ptkp_map
.get(tier.key())
.copied()
.ok_or_else(|| StatutoryError::UnknownPtkpTier(tier.key().to_string()))
}
}
impl StatutoryConfig {
pub fn from_yaml_str(application_yml: &str) -> Result<Self, StatutoryError> {
let root: serde_yaml::Value = serde_yaml::from_str(application_yml)?;
match root.get("statutory") {
Some(block) => Ok(serde_yaml::from_value(block.clone())?),
None => Ok(Self::default()),
}
}
pub fn load_from_config_dir(dir: &Path, environment: &str) -> Result<Self, StatutoryError> {
let base = std::fs::read_to_string(dir.join("application.yml"))?;
let mut root: serde_yaml::Value = serde_yaml::from_str(&base)?;
let env_path = dir.join(format!("application-{}.yml", environment));
if env_path.exists() {
let env_str = std::fs::read_to_string(&env_path)?;
if let Ok(env_root) = serde_yaml::from_str::<serde_yaml::Value>(&env_str) {
if let Some(env_stat) = env_root.get("statutory") {
match root.get_mut("statutory") {
Some(slot) => *slot = env_stat.clone(),
None => {
if let serde_yaml::Value::Mapping(ref mut m) = root {
m.insert(
serde_yaml::Value::String("statutory".into()),
env_stat.clone(),
);
}
}
}
}
}
}
match root.get("statutory") {
Some(block) => Ok(serde_yaml::from_value(block.clone())?),
None => Ok(Self::default()),
}
}
}
fn progressive_tax(taxable: Decimal, brackets: &[Pph21Bracket]) -> Decimal {
let mut tax = Decimal::ZERO;
for b in brackets {
if taxable <= b.lower_bound {
break;
}
let slice = match &b.upper_bound {
Some(upper) => {
let top = if taxable < *upper {
taxable
} else {
*upper
};
top - b.lower_bound
}
None => taxable - b.lower_bound,
};
tax += slice * b.rate;
}
tax
}
pub fn pph21(
ptkp: PtkpTier,
has_npwp: bool,
gross_monthly: Decimal,
cfg: &Pph21Config,
) -> Result<Decimal, StatutoryError> {
let twelve = Decimal::new(12, 0);
let gross_annual = gross_monthly * twelve;
let relief = cfg.ptkp_relief(ptkp)?;
let annual_taxable = if gross_annual > relief {
gross_annual - relief
} else {
Decimal::ZERO
};
let mut annual_tax = progressive_tax(annual_taxable, &cfg.brackets);
if !has_npwp {
annual_tax *= cfg.npwp_surtax_multiplier;
}
let monthly_tax = annual_tax / twelve;
Ok(money(monthly_tax))
}
pub fn pph21_ter(
category: TerCategory,
has_npwp: bool,
gross_monthly: Decimal,
cfg: &StatutoryConfig,
) -> Result<Decimal, StatutoryError> {
let bands = cfg
.pph21
.ter
.get(category.key())
.filter(|list| !list.is_empty())
.ok_or_else(|| StatutoryError::NoTerRates(category.key().to_string()))?;
let tk = &cfg.bpjs.ketenagakerjaan;
let jp_base = if gross_monthly > tk.jp_salary_cap { tk.jp_salary_cap } else { gross_monthly };
let insurance_share = gross_monthly * tk.jht_employee_rate + jp_base * tk.jp_employee_rate;
let ter_base = (gross_monthly - insurance_share).max(Decimal::ZERO);
let mut rate: Option<Decimal> = None;
for b in bands {
if ter_base >= b.lower_bound {
rate = Some(b.rate);
} else {
break;
}
}
let rate = rate.ok_or_else(|| StatutoryError::NoTerRates(category.key().to_string()))?;
let mut tax = ter_base * rate;
if !has_npwp {
tax *= cfg.pph21.npwp_surtax_multiplier;
}
Ok(money(tax))
}
pub fn bpjs_kesehatan(gross_monthly: Decimal, cfg: &BpjsConfig) -> (Decimal, Decimal) {
let k = &cfg.kesehatan;
let capped = if gross_monthly > k.salary_cap {
k.salary_cap
} else {
gross_monthly
};
let employee = money(capped * k.employee_rate);
let employer = money(capped * k.employer_rate);
(employee, employer)
}
#[derive(Debug, Clone, PartialEq)]
pub struct BpjsTkBreakdown {
pub jht_employee: Decimal,
pub jht_employer: Decimal,
pub jp_employee: Decimal,
pub jp_employer: Decimal,
pub jkk_employer: Decimal,
pub jkm_employer: Decimal,
pub employee_total: Decimal,
pub employer_total: Decimal,
}
pub fn bpjs_ketenagakerjaan(
gross_monthly: Decimal,
risk_class: u8,
cfg: &BpjsConfig,
) -> Result<BpjsTkBreakdown, StatutoryError> {
let tk = &cfg.ketenagakerjaan;
let jht_employee = money(gross_monthly * tk.jht_employee_rate);
let jht_employer = money(gross_monthly * tk.jht_employer_rate);
let jp_capped = if gross_monthly > tk.jp_salary_cap {
tk.jp_salary_cap
} else {
gross_monthly
};
let jp_employee = money(jp_capped * tk.jp_employee_rate);
let jp_employer = money(jp_capped * tk.jp_employer_rate);
let jkk_rate = tk
.jkk_rates_by_risk_class
.get(&risk_class.to_string())
.copied()
.ok_or(StatutoryError::UnknownRiskClass(risk_class))?;
let jkk_employer = money(gross_monthly * jkk_rate);
let jkm_employer = money(gross_monthly * tk.jkm_rate);
let employee_total = money(jht_employee + jp_employee);
let employer_total = money(jht_employer + jp_employer + jkk_employer + jkm_employer);
Ok(BpjsTkBreakdown {
jht_employee,
jht_employer,
jp_employee,
jp_employer,
jkk_employer,
jkm_employer,
employee_total,
employer_total,
})
}
pub fn thr(monthly_salary: Decimal, tenure_months: Decimal) -> Decimal {
let twelve = Decimal::new(12, 0);
let fraction = if tenure_months >= twelve {
Decimal::new(1, 0)
} else if tenure_months > Decimal::ZERO {
tenure_months / twelve
} else {
Decimal::ZERO
};
money(monthly_salary * fraction)
}
pub fn overtime_pay(hours: Decimal, monthly_base: Decimal, cfg: &OvertimeConfig) -> Result<Decimal, StatutoryError> {
if hours <= Decimal::ZERO || monthly_base <= Decimal::ZERO {
return Ok(Decimal::ZERO);
}
if cfg.workday.is_empty() || cfg.hours_per_month <= Decimal::ZERO {
return Err(StatutoryError::MissingOvertimeBands);
}
let hourly = monthly_base / cfg.hours_per_month;
let whole = hours.floor();
let frac = hours - whole;
let mut total = Decimal::ZERO;
let multiplier_for = |hour: i64| -> Option<Decimal> {
cfg.workday
.iter()
.find(|b| {
hour >= b.hour_from as i64
&& b.hour_to.map_or(true, |to| hour <= to as i64)
})
.map(|b| b.multiplier)
};
for h in 1..=(whole.to_i64().unwrap_or(i64::MAX)) {
total += hourly * multiplier_for(h).ok_or(StatutoryError::MissingOvertimeBands)?;
}
if frac > Decimal::ZERO {
let next = whole.to_i64().unwrap_or(i64::MAX) + 1;
total += hourly * multiplier_for(next).ok_or(StatutoryError::MissingOvertimeBands)? * frac;
}
Ok(money(total))
}
#[derive(Debug, Clone)]
pub struct StatutoryComponent {
pub name: String,
pub component_type: String, pub amount: Decimal,
}
impl StatutoryComponent {
fn earning(name: impl Into<String>, amount: Decimal) -> Self {
Self { name: name.into(), component_type: "earning".into(), amount }
}
fn deduction(name: impl Into<String>, amount: Decimal) -> Self {
Self { name: name.into(), component_type: "deduction".into(), amount }
}
}
pub fn compute_statutory(
method: Pph21Method,
ptkp: PtkpTier,
has_npwp: bool,
gross_monthly: Decimal,
risk_class: u8,
thr_tenure_months: Decimal,
cfg: &StatutoryConfig,
) -> Result<Vec<StatutoryComponent>, StatutoryError> {
let mut out = Vec::new();
let (kes_employee, _kes_employer) = bpjs_kesehatan(gross_monthly, &cfg.bpjs);
let tk = bpjs_ketenagakerjaan(gross_monthly, risk_class, &cfg.bpjs)?;
let thr_amt = thr(gross_monthly, thr_tenure_months);
if thr_amt > Decimal::ZERO {
out.push(StatutoryComponent::earning("THR", thr_amt));
}
let pph = match method {
Pph21Method::NpwpBrackets => pph21(ptkp, has_npwp, gross_monthly, &cfg.pph21)?,
Pph21Method::Ter(category) => pph21_ter(category, has_npwp, gross_monthly, cfg)?,
};
if pph > Decimal::ZERO {
out.push(StatutoryComponent::deduction("PPh 21", pph));
}
if kes_employee > Decimal::ZERO {
out.push(StatutoryComponent::deduction("BPJS Kesehatan", kes_employee));
}
if tk.employee_total > Decimal::ZERO {
out.push(StatutoryComponent::deduction("BPJS Ketenagakerjaan", tk.employee_total));
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
use std::str::FromStr;
fn cfg() -> StatutoryConfig {
StatutoryConfig::default()
}
#[test]
fn pph21_tk0_npwp_12m_is_625000() {
let monthly = pph21(
PtkpTier::Tk0,
true,
Decimal::new(12_000_000, 0),
&cfg().pph21,
)
.expect("tk0 is in the default ptkp_map");
assert_eq!(monthly, Decimal::new(625_000, 0));
}
#[test]
fn pph21_no_npwp_surtax_is_120x() {
let monthly = pph21(
PtkpTier::Tk0,
false,
Decimal::new(12_000_000, 0),
&cfg().pph21,
)
.expect("tk0 is in the default ptkp_map");
assert_eq!(monthly, Decimal::new(750_000, 0));
}
#[test]
fn pph21_k3_high_income_hits_four_brackets() {
let monthly = pph21(
PtkpTier::K3,
true,
Decimal::new(50_000_000, 0),
&cfg().pph21,
)
.expect("k3 is in the default ptkp_map");
assert_eq!(monthly, Decimal::from_str("8533333.33").unwrap());
}
#[test]
fn pph21_salary_below_ptkp_is_zero() {
let monthly = pph21(
PtkpTier::Tk3,
true,
Decimal::new(5_000_000, 0),
&cfg().pph21,
)
.unwrap();
assert_eq!(monthly, Decimal::ZERO);
}
#[test]
fn bpjs_kesehatan_at_cap_is_120k_480k() {
let (emp, er) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &cfg().bpjs);
assert_eq!(emp, Decimal::new(120_000, 0));
assert_eq!(er, Decimal::new(480_000, 0));
}
#[test]
fn bpjs_kesehatan_above_cap_clamps() {
let (emp, er) = bpjs_kesehatan(Decimal::new(20_000_000, 0), &cfg().bpjs);
assert_eq!(emp, Decimal::new(120_000, 0));
assert_eq!(er, Decimal::new(480_000, 0));
}
#[test]
fn bpjs_kesehatan_below_cap_pro_rata() {
let (emp, er) = bpjs_kesehatan(Decimal::new(7_500_000, 0), &cfg().bpjs);
assert_eq!(emp, Decimal::new(75_000, 0));
assert_eq!(er, Decimal::new(300_000, 0));
}
#[test]
fn bpjs_tk_risk_class_3_at_10m() {
let b = bpjs_ketenagakerjaan(Decimal::new(10_000_000, 0), 3, &cfg().bpjs).unwrap();
assert_eq!(b.jht_employee, Decimal::new(200_000, 0));
assert_eq!(b.jht_employer, Decimal::new(370_000, 0));
assert_eq!(b.jp_employee, Decimal::new(100_000, 0));
assert_eq!(b.jp_employer, Decimal::new(200_000, 0));
assert_eq!(b.jkk_employer, Decimal::new(89_000, 0));
assert_eq!(b.jkm_employer, Decimal::new(30_000, 0));
assert_eq!(b.employee_total, Decimal::new(300_000, 0));
assert_eq!(b.employer_total, Decimal::new(689_000, 0));
}
#[test]
fn bpjs_tk_jp_cap_kicks_in_above_cap() {
let b = bpjs_ketenagakerjaan(Decimal::new(12_000_000, 0), 1, &cfg().bpjs).unwrap();
assert_eq!(b.jp_employee, Decimal::new(105_474, 0));
assert_eq!(b.jp_employer, Decimal::new(210_948, 0));
assert_eq!(b.jht_employee, Decimal::new(240_000, 0));
assert_eq!(b.jkk_employer, Decimal::new(28_800, 0));
assert_eq!(b.jkm_employer, Decimal::new(36_000, 0));
}
#[test]
fn bpjs_tk_unknown_risk_class_errors() {
let err = bpjs_ketenagakerjaan(Decimal::new(10_000_000, 0), 9, &cfg().bpjs)
.expect_err("class 9 should be unknown");
assert!(matches!(err, StatutoryError::UnknownRiskClass(9)));
}
#[test]
fn thr_prorated_6_months_is_half() {
let amount = thr(Decimal::new(12_000_000, 0), Decimal::new(6, 0));
assert_eq!(amount, Decimal::new(6_000_000, 0));
}
#[test]
fn thr_full_at_12_months() {
let amount = thr(Decimal::new(15_000_000, 0), Decimal::new(12, 0));
assert_eq!(amount, Decimal::new(15_000_000, 0));
}
#[test]
fn thr_capped_above_12_months() {
let amount = thr(Decimal::new(15_000_000, 0), Decimal::new(24, 0));
assert_eq!(amount, Decimal::new(15_000_000, 0));
}
#[test]
fn thr_zero_tenure_is_zero() {
let amount = thr(Decimal::new(12_000_000, 0), Decimal::ZERO);
assert_eq!(amount, Decimal::ZERO);
}
#[test]
fn ptkp_tier_roundtrips_as_snake_case() {
for tier in [
PtkpTier::Tk0,
PtkpTier::Tk1,
PtkpTier::Tk2,
PtkpTier::Tk3,
PtkpTier::K0,
PtkpTier::K1,
PtkpTier::K2,
PtkpTier::K3,
] {
let s = tier.to_string();
assert_eq!(PtkpTier::from_str(&s).unwrap(), tier);
}
assert!(PtkpTier::from_str("tk9").is_err());
}
#[test]
fn config_from_yaml_drives_same_calc_as_default() {
let yaml = include_str!("../../../config/application.yml");
let loaded = StatutoryConfig::from_yaml_str(yaml).expect("application.yml parses");
let via_default =
pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &cfg().pph21).unwrap();
let via_loaded =
pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &loaded.pph21).unwrap();
assert_eq!(via_default, via_loaded);
assert_eq!(via_loaded, Decimal::new(625_000, 0));
let (emp_default, _) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &cfg().bpjs);
let (emp_loaded, _) = bpjs_kesehatan(Decimal::new(12_000_000, 0), &loaded.bpjs);
assert_eq!(emp_default, emp_loaded);
}
#[test]
fn config_missing_statutory_block_falls_back_to_default() {
let yaml = "server:\n port: 8080\n";
let loaded = StatutoryConfig::from_yaml_str(yaml).unwrap();
let monthly = pph21(PtkpTier::Tk0, true, Decimal::new(12_000_000, 0), &loaded.pph21).unwrap();
assert_eq!(monthly, Decimal::new(625_000, 0));
}
#[test]
fn compute_statutory_tk0_npwp_12m_full_tenure_emits_four_components() {
let comps = compute_statutory(
Pph21Method::NpwpBrackets,
PtkpTier::Tk0,
true,
Decimal::new(12_000_000, 0),
3,
Decimal::new(12, 0),
&cfg(),
)
.expect("tk0 + risk class 3 are in the default config");
let by_name: std::collections::HashMap<String, (String, Decimal)> = comps
.iter()
.map(|c| (c.name.clone(), (c.component_type.clone(), c.amount)))
.collect();
assert_eq!(by_name["THR"], ("earning".into(), Decimal::new(12_000_000, 0)));
assert_eq!(by_name["PPh 21"], ("deduction".into(), Decimal::new(625_000, 0)));
assert_eq!(by_name["BPJS Kesehatan"], ("deduction".into(), Decimal::new(120_000, 0)));
assert_eq!(by_name["BPJS Ketenagakerjaan"], ("deduction".into(), Decimal::new(345_474, 0)));
assert_eq!(comps.len(), 4, "exactly four components");
let total_deductions: Decimal = comps
.iter()
.filter(|c| c.component_type == "deduction")
.map(|c| c.amount)
.sum();
assert_eq!(total_deductions, Decimal::new(1_090_474, 0));
}
#[test]
fn compute_statutory_zero_tenure_drops_thr() {
let comps = compute_statutory(
Pph21Method::NpwpBrackets,
PtkpTier::Tk0,
true,
Decimal::new(12_000_000, 0),
1,
Decimal::ZERO,
&cfg(),
)
.unwrap();
assert!(!comps.iter().any(|c| c.name == "THR"), "zero-tenure THR must be dropped");
assert_eq!(comps.len(), 3, "PPh21 + BPJS Kesehatan + BPJS TK only");
}
#[test]
fn pph21_ter_a_10m_base_9_7m_is_242500() {
let tax = pph21_ter(
TerCategory::TerA,
true,
Decimal::new(10_000_000, 0),
&cfg(),
)
.expect("ter_a bands are seeded");
assert_eq!(tax, Decimal::new(242_500, 0));
}
#[test]
fn pph21_ter_a_no_npwp_is_291000() {
let tax = pph21_ter(
TerCategory::TerA,
false,
Decimal::new(10_000_000, 0),
&cfg(),
)
.unwrap();
assert_eq!(tax, Decimal::new(291_000, 0));
}
#[test]
fn pph21_ter_base_uses_unrounded_insurance_products() {
let tax = pph21_ter(
TerCategory::TerA,
true,
Decimal::from_str("9999999.99").unwrap(),
&cfg(),
)
.unwrap();
assert_eq!(tax, Decimal::from_str("242500.00").unwrap());
}
#[test]
fn pph21_ter_band_edge_is_lower_bound_inclusive() {
let mut c = cfg();
c.pph21.ter.insert(
"ter_a".into(),
vec![
TerRateBand { lower_bound: Decimal::ZERO, rate: Decimal::new(0, 0) },
TerRateBand { lower_bound: Decimal::new(1_000_000, 0), rate: Decimal::new(10, 2) },
],
);
c.bpjs.ketenagakerjaan.jht_employee_rate = Decimal::ZERO;
c.bpjs.ketenagakerjaan.jp_employee_rate = Decimal::ZERO;
let at = pph21_ter(TerCategory::TerA, true, Decimal::new(1_000_000, 0), &c).unwrap();
assert_eq!(at, Decimal::new(100_000, 0), "base exactly at the bound is IN the band above");
let below = pph21_ter(TerCategory::TerA, true, Decimal::from_str("999999.99").unwrap(), &c).unwrap();
assert_eq!(below, Decimal::ZERO, "one sen below the bound is the lower band (0%)");
}
#[test]
fn pph21_ter_empty_table_fails_closed() {
let mut c = cfg();
c.pph21.ter.remove("ter_a");
let err = pph21_ter(TerCategory::TerA, true, Decimal::new(10_000_000, 0), &c)
.expect_err("missing ter_a bands must fail");
assert!(matches!(err, StatutoryError::NoTerRates(cat) if cat == "ter_a"));
c.pph21.ter.insert("ter_a".into(), vec![]);
assert!(matches!(
pph21_ter(TerCategory::TerA, true, Decimal::new(10_000_000, 0), &c),
Err(StatutoryError::NoTerRates(_))
));
}
#[test]
fn pph21_ter_high_base_uses_top_band() {
let tax = pph21_ter(
TerCategory::TerC,
true,
Decimal::new(50_000_000, 0),
&cfg(),
)
.unwrap();
assert_eq!(tax, Decimal::from_str("15646248.32").unwrap());
}
#[test]
fn pph21_ter_interops_with_compute_statutory_dispatch() {
let brackets = compute_statutory(
Pph21Method::NpwpBrackets,
PtkpTier::Tk0,
true,
Decimal::new(10_000_000, 0),
3,
Decimal::ZERO,
&cfg(),
)
.unwrap();
let ter = compute_statutory(
Pph21Method::Ter(TerCategory::TerA),
PtkpTier::Tk0,
true,
Decimal::new(10_000_000, 0),
3,
Decimal::ZERO,
&cfg(),
)
.unwrap();
let find = |v: &Vec<StatutoryComponent>, n: &str| {
v.iter().find(|c| c.name == n).map(|c| c.amount).unwrap()
};
assert_eq!(find(&brackets, "PPh 21"), Decimal::from_str("325000.00").unwrap());
assert_eq!(find(&ter, "PPh 21"), Decimal::new(242_500, 0));
assert_eq!(find(&brackets, "BPJS Kesehatan"), find(&ter, "BPJS Kesehatan"));
assert_eq!(find(&brackets, "BPJS Ketenagakerjaan"), find(&ter, "BPJS Ketenagakerjaan"));
}
#[test]
fn overtime_10h_at_8_7m_base_is_980635_84() {
let pay = overtime_pay(
Decimal::new(10, 0),
Decimal::new(8_700_000, 0),
&cfg().overtime,
)
.unwrap();
assert_eq!(pay, Decimal::from_str("980635.84").unwrap());
}
#[test]
fn overtime_hour_beyond_the_last_band_fails_closed() {
let mut c = cfg();
c.overtime.workday = vec![
OvertimeBand { hour_from: 1, hour_to: Some(1), multiplier: Decimal::from_str("1.5").unwrap() },
OvertimeBand { hour_from: 2, hour_to: Some(3), multiplier: Decimal::new(2, 0) },
];
assert!(matches!(
overtime_pay(Decimal::new(4, 0), Decimal::new(10_000_000, 0), &c.overtime),
Err(StatutoryError::MissingOvertimeBands)
));
let pay = overtime_pay(Decimal::new(3, 0), Decimal::new(10_000_000, 0), &c.overtime).unwrap();
let hourly = Decimal::new(10_000_000, 0) / Decimal::new(173, 0);
assert_eq!(pay, (Decimal::from_str("5.5").unwrap() * hourly).round_dp_with_strategy(2, RoundingStrategy::MidpointAwayFromZero));
}
#[test]
fn overtime_first_hour_only_is_1_5x() {
let pay = overtime_pay(
Decimal::new(1, 0),
Decimal::new(10_000_000, 0),
&cfg().overtime,
)
.unwrap();
assert_eq!(pay, Decimal::from_str("86705.20").unwrap());
}
#[test]
fn overtime_fractional_hour_prorates_the_band() {
let pay = overtime_pay(
Decimal::from_str("2.5").unwrap(),
Decimal::new(8_700_000, 0),
&cfg().overtime,
)
.unwrap();
assert_eq!(pay, Decimal::from_str("226300.58").unwrap());
}
#[test]
fn overtime_zero_hours_or_base_is_zero() {
assert_eq!(overtime_pay(Decimal::ZERO, Decimal::new(10_000_000, 0), &cfg().overtime).unwrap(), Decimal::ZERO);
assert_eq!(overtime_pay(Decimal::new(3, 0), Decimal::ZERO, &cfg().overtime).unwrap(), Decimal::ZERO);
}
#[test]
fn overtime_missing_bands_fail_closed() {
let mut c = cfg();
c.overtime.workday = vec![];
assert!(matches!(
overtime_pay(Decimal::new(2, 0), Decimal::new(10_000_000, 0), &c.overtime),
Err(StatutoryError::MissingOvertimeBands)
));
}
#[test]
fn pph21_method_labels_are_the_audit_stamp() {
assert_eq!(Pph21Method::NpwpBrackets.label(), "npwp_brackets");
assert_eq!(Pph21Method::Ter(TerCategory::TerB).label(), "ter_b");
assert_eq!(Pph21Method::Ter(TerCategory::TerC).to_string(), "ter_c");
}
}