1use chrono::Utc;
4use r2d2::Pool;
5use r2d2_sqlite::SqliteConnectionManager;
6use rust_decimal::Decimal;
7use stateset_core::{
8 CommerceError, CreateCreditAccount, CreditAccount, CreditAccountFilter, CreditAccountStatus,
9 CreditAgingBucket, CreditApplication, CreditApplicationFilter, CreditApplicationStatus,
10 CreditCheckResult, CreditHold, CreditHoldFilter, CreditHoldStatus, CreditId, CreditRepository,
11 CreditTransaction, CreditTransactionFilter, CreditTransactionType, CustomerCreditSummary,
12 CustomerId, OrderId, PlaceCreditHold, RecordCreditTransaction, ReleaseCreditHold, Result,
13 ReviewCreditApplication, SubmitCreditApplication, UpdateCreditAccount,
14 generate_credit_application_number,
15};
16use uuid::Uuid;
17
18use super::{
19 map_db_error, parse_datetime_opt_row, parse_datetime_row, parse_decimal_opt_row,
20 parse_decimal_row, parse_enum_row, parse_uuid_opt_row, parse_uuid_row,
21 with_immediate_transaction,
22};
23
24#[derive(Debug)]
25pub struct SqliteCreditRepository {
26 pool: Pool<SqliteConnectionManager>,
27}
28
29impl SqliteCreditRepository {
30 #[must_use]
31 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
32 Self { pool }
33 }
34
35 fn row_to_credit_account(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CreditAccount> {
36 Ok(CreditAccount {
37 id: CreditId::from(parse_uuid_row(&row.get::<_, String>(0)?, "credit_account", "id")?),
38 customer_id: CustomerId::from(parse_uuid_row(
39 &row.get::<_, String>(1)?,
40 "credit_account",
41 "customer_id",
42 )?),
43 credit_limit: parse_decimal_row(
44 &row.get::<_, String>(2)?,
45 "credit_account",
46 "credit_limit",
47 )?,
48 available_credit: parse_decimal_row(
49 &row.get::<_, String>(3)?,
50 "credit_account",
51 "available_credit",
52 )?,
53 current_balance: parse_decimal_row(
54 &row.get::<_, String>(4)?,
55 "credit_account",
56 "current_balance",
57 )?,
58 hold_amount: parse_decimal_row(
59 &row.get::<_, String>(5)?,
60 "credit_account",
61 "hold_amount",
62 )?,
63 currency: row.get(6)?,
64 status: parse_enum_row(&row.get::<_, String>(7)?, "credit_account", "status")?,
65 payment_terms: row.get(8)?,
66 risk_rating: match row.get::<_, Option<String>>(9)? {
67 Some(value) if !value.is_empty() => {
68 Some(parse_enum_row(&value, "credit_account", "risk_rating")?)
69 }
70 _ => None,
71 },
72 last_review_date: parse_datetime_opt_row(
73 row.get::<_, Option<String>>(10)?,
74 "credit_account",
75 "last_review_date",
76 )?,
77 next_review_date: parse_datetime_opt_row(
78 row.get::<_, Option<String>>(11)?,
79 "credit_account",
80 "next_review_date",
81 )?,
82 notes: row.get(12)?,
83 created_at: parse_datetime_row(
84 &row.get::<_, String>(13)?,
85 "credit_account",
86 "created_at",
87 )?,
88 updated_at: parse_datetime_row(
89 &row.get::<_, String>(14)?,
90 "credit_account",
91 "updated_at",
92 )?,
93 })
94 }
95
96 fn row_to_credit_hold(&self, row: &rusqlite::Row<'_>) -> rusqlite::Result<CreditHold> {
97 Ok(CreditHold {
98 id: parse_uuid_row(&row.get::<_, String>(0)?, "credit_hold", "id")?,
99 customer_id: CustomerId::from(parse_uuid_row(
100 &row.get::<_, String>(1)?,
101 "credit_hold",
102 "customer_id",
103 )?),
104 order_id: parse_uuid_opt_row(
105 row.get::<_, Option<String>>(2)?,
106 "credit_hold",
107 "order_id",
108 )?
109 .map(OrderId::from),
110 hold_type: parse_enum_row(&row.get::<_, String>(3)?, "credit_hold", "hold_type")?,
111 hold_amount: parse_decimal_row(
112 &row.get::<_, String>(4)?,
113 "credit_hold",
114 "hold_amount",
115 )?,
116 reason: row.get(5)?,
117 status: parse_enum_row(&row.get::<_, String>(6)?, "credit_hold", "status")?,
118 placed_by: row.get(7)?,
119 placed_at: parse_datetime_row(&row.get::<_, String>(8)?, "credit_hold", "placed_at")?,
120 released_by: row.get(9)?,
121 released_at: parse_datetime_opt_row(
122 row.get::<_, Option<String>>(10)?,
123 "credit_hold",
124 "released_at",
125 )?,
126 release_notes: row.get(11)?,
127 created_at: parse_datetime_row(
128 &row.get::<_, String>(12)?,
129 "credit_hold",
130 "created_at",
131 )?,
132 })
133 }
134
135 fn row_to_credit_application(
136 &self,
137 row: &rusqlite::Row<'_>,
138 ) -> rusqlite::Result<CreditApplication> {
139 Ok(CreditApplication {
140 id: parse_uuid_row(&row.get::<_, String>(0)?, "credit_application", "id")?,
141 application_number: row.get(1)?,
142 customer_id: CustomerId::from(parse_uuid_row(
143 &row.get::<_, String>(2)?,
144 "credit_application",
145 "customer_id",
146 )?),
147 requested_limit: parse_decimal_row(
148 &row.get::<_, String>(3)?,
149 "credit_application",
150 "requested_limit",
151 )?,
152 approved_limit: parse_decimal_opt_row(
153 row.get::<_, Option<String>>(4)?,
154 "credit_application",
155 "approved_limit",
156 )?,
157 status: parse_enum_row(&row.get::<_, String>(5)?, "credit_application", "status")?,
158 business_name: row.get(6)?,
159 tax_id: row.get(7)?,
160 years_in_business: row.get(8)?,
161 annual_revenue: parse_decimal_opt_row(
162 row.get::<_, Option<String>>(9)?,
163 "credit_application",
164 "annual_revenue",
165 )?,
166 bank_reference: row.get(10)?,
167 trade_references: row.get(11)?,
168 submitted_at: parse_datetime_row(
169 &row.get::<_, String>(12)?,
170 "credit_application",
171 "submitted_at",
172 )?,
173 reviewed_by: row.get(13)?,
174 reviewed_at: parse_datetime_opt_row(
175 row.get::<_, Option<String>>(14)?,
176 "credit_application",
177 "reviewed_at",
178 )?,
179 decision_notes: row.get(15)?,
180 created_at: parse_datetime_row(
181 &row.get::<_, String>(16)?,
182 "credit_application",
183 "created_at",
184 )?,
185 updated_at: parse_datetime_row(
186 &row.get::<_, String>(17)?,
187 "credit_application",
188 "updated_at",
189 )?,
190 })
191 }
192
193 fn row_to_credit_transaction(
194 &self,
195 row: &rusqlite::Row<'_>,
196 ) -> rusqlite::Result<CreditTransaction> {
197 Ok(CreditTransaction {
198 id: parse_uuid_row(&row.get::<_, String>(0)?, "credit_transaction", "id")?,
199 customer_id: CustomerId::from(parse_uuid_row(
200 &row.get::<_, String>(1)?,
201 "credit_transaction",
202 "customer_id",
203 )?),
204 transaction_type: parse_enum_row(
205 &row.get::<_, String>(2)?,
206 "credit_transaction",
207 "transaction_type",
208 )?,
209 amount: parse_decimal_row(&row.get::<_, String>(3)?, "credit_transaction", "amount")?,
210 running_balance: parse_decimal_row(
211 &row.get::<_, String>(4)?,
212 "credit_transaction",
213 "running_balance",
214 )?,
215 reference_type: row.get(5)?,
216 reference_id: parse_uuid_opt_row(
217 row.get::<_, Option<String>>(6)?,
218 "credit_transaction",
219 "reference_id",
220 )?,
221 notes: row.get(7)?,
222 created_at: parse_datetime_row(
223 &row.get::<_, String>(8)?,
224 "credit_transaction",
225 "created_at",
226 )?,
227 })
228 }
229
230 fn recalculate_available_credit(&self, customer_id: CustomerId) -> Result<()> {
231 with_immediate_transaction(&self.pool, |tx| {
247 let (limit, balance, hold): (String, String, String) = tx.query_row(
248 "SELECT credit_limit, current_balance, hold_amount FROM credit_accounts WHERE customer_id = ?",
249 [customer_id.to_string()],
250 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
251 )?;
252
253 let available = parse_decimal_row(&limit, "credit_account", "credit_limit")?
254 - parse_decimal_row(&balance, "credit_account", "current_balance")?
255 - parse_decimal_row(&hold, "credit_account", "hold_amount")?;
256
257 tx.execute(
258 "UPDATE credit_accounts SET available_credit = ? WHERE customer_id = ?",
259 [&available.to_string(), &customer_id.to_string()],
260 )?;
261
262 Ok(())
263 })
264 }
265}
266
267impl CreditRepository for SqliteCreditRepository {
268 fn create_credit_account(&self, input: CreateCreditAccount) -> Result<CreditAccount> {
269 let id = CreditId::new();
270 let now = Utc::now();
271 let currency = input.currency.unwrap_or_default();
272
273 {
274 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
275 conn.execute(
276 "INSERT INTO credit_accounts (id, customer_id, credit_limit, available_credit, current_balance,
277 hold_amount, currency, status, payment_terms, risk_rating, notes, created_at, updated_at)
278 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)",
279 rusqlite::params![
280 id.to_string(),
281 input.customer_id.to_string(),
282 input.credit_limit.to_string(),
283 input.credit_limit.to_string(), "0",
285 "0",
286 ¤cy,
287 CreditAccountStatus::Active.to_string(),
288 input.payment_terms,
289 input.risk_rating.map(|r| r.to_string()),
290 input.notes,
291 now.to_rfc3339(),
292 now.to_rfc3339(),
293 ],
294 ).map_err(map_db_error)?;
295 }
296
297 self.get_credit_account(id)?.ok_or(CommerceError::NotFound)
298 }
299
300 fn get_credit_account(&self, id: CreditId) -> Result<Option<CreditAccount>> {
301 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
302 let result = conn.query_row(
303 "SELECT id, customer_id, credit_limit, available_credit, current_balance, hold_amount,
304 currency, status, payment_terms, risk_rating, last_review_date, next_review_date,
305 notes, created_at, updated_at
306 FROM credit_accounts WHERE id = ?",
307 [id.to_string()],
308 |row| self.row_to_credit_account(row),
309 );
310
311 match result {
312 Ok(account) => Ok(Some(account)),
313 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
314 Err(e) => Err(map_db_error(e)),
315 }
316 }
317
318 fn get_credit_account_by_customer(
319 &self,
320 customer_id: CustomerId,
321 ) -> Result<Option<CreditAccount>> {
322 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
323 let result = conn.query_row(
324 "SELECT id, customer_id, credit_limit, available_credit, current_balance, hold_amount,
325 currency, status, payment_terms, risk_rating, last_review_date, next_review_date,
326 notes, created_at, updated_at
327 FROM credit_accounts WHERE customer_id = ?",
328 [customer_id.to_string()],
329 |row| self.row_to_credit_account(row),
330 );
331
332 match result {
333 Ok(account) => Ok(Some(account)),
334 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
335 Err(e) => Err(map_db_error(e)),
336 }
337 }
338
339 fn update_credit_account(
340 &self,
341 id: CreditId,
342 input: UpdateCreditAccount,
343 ) -> Result<CreditAccount> {
344 let now = Utc::now();
345
346 let account = self.get_credit_account(id)?.ok_or(CommerceError::NotFound)?;
347
348 {
349 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
350 conn.execute(
351 "UPDATE credit_accounts SET
352 credit_limit = COALESCE(?, credit_limit),
353 status = COALESCE(?, status),
354 payment_terms = COALESCE(?, payment_terms),
355 risk_rating = COALESCE(?, risk_rating),
356 notes = COALESCE(?, notes),
357 updated_at = ?
358 WHERE id = ?",
359 rusqlite::params![
360 input.credit_limit.map(|l| l.to_string()),
361 input.status.map(|s| s.to_string()),
362 input.payment_terms,
363 input.risk_rating.map(|r| r.to_string()),
364 input.notes,
365 now.to_rfc3339(),
366 id.to_string(),
367 ],
368 )
369 .map_err(map_db_error)?;
370 }
371
372 self.recalculate_available_credit(account.customer_id)?;
373 self.get_credit_account(id)?.ok_or(CommerceError::NotFound)
374 }
375
376 fn list_credit_accounts(&self, filter: CreditAccountFilter) -> Result<Vec<CreditAccount>> {
377 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
378 let mut sql = String::from(
379 "SELECT id, customer_id, credit_limit, available_credit, current_balance, hold_amount,
380 currency, status, payment_terms, risk_rating, last_review_date, next_review_date,
381 notes, created_at, updated_at
382 FROM credit_accounts WHERE 1=1"
383 );
384 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
385
386 if let Some(ref cust_id) = filter.customer_id {
387 sql.push_str(" AND customer_id = ?");
388 params.push(Box::new(cust_id.to_string()));
389 }
390 if let Some(ref status) = filter.status {
391 sql.push_str(" AND status = ?");
392 params.push(Box::new(status.to_string()));
393 }
394
395 sql.push_str(" ORDER BY created_at DESC");
396
397 if filter.over_limit != Some(true) {
401 if let Some(limit) = filter.limit {
402 sql.push_str(&format!(" LIMIT {limit}"));
403 }
404 }
405
406 let param_refs: Vec<&dyn rusqlite::ToSql> =
407 params.iter().map(std::convert::AsRef::as_ref).collect();
408 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
409 let rows = stmt
410 .query_map(param_refs.as_slice(), |row| self.row_to_credit_account(row))
411 .map_err(map_db_error)?;
412
413 let mut accounts = Vec::new();
414 for row in rows {
415 accounts.push(row.map_err(map_db_error)?);
416 }
417
418 if filter.over_limit == Some(true) {
424 accounts.retain(|acc| acc.current_balance > acc.credit_limit);
425 if let Some(limit) = filter.limit {
426 accounts.truncate(limit as usize);
427 }
428 }
429
430 Ok(accounts)
431 }
432
433 fn adjust_credit_limit(
434 &self,
435 customer_id: CustomerId,
436 new_limit: Decimal,
437 reason: &str,
438 ) -> Result<CreditAccount> {
439 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
440 let now = Utc::now();
441
442 let account =
443 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)?;
444 let old_limit = account.credit_limit;
445
446 conn.execute(
447 "UPDATE credit_accounts SET credit_limit = ?, updated_at = ? WHERE customer_id = ?",
448 [&new_limit.to_string(), &now.to_rfc3339(), &customer_id.to_string()],
449 )
450 .map_err(map_db_error)?;
451
452 self.record_transaction(RecordCreditTransaction {
454 customer_id,
455 transaction_type: CreditTransactionType::LimitChange,
456 amount: new_limit - old_limit,
457 reference_type: None,
458 reference_id: None,
459 notes: Some(reason.to_string()),
460 })?;
461
462 self.recalculate_available_credit(customer_id)?;
463 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
464 }
465
466 fn suspend_credit_account(
467 &self,
468 customer_id: CustomerId,
469 reason: &str,
470 ) -> Result<CreditAccount> {
471 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
472 let now = Utc::now();
473
474 conn.execute(
475 "UPDATE credit_accounts SET status = ?, notes = COALESCE(notes, '') || ? || '\n', updated_at = ?
476 WHERE customer_id = ?",
477 rusqlite::params![
478 CreditAccountStatus::Suspended.to_string(),
479 format!("\nSuspended: {}", reason),
480 now.to_rfc3339(),
481 customer_id.to_string(),
482 ],
483 ).map_err(map_db_error)?;
484
485 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
486 }
487
488 fn reactivate_credit_account(&self, customer_id: CustomerId) -> Result<CreditAccount> {
489 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
490 let now = Utc::now();
491
492 conn.execute(
493 "UPDATE credit_accounts SET status = ?, updated_at = ? WHERE customer_id = ?",
494 [CreditAccountStatus::Active.to_string(), now.to_rfc3339(), customer_id.to_string()],
495 )
496 .map_err(map_db_error)?;
497
498 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
499 }
500
501 fn check_credit(
502 &self,
503 customer_id: CustomerId,
504 order_amount: Decimal,
505 ) -> Result<CreditCheckResult> {
506 let now = Utc::now();
507 let account = self.get_credit_account_by_customer(customer_id)?;
508
509 match account {
510 Some(acc) => {
511 let approved = acc.status == CreditAccountStatus::Active
512 && acc.available_credit >= order_amount;
513 let reason = if approved {
514 None
515 } else if acc.status == CreditAccountStatus::Active {
516 Some(format!(
517 "Insufficient credit: available ${}, required ${}",
518 acc.available_credit, order_amount
519 ))
520 } else {
521 Some(format!("Account status: {}", acc.status))
522 };
523
524 Ok(CreditCheckResult {
525 customer_id,
526 order_amount,
527 credit_limit: acc.credit_limit,
528 available_credit: acc.available_credit,
529 current_balance: acc.current_balance,
530 approved,
531 reason,
532 requires_approval: !approved && acc.status == CreditAccountStatus::Active,
533 checked_at: now,
534 })
535 }
536 None => Ok(CreditCheckResult {
537 customer_id,
538 order_amount,
539 credit_limit: Decimal::ZERO,
540 available_credit: Decimal::ZERO,
541 current_balance: Decimal::ZERO,
542 approved: false,
543 reason: Some("No credit account found".to_string()),
544 requires_approval: true,
545 checked_at: now,
546 }),
547 }
548 }
549
550 fn reserve_credit(
551 &self,
552 customer_id: CustomerId,
553 order_id: OrderId,
554 amount: Decimal,
555 ) -> Result<CreditAccount> {
556 if amount <= Decimal::ZERO {
557 return Err(CommerceError::ValidationError(
558 "Credit reservation amount must be positive".to_string(),
559 ));
560 }
561
562 let now = Utc::now();
563 let id = Uuid::new_v4();
564
565 with_immediate_transaction(&self.pool, |tx| {
578 let (limit_str, balance_str, hold_str): (String, String, String) = tx.query_row(
579 "SELECT credit_limit, current_balance, hold_amount FROM credit_accounts WHERE customer_id = ?",
580 [customer_id.to_string()],
581 |row| Ok((row.get(0)?, row.get(1)?, row.get(2)?)),
582 )?;
583 let hold = parse_decimal_row(&hold_str, "credit_account", "hold_amount")?;
584 let available = parse_decimal_row(&limit_str, "credit_account", "credit_limit")?
585 - parse_decimal_row(&balance_str, "credit_account", "current_balance")?
586 - hold;
587 if amount > available {
588 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
589 CommerceError::ValidationError(format!(
590 "Insufficient available credit: requested {amount}, available {available}"
591 )),
592 )));
593 }
594
595 tx.execute(
596 "INSERT INTO credit_reservations (id, customer_id, order_id, amount, status, created_at)
597 VALUES (?, ?, ?, ?, 'active', ?)",
598 rusqlite::params![
599 id.to_string(),
600 customer_id.to_string(),
601 order_id.to_string(),
602 amount.to_string(),
603 now.to_rfc3339(),
604 ],
605 )?;
606
607 let new_hold = hold + amount;
608 tx.execute(
609 "UPDATE credit_accounts SET hold_amount = ? WHERE customer_id = ?",
610 [&new_hold.to_string(), &customer_id.to_string()],
611 )?;
612
613 Ok(())
614 })?;
615
616 self.recalculate_available_credit(customer_id)?;
617 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
618 }
619
620 fn release_credit_reservation(
621 &self,
622 customer_id: CustomerId,
623 order_id: OrderId,
624 ) -> Result<CreditAccount> {
625 let now = Utc::now();
626
627 with_immediate_transaction(&self.pool, |tx| {
638 let amount: Option<String> = match tx.query_row(
639 "SELECT amount FROM credit_reservations WHERE customer_id = ? AND order_id = ? AND status = 'active'",
640 [customer_id.to_string(), order_id.to_string()],
641 |row| row.get(0),
642 ) {
643 Ok(value) => Some(value),
644 Err(rusqlite::Error::QueryReturnedNoRows) => None,
645 Err(e) => return Err(e),
646 };
647
648 let amount = match amount {
649 Some(value) => parse_decimal_row(&value, "credit_reservation", "amount")?,
650 None => Decimal::ZERO,
651 };
652
653 tx.execute(
654 "UPDATE credit_reservations SET status = 'released', released_at = ?
655 WHERE customer_id = ? AND order_id = ? AND status = 'active'",
656 [now.to_rfc3339(), customer_id.to_string(), order_id.to_string()],
657 )?;
658
659 let current_hold: String = tx.query_row(
660 "SELECT hold_amount FROM credit_accounts WHERE customer_id = ?",
661 [customer_id.to_string()],
662 |row| row.get(0),
663 )?;
664 let new_hold = (parse_decimal_row(¤t_hold, "credit_account", "hold_amount")?
665 - amount)
666 .max(Decimal::ZERO);
667 tx.execute(
668 "UPDATE credit_accounts SET hold_amount = ? WHERE customer_id = ?",
669 [&new_hold.to_string(), &customer_id.to_string()],
670 )?;
671
672 Ok(())
673 })?;
674
675 self.recalculate_available_credit(customer_id)?;
676 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
677 }
678
679 fn charge_credit(
680 &self,
681 customer_id: CustomerId,
682 order_id: OrderId,
683 amount: Decimal,
684 ) -> Result<CreditAccount> {
685 if amount <= Decimal::ZERO {
686 return Err(CommerceError::ValidationError(
687 "Credit charge amount must be positive".to_string(),
688 ));
689 }
690
691 with_immediate_transaction(&self.pool, |tx| {
709 let (current_balance, limit_str): (String, String) = tx.query_row(
710 "SELECT current_balance, credit_limit FROM credit_accounts WHERE customer_id = ?",
711 [customer_id.to_string()],
712 |row| Ok((row.get(0)?, row.get(1)?)),
713 )?;
714 let new_balance =
715 parse_decimal_row(¤t_balance, "credit_account", "current_balance")? + amount;
716 let credit_limit = parse_decimal_row(&limit_str, "credit_account", "credit_limit")?;
717 if new_balance > credit_limit {
718 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
719 CommerceError::ValidationError(format!(
720 "Charge would exceed credit limit: new balance {new_balance}, limit {credit_limit}"
721 )),
722 )));
723 }
724 tx.execute(
725 "UPDATE credit_accounts SET current_balance = ? WHERE customer_id = ?",
726 [&new_balance.to_string(), &customer_id.to_string()],
727 )?;
728 Ok(())
729 })?;
730
731 self.release_credit_reservation(customer_id, order_id)?;
734 self.record_transaction(RecordCreditTransaction {
735 customer_id,
736 transaction_type: CreditTransactionType::Charge,
737 amount,
738 reference_type: Some("order".to_string()),
739 reference_id: Some(Uuid::from(order_id)),
740 notes: None,
741 })?;
742
743 self.recalculate_available_credit(customer_id)?;
744 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
745 }
746
747 fn place_hold(&self, input: PlaceCreditHold) -> Result<CreditHold> {
748 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
749 let id = Uuid::new_v4();
750 let now = Utc::now();
751
752 conn.execute(
753 "INSERT INTO credit_holds (id, customer_id, order_id, hold_type, hold_amount, reason,
754 status, placed_by, placed_at, created_at)
755 VALUES (?, ?, ?, ?, ?, ?, 'active', ?, ?, ?)",
756 rusqlite::params![
757 id.to_string(),
758 input.customer_id.to_string(),
759 input.order_id.map(|id| id.to_string()),
760 input.hold_type.to_string(),
761 input.hold_amount.to_string(),
762 &input.reason,
763 input.placed_by,
764 now.to_rfc3339(),
765 now.to_rfc3339(),
766 ],
767 )
768 .map_err(map_db_error)?;
769
770 self.get_hold(id)?.ok_or(CommerceError::NotFound)
771 }
772
773 fn get_hold(&self, id: Uuid) -> Result<Option<CreditHold>> {
774 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
775 let result = conn.query_row(
776 "SELECT id, customer_id, order_id, hold_type, hold_amount, reason, status,
777 placed_by, placed_at, released_by, released_at, release_notes, created_at
778 FROM credit_holds WHERE id = ?",
779 [id.to_string()],
780 |row| self.row_to_credit_hold(row),
781 );
782
783 match result {
784 Ok(hold) => Ok(Some(hold)),
785 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
786 Err(e) => Err(map_db_error(e)),
787 }
788 }
789
790 fn list_holds(&self, filter: CreditHoldFilter) -> Result<Vec<CreditHold>> {
791 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
792 let mut sql = String::from(
793 "SELECT id, customer_id, order_id, hold_type, hold_amount, reason, status,
794 placed_by, placed_at, released_by, released_at, release_notes, created_at
795 FROM credit_holds WHERE 1=1",
796 );
797 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
798
799 if let Some(ref cust_id) = filter.customer_id {
800 sql.push_str(" AND customer_id = ?");
801 params.push(Box::new(cust_id.to_string()));
802 }
803 if let Some(ref ord_id) = filter.order_id {
804 sql.push_str(" AND order_id = ?");
805 params.push(Box::new(ord_id.to_string()));
806 }
807 if let Some(ref status) = filter.status {
808 sql.push_str(" AND status = ?");
809 params.push(Box::new(status.to_string()));
810 }
811
812 sql.push_str(" ORDER BY placed_at DESC");
813
814 if let Some(limit) = filter.limit {
815 sql.push_str(&format!(" LIMIT {limit}"));
816 }
817
818 let param_refs: Vec<&dyn rusqlite::ToSql> =
819 params.iter().map(std::convert::AsRef::as_ref).collect();
820 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
821 let rows = stmt
822 .query_map(param_refs.as_slice(), |row| self.row_to_credit_hold(row))
823 .map_err(map_db_error)?;
824
825 let mut holds = Vec::new();
826 for row in rows {
827 holds.push(row.map_err(map_db_error)?);
828 }
829 Ok(holds)
830 }
831
832 fn release_hold(&self, input: ReleaseCreditHold) -> Result<CreditHold> {
833 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
834 let now = Utc::now();
835
836 conn.execute(
837 "UPDATE credit_holds SET status = 'released', released_by = ?, released_at = ?, release_notes = ?
838 WHERE id = ?",
839 rusqlite::params![
840 input.released_by,
841 now.to_rfc3339(),
842 input.release_notes,
843 input.hold_id.to_string(),
844 ],
845 ).map_err(map_db_error)?;
846
847 self.get_hold(input.hold_id)?.ok_or(CommerceError::NotFound)
848 }
849
850 fn get_active_holds(&self, customer_id: CustomerId) -> Result<Vec<CreditHold>> {
851 self.list_holds(CreditHoldFilter {
852 customer_id: Some(customer_id),
853 status: Some(CreditHoldStatus::Active),
854 ..Default::default()
855 })
856 }
857
858 fn get_holds_for_order(&self, order_id: OrderId) -> Result<Vec<CreditHold>> {
859 self.list_holds(CreditHoldFilter { order_id: Some(order_id), ..Default::default() })
860 }
861
862 fn submit_application(&self, input: SubmitCreditApplication) -> Result<CreditApplication> {
863 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
864 let id = Uuid::new_v4();
865 let now = Utc::now();
866 let app_number = generate_credit_application_number();
867
868 conn.execute(
869 "INSERT INTO credit_applications (id, application_number, customer_id, requested_limit,
870 status, business_name, tax_id, years_in_business, annual_revenue, bank_reference,
871 trade_references, submitted_at, created_at, updated_at)
872 VALUES (?, ?, ?, ?, 'pending', ?, ?, ?, ?, ?, ?, ?, ?, ?)",
873 rusqlite::params![
874 id.to_string(),
875 &app_number,
876 input.customer_id.to_string(),
877 input.requested_limit.to_string(),
878 input.business_name,
879 input.tax_id,
880 input.years_in_business,
881 input.annual_revenue.map(|r| r.to_string()),
882 input.bank_reference,
883 input.trade_references,
884 now.to_rfc3339(),
885 now.to_rfc3339(),
886 now.to_rfc3339(),
887 ],
888 )
889 .map_err(map_db_error)?;
890
891 self.get_application(id)?.ok_or(CommerceError::NotFound)
892 }
893
894 fn get_application(&self, id: Uuid) -> Result<Option<CreditApplication>> {
895 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
896 let result = conn.query_row(
897 "SELECT id, application_number, customer_id, requested_limit, approved_limit, status,
898 business_name, tax_id, years_in_business, annual_revenue, bank_reference,
899 trade_references, submitted_at, reviewed_by, reviewed_at, decision_notes,
900 created_at, updated_at
901 FROM credit_applications WHERE id = ?",
902 [id.to_string()],
903 |row| self.row_to_credit_application(row),
904 );
905
906 match result {
907 Ok(app) => Ok(Some(app)),
908 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
909 Err(e) => Err(map_db_error(e)),
910 }
911 }
912
913 fn list_applications(&self, filter: CreditApplicationFilter) -> Result<Vec<CreditApplication>> {
914 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
915 let mut sql = String::from(
916 "SELECT id, application_number, customer_id, requested_limit, approved_limit, status,
917 business_name, tax_id, years_in_business, annual_revenue, bank_reference,
918 trade_references, submitted_at, reviewed_by, reviewed_at, decision_notes,
919 created_at, updated_at
920 FROM credit_applications WHERE 1=1",
921 );
922 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
923
924 if let Some(ref cust_id) = filter.customer_id {
925 sql.push_str(" AND customer_id = ?");
926 params.push(Box::new(cust_id.to_string()));
927 }
928 if let Some(ref status) = filter.status {
929 sql.push_str(" AND status = ?");
930 params.push(Box::new(status.to_string()));
931 }
932
933 sql.push_str(" ORDER BY submitted_at DESC");
934
935 if let Some(limit) = filter.limit {
936 sql.push_str(&format!(" LIMIT {limit}"));
937 }
938
939 let param_refs: Vec<&dyn rusqlite::ToSql> =
940 params.iter().map(std::convert::AsRef::as_ref).collect();
941 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
942 let rows = stmt
943 .query_map(param_refs.as_slice(), |row| self.row_to_credit_application(row))
944 .map_err(map_db_error)?;
945
946 let mut apps = Vec::new();
947 for row in rows {
948 apps.push(row.map_err(map_db_error)?);
949 }
950 Ok(apps)
951 }
952
953 fn review_application(&self, input: ReviewCreditApplication) -> Result<CreditApplication> {
954 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
955 let now = Utc::now();
956
957 let app = self.get_application(input.application_id)?.ok_or(CommerceError::NotFound)?;
958
959 conn.execute(
960 "UPDATE credit_applications SET approved_limit = ?, status = ?, reviewed_by = ?,
961 reviewed_at = ?, decision_notes = ?, updated_at = ?
962 WHERE id = ?",
963 rusqlite::params![
964 input.approved_limit.map(|l| l.to_string()),
965 input.status.to_string(),
966 &input.reviewed_by,
967 now.to_rfc3339(),
968 input.decision_notes,
969 now.to_rfc3339(),
970 input.application_id.to_string(),
971 ],
972 )
973 .map_err(map_db_error)?;
974
975 if input.status == CreditApplicationStatus::Approved {
977 if let Some(limit) = input.approved_limit {
978 let existing = self.get_credit_account_by_customer(app.customer_id)?;
979 if existing.is_some() {
980 self.adjust_credit_limit(
981 app.customer_id,
982 limit,
983 "Credit application approved",
984 )?;
985 } else {
986 self.create_credit_account(CreateCreditAccount {
987 customer_id: app.customer_id,
988 credit_limit: limit,
989 ..Default::default()
990 })?;
991 }
992 }
993 }
994
995 self.get_application(input.application_id)?.ok_or(CommerceError::NotFound)
996 }
997
998 fn withdraw_application(&self, id: Uuid) -> Result<CreditApplication> {
999 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1000 let now = Utc::now();
1001
1002 conn.execute(
1003 "UPDATE credit_applications SET status = 'withdrawn', updated_at = ? WHERE id = ?",
1004 [now.to_rfc3339(), id.to_string()],
1005 )
1006 .map_err(map_db_error)?;
1007
1008 self.get_application(id)?.ok_or(CommerceError::NotFound)
1009 }
1010
1011 fn record_transaction(&self, input: RecordCreditTransaction) -> Result<CreditTransaction> {
1012 let id = Uuid::new_v4();
1013 let now = Utc::now();
1014
1015 let running_balance = with_immediate_transaction(&self.pool, |tx| {
1022 let balance: String = tx.query_row(
1023 "SELECT current_balance FROM credit_accounts WHERE customer_id = ?",
1024 [input.customer_id.to_string()],
1025 |row| row.get(0),
1026 )?;
1027
1028 let current_balance = parse_decimal_row(&balance, "credit_account", "current_balance")?;
1029 let running_balance = match input.transaction_type {
1030 CreditTransactionType::Payment | CreditTransactionType::CreditMemo => {
1031 current_balance - input.amount
1032 }
1033 _ => current_balance,
1034 };
1035
1036 tx.execute(
1037 "INSERT INTO credit_transactions (id, customer_id, transaction_type, amount,
1038 running_balance, reference_type, reference_id, notes, created_at)
1039 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)",
1040 rusqlite::params![
1041 id.to_string(),
1042 input.customer_id.to_string(),
1043 input.transaction_type.to_string(),
1044 input.amount.to_string(),
1045 running_balance.to_string(),
1046 &input.reference_type,
1047 input.reference_id.map(|id| id.to_string()),
1048 &input.notes,
1049 now.to_rfc3339(),
1050 ],
1051 )?;
1052
1053 Ok(running_balance)
1054 })?;
1055
1056 Ok(CreditTransaction {
1057 id,
1058 customer_id: input.customer_id,
1059 transaction_type: input.transaction_type,
1060 amount: input.amount,
1061 running_balance,
1062 reference_type: input.reference_type,
1063 reference_id: input.reference_id,
1064 notes: input.notes,
1065 created_at: now,
1066 })
1067 }
1068
1069 fn list_transactions(&self, filter: CreditTransactionFilter) -> Result<Vec<CreditTransaction>> {
1070 let conn = self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
1071 let mut sql = String::from(
1072 "SELECT id, customer_id, transaction_type, amount, running_balance,
1073 reference_type, reference_id, notes, created_at
1074 FROM credit_transactions WHERE 1=1",
1075 );
1076 let mut params: Vec<Box<dyn rusqlite::ToSql>> = Vec::new();
1077
1078 if let Some(ref cust_id) = filter.customer_id {
1079 sql.push_str(" AND customer_id = ?");
1080 params.push(Box::new(cust_id.to_string()));
1081 }
1082 if let Some(ref tx_type) = filter.transaction_type {
1083 sql.push_str(" AND transaction_type = ?");
1084 params.push(Box::new(tx_type.to_string()));
1085 }
1086
1087 sql.push_str(" ORDER BY created_at DESC");
1088
1089 if let Some(limit) = filter.limit {
1090 sql.push_str(&format!(" LIMIT {limit}"));
1091 }
1092
1093 let param_refs: Vec<&dyn rusqlite::ToSql> =
1094 params.iter().map(std::convert::AsRef::as_ref).collect();
1095 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
1096 let rows = stmt
1097 .query_map(param_refs.as_slice(), |row| self.row_to_credit_transaction(row))
1098 .map_err(map_db_error)?;
1099
1100 let mut txns = Vec::new();
1101 for row in rows {
1102 txns.push(row.map_err(map_db_error)?);
1103 }
1104 Ok(txns)
1105 }
1106
1107 fn apply_payment(
1108 &self,
1109 customer_id: CustomerId,
1110 amount: Decimal,
1111 reference_id: Option<Uuid>,
1112 ) -> Result<CreditAccount> {
1113 if amount <= Decimal::ZERO {
1114 return Err(CommerceError::ValidationError(
1115 "Payment amount must be positive".to_string(),
1116 ));
1117 }
1118
1119 with_immediate_transaction(&self.pool, |tx| {
1129 let current_balance: String = tx.query_row(
1130 "SELECT current_balance FROM credit_accounts WHERE customer_id = ?",
1131 [customer_id.to_string()],
1132 |row| row.get(0),
1133 )?;
1134 let new_balance =
1135 (parse_decimal_row(¤t_balance, "credit_account", "current_balance")?
1136 - amount)
1137 .max(Decimal::ZERO);
1138 tx.execute(
1139 "UPDATE credit_accounts SET current_balance = ? WHERE customer_id = ?",
1140 [&new_balance.to_string(), &customer_id.to_string()],
1141 )?;
1142 Ok(())
1143 })?;
1144
1145 self.record_transaction(RecordCreditTransaction {
1147 customer_id,
1148 transaction_type: CreditTransactionType::Payment,
1149 amount,
1150 reference_type: Some("payment".to_string()),
1151 reference_id,
1152 notes: None,
1153 })?;
1154
1155 self.recalculate_available_credit(customer_id)?;
1156 self.get_credit_account_by_customer(customer_id)?.ok_or(CommerceError::NotFound)
1157 }
1158
1159 fn get_customer_summary(
1160 &self,
1161 customer_id: CustomerId,
1162 ) -> Result<Option<CustomerCreditSummary>> {
1163 let account = self.get_credit_account_by_customer(customer_id)?;
1164
1165 match account {
1166 Some(acc) => {
1167 let holds = self.get_active_holds(customer_id)?;
1168
1169 Ok(Some(CustomerCreditSummary {
1170 customer_id,
1171 credit_limit: acc.credit_limit,
1172 current_balance: acc.current_balance,
1173 available_credit: acc.available_credit,
1174 oldest_due_date: None, days_past_due: 0,
1176 hold_count: holds.len() as i32,
1177 }))
1178 }
1179 None => Ok(None),
1180 }
1181 }
1182
1183 fn get_aging_report(&self) -> Result<Vec<(CustomerId, CreditAgingBucket)>> {
1184 let accounts = self.list_credit_accounts(CreditAccountFilter::default())?;
1186 let mut report = Vec::new();
1187
1188 for acc in accounts {
1189 report.push((
1190 acc.customer_id,
1191 CreditAgingBucket {
1192 current: acc.current_balance,
1193 days_1_30: Decimal::ZERO,
1194 days_31_60: Decimal::ZERO,
1195 days_61_90: Decimal::ZERO,
1196 days_over_90: Decimal::ZERO,
1197 total: acc.current_balance,
1198 },
1199 ));
1200 }
1201
1202 Ok(report)
1203 }
1204
1205 fn get_over_limit_customers(&self) -> Result<Vec<CreditAccount>> {
1206 self.list_credit_accounts(CreditAccountFilter {
1207 over_limit: Some(true),
1208 ..Default::default()
1209 })
1210 }
1211}
1212
1213#[cfg(test)]
1214mod tests {
1215 use super::*;
1216 use crate::{DatabaseConfig, SqliteDatabase};
1217 use rust_decimal_macros::dec;
1218 use stateset_core::{
1219 CreateCreditAccount, CreditAccountFilter, CreditAccountStatus, CreditRepository,
1220 CustomerId, RiskRating, UpdateCreditAccount,
1221 };
1222
1223 fn fresh_repo() -> SqliteCreditRepository {
1224 SqliteDatabase::in_memory().expect("in-memory").credit()
1225 }
1226
1227 fn is_expected_or_transient(e: &CommerceError) -> bool {
1232 match e {
1233 CommerceError::ValidationError(_) => true,
1234 CommerceError::DatabaseError(msg) => {
1235 let m = msg.to_lowercase();
1236 m.contains("locked") || m.contains("timed out") || m.contains("busy")
1237 }
1238 _ => false,
1239 }
1240 }
1241
1242 fn make_account(
1243 repo: &SqliteCreditRepository,
1244 customer: CustomerId,
1245 limit: Decimal,
1246 ) -> CreditAccount {
1247 repo.create_credit_account(CreateCreditAccount {
1248 customer_id: customer,
1249 credit_limit: limit,
1250 currency: None,
1251 payment_terms: Some("NET30".into()),
1252 risk_rating: Some(RiskRating::Low),
1253 notes: Some("standard terms".into()),
1254 })
1255 .expect("create credit account")
1256 }
1257
1258 #[test]
1259 fn concurrent_charges_cannot_exceed_credit_limit() {
1260 use std::sync::{Arc, Barrier};
1265 use std::thread;
1266
1267 let db = Arc::new(SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap());
1268 let repo = SqliteCreditRepository::new(db.pool().clone());
1269 let cust = CustomerId::new();
1270 make_account(&repo, cust, dec!(100)); let thread_count = 10;
1273 let barrier = Arc::new(Barrier::new(thread_count));
1274 let mut handles = Vec::new();
1275 for _ in 0..thread_count {
1276 let db = Arc::clone(&db);
1277 let barrier = Arc::clone(&barrier);
1278 handles.push(thread::spawn(move || {
1279 let repo = SqliteCreditRepository::new(db.pool().clone());
1280 barrier.wait();
1281 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(20))
1282 }));
1283 }
1284 let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
1285 let successes = results.iter().filter(|r| r.is_ok()).count();
1286
1287 let acct = repo.get_credit_account_by_customer(cust).expect("get").expect("found");
1288 assert!(
1291 acct.current_balance <= dec!(100),
1292 "credit limit exceeded under concurrency: balance {}",
1293 acct.current_balance
1294 );
1295 assert!(
1302 acct.current_balance >= dec!(20) * Decimal::from(successes as u64),
1303 "lost update: balance {} < confirmed charges {}: {results:?}",
1304 acct.current_balance,
1305 successes
1306 );
1307 assert!(
1308 (acct.current_balance / dec!(20)).fract().is_zero(),
1309 "balance must be an exact multiple of the $20 charge: {}",
1310 acct.current_balance
1311 );
1312 assert!(successes <= 5, "at most five $20 charges fit under the $100 limit: {results:?}");
1313 assert!(
1314 results.iter().all(|r| match r {
1315 Ok(_) => true,
1316 Err(e) => is_expected_or_transient(e),
1317 }),
1318 "every failure must be a limit rejection or a transient lock: {results:?}"
1319 );
1320 }
1321
1322 #[test]
1323 fn concurrent_payments_are_not_lost() {
1324 use std::sync::{Arc, Barrier};
1328 use std::thread;
1329
1330 let db = Arc::new(SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap());
1331 let repo = SqliteCreditRepository::new(db.pool().clone());
1332 let cust = CustomerId::new();
1333 make_account(&repo, cust, dec!(1000));
1334 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(100)).expect("charge 100");
1335
1336 let thread_count = 10;
1337 let barrier = Arc::new(Barrier::new(thread_count));
1338 let mut handles = Vec::new();
1339 for _ in 0..thread_count {
1340 let db = Arc::clone(&db);
1341 let barrier = Arc::clone(&barrier);
1342 handles.push(thread::spawn(move || {
1343 let repo = SqliteCreditRepository::new(db.pool().clone());
1344 barrier.wait();
1345 repo.apply_payment(cust, dec!(10), None)
1346 }));
1347 }
1348 let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
1349 let successes = results.iter().filter(|r| r.is_ok()).count();
1350
1351 let acct = repo.get_credit_account_by_customer(cust).expect("get").expect("found");
1352 assert!(
1359 acct.current_balance <= dec!(100) - dec!(10) * Decimal::from(successes as u64),
1360 "lost update: balance {} exceeds 100 - 10*{} successes: {results:?}",
1361 acct.current_balance,
1362 successes
1363 );
1364 assert!(acct.current_balance >= Decimal::ZERO, "balance went negative: {results:?}");
1365 assert!(
1366 (acct.current_balance / dec!(10)).fract().is_zero(),
1367 "balance must be an exact multiple of the $10 payment: {}",
1368 acct.current_balance
1369 );
1370 assert!(successes >= 1, "at least one payment must succeed: {results:?}");
1371 assert!(
1372 results.iter().all(|r| match r {
1373 Ok(_) => true,
1374 Err(e) => is_expected_or_transient(e),
1375 }),
1376 "every failure must be a transient lock: {results:?}"
1377 );
1378 }
1379
1380 #[test]
1381 fn concurrent_reservations_cannot_exceed_available_credit() {
1382 use std::sync::{Arc, Barrier};
1388 use std::thread;
1389
1390 let db = Arc::new(SqliteDatabase::new(&DatabaseConfig::in_memory()).unwrap());
1391 let repo = SqliteCreditRepository::new(db.pool().clone());
1392 let cust = CustomerId::new();
1393 make_account(&repo, cust, dec!(100)); let thread_count = 10;
1396 let barrier = Arc::new(Barrier::new(thread_count));
1397 let mut handles = Vec::new();
1398 for _ in 0..thread_count {
1399 let db = Arc::clone(&db);
1400 let barrier = Arc::clone(&barrier);
1401 handles.push(thread::spawn(move || {
1402 let repo = SqliteCreditRepository::new(db.pool().clone());
1403 barrier.wait();
1404 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(20))
1405 }));
1406 }
1407 let results: Vec<_> = handles.into_iter().map(|h| h.join().expect("thread")).collect();
1408 let successes = results.iter().filter(|r| r.is_ok()).count();
1409
1410 let acct = repo.get_credit_account_by_customer(cust).expect("get").expect("found");
1411 assert!(
1413 acct.hold_amount <= dec!(100),
1414 "available credit over-reserved under concurrency: holds {}",
1415 acct.hold_amount
1416 );
1417 assert!(
1423 acct.hold_amount >= dec!(20) * Decimal::from(successes as u64),
1424 "lost update: holds {} < confirmed reservations {}: {results:?}",
1425 acct.hold_amount,
1426 successes
1427 );
1428 assert!(
1429 (acct.hold_amount / dec!(20)).fract().is_zero(),
1430 "holds must be an exact multiple of the $20 reservation: {}",
1431 acct.hold_amount
1432 );
1433 assert!(successes <= 5, "at most five $20 reservations fit under $100: {results:?}");
1434 assert!(
1435 results.iter().all(|r| match r {
1436 Ok(_) => true,
1437 Err(e) => is_expected_or_transient(e),
1438 }),
1439 "every failure must be an available-credit rejection or a transient lock: {results:?}"
1440 );
1441 assert_eq!(acct.available_credit, dec!(100) - acct.hold_amount);
1443 }
1444
1445 #[test]
1446 fn apply_payment_rejects_nonpositive_amount() {
1447 let repo = fresh_repo();
1451 let cust = CustomerId::new();
1452 make_account(&repo, cust, dec!(100));
1453 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(50)).expect("charge 50");
1454
1455 assert!(repo.apply_payment(cust, dec!(0), None).is_err(), "zero must be rejected");
1456 assert!(repo.apply_payment(cust, dec!(-25), None).is_err(), "negative must be rejected");
1457
1458 let acct = repo.get_credit_account_by_customer(cust).expect("get").expect("found");
1459 assert_eq!(acct.current_balance, dec!(50), "rejected payment must not change the balance");
1460 }
1461
1462 #[test]
1463 fn create_credit_account_round_trips() {
1464 let repo = fresh_repo();
1465 let cust = CustomerId::new();
1466 let acct = make_account(&repo, cust, dec!(5000));
1467 assert_eq!(acct.customer_id, cust);
1468 assert_eq!(acct.credit_limit, dec!(5000));
1469 assert_eq!(acct.status, CreditAccountStatus::Active);
1470
1471 let by_id = repo.get_credit_account(acct.id).expect("ok").expect("found");
1472 assert_eq!(by_id.id, acct.id);
1473
1474 let by_cust = repo.get_credit_account_by_customer(cust).expect("ok").expect("found");
1475 assert_eq!(by_cust.id, acct.id);
1476 }
1477
1478 #[test]
1479 fn update_credit_account_changes_limit_and_status() {
1480 let repo = fresh_repo();
1481 let cust = CustomerId::new();
1482 let acct = make_account(&repo, cust, dec!(1000));
1483 let updated = repo
1484 .update_credit_account(
1485 acct.id,
1486 UpdateCreditAccount {
1487 credit_limit: Some(dec!(2500)),
1488 status: Some(CreditAccountStatus::Suspended),
1489 risk_rating: Some(RiskRating::High),
1490 ..Default::default()
1491 },
1492 )
1493 .expect("update");
1494 assert_eq!(updated.credit_limit, dec!(2500));
1495 assert_eq!(updated.status, CreditAccountStatus::Suspended);
1496 assert_eq!(updated.risk_rating, Some(RiskRating::High));
1497 }
1498
1499 #[test]
1500 fn list_credit_accounts_filters_by_status() {
1501 let repo = fresh_repo();
1502 let active = make_account(&repo, CustomerId::new(), dec!(100));
1503 let to_suspend = make_account(&repo, CustomerId::new(), dec!(200));
1504 repo.update_credit_account(
1505 to_suspend.id,
1506 UpdateCreditAccount {
1507 status: Some(CreditAccountStatus::Suspended),
1508 ..Default::default()
1509 },
1510 )
1511 .expect("suspend");
1512
1513 let actives = repo
1514 .list_credit_accounts(CreditAccountFilter {
1515 status: Some(CreditAccountStatus::Active),
1516 ..Default::default()
1517 })
1518 .expect("active");
1519 let suspended = repo
1520 .list_credit_accounts(CreditAccountFilter {
1521 status: Some(CreditAccountStatus::Suspended),
1522 ..Default::default()
1523 })
1524 .expect("suspended");
1525 assert!(actives.iter().any(|a| a.id == active.id));
1526 assert!(suspended.iter().any(|a| a.id == to_suspend.id));
1527 }
1528
1529 #[test]
1530 fn get_active_holds_for_unknown_customer_is_empty() {
1531 let repo = fresh_repo();
1532 let holds = repo.get_active_holds(CustomerId::new()).expect("ok");
1533 assert!(holds.is_empty());
1534 }
1535
1536 #[test]
1537 fn get_holds_for_unknown_order_is_empty() {
1538 let repo = fresh_repo();
1539 let holds = repo.get_holds_for_order(stateset_core::OrderId::new()).expect("ok");
1540 assert!(holds.is_empty());
1541 }
1542
1543 #[test]
1544 fn get_over_limit_customers_empty_on_fresh_db() {
1545 let repo = fresh_repo();
1546 let over = repo.get_over_limit_customers().expect("ok");
1547 assert!(over.is_empty());
1548 }
1549
1550 #[test]
1551 fn aging_report_empty_on_fresh_db() {
1552 let repo = fresh_repo();
1553 let aging = repo.get_aging_report().expect("ok");
1554 assert!(aging.is_empty());
1555 }
1556
1557 #[test]
1558 fn get_credit_account_unknown_returns_none() {
1559 let repo = fresh_repo();
1560 assert!(repo.get_credit_account(stateset_core::CreditId::new()).expect("ok").is_none());
1561 }
1562
1563 #[test]
1564 fn get_credit_account_by_unknown_customer_returns_none() {
1565 let repo = fresh_repo();
1566 assert!(repo.get_credit_account_by_customer(CustomerId::new()).expect("ok").is_none());
1567 }
1568
1569 #[test]
1570 fn get_application_unknown_returns_none() {
1571 let repo = fresh_repo();
1572 assert!(repo.get_application(Uuid::new_v4()).expect("ok").is_none());
1573 }
1574
1575 #[test]
1576 fn reserve_credit_rejects_nonpositive_amount() {
1577 let repo = fresh_repo();
1578 let cust = CustomerId::new();
1579 make_account(&repo, cust, dec!(100));
1580
1581 assert!(repo.reserve_credit(cust, stateset_core::OrderId::new(), Decimal::ZERO).is_err());
1582 assert!(repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(-10)).is_err());
1583
1584 let acct = repo.get_credit_account_by_customer(cust).expect("ok").expect("found");
1585 assert_eq!(acct.hold_amount, Decimal::ZERO);
1586 }
1587
1588 #[test]
1589 fn reserve_credit_rejects_exceeding_available() {
1590 let repo = fresh_repo();
1591 let cust = CustomerId::new();
1592 make_account(&repo, cust, dec!(100));
1593
1594 assert!(repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(150)).is_err());
1596
1597 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(60)).expect("reserve 60");
1599 assert!(repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(50)).is_err());
1600 let acct =
1601 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(40)).expect("reserve 40");
1602 assert_eq!(acct.hold_amount, dec!(100));
1603 assert_eq!(acct.available_credit, Decimal::ZERO);
1604 }
1605
1606 #[test]
1607 fn charge_credit_rejects_nonpositive_amount() {
1608 let repo = fresh_repo();
1609 let cust = CustomerId::new();
1610 make_account(&repo, cust, dec!(100));
1611
1612 assert!(repo.charge_credit(cust, stateset_core::OrderId::new(), Decimal::ZERO).is_err());
1613 assert!(repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(-10)).is_err());
1614
1615 let acct = repo.get_credit_account_by_customer(cust).expect("ok").expect("found");
1616 assert_eq!(acct.current_balance, Decimal::ZERO);
1617 }
1618
1619 #[test]
1620 fn charge_credit_rejects_exceeding_limit() {
1621 let repo = fresh_repo();
1622 let cust = CustomerId::new();
1623 make_account(&repo, cust, dec!(100));
1624
1625 let order = stateset_core::OrderId::new();
1626 repo.reserve_credit(cust, order, dec!(60)).expect("reserve");
1627 repo.charge_credit(cust, order, dec!(60)).expect("charge reserved amount");
1628
1629 assert!(repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(50)).is_err());
1631
1632 let acct = repo.get_credit_account_by_customer(cust).expect("ok").expect("found");
1633 assert_eq!(acct.current_balance, dec!(60));
1634
1635 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(40)).expect("charge 40");
1637 }
1638
1639 #[test]
1640 fn two_reservations_keep_hold_amount_exact() {
1641 let repo = fresh_repo();
1645 let cust = CustomerId::new();
1646 make_account(&repo, cust, dec!(1000));
1647
1648 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(0.10)).expect("hold 1");
1649 let acct =
1650 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(0.20)).expect("hold 2");
1651
1652 assert_eq!(acct.hold_amount, dec!(0.30));
1653 assert_eq!(acct.available_credit, dec!(999.70));
1655 }
1656
1657 #[test]
1658 fn release_reservation_keeps_hold_amount_exact() {
1659 let repo = fresh_repo();
1660 let cust = CustomerId::new();
1661 make_account(&repo, cust, dec!(1000));
1662
1663 let order_a = stateset_core::OrderId::new();
1664 repo.reserve_credit(cust, order_a, dec!(0.10)).expect("hold a");
1665 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(0.20)).expect("hold b");
1666
1667 let acct = repo.release_credit_reservation(cust, order_a).expect("release a");
1669 assert_eq!(acct.hold_amount, dec!(0.20));
1670 }
1671
1672 #[test]
1673 fn charge_then_partial_payment_keeps_balance_exact() {
1674 let repo = fresh_repo();
1676 let cust = CustomerId::new();
1677 make_account(&repo, cust, dec!(1000));
1678
1679 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(0.10)).expect("charge 1");
1681 let acct =
1682 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(0.20)).expect("charge 2");
1683 assert_eq!(acct.current_balance, dec!(0.30));
1684
1685 let acct = repo.apply_payment(cust, dec!(0.10), None).expect("payment");
1687 assert_eq!(acct.current_balance, dec!(0.20));
1688 assert_eq!(acct.available_credit, dec!(999.80));
1689 }
1690
1691 #[test]
1692 fn payment_clamps_balance_at_zero() {
1693 let repo = fresh_repo();
1694 let cust = CustomerId::new();
1695 make_account(&repo, cust, dec!(1000));
1696
1697 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(50)).expect("charge");
1698 let acct = repo.apply_payment(cust, dec!(75), None).expect("overpay");
1700 assert_eq!(acct.current_balance, Decimal::ZERO);
1701 }
1702
1703 #[test]
1704 fn release_reservation_clamps_hold_at_zero() {
1705 let repo = fresh_repo();
1706 let cust = CustomerId::new();
1707 make_account(&repo, cust, dec!(1000));
1708
1709 let order = stateset_core::OrderId::new();
1710 repo.reserve_credit(cust, order, dec!(10)).expect("hold");
1711 let acct = repo.release_credit_reservation(cust, order).expect("release");
1713 assert_eq!(acct.hold_amount, Decimal::ZERO);
1714 }
1715
1716 #[test]
1717 fn available_credit_is_exact_after_charge() {
1718 let repo = fresh_repo();
1723 let cust = CustomerId::new();
1724 make_account(&repo, cust, dec!(1000.00));
1725
1726 let acct =
1727 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(999.90)).expect("charge");
1728 assert_eq!(acct.current_balance, dec!(999.90));
1729 assert_eq!(acct.available_credit, dec!(0.10));
1731 }
1732
1733 #[test]
1734 fn available_credit_is_exact_after_charge_and_hold() {
1735 let repo = fresh_repo();
1736 let cust = CustomerId::new();
1737 make_account(&repo, cust, dec!(1000.00));
1738
1739 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(999.80)).expect("charge");
1740 let acct =
1741 repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(0.10)).expect("hold");
1742 assert_eq!(acct.available_credit, dec!(0.10));
1744 }
1745
1746 #[test]
1747 fn check_credit_approves_and_denies_on_exact_available() {
1748 let repo = fresh_repo();
1752 let cust = CustomerId::new();
1753 make_account(&repo, cust, dec!(1000.00));
1754 repo.charge_credit(cust, stateset_core::OrderId::new(), dec!(999.90)).expect("charge");
1755
1756 let exact = repo.check_credit(cust, dec!(0.10)).expect("check exact");
1757 assert_eq!(exact.available_credit, dec!(0.10));
1758 assert!(exact.approved, "order equal to exact available must be approved: {exact:?}");
1759
1760 let over = repo.check_credit(cust, dec!(0.11)).expect("check over");
1761 assert!(!over.approved, "order exceeding available must be denied");
1762 assert!(over.requires_approval);
1763 }
1764
1765 #[test]
1766 fn over_limit_filter_is_exact_at_the_boundary() {
1767 let repo = fresh_repo();
1771
1772 let at_limit = CustomerId::new();
1773 make_account(&repo, at_limit, dec!(100.00));
1774 repo.charge_credit(at_limit, stateset_core::OrderId::new(), dec!(100.00)).expect("charge");
1775
1776 let over = CustomerId::new();
1780 let over_acct = make_account(&repo, over, dec!(200.00));
1781 repo.charge_credit(over, stateset_core::OrderId::new(), dec!(100.01)).expect("charge");
1782 repo.update_credit_account(
1783 over_acct.id,
1784 UpdateCreditAccount { credit_limit: Some(dec!(100.00)), ..Default::default() },
1785 )
1786 .expect("reduce limit");
1787
1788 let flagged = repo.get_over_limit_customers().expect("over limit");
1789 assert!(
1790 flagged.iter().any(|a| a.customer_id == over),
1791 "the over-limit account must be reported"
1792 );
1793 assert!(
1794 !flagged.iter().any(|a| a.customer_id == at_limit),
1795 "an account exactly at its limit must not be reported as over-limit"
1796 );
1797 }
1798}