Skip to main content

backbone_payroll/infrastructure/persistence/
statutory_params_repository.rs

1//! As-of resolver for the statutory parameter tables.
2//!
3//! The authoritative source of statutory rates is the five effective-dated tables seeded by
4//! migration (`pph21_brackets`, `pph21_ptkp`, `pph21_ter_rates`, `bpjs_params`, `overtime_params`).
5//! This repository resolves each table AS OF a payroll period into one [`StatutoryConfig`] — the
6//! same struct the pure calcs consume — so the calc layer never knows where its numbers came from.
7//!
8//! Resolution semantics: for each table, the `effective_from` that applies is the greatest one
9//! `<= as_of`; every row at that `effective_from` is loaded together (a correction is a NEW
10//! effective-dated row set, never an edit of live rows, so a set is always internally consistent).
11//!
12//! Fail-closed: any table with NO rows effective at `as_of` (and any incomplete BPJS/overtime
13//! component set) is [`StatutoryError::NoParamsForPeriod`] — payroll refuses to compute rather
14//! than silently zeroing a tax or an insurance contribution. There is deliberately NO runtime
15//! fallback to YAML/`Default` config: those are seed-source material only; serving them at runtime
16//! would let a deployment drift from the auditable parameter history.
17
18use chrono::NaiveDate;
19use rust_decimal::Decimal;
20use sqlx::PgPool;
21use std::collections::HashMap;
22
23use crate::application::service::statutory_calcs::{
24    BpjsConfig, BpjsKesehatanConfig, BpjsTkConfig, OvertimeBand, OvertimeConfig, Pph21Bracket,
25    Pph21Config, StatutoryConfig, StatutoryError, TerRateBand,
26};
27
28/// Reads the statutory parameter tables. Thin struct (pool holder) for symmetry with the module's
29/// other repositories; the reads are global-master lookups, so there is no company scoping here —
30/// the tables are unfenced national-law data (no `company_id`, no RLS) by design.
31pub struct StatutoryParamsRepository {
32    pool: PgPool,
33}
34
35impl StatutoryParamsRepository {
36    /// The database this read runs on: the composer's request pool when the
37    /// tenant router installed one, else the composed pool (ADR-0029 pool law).
38    fn rpool(&self) -> PgPool {
39        crate::request_pool::current().unwrap_or_else(|| self.pool.clone())
40    }
41
42    pub fn new(pool: PgPool) -> Self {
43        Self { pool }
44    }
45
46    /// Resolve every statutory parameter table as-of `as_of` into one config. See the module docs
47    /// for the resolution and fail-closed semantics.
48    pub async fn resolve_as_of(
49        &self,
50        country_code: &str,
51        as_of: NaiveDate,
52    ) -> Result<StatutoryConfig, StatutoryError> {
53        let brackets = self.brackets_as_of(country_code, as_of).await?;
54        let ptkp_map = self.ptkp_as_of(country_code, as_of).await?;
55        let ter = self.ter_as_of(country_code, as_of).await?;
56        let bpjs = self.bpjs_as_of(country_code, as_of).await?;
57        let overtime = self.overtime_as_of(country_code, as_of).await?;
58
59        Ok(StatutoryConfig {
60            pph21: Pph21Config {
61                brackets,
62                ptkp_map,
63                // The no-NPWP surtax is a fixed statutory multiplier, not effective-dated table
64                // data; the Default (1.2) is the law's constant.
65                npwp_surtax_multiplier: Decimal::new(12, 1),
66                ter,
67            },
68            bpjs,
69            overtime,
70        })
71    }
72
73    /// The `effective_from` that applies at `as_of` for `table`, or `None` when the table has no
74    /// effective rows (each caller turns that into the fail-closed error).
75    async fn effective_at(
76        &self,
77        table: &str,
78        country_code: &str,
79        as_of: NaiveDate,
80    ) -> Result<Option<NaiveDate>, sqlx::Error> {
81        let effective: Option<(Option<NaiveDate>,)> = sqlx::query_as(&format!(
82            "SELECT MAX(effective_from) FROM {table} WHERE country_code = $1 AND effective_from <= $2"
83        ))
84        .bind(country_code)
85        .bind(as_of)
86        .fetch_optional(&self.rpool())
87        .await?;
88        Ok(effective.and_then(|(d,)| d))
89    }
90
91    async fn brackets_as_of(
92        &self,
93        country_code: &str,
94        as_of: NaiveDate,
95    ) -> Result<Vec<Pph21Bracket>, StatutoryError> {
96        let effective = self
97            .effective_at("payroll.pph21_brackets", country_code, as_of)
98            .await?
99            .ok_or_else(|| no_params(country_code, as_of))?;
100        let rows: Vec<(i32, Decimal, Option<Decimal>, Decimal)> = sqlx::query_as(
101            r#"SELECT seq, lower_bound, upper_bound, rate
102                 FROM payroll.pph21_brackets
103                WHERE country_code = $1 AND effective_from = $2
104                ORDER BY seq"#,
105        )
106        .bind(country_code)
107        .bind(effective)
108        .fetch_all(&self.rpool())
109        .await?;
110        let brackets: Vec<Pph21Bracket> = rows
111            .into_iter()
112            .map(|(_, lower, upper, rate)| Pph21Bracket { lower_bound: lower, upper_bound: upper, rate })
113            .collect();
114        // A complete bracket set opens at income zero and closes open-ended: the progressive-tax
115        // walk silently zero-taxes everything below a set that starts above zero, so a lone
116        // correction row (an incomplete set) must refuse here rather than compute.
117        if brackets.is_empty()
118            || brackets.first().map(|b| b.lower_bound) != Some(Decimal::ZERO)
119            || brackets.last().and_then(|b| b.upper_bound).is_some()
120        {
121            return Err(no_params(country_code, as_of));
122        }
123        Ok(brackets)
124    }
125
126    async fn ptkp_as_of(
127        &self,
128        country_code: &str,
129        as_of: NaiveDate,
130    ) -> Result<HashMap<String, Decimal>, StatutoryError> {
131        let effective = self
132            .effective_at("payroll.pph21_ptkp", country_code, as_of)
133            .await?
134            .ok_or_else(|| no_params(country_code, as_of))?;
135        let rows: Vec<(String, Decimal)> = sqlx::query_as(
136            r#"SELECT tier, annual_amount
137                 FROM payroll.pph21_ptkp
138                WHERE country_code = $1 AND effective_from = $2"#,
139        )
140        .bind(country_code)
141        .bind(effective)
142        .fetch_all(&self.rpool())
143        .await?;
144        let ptkp_map: HashMap<String, Decimal> = rows.into_iter().collect();
145        // The tier axis is closed (eight tiers); a set missing any of them is an incomplete
146        // correction — the employee lookup would 500 on the first affected worker otherwise.
147        for tier in ["tk0", "tk1", "tk2", "tk3", "k0", "k1", "k2", "k3"] {
148            if !ptkp_map.contains_key(tier) {
149                return Err(no_params(country_code, as_of));
150            }
151        }
152        Ok(ptkp_map)
153    }
154
155    async fn ter_as_of(
156        &self,
157        country_code: &str,
158        as_of: NaiveDate,
159    ) -> Result<HashMap<String, Vec<TerRateBand>>, StatutoryError> {
160        let effective = self
161            .effective_at("payroll.pph21_ter_rates", country_code, as_of)
162            .await?
163            .ok_or_else(|| no_params(country_code, as_of))?;
164        let rows: Vec<(String, i32, Decimal, Decimal)> = sqlx::query_as(
165            r#"SELECT category, seq, lower_bound, rate
166                 FROM payroll.pph21_ter_rates
167                WHERE country_code = $1 AND effective_from = $2
168                ORDER BY category, seq"#,
169        )
170        .bind(country_code)
171        .bind(effective)
172        .fetch_all(&self.rpool())
173        .await?;
174        let mut map: HashMap<String, Vec<TerRateBand>> = HashMap::new();
175        for (category, _, lower, rate) in rows {
176            map.entry(category).or_default().push(TerRateBand { lower_bound: lower, rate });
177        }
178        // A complete TER set carries every category, each opening at base zero — a lone category
179        // or a band list that starts above zero is an incomplete correction and must refuse.
180        for category in ["ter_a", "ter_b", "ter_c"] {
181            match map.get(category) {
182                Some(bands)
183                    if !bands.is_empty() && bands.first().map(|b| b.lower_bound) == Some(Decimal::ZERO) =>
184                {}
185                _ => return Err(no_params(country_code, as_of)),
186            }
187        }
188        Ok(map)
189    }
190
191    async fn bpjs_as_of(&self, country_code: &str, as_of: NaiveDate) -> Result<BpjsConfig, StatutoryError> {
192        let effective = self
193            .effective_at("payroll.bpjs_params", country_code, as_of)
194            .await?
195            .ok_or_else(|| no_params(country_code, as_of))?;
196        let rows: Vec<(String, String, Decimal, Option<Decimal>)> = sqlx::query_as(
197            r#"SELECT component, side, rate, wage_cap
198                 FROM payroll.bpjs_params
199                WHERE country_code = $1 AND effective_from = $2"#,
200        )
201        .bind(country_code)
202        .bind(effective)
203        .fetch_all(&self.rpool())
204        .await?;
205
206        let mut kes_employee = None;
207        let mut kes_employer = None;
208        let mut kes_cap: Option<Option<Decimal>> = None;
209        let mut jht_employee = None;
210        let mut jht_employer = None;
211        let mut jp_employee = None;
212        let mut jp_employer = None;
213        let mut jp_cap: Option<Option<Decimal>> = None;
214        let mut jkk: HashMap<String, Decimal> = HashMap::new();
215        let mut jkm = None;
216
217        for (component, side, rate, cap) in rows {
218            match (component.as_str(), side.as_str()) {
219                ("kes", "employee") => kes_employee = Some(rate),
220                ("kes", "employer") => kes_employer = Some(rate),
221                ("jht", "employee") => jht_employee = Some(rate),
222                ("jht", "employer") => jht_employer = Some(rate),
223                ("jp", "employee") => jp_employee = Some(rate),
224                ("jp", "employer") => jp_employer = Some(rate),
225                (jk, "employer") if jk.starts_with("jkk_") => {
226                    jkk.insert(jk.trim_start_matches("jkk_").to_string(), rate);
227                }
228                ("jkm", "employer") => jkm = Some(rate),
229                // An unknown component/side pair means the table carries data this build cannot
230                // interpret — refuse instead of half-applying it.
231                _ => return Err(no_params(country_code, as_of)),
232            }
233            // Kesehatan/Jaminan Pensiun are capped components by law; a NULL cap row means the
234            // seeded set is incomplete — the `flatten().ok_or` below refuses rather than inventing
235            // an unbounded cap.
236            if component == "kes" {
237                kes_cap = Some(cap);
238            }
239            if component == "jp" {
240                jp_cap = Some(cap);
241            }
242        }
243
244        Ok(BpjsConfig {
245            kesehatan: BpjsKesehatanConfig {
246                employee_rate: kes_employee.ok_or_else(|| no_params(country_code, as_of))?,
247                employer_rate: kes_employer.ok_or_else(|| no_params(country_code, as_of))?,
248                salary_cap: kes_cap.flatten().ok_or_else(|| no_params(country_code, as_of))?,
249            },
250            ketenagakerjaan: BpjsTkConfig {
251                jht_employee_rate: jht_employee.ok_or_else(|| no_params(country_code, as_of))?,
252                jht_employer_rate: jht_employer.ok_or_else(|| no_params(country_code, as_of))?,
253                jp_employee_rate: jp_employee.ok_or_else(|| no_params(country_code, as_of))?,
254                jp_employer_rate: jp_employer.ok_or_else(|| no_params(country_code, as_of))?,
255                jp_salary_cap: jp_cap.flatten().ok_or_else(|| no_params(country_code, as_of))?,
256                jkk_rates_by_risk_class: jkk,
257                jkm_rate: jkm.ok_or_else(|| no_params(country_code, as_of))?,
258            },
259        })
260    }
261
262    async fn overtime_as_of(
263        &self,
264        country_code: &str,
265        as_of: NaiveDate,
266    ) -> Result<OvertimeConfig, StatutoryError> {
267        let effective = self
268            .effective_at("payroll.overtime_params", country_code, as_of)
269            .await?
270            .ok_or_else(|| no_params(country_code, as_of))?;
271        let rows: Vec<(String, i32, Option<i32>, Decimal)> = sqlx::query_as(
272            r#"SELECT day_kind, hour_from, hour_to, multiplier
273                 FROM payroll.overtime_params
274                WHERE country_code = $1 AND effective_from = $2
275                ORDER BY day_kind, hour_from"#,
276        )
277        .bind(country_code)
278        .bind(effective)
279        .fetch_all(&self.rpool())
280        .await?;
281
282        let mut workday = Vec::new();
283        let mut rest_day = Vec::new();
284        for (day_kind, hour_from, hour_to, multiplier) in rows {
285            let band = OvertimeBand { hour_from, hour_to, multiplier };
286            match day_kind.as_str() {
287                "workday" => workday.push(band),
288                "rest_day" => rest_day.push(band),
289                _ => return Err(no_params(country_code, as_of)),
290            }
291        }
292        // The pay calc dispatches the workday schedule; an effective set with no workday bands
293        // cannot price ANY overtime — refuse.
294        if workday.is_empty() {
295            return Err(no_params(country_code, as_of));
296        }
297
298        Ok(OvertimeConfig {
299            // The monthly-hours divisor is a fixed statutory constant, not effective-dated table
300            // data; 173 is the law's value.
301            hours_per_month: Decimal::new(173, 0),
302            workday,
303            rest_day,
304        })
305    }
306}
307
308fn no_params(country_code: &str, as_of: NaiveDate) -> StatutoryError {
309    StatutoryError::NoParamsForPeriod(country_code.to_string(), as_of.to_string())
310}