Skip to main content

stateset_db/sqlite/
credit.rs

1//! SQLite implementation of credit repository
2
3use 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        // available = limit - balance - holds.
232        //
233        // Read-modify-write of `available_credit`, run in an IMMEDIATE
234        // transaction so it is serialized against concurrent charges/payments/
235        // reservations (which move balance and holds) and retried on lock
236        // contention rather than failing with "database table is locked".
237        //
238        // `credit_limit`, `current_balance`, and `hold_amount` are TEXT columns
239        // (migration 021), so subtracting in SQL
240        // ('CAST(credit_limit AS REAL) - CAST(current_balance AS REAL) - ...')
241        // coerces every operand to an IEEE-754 float and stores the rounded
242        // result (e.g. 1000.00 - 999.90 - 0 = 0.09999999999990905 instead of
243        // 0.10). `check_credit` then approves/denies orders against that drifted
244        // value. Instead we read the three exact TEXT values, subtract with
245        // `rust_decimal::Decimal`, and write the exact string back.
246        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(), // available = limit initially
284                    "0",
285                    "0",
286                    &currency,
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        // NB: when filtering `over_limit`, the SQL `LIMIT` is intentionally
398        // omitted here and re-applied in Rust *after* the exact comparison, so
399        // the row cap counts only matching accounts.
400        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        // Apply the over-limit filter in Rust on the already-parsed `Decimal`
419        // values. `current_balance` and `credit_limit` are TEXT columns, so
420        // comparing them in SQL with `CAST(... AS REAL)` coerces both operands
421        // to IEEE-754 floats and can misclassify accounts right at the boundary;
422        // the `Decimal` comparison is exact.
423        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        // Record the change
453        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        // The available-credit check, the reservation INSERT, and the hold bump
566        // must be atomic and serialized: otherwise concurrent reservations each
567        // pass the check against the same stale hold and all commit, extending
568        // credit past the agreed line. Run them in an IMMEDIATE transaction (the
569        // same hardening `charge_credit` and the Postgres backend use), then
570        // recompute available credit afterward — outside the transaction, so we
571        // never hold the write connection while that helper opens its own.
572        //
573        // `credit_limit`, `current_balance`, and `hold_amount` are TEXT columns
574        // (migration 021), so the arithmetic is done with exact
575        // `rust_decimal::Decimal` and the new hold is written back as an exact
576        // bound parameter rather than coercing to a float in SQL.
577        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        // Release the reservation and decrement the hold in one IMMEDIATE
628        // transaction so the read-modify-write of `hold_amount` is serialized
629        // against concurrent reservations/releases and retried on lock
630        // contention. `recalculate_available_credit` runs afterward, outside the
631        // transaction (it opens its own), so we never hold this write connection
632        // while it acquires another.
633        //
634        // `hold_amount` is a TEXT column (migration 021), so the subtraction is
635        // done with exact `rust_decimal::Decimal`, clamped at zero, and written
636        // back as an exact bound parameter rather than coercing to a float in SQL.
637        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(&current_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        // The limit check and the balance write must be atomic and serialized:
692        // otherwise two concurrent charges each read the same balance, both pass
693        // the limit check, and both commit — together exceeding the credit limit.
694        // Run them in an IMMEDIATE transaction (write lock held across the
695        // read-modify-write), the same hardening the gift-card, store-credit,
696        // and invoice-payment paths use.
697        //
698        // A rejected charge (limit exceeded) returns before the reservation is
699        // released, so the hold is preserved. The reservation release, ledger
700        // entry, and available-credit recompute run *after* the transaction
701        // commits and outside it, so we never hold the write connection while
702        // those helpers acquire their own pooled connections (which would
703        // exhaust the pool under concurrency).
704        //
705        // `current_balance` is a TEXT column (migration 021), so the arithmetic
706        // is done with exact `rust_decimal::Decimal` and written back as an exact
707        // bound parameter rather than coercing to an IEEE-754 float in SQL.
708        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(&current_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        // Charge committed: release the hold, record the ledger entry, and
732        // recompute available credit.
733        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 approved, create or update credit account
976        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        // Read the current balance and insert the ledger row in one IMMEDIATE
1016        // transaction so it is serialized against concurrent balance changes and
1017        // retried on lock contention. The closure runs on the exact-decimal TEXT
1018        // balance and may re-run on retry, so it borrows `input` and returns only
1019        // the computed running balance; the owned `CreditTransaction` is built
1020        // afterward.
1021        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        // Reduce balance under an IMMEDIATE transaction so concurrent payments
1120        // serialize on the write lock instead of racing on a stale read and
1121        // losing an update. The ledger entry and available-credit recompute run
1122        // afterward, outside the transaction (see `charge_credit`).
1123        //
1124        // `current_balance` is a TEXT column (migration 021), so the subtraction
1125        // is done with exact `rust_decimal::Decimal`, clamped at zero, and
1126        // written back as an exact bound parameter rather than coercing to an
1127        // IEEE-754 float in SQL.
1128        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(&current_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        // Record transaction
1146        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, // Would need AR data to calculate
1175                    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        // Simplified aging report - returns customers with their current balance bucketed
1185        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    /// A concurrent money operation may legitimately fail either because it was
1228    /// rejected on business rules (limit/available exceeded) or because it hit
1229    /// transient SQLite write-lock contention that outlasted the retry budget —
1230    /// the caller retries the latter. Anything else is an unexpected error.
1231    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        // Charging credit is a check-then-act on current_balance vs credit_limit.
1261        // Without serialization, concurrent charges each pass the limit check
1262        // against the same stale balance and both commit, blowing through the
1263        // limit. Ten $20 charges race against a $100 limit — only five may win.
1264        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)); // limit $100, balance $0
1271
1272        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        // The critical safety invariant: concurrent charges can NEVER exceed the
1289        // limit, no matter how the race resolves.
1290        assert!(
1291            acct.current_balance <= dec!(100),
1292            "credit limit exceeded under concurrency: balance {}",
1293            acct.current_balance
1294        );
1295        // No lost updates: the balance reflects AT LEAST every confirmed charge
1296        // ($20 each). It can be a touch higher than 20*successes because under
1297        // extreme contention a charge can commit its balance write yet hit
1298        // "table is locked" on a post-commit step and surface as an Err — its
1299        // money still landed. It can never exceed the limit, and must be an exact
1300        // multiple of $20 (no partial writes). `successes` is at most five.
1301        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        // Applying a payment is a read-modify-write of current_balance; without
1325        // serialization concurrent payments overwrite each other. Ten $10
1326        // payments against a $100 balance must fully settle it.
1327        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        // No lost updates: each $10 payment reduces the $100 balance. The balance
1353        // is reduced by AT LEAST every confirmed payment; it can be a touch lower
1354        // than 100-10*successes because under contention a payment can commit yet
1355        // hit "table is locked" on a post-commit step and surface as an Err (its
1356        // money still landed). Balance never goes below zero and stays an exact
1357        // multiple of $10.
1358        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        // Reserving credit checks the reservation against available credit
1383        // (limit - balance - holds), then bumps the hold. Without serialization,
1384        // concurrent reservations each pass the check against the same stale
1385        // hold and all commit, over-reserving past the credit line. Ten $20
1386        // reservations race against $100 available — only five may win.
1387        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)); // available $100
1394
1395        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        // Safety invariant: holds can never over-reserve past the credit line.
1412        assert!(
1413            acct.hold_amount <= dec!(100),
1414            "available credit over-reserved under concurrency: holds {}",
1415            acct.hold_amount
1416        );
1417        // No lost updates: holds reflect AT LEAST every confirmed reservation
1418        // ($20 each). They can be a touch higher than 20*successes because under
1419        // contention a reservation can commit yet hit "table is locked" on a
1420        // post-commit step and surface as an Err (its hold still landed). Holds
1421        // stay an exact multiple of $20.
1422        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        // available = limit - balance - holds; balance is zero here.
1442        assert_eq!(acct.available_credit, dec!(100) - acct.hold_amount);
1443    }
1444
1445    #[test]
1446    fn apply_payment_rejects_nonpositive_amount() {
1447        // A negative "payment" would compute (balance - (-x)) = balance + x,
1448        // inflating the credit balance past the limit (apply_payment does not
1449        // re-check the limit). Zero and negative amounts must be rejected.
1450        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        // A reservation larger than the whole line is rejected outright.
1595        assert!(repo.reserve_credit(cust, stateset_core::OrderId::new(), dec!(150)).is_err());
1596
1597        // Reservations must respect what earlier holds already consumed.
1598        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        // A further charge that would push the balance past the limit fails.
1630        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        // Charging the exact remaining headroom still works.
1636        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        // Regression: hold_amount is a TEXT column and was mutated via
1642        // 'CAST(hold_amount AS REAL) + ?', so 0.10 + 0.20 stored as
1643        // 0.30000000000000004. With Decimal arithmetic it must be exactly 0.30.
1644        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        // available = limit - balance - holds = 1000 - 0 - 0.30
1654        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        // Releasing the 0.10 hold must leave exactly 0.20, not a float residue.
1668        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        // Regression: current_balance is TEXT, mutated via CAST(... AS REAL).
1675        let repo = fresh_repo();
1676        let cust = CustomerId::new();
1677        make_account(&repo, cust, dec!(1000));
1678
1679        // Charge 0.10 then 0.20 -> balance must be exactly 0.30.
1680        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        // Partial payment of 0.10 leaves exactly 0.20.
1686        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        // Overpaying must clamp the balance at exactly 0, never go negative.
1699        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        // Releasing the only hold drops hold_amount to exactly 0 (never below).
1712        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        // Regression: available_credit was derived in SQL as
1719        // 'CAST(credit_limit AS REAL) - CAST(current_balance AS REAL) - CAST(hold_amount AS REAL)',
1720        // so limit 1000.00 minus a 999.90 balance produced 0.09999999999990905
1721        // instead of exactly 0.10. With Decimal arithmetic it must be exact.
1722        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        // available = 1000.00 - 999.90 - 0 = exactly 0.10
1730        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        // available = 1000.00 - 999.80 - 0.10 = exactly 0.10
1743        assert_eq!(acct.available_credit, dec!(0.10));
1744    }
1745
1746    #[test]
1747    fn check_credit_approves_and_denies_on_exact_available() {
1748        // With the float-drift bug, available was 0.0999999... so an order of
1749        // exactly 0.10 would be DENIED (0.0999... < 0.10). On the exact value it
1750        // must be APPROVED, and an order of 0.11 must be DENIED.
1751        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        // A balance exactly equal to the limit is NOT over-limit (strict '>'),
1768        // and the comparison must use exact decimals rather than CAST-as-REAL
1769        // float coercion.
1770        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        // charge_credit now rejects charges past the limit, so create the
1777        // over-limit state the way it legitimately arises: charge within a
1778        // larger limit, then reduce the limit below the balance.
1779        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}