1use chrono::{NaiveDate, Utc};
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rusqlite::params;
7use rust_decimal::Decimal;
8use stateset_core::{
9 CommerceError, CreateTaxExemption, CreateTaxJurisdiction, CreateTaxRate, JurisdictionSummary,
10 LineItemTax, ProductTaxCategory, Result, TaxAddress, TaxBreakdown, TaxCalculationRequest,
11 TaxCalculationResult, TaxDetail, TaxExemption, TaxJurisdiction, TaxJurisdictionFilter, TaxRate,
12 TaxRateFilter, TaxRepository, TaxSettings,
13};
14use uuid::Uuid;
15
16use super::{
17 map_db_error, parse_date_row, parse_datetime_opt_row, parse_datetime_row,
18 parse_decimal_opt_row, parse_decimal_row, parse_enum_row, parse_json_opt_row, parse_json_row,
19 parse_uuid_opt_row, parse_uuid_row,
20};
21
22#[derive(Debug)]
24pub struct SqliteTaxRepository {
25 pool: Pool<SqliteConnectionManager>,
26}
27
28impl SqliteTaxRepository {
29 #[must_use]
30 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
31 Self { pool }
32 }
33
34 fn parse_date_opt(
35 value: Option<String>,
36 entity: &str,
37 field: &str,
38 ) -> rusqlite::Result<Option<NaiveDate>> {
39 match value {
40 Some(ref val) if !val.is_empty() => Ok(Some(parse_date_row(val, entity, field)?)),
41 _ => Ok(None),
42 }
43 }
44}
45
46impl SqliteTaxRepository {
51 pub fn get_jurisdiction(&self, id: Uuid) -> Result<Option<TaxJurisdiction>> {
53 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
54
55 let result = conn.query_row(
56 "SELECT id, parent_id, name, code, level, country_code, state_code, county, city, postal_codes, active, created_at, updated_at
57 FROM tax_jurisdictions WHERE id = ?",
58 params![id.to_string()],
59 |row| {
60 let postal_codes_json: String = row.get(9)?;
61 let postal_codes: Vec<String> =
62 parse_json_row(&postal_codes_json, "tax_jurisdiction", "postal_codes")?;
63
64 Ok(TaxJurisdiction {
65 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_jurisdiction", "id")?,
66 parent_id: parse_uuid_opt_row(
67 row.get::<_, Option<String>>(1)?,
68 "tax_jurisdiction",
69 "parent_id",
70 )?,
71 name: row.get(2)?,
72 code: row.get(3)?,
73 level: parse_enum_row(&row.get::<_, String>(4)?, "tax_jurisdiction", "level")?,
74 country_code: row.get(5)?,
75 state_code: row.get(6)?,
76 county: row.get(7)?,
77 city: row.get(8)?,
78 postal_codes,
79 active: row.get::<_, i32>(10)? != 0,
80 created_at: parse_datetime_row(
81 &row.get::<_, String>(11)?,
82 "tax_jurisdiction",
83 "created_at",
84 )?,
85 updated_at: parse_datetime_row(
86 &row.get::<_, String>(12)?,
87 "tax_jurisdiction",
88 "updated_at",
89 )?,
90 })
91 },
92 );
93
94 match result {
95 Ok(jurisdiction) => Ok(Some(jurisdiction)),
96 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
97 Err(e) => Err(map_db_error(e)),
98 }
99 }
100
101 pub fn get_jurisdiction_by_code(&self, code: &str) -> Result<Option<TaxJurisdiction>> {
103 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
104
105 let result = conn.query_row(
106 "SELECT id, parent_id, name, code, level, country_code, state_code, county, city, postal_codes, active, created_at, updated_at
107 FROM tax_jurisdictions WHERE code = ?",
108 params![code],
109 |row| {
110 let postal_codes_json: String = row.get(9)?;
111 let postal_codes: Vec<String> =
112 parse_json_row(&postal_codes_json, "tax_jurisdiction", "postal_codes")?;
113
114 Ok(TaxJurisdiction {
115 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_jurisdiction", "id")?,
116 parent_id: parse_uuid_opt_row(
117 row.get::<_, Option<String>>(1)?,
118 "tax_jurisdiction",
119 "parent_id",
120 )?,
121 name: row.get(2)?,
122 code: row.get(3)?,
123 level: parse_enum_row(&row.get::<_, String>(4)?, "tax_jurisdiction", "level")?,
124 country_code: row.get(5)?,
125 state_code: row.get(6)?,
126 county: row.get(7)?,
127 city: row.get(8)?,
128 postal_codes,
129 active: row.get::<_, i32>(10)? != 0,
130 created_at: parse_datetime_row(
131 &row.get::<_, String>(11)?,
132 "tax_jurisdiction",
133 "created_at",
134 )?,
135 updated_at: parse_datetime_row(
136 &row.get::<_, String>(12)?,
137 "tax_jurisdiction",
138 "updated_at",
139 )?,
140 })
141 },
142 );
143
144 match result {
145 Ok(jurisdiction) => Ok(Some(jurisdiction)),
146 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
147 Err(e) => Err(map_db_error(e)),
148 }
149 }
150
151 pub fn list_jurisdictions(
153 &self,
154 filter: TaxJurisdictionFilter,
155 ) -> Result<Vec<TaxJurisdiction>> {
156 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
157
158 let mut query = String::from(
159 "SELECT id, parent_id, name, code, level, country_code, state_code, county, city, postal_codes, active, created_at, updated_at
160 FROM tax_jurisdictions WHERE 1=1"
161 );
162 let mut params_vec: Vec<String> = Vec::new();
163
164 if let Some(country) = &filter.country_code {
165 query.push_str(" AND country_code = ?");
166 params_vec.push(country.clone());
167 }
168
169 if let Some(state) = &filter.state_code {
170 query.push_str(" AND state_code = ?");
171 params_vec.push(state.clone());
172 }
173
174 if let Some(level) = &filter.level {
175 query.push_str(" AND level = ?");
176 params_vec.push(level.to_string());
177 }
178
179 if filter.active_only {
180 query.push_str(" AND active = 1");
181 }
182
183 query.push_str(" ORDER BY country_code, COALESCE(state_code, ''), level, name");
186 crate::sqlite::append_limit_offset(&mut query, filter.limit, filter.offset);
187
188 let mut stmt = conn.prepare(&query).map_err(map_db_error)?;
189 let params: Vec<&dyn rusqlite::ToSql> =
190 params_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
191
192 let rows = stmt
193 .query_map(params.as_slice(), |row| {
194 let postal_codes_json: String = row.get(9)?;
195 let postal_codes: Vec<String> =
196 parse_json_row(&postal_codes_json, "tax_jurisdiction", "postal_codes")?;
197
198 Ok(TaxJurisdiction {
199 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_jurisdiction", "id")?,
200 parent_id: parse_uuid_opt_row(
201 row.get::<_, Option<String>>(1)?,
202 "tax_jurisdiction",
203 "parent_id",
204 )?,
205 name: row.get(2)?,
206 code: row.get(3)?,
207 level: parse_enum_row(&row.get::<_, String>(4)?, "tax_jurisdiction", "level")?,
208 country_code: row.get(5)?,
209 state_code: row.get(6)?,
210 county: row.get(7)?,
211 city: row.get(8)?,
212 postal_codes,
213 active: row.get::<_, i32>(10)? != 0,
214 created_at: parse_datetime_row(
215 &row.get::<_, String>(11)?,
216 "tax_jurisdiction",
217 "created_at",
218 )?,
219 updated_at: parse_datetime_row(
220 &row.get::<_, String>(12)?,
221 "tax_jurisdiction",
222 "updated_at",
223 )?,
224 })
225 })
226 .map_err(map_db_error)?;
227
228 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(map_db_error)
229 }
230
231 pub fn create_jurisdiction(&self, input: CreateTaxJurisdiction) -> Result<TaxJurisdiction> {
233 let id = Uuid::new_v4();
234 let now = Utc::now();
235 let postal_codes_json = serde_json::to_string(&input.postal_codes)
236 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
237
238 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
239
240 conn.execute(
241 "INSERT INTO tax_jurisdictions (id, parent_id, name, code, level, country_code, state_code, county, city, postal_codes, active, created_at, updated_at)
242 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
243 params![
244 id.to_string(),
245 input.parent_id.map(|id| id.to_string()),
246 input.name,
247 input.code,
248 input.level.to_string(),
249 input.country_code,
250 input.state_code,
251 input.county,
252 input.city,
253 postal_codes_json,
254 now.to_rfc3339(),
255 now.to_rfc3339()
256 ],
257 ).map_err(map_db_error)?;
258
259 drop(conn);
260
261 self.get_jurisdiction(id)?.ok_or(CommerceError::NotFound)
262 }
263}
264
265impl SqliteTaxRepository {
270 pub fn get_rate(&self, id: Uuid) -> Result<Option<TaxRate>> {
272 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
273
274 let result = conn.query_row(
275 "SELECT id, jurisdiction_id, tax_type, product_category, rate, name, description, is_compound, priority, threshold_min, threshold_max, fixed_amount, effective_from, effective_to, active, created_at, updated_at
276 FROM tax_rates WHERE id = ?",
277 params![id.to_string()],
278 |row| {
279 Ok(TaxRate {
280 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_rate", "id")?,
281 jurisdiction_id: parse_uuid_row(
282 &row.get::<_, String>(1)?,
283 "tax_rate",
284 "jurisdiction_id",
285 )?,
286 tax_type: parse_enum_row(&row.get::<_, String>(2)?, "tax_rate", "tax_type")?,
287 product_category: parse_enum_row(
288 &row.get::<_, String>(3)?,
289 "tax_rate",
290 "product_category",
291 )?,
292 rate: parse_decimal_row(&row.get::<_, String>(4)?, "tax_rate", "rate")?,
293 name: row.get(5)?,
294 description: row.get(6)?,
295 is_compound: row.get::<_, i32>(7)? != 0,
296 priority: row.get(8)?,
297 threshold_min: parse_decimal_opt_row(
298 row.get::<_, Option<String>>(9)?,
299 "tax_rate",
300 "threshold_min",
301 )?,
302 threshold_max: parse_decimal_opt_row(
303 row.get::<_, Option<String>>(10)?,
304 "tax_rate",
305 "threshold_max",
306 )?,
307 fixed_amount: parse_decimal_opt_row(
308 row.get::<_, Option<String>>(11)?,
309 "tax_rate",
310 "fixed_amount",
311 )?,
312 effective_from: parse_date_row(
313 &row.get::<_, String>(12)?,
314 "tax_rate",
315 "effective_from",
316 )?,
317 effective_to: Self::parse_date_opt(
318 row.get::<_, Option<String>>(13)?,
319 "tax_rate",
320 "effective_to",
321 )?,
322 active: row.get::<_, i32>(14)? != 0,
323 created_at: parse_datetime_row(&row.get::<_, String>(15)?, "tax_rate", "created_at")?,
324 updated_at: parse_datetime_row(&row.get::<_, String>(16)?, "tax_rate", "updated_at")?,
325 })
326 },
327 );
328
329 match result {
330 Ok(rate) => Ok(Some(rate)),
331 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
332 Err(e) => Err(map_db_error(e)),
333 }
334 }
335
336 pub fn list_rates(&self, filter: TaxRateFilter) -> Result<Vec<TaxRate>> {
338 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
339
340 let mut query = String::from(
341 "SELECT id, jurisdiction_id, tax_type, product_category, rate, name, description, is_compound, priority, threshold_min, threshold_max, fixed_amount, effective_from, effective_to, active, created_at, updated_at
342 FROM tax_rates WHERE 1=1"
343 );
344 let mut params_vec: Vec<String> = Vec::new();
345
346 if let Some(jurisdiction_id) = &filter.jurisdiction_id {
347 query.push_str(" AND jurisdiction_id = ?");
348 params_vec.push(jurisdiction_id.to_string());
349 }
350
351 if let Some(tax_type) = &filter.tax_type {
352 query.push_str(" AND tax_type = ?");
353 params_vec.push(tax_type.to_string());
354 }
355
356 if let Some(category) = &filter.product_category {
357 query.push_str(" AND product_category = ?");
358 params_vec.push(category.to_string());
359 }
360
361 if filter.active_only {
362 query.push_str(" AND active = 1");
363 }
364
365 if let Some(date) = &filter.effective_date {
366 query.push_str(
367 " AND effective_from <= ? AND (effective_to IS NULL OR effective_to >= ?)",
368 );
369 params_vec.push(date.to_string());
370 params_vec.push(date.to_string());
371 }
372
373 query.push_str(" ORDER BY priority, name");
374 crate::sqlite::append_limit_offset(&mut query, filter.limit, filter.offset);
375
376 let mut stmt = conn.prepare(&query).map_err(map_db_error)?;
377 let params: Vec<&dyn rusqlite::ToSql> =
378 params_vec.iter().map(|s| s as &dyn rusqlite::ToSql).collect();
379
380 let rows = stmt
381 .query_map(params.as_slice(), |row| {
382 Ok(TaxRate {
383 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_rate", "id")?,
384 jurisdiction_id: parse_uuid_row(
385 &row.get::<_, String>(1)?,
386 "tax_rate",
387 "jurisdiction_id",
388 )?,
389 tax_type: parse_enum_row(&row.get::<_, String>(2)?, "tax_rate", "tax_type")?,
390 product_category: parse_enum_row(
391 &row.get::<_, String>(3)?,
392 "tax_rate",
393 "product_category",
394 )?,
395 rate: parse_decimal_row(&row.get::<_, String>(4)?, "tax_rate", "rate")?,
396 name: row.get(5)?,
397 description: row.get(6)?,
398 is_compound: row.get::<_, i32>(7)? != 0,
399 priority: row.get(8)?,
400 threshold_min: parse_decimal_opt_row(
401 row.get::<_, Option<String>>(9)?,
402 "tax_rate",
403 "threshold_min",
404 )?,
405 threshold_max: parse_decimal_opt_row(
406 row.get::<_, Option<String>>(10)?,
407 "tax_rate",
408 "threshold_max",
409 )?,
410 fixed_amount: parse_decimal_opt_row(
411 row.get::<_, Option<String>>(11)?,
412 "tax_rate",
413 "fixed_amount",
414 )?,
415 effective_from: parse_date_row(
416 &row.get::<_, String>(12)?,
417 "tax_rate",
418 "effective_from",
419 )?,
420 effective_to: Self::parse_date_opt(
421 row.get::<_, Option<String>>(13)?,
422 "tax_rate",
423 "effective_to",
424 )?,
425 active: row.get::<_, i32>(14)? != 0,
426 created_at: parse_datetime_row(
427 &row.get::<_, String>(15)?,
428 "tax_rate",
429 "created_at",
430 )?,
431 updated_at: parse_datetime_row(
432 &row.get::<_, String>(16)?,
433 "tax_rate",
434 "updated_at",
435 )?,
436 })
437 })
438 .map_err(map_db_error)?;
439
440 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(map_db_error)
441 }
442
443 pub fn get_rates_for_address(
445 &self,
446 address: &TaxAddress,
447 category: ProductTaxCategory,
448 date: NaiveDate,
449 ) -> Result<Vec<TaxRate>> {
450 let mut jurisdiction_ids = Vec::new();
452
453 if let Some(country) = self.get_jurisdiction_by_code(&address.country)? {
455 jurisdiction_ids.push(country.id);
456 }
457
458 if let Some(state) = &address.state {
460 let state_code = format!("{}-{}", address.country, state);
461 if let Some(state_jurisdiction) = self.get_jurisdiction_by_code(&state_code)? {
462 jurisdiction_ids.push(state_jurisdiction.id);
463 }
464 }
465
466 if jurisdiction_ids.is_empty() {
467 return Ok(Vec::new());
468 }
469
470 let mut all_rates = Vec::new();
472 for jurisdiction_id in jurisdiction_ids {
473 let filter = TaxRateFilter {
474 jurisdiction_id: Some(jurisdiction_id),
475 product_category: Some(category),
476 active_only: true,
477 effective_date: Some(date),
478 ..Default::default()
479 };
480 let rates = self.list_rates(filter)?;
481 all_rates.extend(rates);
482 }
483
484 all_rates.sort_by_key(|r| r.priority);
486 Ok(all_rates)
487 }
488
489 pub fn create_rate(&self, input: CreateTaxRate) -> Result<TaxRate> {
491 let id = Uuid::new_v4();
492 let now = Utc::now();
493
494 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
495
496 conn.execute(
497 "INSERT INTO tax_rates (id, jurisdiction_id, tax_type, product_category, rate, name, description, is_compound, priority, threshold_min, threshold_max, fixed_amount, effective_from, effective_to, active, created_at, updated_at)
498 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 1, ?, ?)",
499 params![
500 id.to_string(),
501 input.jurisdiction_id.to_string(),
502 input.tax_type.to_string(),
503 input.product_category.to_string(),
504 input.rate.to_string(),
505 input.name,
506 input.description,
507 i32::from(input.is_compound),
508 input.priority,
509 input.threshold_min.map(|d| d.to_string()),
510 input.threshold_max.map(|d| d.to_string()),
511 input.fixed_amount.map(|d| d.to_string()),
512 input.effective_from.to_string(),
513 input.effective_to.map(|d| d.to_string()),
514 now.to_rfc3339(),
515 now.to_rfc3339()
516 ],
517 ).map_err(map_db_error)?;
518
519 drop(conn);
520
521 self.get_rate(id)?.ok_or(CommerceError::NotFound)
522 }
523}
524
525impl SqliteTaxRepository {
530 pub fn get_exemption(&self, id: Uuid) -> Result<Option<TaxExemption>> {
532 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
533
534 let result = conn.query_row(
535 "SELECT id, customer_id, exemption_type, certificate_number, issuing_authority, jurisdiction_ids, exempt_categories, effective_from, expires_at, verified, verified_at, notes, active, created_at, updated_at
536 FROM tax_exemptions WHERE id = ?",
537 params![id.to_string()],
538 |row| {
539 let jurisdiction_ids_json: String = row.get(5)?;
540 let raw_jurisdiction_ids: Vec<String> = parse_json_row(
541 &jurisdiction_ids_json,
542 "tax_exemption",
543 "jurisdiction_ids",
544 )?;
545 let jurisdiction_ids = raw_jurisdiction_ids
546 .into_iter()
547 .map(|value| parse_uuid_row(&value, "tax_exemption", "jurisdiction_ids"))
548 .collect::<rusqlite::Result<Vec<_>>>()?;
549
550 let categories_json: String = row.get(6)?;
551 let raw_categories: Vec<String> =
552 parse_json_row(&categories_json, "tax_exemption", "exempt_categories")?;
553 let exempt_categories = raw_categories
554 .into_iter()
555 .map(|value| parse_enum_row(&value, "tax_exemption", "exempt_categories"))
556 .collect::<rusqlite::Result<Vec<_>>>()?;
557
558 Ok(TaxExemption {
559 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_exemption", "id")?,
560 customer_id: parse_uuid_row(
561 &row.get::<_, String>(1)?,
562 "tax_exemption",
563 "customer_id",
564 )?,
565 exemption_type: parse_enum_row(
566 &row.get::<_, String>(2)?,
567 "tax_exemption",
568 "exemption_type",
569 )?,
570 certificate_number: row.get(3)?,
571 issuing_authority: row.get(4)?,
572 jurisdiction_ids,
573 exempt_categories,
574 effective_from: parse_date_row(
575 &row.get::<_, String>(7)?,
576 "tax_exemption",
577 "effective_from",
578 )?,
579 expires_at: Self::parse_date_opt(
580 row.get::<_, Option<String>>(8)?,
581 "tax_exemption",
582 "expires_at",
583 )?,
584 verified: row.get::<_, i32>(9)? != 0,
585 verified_at: parse_datetime_opt_row(
586 row.get::<_, Option<String>>(10)?,
587 "tax_exemption",
588 "verified_at",
589 )?,
590 notes: row.get(11)?,
591 active: row.get::<_, i32>(12)? != 0,
592 created_at: parse_datetime_row(
593 &row.get::<_, String>(13)?,
594 "tax_exemption",
595 "created_at",
596 )?,
597 updated_at: parse_datetime_row(
598 &row.get::<_, String>(14)?,
599 "tax_exemption",
600 "updated_at",
601 )?,
602 })
603 },
604 );
605
606 match result {
607 Ok(exemption) => Ok(Some(exemption)),
608 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
609 Err(e) => Err(map_db_error(e)),
610 }
611 }
612
613 pub fn get_customer_exemptions(&self, customer_id: Uuid) -> Result<Vec<TaxExemption>> {
615 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
616 let today = Utc::now().date_naive().to_string();
617
618 let mut stmt = conn.prepare(
619 "SELECT id, customer_id, exemption_type, certificate_number, issuing_authority, jurisdiction_ids, exempt_categories, effective_from, expires_at, verified, verified_at, notes, active, created_at, updated_at
620 FROM tax_exemptions
621 WHERE customer_id = ? AND active = 1 AND effective_from <= ? AND (expires_at IS NULL OR expires_at >= ?)"
622 ).map_err(map_db_error)?;
623
624 let rows = stmt
625 .query_map(params![customer_id.to_string(), &today, &today], |row| {
626 let jurisdiction_ids_json: String = row.get(5)?;
627 let raw_jurisdiction_ids: Vec<String> =
628 parse_json_row(&jurisdiction_ids_json, "tax_exemption", "jurisdiction_ids")?;
629 let jurisdiction_ids = raw_jurisdiction_ids
630 .into_iter()
631 .map(|value| parse_uuid_row(&value, "tax_exemption", "jurisdiction_ids"))
632 .collect::<rusqlite::Result<Vec<_>>>()?;
633
634 let categories_json: String = row.get(6)?;
635 let raw_categories: Vec<String> =
636 parse_json_row(&categories_json, "tax_exemption", "exempt_categories")?;
637 let exempt_categories = raw_categories
638 .into_iter()
639 .map(|value| parse_enum_row(&value, "tax_exemption", "exempt_categories"))
640 .collect::<rusqlite::Result<Vec<_>>>()?;
641
642 Ok(TaxExemption {
643 id: parse_uuid_row(&row.get::<_, String>(0)?, "tax_exemption", "id")?,
644 customer_id: parse_uuid_row(
645 &row.get::<_, String>(1)?,
646 "tax_exemption",
647 "customer_id",
648 )?,
649 exemption_type: parse_enum_row(
650 &row.get::<_, String>(2)?,
651 "tax_exemption",
652 "exemption_type",
653 )?,
654 certificate_number: row.get(3)?,
655 issuing_authority: row.get(4)?,
656 jurisdiction_ids,
657 exempt_categories,
658 effective_from: parse_date_row(
659 &row.get::<_, String>(7)?,
660 "tax_exemption",
661 "effective_from",
662 )?,
663 expires_at: Self::parse_date_opt(
664 row.get::<_, Option<String>>(8)?,
665 "tax_exemption",
666 "expires_at",
667 )?,
668 verified: row.get::<_, i32>(9)? != 0,
669 verified_at: parse_datetime_opt_row(
670 row.get::<_, Option<String>>(10)?,
671 "tax_exemption",
672 "verified_at",
673 )?,
674 notes: row.get(11)?,
675 active: row.get::<_, i32>(12)? != 0,
676 created_at: parse_datetime_row(
677 &row.get::<_, String>(13)?,
678 "tax_exemption",
679 "created_at",
680 )?,
681 updated_at: parse_datetime_row(
682 &row.get::<_, String>(14)?,
683 "tax_exemption",
684 "updated_at",
685 )?,
686 })
687 })
688 .map_err(map_db_error)?;
689
690 rows.collect::<std::result::Result<Vec<_>, _>>().map_err(map_db_error)
691 }
692
693 pub fn create_exemption(&self, input: CreateTaxExemption) -> Result<TaxExemption> {
695 let id = Uuid::new_v4();
696 let now = Utc::now();
697
698 let jurisdiction_ids_json = serde_json::to_string(
699 &input
700 .jurisdiction_ids
701 .iter()
702 .map(std::string::ToString::to_string)
703 .collect::<Vec<_>>(),
704 )
705 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
706
707 let categories_json = serde_json::to_string(
708 &input
709 .exempt_categories
710 .iter()
711 .map(std::string::ToString::to_string)
712 .collect::<Vec<_>>(),
713 )
714 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
715
716 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
717
718 conn.execute(
719 "INSERT INTO tax_exemptions (id, customer_id, exemption_type, certificate_number, issuing_authority, jurisdiction_ids, exempt_categories, effective_from, expires_at, verified, notes, active, created_at, updated_at)
720 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 0, ?, 1, ?, ?)",
721 params![
722 id.to_string(),
723 input.customer_id.to_string(),
724 input.exemption_type.to_string(),
725 input.certificate_number,
726 input.issuing_authority,
727 jurisdiction_ids_json,
728 categories_json,
729 input.effective_from.to_string(),
730 input.expires_at.map(|d| d.to_string()),
731 input.notes,
732 now.to_rfc3339(),
733 now.to_rfc3339()
734 ],
735 ).map_err(map_db_error)?;
736
737 drop(conn);
738
739 self.get_exemption(id)?.ok_or(CommerceError::NotFound)
740 }
741}
742
743impl SqliteTaxRepository {
748 pub fn get_settings(&self) -> Result<TaxSettings> {
750 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
751
752 let result = conn.query_row(
753 "SELECT id, enabled, calculation_method, compound_method, tax_shipping, tax_handling, tax_gift_wrap, origin_address, default_product_category, rounding_mode, decimal_places, validate_addresses, tax_provider, provider_credentials, created_at, updated_at
754 FROM tax_settings WHERE id = 'default'",
755 [],
756 |row| {
757 let origin_address_json: Option<String> = row.get(7)?;
758 let origin_address: Option<TaxAddress> =
759 parse_json_opt_row(origin_address_json, "tax_settings", "origin_address")?;
760
761 let id_str: String = row.get(0)?;
762 let id = if id_str == "default" {
763 Uuid::nil()
764 } else {
765 parse_uuid_row(&id_str, "tax_settings", "id")?
766 };
767
768 Ok(TaxSettings {
769 id,
770 enabled: row.get::<_, i32>(1)? != 0,
771 calculation_method: parse_enum_row(
772 &row.get::<_, String>(2)?,
773 "tax_settings",
774 "calculation_method",
775 )?,
776 compound_method: parse_enum_row(
777 &row.get::<_, String>(3)?,
778 "tax_settings",
779 "compound_method",
780 )?,
781 tax_shipping: row.get::<_, i32>(4)? != 0,
782 tax_handling: row.get::<_, i32>(5)? != 0,
783 tax_gift_wrap: row.get::<_, i32>(6)? != 0,
784 origin_address,
785 default_product_category: parse_enum_row(
786 &row.get::<_, String>(8)?,
787 "tax_settings",
788 "default_product_category",
789 )?,
790 rounding_mode: row.get(9)?,
791 decimal_places: row.get(10)?,
792 validate_addresses: row.get::<_, i32>(11)? != 0,
793 tax_provider: row.get(12)?,
794 provider_credentials: row.get(13)?,
795 created_at: parse_datetime_row(
796 &row.get::<_, String>(14)?,
797 "tax_settings",
798 "created_at",
799 )?,
800 updated_at: parse_datetime_row(
801 &row.get::<_, String>(15)?,
802 "tax_settings",
803 "updated_at",
804 )?,
805 })
806 },
807 );
808
809 match result {
810 Ok(settings) => Ok(settings),
811 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(TaxSettings::default()),
812 Err(e) => Err(map_db_error(e)),
813 }
814 }
815
816 pub fn update_settings(&self, settings: TaxSettings) -> Result<TaxSettings> {
818 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
819
820 let origin_address_json = settings
821 .origin_address
822 .as_ref()
823 .map(serde_json::to_string)
824 .transpose()
825 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
826
827 let calc_method = settings.calculation_method.to_string();
828 let compound_method = settings.compound_method.to_string();
829
830 conn.execute(
831 "INSERT INTO tax_settings (id, enabled, calculation_method, compound_method, tax_shipping, tax_handling, tax_gift_wrap, origin_address, default_product_category, rounding_mode, decimal_places, validate_addresses, tax_provider, provider_credentials, updated_at)
832 VALUES ('default', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, datetime('now'))
833 ON CONFLICT (id) DO UPDATE SET
834 enabled = excluded.enabled,
835 calculation_method = excluded.calculation_method,
836 compound_method = excluded.compound_method,
837 tax_shipping = excluded.tax_shipping,
838 tax_handling = excluded.tax_handling,
839 tax_gift_wrap = excluded.tax_gift_wrap,
840 origin_address = excluded.origin_address,
841 default_product_category = excluded.default_product_category,
842 rounding_mode = excluded.rounding_mode,
843 decimal_places = excluded.decimal_places,
844 validate_addresses = excluded.validate_addresses,
845 tax_provider = excluded.tax_provider,
846 provider_credentials = excluded.provider_credentials,
847 updated_at = excluded.updated_at",
848 params![
849 i32::from(settings.enabled),
850 calc_method,
851 compound_method,
852 i32::from(settings.tax_shipping),
853 i32::from(settings.tax_handling),
854 i32::from(settings.tax_gift_wrap),
855 origin_address_json,
856 settings.default_product_category.to_string(),
857 settings.rounding_mode,
858 settings.decimal_places,
859 i32::from(settings.validate_addresses),
860 settings.tax_provider,
861 settings.provider_credentials
862 ],
863 ).map_err(map_db_error)?;
864
865 self.get_settings()
866 }
867}
868
869impl SqliteTaxRepository {
874 pub fn calculate_tax(&self, request: TaxCalculationRequest) -> Result<TaxCalculationResult> {
876 let settings = self.get_settings()?;
877 let now = Utc::now();
878 let transaction_date = request.transaction_date.unwrap_or_else(|| now.date_naive());
879
880 let exemptions = if let Some(customer_id) = request.customer_id {
882 self.get_customer_exemptions(customer_id)?
883 } else {
884 Vec::new()
885 };
886
887 let mut subtotal = Decimal::ZERO;
888 let mut total_tax = Decimal::ZERO;
889 let mut line_item_taxes = Vec::new();
890 let mut tax_breakdown: Vec<TaxBreakdown> = Vec::new();
891 let mut jurisdictions_map = std::collections::HashMap::new();
892
893 for item in &request.line_items {
895 let mut line_amount = item.unit_price * item.quantity - item.discount_amount;
896 if line_amount < Decimal::ZERO {
897 line_amount = Decimal::ZERO;
898 }
899 subtotal += line_amount;
900
901 let is_exempt = exemptions.iter().any(|e| {
903 e.exempt_categories.is_empty() || e.exempt_categories.contains(&item.tax_category)
904 });
905
906 if is_exempt || item.tax_category == ProductTaxCategory::Exempt {
907 line_item_taxes.push(LineItemTax {
908 line_item_id: item.id.clone(),
909 taxable_amount: line_amount,
910 tax_amount: Decimal::ZERO,
911 effective_rate: Decimal::ZERO,
912 is_exempt: true,
913 exemption_reason: Some("Customer exemption".to_string()),
914 tax_details: Vec::new(),
915 });
916 continue;
917 }
918
919 let rates = self.get_rates_for_address(
921 &request.shipping_address,
922 item.tax_category,
923 transaction_date,
924 )?;
925
926 let mut line_tax = Decimal::ZERO;
927 let mut line_tax_details = Vec::new();
928
929 for rate in &rates {
930 if line_amount <= Decimal::ZERO {
931 continue;
932 }
933
934 if let Some(min) = rate.threshold_min {
935 if line_amount < min {
936 continue;
937 }
938 }
939
940 let capped_base = match rate.threshold_max {
941 Some(max) if line_amount > max => max,
942 _ => line_amount,
943 };
944
945 if capped_base <= Decimal::ZERO {
946 continue;
947 }
948
949 let taxable_amount = if rate.fixed_amount.is_some() {
950 capped_base
951 } else if rate.is_compound {
952 capped_base + line_tax
954 } else {
955 capped_base
956 };
957
958 let rate_tax = if let Some(fixed) = rate.fixed_amount {
959 fixed
960 } else {
961 taxable_amount * rate.rate
962 };
963
964 line_tax += rate_tax;
965
966 if let Some(jurisdiction) = self.get_jurisdiction(rate.jurisdiction_id)? {
968 jurisdictions_map.entry(jurisdiction.id).or_insert_with(|| {
969 JurisdictionSummary {
970 id: jurisdiction.id,
971 name: jurisdiction.name.clone(),
972 code: jurisdiction.code.clone(),
973 level: jurisdiction.level,
974 total_rate: Decimal::ZERO,
975 total_tax: Decimal::ZERO,
976 }
977 });
978
979 if let Some(summary) = jurisdictions_map.get_mut(&jurisdiction.id) {
980 summary.total_rate += rate.rate;
981 summary.total_tax += rate_tax;
982 }
983
984 if let Some(existing) = tax_breakdown.iter_mut().find(|b| {
986 b.jurisdiction_id == jurisdiction.id && b.tax_type == rate.tax_type
987 }) {
988 existing.taxable_amount += taxable_amount;
989 existing.tax_amount += rate_tax;
990 } else {
991 tax_breakdown.push(TaxBreakdown {
992 jurisdiction_id: jurisdiction.id,
993 jurisdiction_name: jurisdiction.name.clone(),
994 tax_type: rate.tax_type,
995 rate_name: rate.name.clone(),
996 rate: rate.rate,
997 taxable_amount,
998 tax_amount: rate_tax,
999 is_compound: rate.is_compound,
1000 });
1001 }
1002
1003 line_tax_details.push(TaxDetail {
1004 tax_type: rate.tax_type,
1005 jurisdiction_name: jurisdiction.name,
1006 rate: rate.rate,
1007 amount: rate_tax,
1008 });
1009 }
1010 }
1011
1012 let effective_rate =
1013 if line_amount.is_zero() { Decimal::ZERO } else { line_tax / line_amount };
1014
1015 total_tax += line_tax;
1016 line_item_taxes.push(LineItemTax {
1017 line_item_id: item.id.clone(),
1018 taxable_amount: line_amount,
1019 tax_amount: line_tax,
1020 effective_rate,
1021 is_exempt: false,
1022 exemption_reason: None,
1023 tax_details: line_tax_details,
1024 });
1025 }
1026
1027 let mut shipping_tax = Decimal::ZERO;
1029 if settings.tax_shipping {
1030 if let Some(mut shipping_amount) = request.shipping_amount {
1031 if shipping_amount < Decimal::ZERO {
1032 shipping_amount = Decimal::ZERO;
1033 }
1034
1035 let shipping_rates = self.get_rates_for_address(
1036 &request.shipping_address,
1037 ProductTaxCategory::Standard,
1038 transaction_date,
1039 )?;
1040 for rate in &shipping_rates {
1041 if shipping_amount <= Decimal::ZERO {
1042 continue;
1043 }
1044
1045 if let Some(min) = rate.threshold_min {
1046 if shipping_amount < min {
1047 continue;
1048 }
1049 }
1050
1051 let capped_base = match rate.threshold_max {
1052 Some(max) if shipping_amount > max => max,
1053 _ => shipping_amount,
1054 };
1055
1056 if capped_base <= Decimal::ZERO {
1057 continue;
1058 }
1059
1060 let taxable_amount = if rate.fixed_amount.is_some() {
1061 capped_base
1062 } else if rate.is_compound {
1063 capped_base + shipping_tax
1064 } else {
1065 capped_base
1066 };
1067
1068 let rate_tax = if let Some(fixed) = rate.fixed_amount {
1069 fixed
1070 } else {
1071 taxable_amount * rate.rate
1072 };
1073
1074 shipping_tax += rate_tax;
1075
1076 if let Some(jurisdiction) = self.get_jurisdiction(rate.jurisdiction_id)? {
1077 jurisdictions_map.entry(jurisdiction.id).or_insert_with(|| {
1078 JurisdictionSummary {
1079 id: jurisdiction.id,
1080 name: jurisdiction.name.clone(),
1081 code: jurisdiction.code.clone(),
1082 level: jurisdiction.level,
1083 total_rate: Decimal::ZERO,
1084 total_tax: Decimal::ZERO,
1085 }
1086 });
1087
1088 if let Some(summary) = jurisdictions_map.get_mut(&jurisdiction.id) {
1089 summary.total_rate += rate.rate;
1090 summary.total_tax += rate_tax;
1091 }
1092
1093 if let Some(existing) = tax_breakdown.iter_mut().find(|b| {
1094 b.jurisdiction_id == jurisdiction.id && b.tax_type == rate.tax_type
1095 }) {
1096 existing.taxable_amount += taxable_amount;
1097 existing.tax_amount += rate_tax;
1098 } else {
1099 tax_breakdown.push(TaxBreakdown {
1100 jurisdiction_id: jurisdiction.id,
1101 jurisdiction_name: jurisdiction.name.clone(),
1102 tax_type: rate.tax_type,
1103 rate_name: rate.name.clone(),
1104 rate: rate.rate,
1105 taxable_amount,
1106 tax_amount: rate_tax,
1107 is_compound: rate.is_compound,
1108 });
1109 }
1110 }
1111 }
1112
1113 total_tax += shipping_tax;
1114 }
1115 }
1116
1117 let decimal_places = settings.decimal_places as u32;
1119 let strategy = settings.rounding_strategy();
1120 let total_tax = total_tax.round_dp_with_strategy(decimal_places, strategy);
1121 let shipping_tax = shipping_tax.round_dp_with_strategy(decimal_places, strategy);
1122
1123 let total = subtotal + total_tax + request.shipping_amount.unwrap_or_default();
1124
1125 let mut jurisdictions: Vec<JurisdictionSummary> = jurisdictions_map.into_values().collect();
1129 jurisdictions.sort_by(|a, b| a.code.cmp(&b.code).then_with(|| a.id.cmp(&b.id)));
1130
1131 Ok(TaxCalculationResult {
1132 id: Uuid::new_v4(),
1133 total_tax,
1134 subtotal,
1135 total,
1136 shipping_tax,
1137 tax_breakdown,
1138 line_item_taxes,
1139 exemptions_applied: !exemptions.is_empty(),
1140 exemption_details: None, jurisdictions,
1142 calculated_at: now,
1143 is_estimate: true,
1144 })
1145 }
1146
1147 pub fn save_calculation(
1149 &self,
1150 result: &TaxCalculationResult,
1151 order_id: Option<Uuid>,
1152 cart_id: Option<Uuid>,
1153 customer_id: Option<Uuid>,
1154 address: &TaxAddress,
1155 currency: &str,
1156 ) -> Result<()> {
1157 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1158
1159 let address_json = serde_json::to_string(address)
1160 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1161 let line_items_json = serde_json::to_string(&result.line_item_taxes)
1162 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1163 let breakdown_json = serde_json::to_string(&result.tax_breakdown)
1164 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1165 let exemption_json = result
1166 .exemption_details
1167 .as_ref()
1168 .map(serde_json::to_string)
1169 .transpose()
1170 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1171
1172 conn.execute(
1173 "INSERT INTO tax_calculations (id, order_id, cart_id, customer_id, subtotal, total_tax, shipping_tax, total, currency, shipping_address, line_items, tax_breakdown, exemptions_applied, exemption_details, is_estimate, calculated_at)
1174 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
1175 params![
1176 result.id.to_string(),
1177 order_id.map(|id| id.to_string()),
1178 cart_id.map(|id| id.to_string()),
1179 customer_id.map(|id| id.to_string()),
1180 result.subtotal.to_string(),
1181 result.total_tax.to_string(),
1182 result.shipping_tax.to_string(),
1183 result.total.to_string(),
1184 currency,
1185 address_json,
1186 line_items_json,
1187 breakdown_json,
1188 i32::from(result.exemptions_applied),
1189 exemption_json,
1190 i32::from(result.is_estimate),
1191 result.calculated_at.to_rfc3339()
1192 ],
1193 ).map_err(map_db_error)?;
1194
1195 Ok(())
1196 }
1197}
1198
1199impl TaxRepository for SqliteTaxRepository {
1200 fn create_jurisdiction(&self, input: CreateTaxJurisdiction) -> Result<TaxJurisdiction> {
1201 Self::create_jurisdiction(self, input)
1202 }
1203
1204 fn get_jurisdiction(&self, id: Uuid) -> Result<Option<TaxJurisdiction>> {
1205 Self::get_jurisdiction(self, id)
1206 }
1207
1208 fn get_jurisdiction_by_code(&self, code: &str) -> Result<Option<TaxJurisdiction>> {
1209 Self::get_jurisdiction_by_code(self, code)
1210 }
1211
1212 fn list_jurisdictions(&self, filter: TaxJurisdictionFilter) -> Result<Vec<TaxJurisdiction>> {
1213 Self::list_jurisdictions(self, filter)
1214 }
1215
1216 fn create_rate(&self, input: CreateTaxRate) -> Result<TaxRate> {
1217 Self::create_rate(self, input)
1218 }
1219
1220 fn get_rate(&self, id: Uuid) -> Result<Option<TaxRate>> {
1221 Self::get_rate(self, id)
1222 }
1223
1224 fn list_rates(&self, filter: TaxRateFilter) -> Result<Vec<TaxRate>> {
1225 Self::list_rates(self, filter)
1226 }
1227
1228 fn get_rates_for_address(
1229 &self,
1230 address: &TaxAddress,
1231 category: ProductTaxCategory,
1232 date: chrono::NaiveDate,
1233 ) -> Result<Vec<TaxRate>> {
1234 Self::get_rates_for_address(self, address, category, date)
1235 }
1236
1237 fn create_exemption(&self, input: CreateTaxExemption) -> Result<TaxExemption> {
1238 Self::create_exemption(self, input)
1239 }
1240
1241 fn get_exemption(&self, id: Uuid) -> Result<Option<TaxExemption>> {
1242 Self::get_exemption(self, id)
1243 }
1244
1245 fn get_customer_exemptions(&self, customer_id: Uuid) -> Result<Vec<TaxExemption>> {
1246 Self::get_customer_exemptions(self, customer_id)
1247 }
1248
1249 fn get_settings(&self) -> Result<TaxSettings> {
1250 Self::get_settings(self)
1251 }
1252
1253 fn update_settings(&self, settings: TaxSettings) -> Result<TaxSettings> {
1254 Self::update_settings(self, settings)
1255 }
1256
1257 fn calculate_tax(&self, request: TaxCalculationRequest) -> Result<TaxCalculationResult> {
1258 Self::calculate_tax(self, request)
1259 }
1260
1261 fn save_calculation(
1262 &self,
1263 result: &TaxCalculationResult,
1264 order_id: Option<Uuid>,
1265 cart_id: Option<Uuid>,
1266 customer_id: Option<Uuid>,
1267 address: &TaxAddress,
1268 currency: &str,
1269 ) -> Result<()> {
1270 Self::save_calculation(self, result, order_id, cart_id, customer_id, address, currency)
1271 }
1272}
1273
1274#[cfg(test)]
1275mod tests {
1276 use super::*;
1277 use crate::SqliteDatabase;
1278 use chrono::NaiveDate;
1279 use rust_decimal_macros::dec;
1280 use stateset_core::{
1281 CreateTaxExemption, CreateTaxJurisdiction, CreateTaxRate, CurrencyCode, ExemptionType,
1282 JurisdictionLevel, ProductTaxCategory, TaxAddress, TaxCalculationRequest,
1283 TaxJurisdictionFilter, TaxLineItem, TaxRateFilter, TaxType,
1284 };
1285
1286 fn fresh_repo() -> SqliteTaxRepository {
1287 SqliteDatabase::in_memory().expect("in-memory").tax()
1288 }
1289
1290 fn make_state_jur(repo: &SqliteTaxRepository, state: &str) -> TaxJurisdiction {
1291 repo.create_jurisdiction(CreateTaxJurisdiction {
1293 parent_id: None,
1294 name: format!("{state} Test State"),
1295 code: format!("ZZ-{state}"),
1296 level: JurisdictionLevel::State,
1297 country_code: "ZZ".into(),
1298 state_code: Some(state.into()),
1299 county: None,
1300 city: None,
1301 postal_codes: vec![],
1302 })
1303 .expect("create jurisdiction")
1304 }
1305
1306 fn make_rate(repo: &SqliteTaxRepository, jur_id: Uuid, rate: Decimal) -> TaxRate {
1307 repo.create_rate(CreateTaxRate {
1308 jurisdiction_id: jur_id,
1309 tax_type: TaxType::SalesTax,
1310 product_category: ProductTaxCategory::Standard,
1311 rate,
1312 name: "Sales Tax".into(),
1313 description: None,
1314 is_compound: false,
1315 priority: 1,
1316 threshold_min: None,
1317 threshold_max: None,
1318 fixed_amount: None,
1319 effective_from: NaiveDate::from_ymd_opt(2026, 1, 1).expect("date"),
1320 effective_to: None,
1321 })
1322 .expect("create rate")
1323 }
1324
1325 #[test]
1326 fn create_jurisdiction_round_trips() {
1327 let repo = fresh_repo();
1328 let j = make_state_jur(&repo, "CA");
1329 assert_eq!(j.code, "ZZ-CA");
1330 assert_eq!(j.level, JurisdictionLevel::State);
1331
1332 let by_id = repo.get_jurisdiction(j.id).expect("ok").expect("found");
1333 assert_eq!(by_id.id, j.id);
1334 let by_code = repo.get_jurisdiction_by_code("ZZ-CA").expect("ok").expect("found");
1335 assert_eq!(by_code.id, j.id);
1336 assert!(repo.get_jurisdiction_by_code("missing-xyz").expect("ok").is_none());
1337 }
1338
1339 #[test]
1340 fn list_jurisdictions_applies_limit_and_offset() {
1341 let repo = fresh_repo();
1342 make_state_jur(&repo, "AA");
1343 make_state_jur(&repo, "BB");
1344 make_state_jur(&repo, "CC");
1345
1346 let base =
1347 || TaxJurisdictionFilter { country_code: Some("ZZ".into()), ..Default::default() };
1348 let all = repo.list_jurisdictions(base()).expect("list all");
1349 assert_eq!(all.len(), 3);
1350
1351 let page = repo
1352 .list_jurisdictions(TaxJurisdictionFilter { limit: Some(2), ..base() })
1353 .expect("limited");
1354 assert_eq!(page.len(), 2, "limit must bound the result set");
1355 assert_eq!(page[0].id, all[0].id);
1356
1357 let rest = repo
1358 .list_jurisdictions(TaxJurisdictionFilter { limit: Some(2), offset: Some(2), ..base() })
1359 .expect("offset");
1360 assert_eq!(rest.len(), 1);
1361 assert_eq!(rest[0].id, all[2].id);
1362 }
1363
1364 #[test]
1365 fn list_rates_applies_limit_and_offset() {
1366 let repo = fresh_repo();
1367 let jur = make_state_jur(&repo, "LR");
1368 for _ in 0..3 {
1369 make_rate(&repo, jur.id, dec!(0.05));
1370 }
1371
1372 let base = || TaxRateFilter { jurisdiction_id: Some(jur.id), ..Default::default() };
1373 let all = repo.list_rates(base()).expect("list all");
1374 assert_eq!(all.len(), 3);
1375
1376 let page = repo.list_rates(TaxRateFilter { limit: Some(2), ..base() }).expect("limited");
1377 assert_eq!(page.len(), 2, "limit must bound the result set");
1378
1379 let rest = repo
1380 .list_rates(TaxRateFilter { limit: Some(2), offset: Some(2), ..base() })
1381 .expect("offset");
1382 assert_eq!(rest.len(), 1);
1383 }
1384
1385 #[test]
1386 fn list_jurisdictions_filters_by_country_and_state() {
1387 let repo = fresh_repo();
1388 make_state_jur(&repo, "AA");
1389 make_state_jur(&repo, "BB");
1390 repo.create_jurisdiction(CreateTaxJurisdiction {
1391 parent_id: None,
1392 name: "Test BC".into(),
1393 code: "YY-BC".into(),
1394 level: JurisdictionLevel::State,
1395 country_code: "YY".into(),
1396 state_code: Some("BC".into()),
1397 county: None,
1398 city: None,
1399 postal_codes: vec![],
1400 })
1401 .expect("yy-bc");
1402
1403 let zz = repo
1404 .list_jurisdictions(TaxJurisdictionFilter {
1405 country_code: Some("ZZ".into()),
1406 ..Default::default()
1407 })
1408 .expect("zz");
1409 assert_eq!(zz.len(), 2);
1410
1411 let zz_aa = repo
1412 .list_jurisdictions(TaxJurisdictionFilter {
1413 country_code: Some("ZZ".into()),
1414 state_code: Some("AA".into()),
1415 ..Default::default()
1416 })
1417 .expect("aa");
1418 assert_eq!(zz_aa.len(), 1);
1419 assert_eq!(zz_aa[0].state_code.as_deref(), Some("AA"));
1420 }
1421
1422 #[test]
1423 fn list_jurisdictions_orders_by_country_then_state() {
1424 let repo = fresh_repo();
1425 repo.create_jurisdiction(CreateTaxJurisdiction {
1429 parent_id: None,
1430 name: "Zebra".into(),
1431 code: "XA-1".into(),
1432 level: JurisdictionLevel::State,
1433 country_code: "XA".into(),
1434 state_code: Some("X1".into()),
1435 county: None,
1436 city: None,
1437 postal_codes: vec![],
1438 })
1439 .expect("xa");
1440 repo.create_jurisdiction(CreateTaxJurisdiction {
1441 parent_id: None,
1442 name: "Apple".into(),
1443 code: "XB-1".into(),
1444 level: JurisdictionLevel::State,
1445 country_code: "XB".into(),
1446 state_code: Some("X2".into()),
1447 county: None,
1448 city: None,
1449 postal_codes: vec![],
1450 })
1451 .expect("xb");
1452
1453 let all = repo.list_jurisdictions(TaxJurisdictionFilter::default()).expect("list");
1454 let mine: Vec<&str> = all
1455 .iter()
1456 .filter(|j| j.country_code == "XA" || j.country_code == "XB")
1457 .map(|j| j.country_code.as_str())
1458 .collect();
1459 assert_eq!(mine, vec!["XA", "XB"], "must order by country_code, not by name");
1460 }
1461
1462 #[test]
1463 fn create_rate_round_trips() {
1464 let repo = fresh_repo();
1465 let j = make_state_jur(&repo, "CA");
1466 let r = make_rate(&repo, j.id, dec!(0.0725));
1467 assert_eq!(r.rate, dec!(0.0725));
1468 let by_id = repo.get_rate(r.id).expect("ok").expect("found");
1469 assert_eq!(by_id.id, r.id);
1470 }
1471
1472 fn single_item_request(
1473 country: &str,
1474 state: &str,
1475 unit_price: Decimal,
1476 ) -> TaxCalculationRequest {
1477 TaxCalculationRequest {
1478 line_items: vec![TaxLineItem {
1479 id: "line-1".into(),
1480 sku: None,
1481 product_id: None,
1482 quantity: dec!(1),
1483 unit_price,
1484 discount_amount: Decimal::ZERO,
1485 tax_category: ProductTaxCategory::Standard,
1486 tax_code: None,
1487 description: None,
1488 }],
1489 shipping_address: TaxAddress {
1490 line1: None,
1491 line2: None,
1492 city: None,
1493 state: Some(state.into()),
1494 postal_code: None,
1495 country: country.into(),
1496 },
1497 billing_address: None,
1498 customer_id: None,
1499 shipping_amount: None,
1500 currency: CurrencyCode::USD,
1501 transaction_date: Some(NaiveDate::from_ymd_opt(2026, 6, 1).expect("date")),
1502 prices_include_tax: false,
1503 }
1504 }
1505
1506 #[test]
1507 fn calculate_tax_honors_configured_rounding_mode() {
1508 let repo = fresh_repo();
1513 let j = make_state_jur(&repo, "CA");
1514 make_rate(&repo, j.id, dec!(0.05));
1515 let request = single_item_request("ZZ", "CA", dec!(2.50));
1516
1517 let mut settings = repo.get_settings().expect("settings");
1518 settings.rounding_mode = "half_even".into();
1519 repo.update_settings(settings).expect("update settings");
1520 let even = repo.calculate_tax(request.clone()).expect("calc");
1521 assert_eq!(
1522 even.total_tax,
1523 dec!(0.12),
1524 "half_even must round $0.125 down to 0.12 (retained digit is even): {even:?}"
1525 );
1526
1527 let mut settings = repo.get_settings().expect("settings");
1528 settings.rounding_mode = "half_up".into();
1529 repo.update_settings(settings).expect("update settings");
1530 let up = repo.calculate_tax(request).expect("calc");
1531 assert_eq!(up.total_tax, dec!(0.13), "half_up must round $0.125 up to 0.13: {up:?}");
1532 }
1533
1534 #[test]
1535 fn list_rates_filters_by_jurisdiction() {
1536 let repo = fresh_repo();
1537 let j_a = make_state_jur(&repo, "AA");
1538 let j_b = make_state_jur(&repo, "BB");
1539 make_rate(&repo, j_a.id, dec!(0.0725));
1540 make_rate(&repo, j_a.id, dec!(0.01));
1541 make_rate(&repo, j_b.id, dec!(0.04));
1542
1543 let a_rates = repo
1544 .list_rates(TaxRateFilter { jurisdiction_id: Some(j_a.id), ..Default::default() })
1545 .expect("a");
1546 assert_eq!(a_rates.len(), 2);
1547 }
1548
1549 #[test]
1550 fn calculate_tax_returns_jurisdictions_in_stable_order() {
1551 let repo = fresh_repo();
1555 let country = repo
1557 .create_jurisdiction(CreateTaxJurisdiction {
1558 parent_id: None,
1559 name: "ZZ Country".into(),
1560 code: "ZZ".into(),
1561 level: JurisdictionLevel::Country,
1562 country_code: "ZZ".into(),
1563 state_code: None,
1564 county: None,
1565 city: None,
1566 postal_codes: vec![],
1567 })
1568 .expect("create country");
1569 make_rate(&repo, country.id, dec!(0.05));
1570 let state = make_state_jur(&repo, "CA"); make_rate(&repo, state.id, dec!(0.03));
1572
1573 let result =
1574 repo.calculate_tax(single_item_request("ZZ", "CA", dec!(100.00))).expect("calc");
1575
1576 let codes: Vec<&str> = result.jurisdictions.iter().map(|j| j.code.as_str()).collect();
1577 assert_eq!(
1578 codes,
1579 vec!["ZZ", "ZZ-CA"],
1580 "jurisdictions must be returned in stable code order: {codes:?}"
1581 );
1582 assert!(
1583 result.jurisdictions.windows(2).all(|w| w[0].code <= w[1].code),
1584 "jurisdictions must be sorted by code"
1585 );
1586 }
1587
1588 #[test]
1589 fn create_exemption_requires_existing_customer() {
1590 let repo = fresh_repo();
1594 let j = make_state_jur(&repo, "AA");
1595 let result = repo.create_exemption(CreateTaxExemption {
1596 customer_id: Uuid::new_v4(),
1597 exemption_type: ExemptionType::Resale,
1598 certificate_number: Some("RES-12345".into()),
1599 issuing_authority: None,
1600 jurisdiction_ids: vec![j.id],
1601 exempt_categories: vec![ProductTaxCategory::Standard],
1602 effective_from: NaiveDate::from_ymd_opt(2026, 1, 1).expect("date"),
1603 expires_at: Some(NaiveDate::from_ymd_opt(2027, 1, 1).expect("date")),
1604 notes: None,
1605 });
1606 assert!(result.is_err(), "expected FK rejection for unknown customer");
1607 }
1608
1609 #[test]
1610 fn get_customer_exemptions_unknown_customer_is_empty() {
1611 let repo = fresh_repo();
1612 let exemptions = repo.get_customer_exemptions(Uuid::new_v4()).expect("ok");
1613 assert!(exemptions.is_empty());
1614 }
1615
1616 #[test]
1617 fn get_settings_returns_defaults() {
1618 let repo = fresh_repo();
1619 let _ = repo.get_settings().expect("settings");
1620 }
1621
1622 #[test]
1623 fn get_jurisdiction_unknown_id_returns_none() {
1624 let repo = fresh_repo();
1625 assert!(repo.get_jurisdiction(Uuid::new_v4()).expect("ok").is_none());
1626 }
1627
1628 #[test]
1629 fn get_rate_unknown_id_returns_none() {
1630 let repo = fresh_repo();
1631 assert!(repo.get_rate(Uuid::new_v4()).expect("ok").is_none());
1632 }
1633
1634 #[test]
1635 fn get_exemption_unknown_id_returns_none() {
1636 let repo = fresh_repo();
1637 assert!(repo.get_exemption(Uuid::new_v4()).expect("ok").is_none());
1638 }
1639}