1use 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 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 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}