Skip to main content

stateset_db/sqlite/
vendor_credits.rs

1//! SQLite implementation of the vendor credit repository
2
3use super::{
4    map_db_error, parse_datetime_row, parse_decimal_row, parse_enum_row, parse_uuid_opt_row,
5    parse_uuid_row, with_immediate_transaction,
6};
7use chrono::Utc;
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rusqlite::OptionalExtension;
11use rust_decimal::Decimal;
12use stateset_core::{
13    ApplyVendorCredit, CommerceError, CreateVendorCredit, CurrencyCode, Result, VendorCredit,
14    VendorCreditApplication, VendorCreditApplicationId, VendorCreditFilter, VendorCreditId,
15    VendorCreditRepository, VendorCreditStatus, VendorCreditTargetType,
16};
17
18#[derive(Debug)]
19pub struct SqliteVendorCreditRepository {
20    pool: Pool<SqliteConnectionManager>,
21}
22
23impl SqliteVendorCreditRepository {
24    #[must_use]
25    pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
26        Self { pool }
27    }
28
29    fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
30        self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
31    }
32
33    fn row_to_credit(row: &rusqlite::Row<'_>) -> rusqlite::Result<VendorCredit> {
34        Ok(VendorCredit {
35            id: parse_uuid_row(&row.get::<_, String>("id")?, "vendor_credit", "id")?.into(),
36            number: row.get("number")?,
37            supplier_id: parse_uuid_row(
38                &row.get::<_, String>("supplier_id")?,
39                "vendor_credit",
40                "supplier_id",
41            )?,
42            vendor_return_id: parse_uuid_opt_row(
43                row.get::<_, Option<String>>("vendor_return_id")?,
44                "vendor_credit",
45                "vendor_return_id",
46            )?,
47            amount: parse_decimal_row(&row.get::<_, String>("amount")?, "vendor_credit", "amount")?,
48            remaining: parse_decimal_row(
49                &row.get::<_, String>("remaining")?,
50                "vendor_credit",
51                "remaining",
52            )?,
53            currency: parse_enum_row::<CurrencyCode>(
54                &row.get::<_, String>("currency")?,
55                "vendor_credit",
56                "currency",
57            )?,
58            status: parse_enum_row::<VendorCreditStatus>(
59                &row.get::<_, String>("status")?,
60                "vendor_credit",
61                "status",
62            )?,
63            memo: row.get("memo")?,
64            created_at: parse_datetime_row(
65                &row.get::<_, String>("created_at")?,
66                "vendor_credit",
67                "created_at",
68            )?,
69            updated_at: parse_datetime_row(
70                &row.get::<_, String>("updated_at")?,
71                "vendor_credit",
72                "updated_at",
73            )?,
74        })
75    }
76
77    fn row_to_app(row: &rusqlite::Row<'_>) -> rusqlite::Result<VendorCreditApplication> {
78        Ok(VendorCreditApplication {
79            id: parse_uuid_row(&row.get::<_, String>("id")?, "vendor_credit_app", "id")?.into(),
80            vendor_credit_id: parse_uuid_row(
81                &row.get::<_, String>("vendor_credit_id")?,
82                "vendor_credit_app",
83                "vendor_credit_id",
84            )?
85            .into(),
86            target_type: parse_enum_row::<VendorCreditTargetType>(
87                &row.get::<_, String>("target_type")?,
88                "vendor_credit_app",
89                "target_type",
90            )?,
91            target_id: parse_uuid_row(
92                &row.get::<_, String>("target_id")?,
93                "vendor_credit_app",
94                "target_id",
95            )?,
96            amount: parse_decimal_row(
97                &row.get::<_, String>("amount")?,
98                "vendor_credit_app",
99                "amount",
100            )?,
101            reversed: row.get::<_, i32>("reversed")? != 0,
102            created_at: parse_datetime_row(
103                &row.get::<_, String>("created_at")?,
104                "vendor_credit_app",
105                "created_at",
106            )?,
107        })
108    }
109
110    fn fetch(tx: &rusqlite::Connection, id: &str) -> rusqlite::Result<VendorCredit> {
111        tx.query_row("SELECT * FROM vendor_credits WHERE id = ?", [id], Self::row_to_credit)
112    }
113
114    fn conflict(msg: &str) -> rusqlite::Error {
115        rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::Conflict(msg.to_string())))
116    }
117
118    fn validation(msg: &str) -> rusqlite::Error {
119        rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::ValidationError(
120            msg.to_string(),
121        )))
122    }
123
124    /// Recompute status from remaining balance (does not touch cancelled).
125    fn status_for(remaining: Decimal, current: VendorCreditStatus) -> VendorCreditStatus {
126        if current == VendorCreditStatus::Cancelled {
127            VendorCreditStatus::Cancelled
128        } else if remaining <= Decimal::ZERO {
129            VendorCreditStatus::Applied
130        } else {
131            VendorCreditStatus::Open
132        }
133    }
134}
135
136impl VendorCreditRepository for SqliteVendorCreditRepository {
137    fn create(&self, input: CreateVendorCredit) -> Result<VendorCredit> {
138        if input.amount <= Decimal::ZERO {
139            return Err(CommerceError::ValidationError(
140                "vendor credit amount must be positive".into(),
141            ));
142        }
143        let id = VendorCreditId::new();
144        let id_str = id.to_string();
145        let now_str = Utc::now().to_rfc3339();
146        let number = format!("VC-{}", &id_str[..8]);
147        let currency = input.currency.unwrap_or(CurrencyCode::USD);
148        with_immediate_transaction(&self.pool, |tx| {
149            tx.execute(
150                "INSERT INTO vendor_credits (id, number, supplier_id, vendor_return_id, amount, remaining, currency, status, memo, created_at, updated_at)
151                 VALUES (?, ?, ?, ?, ?, ?, ?, 'open', ?, ?, ?)",
152                rusqlite::params![
153                    &id_str,
154                    &number,
155                    input.supplier_id.to_string(),
156                    input.vendor_return_id.map(|v| v.to_string()),
157                    input.amount.to_string(),
158                    input.amount.to_string(),
159                    currency.to_string(),
160                    &input.memo,
161                    &now_str,
162                    &now_str,
163                ],
164            )?;
165            Self::fetch(tx, &id_str)
166        })
167    }
168
169    fn get(&self, id: VendorCreditId) -> Result<Option<VendorCredit>> {
170        let conn = self.conn()?;
171        match Self::fetch(&conn, &id.to_string()) {
172            Ok(c) => Ok(Some(c)),
173            Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
174            Err(e) => Err(map_db_error(e)),
175        }
176    }
177
178    fn list(&self, filter: VendorCreditFilter) -> Result<Vec<VendorCredit>> {
179        let conn = self.conn()?;
180        let mut sql = "SELECT * FROM vendor_credits WHERE 1=1".to_string();
181        let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
182        if let Some(supplier) = filter.supplier_id {
183            sql.push_str(" AND supplier_id = ?");
184            params.push(Box::new(supplier.to_string()));
185        }
186        if let Some(status) = filter.status {
187            sql.push_str(" AND status = ?");
188            params.push(Box::new(status.to_string()));
189        }
190        sql.push_str(" ORDER BY created_at DESC");
191        crate::sqlite::append_limit_offset(&mut sql, filter.limit, filter.offset);
192        let param_refs: Vec<&dyn rusqlite::types::ToSql> =
193            params.iter().map(|p| p.as_ref()).collect();
194        let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
195        let rows = stmt
196            .query_map(param_refs.as_slice(), Self::row_to_credit)
197            .map_err(map_db_error)?
198            .collect::<std::result::Result<Vec<_>, _>>()
199            .map_err(map_db_error)?;
200        Ok(rows)
201    }
202
203    fn apply(&self, id: VendorCreditId, input: ApplyVendorCredit) -> Result<VendorCredit> {
204        let id_str = id.to_string();
205        let now = Utc::now().to_rfc3339();
206        with_immediate_transaction(&self.pool, |tx| {
207            let credit =
208                Self::fetch(tx, &id_str).optional()?.ok_or(rusqlite::Error::QueryReturnedNoRows)?;
209            if credit.status == VendorCreditStatus::Cancelled {
210                return Err(Self::conflict("cannot apply a cancelled vendor credit"));
211            }
212            if input.amount <= Decimal::ZERO {
213                return Err(Self::validation("application amount must be positive"));
214            }
215            if input.amount > credit.remaining {
216                return Err(Self::validation("application exceeds remaining balance"));
217            }
218            let new_remaining = credit.remaining - input.amount;
219            let new_status = Self::status_for(new_remaining, credit.status);
220            tx.execute(
221                "INSERT INTO vendor_credit_applications (id, vendor_credit_id, target_type, target_id, amount, reversed, created_at)
222                 VALUES (?, ?, ?, ?, ?, 0, ?)",
223                rusqlite::params![
224                    VendorCreditApplicationId::new().to_string(),
225                    &id_str,
226                    input.target_type.to_string(),
227                    input.target_id.to_string(),
228                    input.amount.to_string(),
229                    &now,
230                ],
231            )?;
232            tx.execute(
233                "UPDATE vendor_credits SET remaining = ?, status = ?, updated_at = ? WHERE id = ?",
234                rusqlite::params![new_remaining.to_string(), new_status.to_string(), &now, &id_str],
235            )?;
236            Self::fetch(tx, &id_str)
237        })
238    }
239
240    fn list_applications(&self, id: VendorCreditId) -> Result<Vec<VendorCreditApplication>> {
241        let conn = self.conn()?;
242        let mut stmt = conn
243            .prepare("SELECT * FROM vendor_credit_applications WHERE vendor_credit_id = ? ORDER BY created_at")
244            .map_err(map_db_error)?;
245        let rows = stmt
246            .query_map([id.to_string()], Self::row_to_app)
247            .map_err(map_db_error)?
248            .collect::<std::result::Result<Vec<_>, _>>()
249            .map_err(map_db_error)?;
250        Ok(rows)
251    }
252
253    fn reverse_application(
254        &self,
255        id: VendorCreditId,
256        application_id: VendorCreditApplicationId,
257    ) -> Result<VendorCredit> {
258        let id_str = id.to_string();
259        let app_str = application_id.to_string();
260        let now = Utc::now().to_rfc3339();
261        with_immediate_transaction(&self.pool, |tx| {
262            let row: Option<(String, i32)> = tx
263                .query_row(
264                    "SELECT amount, reversed FROM vendor_credit_applications WHERE id = ? AND vendor_credit_id = ?",
265                    rusqlite::params![&app_str, &id_str],
266                    |r| Ok((r.get(0)?, r.get(1)?)),
267                )
268                .optional()?;
269            let (amount_str, reversed) = row.ok_or(rusqlite::Error::QueryReturnedNoRows)?;
270            if reversed != 0 {
271                return Err(Self::conflict("application already reversed"));
272            }
273            let amount: Decimal = amount_str.parse().unwrap_or(Decimal::ZERO);
274            let credit = Self::fetch(tx, &id_str)?;
275            let new_remaining = credit.remaining + amount;
276            let new_status = Self::status_for(new_remaining, credit.status);
277            tx.execute(
278                "UPDATE vendor_credit_applications SET reversed = 1 WHERE id = ?",
279                [&app_str],
280            )?;
281            tx.execute(
282                "UPDATE vendor_credits SET remaining = ?, status = ?, updated_at = ? WHERE id = ?",
283                rusqlite::params![new_remaining.to_string(), new_status.to_string(), &now, &id_str],
284            )?;
285            Self::fetch(tx, &id_str)
286        })
287    }
288
289    fn cancel(&self, id: VendorCreditId) -> Result<VendorCredit> {
290        let id_str = id.to_string();
291        let now = Utc::now().to_rfc3339();
292        with_immediate_transaction(&self.pool, |tx| {
293            let active: i64 = tx.query_row(
294                "SELECT COUNT(*) FROM vendor_credit_applications WHERE vendor_credit_id = ? AND reversed = 0",
295                [&id_str],
296                |r| r.get(0),
297            )?;
298            if active > 0 {
299                return Err(Self::conflict(
300                    "cannot cancel a vendor credit with active applications",
301                ));
302            }
303            tx.execute(
304                "UPDATE vendor_credits SET status = 'cancelled', updated_at = ? WHERE id = ?",
305                rusqlite::params![&now, &id_str],
306            )?;
307            Self::fetch(tx, &id_str)
308        })
309    }
310}
311
312#[cfg(test)]
313mod tests {
314    use super::*;
315    use crate::DatabaseConfig;
316    use crate::sqlite::SqliteDatabase;
317    use rust_decimal_macros::dec;
318    use uuid::Uuid;
319
320    fn test_repo() -> SqliteVendorCreditRepository {
321        let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
322        SqliteVendorCreditRepository::new(db.pool().clone())
323    }
324
325    fn new_credit(repo: &SqliteVendorCreditRepository, amount: Decimal) -> VendorCredit {
326        repo.create(CreateVendorCredit {
327            supplier_id: Uuid::new_v4(),
328            vendor_return_id: None,
329            amount,
330            currency: Some(CurrencyCode::USD),
331            memo: Some("RTV credit".into()),
332        })
333        .expect("create vendor credit")
334    }
335
336    fn apply(target: VendorCreditTargetType, amount: Decimal) -> ApplyVendorCredit {
337        ApplyVendorCredit { target_type: target, target_id: Uuid::new_v4(), amount }
338    }
339
340    #[test]
341    fn create_rejects_non_positive() {
342        let repo = test_repo();
343        assert!(
344            repo.create(CreateVendorCredit {
345                supplier_id: Uuid::new_v4(),
346                vendor_return_id: None,
347                amount: dec!(0),
348                currency: None,
349                memo: None,
350            })
351            .is_err()
352        );
353    }
354
355    #[test]
356    fn apply_decrements_and_marks_applied() {
357        let repo = test_repo();
358        let c = new_credit(&repo, dec!(100));
359        let after = repo.apply(c.id, apply(VendorCreditTargetType::Bill, dec!(40))).expect("apply");
360        assert_eq!(after.remaining, dec!(60));
361        assert_eq!(after.status, VendorCreditStatus::Open);
362
363        let after = repo
364            .apply(c.id, apply(VendorCreditTargetType::PaymentObligation, dec!(60)))
365            .expect("apply rest");
366        assert_eq!(after.remaining, dec!(0));
367        assert_eq!(after.status, VendorCreditStatus::Applied);
368        assert_eq!(repo.list_applications(c.id).expect("apps").len(), 2);
369    }
370
371    #[test]
372    fn apply_over_balance_rejected() {
373        let repo = test_repo();
374        let c = new_credit(&repo, dec!(50));
375        assert!(repo.apply(c.id, apply(VendorCreditTargetType::Bill, dec!(60))).is_err());
376    }
377
378    #[test]
379    fn reverse_restores_balance() {
380        let repo = test_repo();
381        let c = new_credit(&repo, dec!(100));
382        repo.apply(c.id, apply(VendorCreditTargetType::Bill, dec!(100))).expect("apply");
383        let apps = repo.list_applications(c.id).expect("apps");
384        let reversed = repo.reverse_application(c.id, apps[0].id).expect("reverse");
385        assert_eq!(reversed.remaining, dec!(100));
386        assert_eq!(reversed.status, VendorCreditStatus::Open);
387        // double reverse fails
388        assert!(repo.reverse_application(c.id, apps[0].id).is_err());
389    }
390
391    #[test]
392    fn cancel_blocked_with_active_applications() {
393        let repo = test_repo();
394        let c = new_credit(&repo, dec!(100));
395        repo.apply(c.id, apply(VendorCreditTargetType::Bill, dec!(10))).expect("apply");
396        assert!(repo.cancel(c.id).is_err());
397
398        let apps = repo.list_applications(c.id).expect("apps");
399        repo.reverse_application(c.id, apps[0].id).expect("reverse");
400        let cancelled = repo.cancel(c.id).expect("cancel");
401        assert_eq!(cancelled.status, VendorCreditStatus::Cancelled);
402    }
403}