1use 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
28pub struct StatutoryParamsRepository {
32 pool: PgPool,
33}
34
35impl StatutoryParamsRepository {
36 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 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 npwp_surtax_multiplier: Decimal::new(12, 1),
66 ter,
67 },
68 bpjs,
69 overtime,
70 })
71 }
72
73 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 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 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 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 _ => return Err(no_params(country_code, as_of)),
232 }
233 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 if workday.is_empty() {
295 return Err(no_params(country_code, as_of));
296 }
297
298 Ok(OvertimeConfig {
299 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}