1use super::{
4 map_db_error, parse_date_row, parse_datetime_row, parse_decimal_row, parse_enum_row,
5 parse_json_row, parse_uuid_opt_row, parse_uuid_row, with_immediate_transaction,
6};
7use chrono::{NaiveDate, Utc};
8use r2d2::Pool;
9use r2d2_sqlite::SqliteConnectionManager;
10use rust_decimal::Decimal;
11use stateset_core::{
12 AssetDisposal, CommerceError, CreateFixedAsset, CurrencyCode, DepreciationEntry,
13 DepreciationEntryStatus, DepreciationMethod, DepreciationSchedule, FixedAsset,
14 FixedAssetCategory, FixedAssetFilter, FixedAssetStatus, Result, UpdateFixedAsset, Validate,
15 generate_asset_number, generate_depreciation_schedule,
16};
17use uuid::Uuid;
18
19#[derive(Debug)]
20pub struct SqliteFixedAssetRepository {
21 pool: Pool<SqliteConnectionManager>,
22}
23
24impl SqliteFixedAssetRepository {
25 #[must_use]
26 pub const fn new(pool: Pool<SqliteConnectionManager>) -> Self {
27 Self { pool }
28 }
29
30 fn conn(&self) -> Result<r2d2::PooledConnection<SqliteConnectionManager>> {
31 self.pool.get().map_err(|e| CommerceError::DatabaseError(e.to_string()))
32 }
33
34 fn conflict(msg: &str) -> rusqlite::Error {
35 rusqlite::Error::ToSqlConversionFailure(Box::new(CommerceError::Conflict(msg.to_string())))
36 }
37
38 fn row_to_asset(row: &rusqlite::Row<'_>) -> rusqlite::Result<FixedAsset> {
39 let disposal = match row.get::<_, Option<String>>("disposal_date")? {
40 Some(date) => Some(AssetDisposal {
41 disposal_date: parse_date_row(&date, "fixed_asset", "disposal_date")?,
42 proceeds: parse_decimal_row(
43 &row.get::<_, Option<String>>("disposal_proceeds")?.unwrap_or_default(),
44 "fixed_asset",
45 "disposal_proceeds",
46 )?,
47 book_value_at_disposal: parse_decimal_row(
48 &row.get::<_, Option<String>>("disposal_book_value")?.unwrap_or_default(),
49 "fixed_asset",
50 "disposal_book_value",
51 )?,
52 gain_loss: parse_decimal_row(
53 &row.get::<_, Option<String>>("disposal_gain_loss")?.unwrap_or_default(),
54 "fixed_asset",
55 "disposal_gain_loss",
56 )?,
57 notes: row.get("disposal_notes")?,
58 }),
59 None => None,
60 };
61 let in_service_date = match row.get::<_, Option<String>>("in_service_date")? {
62 Some(s) => Some(parse_date_row(&s, "fixed_asset", "in_service_date")?),
63 None => None,
64 };
65 Ok(FixedAsset {
66 id: parse_uuid_row(&row.get::<_, String>("id")?, "fixed_asset", "id")?,
67 asset_number: row.get("asset_number")?,
68 name: row.get("name")?,
69 description: row.get("description")?,
70 category: parse_enum_row::<FixedAssetCategory>(
71 &row.get::<_, String>("category")?,
72 "fixed_asset",
73 "category",
74 )?,
75 acquisition_date: parse_date_row(
76 &row.get::<_, String>("acquisition_date")?,
77 "fixed_asset",
78 "acquisition_date",
79 )?,
80 acquisition_cost: parse_decimal_row(
81 &row.get::<_, String>("acquisition_cost")?,
82 "fixed_asset",
83 "acquisition_cost",
84 )?,
85 salvage_value: parse_decimal_row(
86 &row.get::<_, String>("salvage_value")?,
87 "fixed_asset",
88 "salvage_value",
89 )?,
90 useful_life_months: row.get::<_, i64>("useful_life_months")? as u32,
91 depreciation_method: parse_json_row::<DepreciationMethod>(
92 &row.get::<_, String>("depreciation_method")?,
93 "fixed_asset",
94 "depreciation_method",
95 )?,
96 status: parse_enum_row::<FixedAssetStatus>(
97 &row.get::<_, String>("status")?,
98 "fixed_asset",
99 "status",
100 )?,
101 in_service_date,
102 location_id: parse_uuid_opt_row(
103 row.get::<_, Option<String>>("location_id")?,
104 "fixed_asset",
105 "location_id",
106 )?,
107 asset_account_id: parse_uuid_opt_row(
108 row.get::<_, Option<String>>("asset_account_id")?,
109 "fixed_asset",
110 "asset_account_id",
111 )?,
112 accumulated_depreciation_account_id: parse_uuid_opt_row(
113 row.get::<_, Option<String>>("accumulated_depreciation_account_id")?,
114 "fixed_asset",
115 "accumulated_depreciation_account_id",
116 )?,
117 depreciation_expense_account_id: parse_uuid_opt_row(
118 row.get::<_, Option<String>>("depreciation_expense_account_id")?,
119 "fixed_asset",
120 "depreciation_expense_account_id",
121 )?,
122 accumulated_depreciation: parse_decimal_row(
123 &row.get::<_, String>("accumulated_depreciation")?,
124 "fixed_asset",
125 "accumulated_depreciation",
126 )?,
127 currency: parse_enum_row::<CurrencyCode>(
128 &row.get::<_, String>("currency")?,
129 "fixed_asset",
130 "currency",
131 )?,
132 disposal,
133 created_at: parse_datetime_row(
134 &row.get::<_, String>("created_at")?,
135 "fixed_asset",
136 "created_at",
137 )?,
138 updated_at: parse_datetime_row(
139 &row.get::<_, String>("updated_at")?,
140 "fixed_asset",
141 "updated_at",
142 )?,
143 })
144 }
145
146 fn row_to_entry(row: &rusqlite::Row<'_>) -> rusqlite::Result<DepreciationEntry> {
147 Ok(DepreciationEntry {
148 period: row.get::<_, i64>("period")? as u32,
149 amount: parse_decimal_row(
150 &row.get::<_, String>("amount")?,
151 "depreciation_entry",
152 "amount",
153 )?,
154 accumulated: parse_decimal_row(
155 &row.get::<_, String>("accumulated")?,
156 "depreciation_entry",
157 "accumulated",
158 )?,
159 book_value: parse_decimal_row(
160 &row.get::<_, String>("book_value")?,
161 "depreciation_entry",
162 "book_value",
163 )?,
164 status: parse_enum_row::<DepreciationEntryStatus>(
165 &row.get::<_, String>("status")?,
166 "depreciation_entry",
167 "status",
168 )?,
169 })
170 }
171
172 fn load_asset(conn: &rusqlite::Connection, id: &str) -> rusqlite::Result<FixedAsset> {
173 conn.query_row("SELECT * FROM fixed_assets WHERE id = ?", [id], Self::row_to_asset)
174 }
175
176 fn load_entries(
177 conn: &rusqlite::Connection,
178 id: &str,
179 ) -> rusqlite::Result<Vec<DepreciationEntry>> {
180 let mut stmt = conn.prepare(
181 "SELECT * FROM fixed_asset_depreciation_entries WHERE asset_id = ? ORDER BY period",
182 )?;
183 stmt.query_map([id], Self::row_to_entry)?.collect()
184 }
185
186 fn transition(
187 &self,
188 id: Uuid,
189 next: FixedAssetStatus,
190 date: NaiveDate,
191 proceeds: Option<Decimal>,
192 notes: Option<String>,
193 ) -> Result<FixedAsset> {
194 let id_str = id.to_string();
195 let now = Utc::now().to_rfc3339();
196 with_immediate_transaction(&self.pool, |tx| {
197 let asset = Self::load_asset(tx, &id_str)?;
198 if !asset.status.can_transition_to(next) {
199 return Err(Self::conflict(&format!(
200 "fixed asset cannot transition from {} to {next}",
201 asset.status
202 )));
203 }
204 match next {
205 FixedAssetStatus::InService => {
206 tx.execute(
207 "UPDATE fixed_assets SET status = 'in_service', in_service_date = ?, updated_at = ? WHERE id = ?",
208 rusqlite::params![date.to_string(), &now, &id_str],
209 )?;
210 }
211 FixedAssetStatus::Disposed | FixedAssetStatus::WrittenOff => {
212 let proceeds = proceeds.unwrap_or(Decimal::ZERO);
213 let disposal =
214 AssetDisposal::new(date, proceeds, asset.book_value(), notes.clone());
215 tx.execute(
216 "UPDATE fixed_assets SET status = ?, disposal_date = ?, disposal_proceeds = ?, disposal_book_value = ?, disposal_gain_loss = ?, disposal_notes = ?, updated_at = ? WHERE id = ?",
217 rusqlite::params![
218 next.to_string(),
219 disposal.disposal_date.to_string(),
220 disposal.proceeds.to_string(),
221 disposal.book_value_at_disposal.to_string(),
222 disposal.gain_loss.to_string(),
223 &disposal.notes,
224 &now,
225 &id_str,
226 ],
227 )?;
228 }
229 _ => {
230 tx.execute(
231 "UPDATE fixed_assets SET status = ?, updated_at = ? WHERE id = ?",
232 rusqlite::params![next.to_string(), &now, &id_str],
233 )?;
234 }
235 }
236 Self::load_asset(tx, &id_str)
237 })
238 }
239}
240
241impl stateset_core::FixedAssetRepository for SqliteFixedAssetRepository {
242 fn create(&self, input: CreateFixedAsset) -> Result<FixedAsset> {
243 input.validate()?;
244 let id = Uuid::new_v4();
245 let id_str = id.to_string();
246 let now = Utc::now().to_rfc3339();
247 let asset_number = input.asset_number.clone().unwrap_or_else(generate_asset_number);
248 let currency = input.currency.unwrap_or_default();
249 let status = if input.in_service_date.is_some() {
250 FixedAssetStatus::InService
251 } else {
252 FixedAssetStatus::Draft
253 };
254 let method_json = serde_json::to_string(&input.depreciation_method)
255 .map_err(|e| CommerceError::DatabaseError(e.to_string()))?;
256 with_immediate_transaction(&self.pool, |tx| {
257 tx.execute(
258 "INSERT INTO fixed_assets (id, asset_number, name, description, category, acquisition_date, acquisition_cost, salvage_value, useful_life_months, depreciation_method, status, in_service_date, location_id, asset_account_id, accumulated_depreciation_account_id, depreciation_expense_account_id, accumulated_depreciation, currency, created_at, updated_at)
259 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, '0', ?, ?, ?)",
260 rusqlite::params![
261 &id_str,
262 &asset_number,
263 &input.name,
264 &input.description,
265 input.category.to_string(),
266 input.acquisition_date.to_string(),
267 input.acquisition_cost.to_string(),
268 input.salvage_value.to_string(),
269 i64::from(input.useful_life_months),
270 &method_json,
271 status.to_string(),
272 input.in_service_date.map(|d| d.to_string()),
273 input.location_id.map(|u| u.to_string()),
274 input.asset_account_id.map(|u| u.to_string()),
275 input.accumulated_depreciation_account_id.map(|u| u.to_string()),
276 input.depreciation_expense_account_id.map(|u| u.to_string()),
277 currency.to_string(),
278 &now,
279 &now,
280 ],
281 )?;
282 Self::load_asset(tx, &id_str)
283 })
284 }
285
286 fn get(&self, id: Uuid) -> Result<Option<FixedAsset>> {
287 let conn = self.conn()?;
288 match Self::load_asset(&conn, &id.to_string()) {
289 Ok(a) => Ok(Some(a)),
290 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
291 Err(e) => Err(map_db_error(e)),
292 }
293 }
294
295 fn list(&self, filter: FixedAssetFilter) -> Result<Vec<FixedAsset>> {
296 let conn = self.conn()?;
297 let mut sql = "SELECT * FROM fixed_assets WHERE 1=1".to_string();
298 let mut params: Vec<Box<dyn rusqlite::types::ToSql>> = vec![];
299 if let Some(category) = filter.category {
300 sql.push_str(" AND category = ?");
301 params.push(Box::new(category.to_string()));
302 }
303 if let Some(status) = filter.status {
304 sql.push_str(" AND status = ?");
305 params.push(Box::new(status.to_string()));
306 }
307 if let Some(location) = filter.location_id {
308 sql.push_str(" AND location_id = ?");
309 params.push(Box::new(location.to_string()));
310 }
311 if let Some(from) = filter.acquired_from {
312 sql.push_str(" AND acquisition_date >= ?");
313 params.push(Box::new(from.to_string()));
314 }
315 if let Some(to) = filter.acquired_to {
316 sql.push_str(" AND acquisition_date <= ?");
317 params.push(Box::new(to.to_string()));
318 }
319 if let Some(search) = filter.search.as_deref() {
320 sql.push_str(" AND (name LIKE ? ESCAPE '\\' OR asset_number LIKE ? ESCAPE '\\')");
321 let pattern = format!("%{}%", super::escape_like(search));
322 params.push(Box::new(pattern.clone()));
323 params.push(Box::new(pattern));
324 }
325 if let Some((cursor_created, cursor_id)) = &filter.after_cursor {
327 sql.push_str(" AND (created_at < ? OR (created_at = ? AND id < ?))");
328 params.push(Box::new(cursor_created.clone()));
329 params.push(Box::new(cursor_created.clone()));
330 params.push(Box::new(cursor_id.clone()));
331 }
332 sql.push_str(" ORDER BY created_at DESC, id DESC");
333 let offset = if filter.after_cursor.is_none() { filter.offset } else { None };
335 crate::sqlite::append_limit_offset(&mut sql, filter.limit, offset);
336 let param_refs: Vec<&dyn rusqlite::types::ToSql> =
337 params.iter().map(|p| p.as_ref()).collect();
338 let mut stmt = conn.prepare(&sql).map_err(map_db_error)?;
339 stmt.query_map(param_refs.as_slice(), Self::row_to_asset)
340 .map_err(map_db_error)?
341 .collect::<std::result::Result<Vec<_>, _>>()
342 .map_err(map_db_error)
343 }
344
345 fn update(&self, id: Uuid, input: UpdateFixedAsset) -> Result<FixedAsset> {
346 input.validate()?;
347 let id_str = id.to_string();
348 let now = Utc::now().to_rfc3339();
349 with_immediate_transaction(&self.pool, |tx| {
350 let asset = Self::load_asset(tx, &id_str)?;
351 if asset.status.is_terminal() {
352 return Err(Self::conflict("disposed or written-off assets cannot be updated"));
353 }
354 let name = input.name.clone().unwrap_or(asset.name);
355 let description = input.description.clone().or(asset.description);
356 let category = input.category.unwrap_or(asset.category);
357 let salvage_value = input.salvage_value.unwrap_or(asset.salvage_value);
358 let useful_life_months = input.useful_life_months.unwrap_or(asset.useful_life_months);
359 let in_service_date = input.in_service_date.or(asset.in_service_date);
360 let location_id = input.location_id.or(asset.location_id);
361 let asset_account_id = input.asset_account_id.or(asset.asset_account_id);
362 let accum_account_id = input
363 .accumulated_depreciation_account_id
364 .or(asset.accumulated_depreciation_account_id);
365 let expense_account_id =
366 input.depreciation_expense_account_id.or(asset.depreciation_expense_account_id);
367 tx.execute(
368 "UPDATE fixed_assets SET name = ?, description = ?, category = ?, salvage_value = ?, useful_life_months = ?, in_service_date = ?, location_id = ?, asset_account_id = ?, accumulated_depreciation_account_id = ?, depreciation_expense_account_id = ?, updated_at = ? WHERE id = ?",
369 rusqlite::params![
370 &name,
371 &description,
372 category.to_string(),
373 salvage_value.to_string(),
374 i64::from(useful_life_months),
375 in_service_date.map(|d| d.to_string()),
376 location_id.map(|u| u.to_string()),
377 asset_account_id.map(|u| u.to_string()),
378 accum_account_id.map(|u| u.to_string()),
379 expense_account_id.map(|u| u.to_string()),
380 &now,
381 &id_str,
382 ],
383 )?;
384 Self::load_asset(tx, &id_str)
385 })
386 }
387
388 fn place_in_service(&self, id: Uuid, date: NaiveDate) -> Result<FixedAsset> {
389 self.transition(id, FixedAssetStatus::InService, date, None, None)
390 }
391
392 fn dispose(
393 &self,
394 id: Uuid,
395 date: NaiveDate,
396 proceeds: Decimal,
397 notes: Option<String>,
398 ) -> Result<FixedAsset> {
399 if proceeds < Decimal::ZERO {
400 return Err(CommerceError::ValidationError("proceeds must be non-negative".into()));
401 }
402 self.transition(id, FixedAssetStatus::Disposed, date, Some(proceeds), notes)
403 }
404
405 fn write_off(&self, id: Uuid, date: NaiveDate, notes: Option<String>) -> Result<FixedAsset> {
406 self.transition(id, FixedAssetStatus::WrittenOff, date, Some(Decimal::ZERO), notes)
407 }
408
409 fn generate_schedule(&self, id: Uuid) -> Result<DepreciationSchedule> {
410 let id_str = id.to_string();
411 with_immediate_transaction(&self.pool, |tx| {
412 let asset = Self::load_asset(tx, &id_str)?;
413 let posted: i64 = tx.query_row(
414 "SELECT COUNT(*) FROM fixed_asset_depreciation_entries WHERE asset_id = ? AND status = 'posted'",
415 [&id_str],
416 |r| r.get(0),
417 )?;
418 if posted > 0 {
419 return Err(Self::conflict(
420 "cannot regenerate a schedule with posted depreciation entries",
421 ));
422 }
423 let entries = generate_depreciation_schedule(
424 asset.depreciation_method,
425 asset.acquisition_cost,
426 asset.salvage_value,
427 asset.useful_life_months,
428 );
429 if entries.is_empty() {
430 return Err(rusqlite::Error::ToSqlConversionFailure(Box::new(
431 CommerceError::ValidationError(
432 "cannot generate a time-based depreciation schedule for this asset".into(),
433 ),
434 )));
435 }
436 tx.execute(
437 "DELETE FROM fixed_asset_depreciation_entries WHERE asset_id = ?",
438 [&id_str],
439 )?;
440 for e in &entries {
441 tx.execute(
442 "INSERT INTO fixed_asset_depreciation_entries (asset_id, period, amount, accumulated, book_value, status)
443 VALUES (?, ?, ?, ?, ?, ?)",
444 rusqlite::params![
445 &id_str,
446 i64::from(e.period),
447 e.amount.to_string(),
448 e.accumulated.to_string(),
449 e.book_value.to_string(),
450 e.status.to_string(),
451 ],
452 )?;
453 }
454 Ok(DepreciationSchedule {
455 asset_id: id,
456 method: asset.depreciation_method,
457 total_depreciation: asset.depreciable_base(),
458 entries,
459 })
460 })
461 }
462
463 fn get_schedule(&self, id: Uuid) -> Result<Option<DepreciationSchedule>> {
464 let conn = self.conn()?;
465 let id_str = id.to_string();
466 let asset = match Self::load_asset(&conn, &id_str) {
467 Ok(a) => a,
468 Err(rusqlite::Error::QueryReturnedNoRows) => return Ok(None),
469 Err(e) => return Err(map_db_error(e)),
470 };
471 let entries = Self::load_entries(&conn, &id_str).map_err(map_db_error)?;
472 if entries.is_empty() {
473 return Ok(None);
474 }
475 Ok(Some(DepreciationSchedule {
476 asset_id: id,
477 method: asset.depreciation_method,
478 total_depreciation: entries.iter().map(|e| e.amount).sum(),
479 entries,
480 }))
481 }
482
483 fn post_depreciation(&self, id: Uuid, periods: u32) -> Result<FixedAsset> {
484 if periods == 0 {
485 return Err(CommerceError::ValidationError("periods must be positive".into()));
486 }
487 let id_str = id.to_string();
488 let now = Utc::now().to_rfc3339();
489 let (asset, posted_amount, first_period, last_period) = with_immediate_transaction(
490 &self.pool,
491 |tx| {
492 let asset = Self::load_asset(tx, &id_str)?;
493 if asset.status != FixedAssetStatus::InService {
494 return Err(Self::conflict(
495 "depreciation can only be posted for in-service assets",
496 ));
497 }
498 let pending: Vec<DepreciationEntry> = {
499 let mut stmt = tx.prepare(
500 "SELECT * FROM fixed_asset_depreciation_entries WHERE asset_id = ? AND status = 'scheduled' ORDER BY period LIMIT ?",
501 )?;
502 stmt.query_map(
503 rusqlite::params![&id_str, i64::from(periods)],
504 Self::row_to_entry,
505 )?
506 .collect::<std::result::Result<Vec<_>, _>>()?
507 };
508 if pending.is_empty() {
509 return Err(Self::conflict("no scheduled depreciation entries remain to post"));
510 }
511 for e in &pending {
512 tx.execute(
513 "UPDATE fixed_asset_depreciation_entries SET status = 'posted' WHERE asset_id = ? AND period = ?",
514 rusqlite::params![&id_str, i64::from(e.period)],
515 )?;
516 }
517 let accumulated =
519 pending.last().map(|e| e.accumulated).unwrap_or(asset.accumulated_depreciation);
520 let fully = accumulated >= asset.depreciable_base();
521 let status = if fully {
522 FixedAssetStatus::FullyDepreciated
523 } else {
524 FixedAssetStatus::InService
525 };
526 tx.execute(
527 "UPDATE fixed_assets SET accumulated_depreciation = ?, status = ?, updated_at = ? WHERE id = ?",
528 rusqlite::params![accumulated.to_string(), status.to_string(), &now, &id_str],
529 )?;
530 let posted_amount: Decimal = pending.iter().map(|e| e.amount).sum();
531 let first_period = pending.first().map_or(0, |e| e.period);
532 let last_period = pending.last().map_or(0, |e| e.period);
533 Ok((Self::load_asset(tx, &id_str)?, posted_amount, first_period, last_period))
534 },
535 )?;
536 self.auto_post_depreciation_entry(&asset, posted_amount, first_period, last_period)?;
537 Ok(asset)
538 }
539}
540
541impl SqliteFixedAssetRepository {
542 fn auto_post_depreciation_entry(
551 &self,
552 asset: &FixedAsset,
553 amount: Decimal,
554 first_period: u32,
555 last_period: u32,
556 ) -> Result<()> {
557 use stateset_core::GeneralLedgerRepository;
558 let gl = super::general_ledger::SqliteGeneralLedgerRepository::new(self.pool.clone());
559 let Some(config) = gl.get_auto_posting_config()? else { return Ok(()) };
560 if !config.auto_post_depreciation || amount <= Decimal::ZERO {
561 return Ok(());
562 }
563 let expense_account_id = resolve_depreciation_account(
564 &gl,
565 asset.depreciation_expense_account_id,
566 stateset_core::AccountSubType::DepreciationExpense,
567 )?;
568 let accumulated_account_id = resolve_depreciation_account(
569 &gl,
570 asset.accumulated_depreciation_account_id,
571 stateset_core::AccountSubType::AccumulatedDepreciation,
572 )?;
573 let periods = if first_period == last_period {
574 format!("period {first_period}")
575 } else {
576 format!("periods {first_period}-{last_period}")
577 };
578 gl.create_journal_entry(stateset_core::CreateJournalEntry {
579 entry_date: Utc::now().date_naive(),
580 entry_type: Some(stateset_core::JournalEntryType::Standard),
581 description: format!("Depreciation {} {periods}", asset.asset_number),
582 lines: vec![
583 stateset_core::CreateJournalEntryLine::debit(
584 expense_account_id,
585 amount,
586 Some("Depreciation Expense".to_string()),
587 ),
588 stateset_core::CreateJournalEntryLine::credit(
589 accumulated_account_id,
590 amount,
591 Some("Accumulated Depreciation".to_string()),
592 ),
593 ],
594 source_document_type: Some("fixed_asset_depreciation".to_string()),
595 source_document_id: Some(asset.id),
596 auto_post: Some(true),
597 })?;
598 Ok(())
599 }
600}
601
602fn resolve_depreciation_account(
606 gl: &dyn stateset_core::GeneralLedgerRepository,
607 preferred: Option<Uuid>,
608 sub_type: stateset_core::AccountSubType,
609) -> Result<Uuid> {
610 if let Some(id) = preferred {
611 return Ok(id);
612 }
613 gl.list_accounts(stateset_core::GlAccountFilter {
614 account_sub_type: Some(sub_type),
615 status: Some(stateset_core::AccountStatus::Active),
616 is_posting: Some(true),
617 limit: Some(1),
618 ..Default::default()
619 })?
620 .first()
621 .map(|a| a.id)
622 .ok_or_else(|| {
623 CommerceError::ValidationError(format!(
624 "auto-post depreciation requires a {sub_type} account on the asset or in the chart of accounts"
625 ))
626 })
627}
628
629#[cfg(test)]
630mod tests {
631 use super::*;
632 use crate::DatabaseConfig;
633 use crate::sqlite::SqliteDatabase;
634 use rust_decimal_macros::dec;
635 use stateset_core::FixedAssetRepository;
636
637 fn test_repo() -> SqliteFixedAssetRepository {
638 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
639 SqliteFixedAssetRepository::new(db.pool().clone())
640 }
641
642 fn create_input() -> CreateFixedAsset {
643 CreateFixedAsset {
644 asset_number: None,
645 name: "Forklift".into(),
646 description: Some("Warehouse forklift".into()),
647 category: FixedAssetCategory::Machinery,
648 acquisition_date: NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(),
649 acquisition_cost: dec!(10000),
650 salvage_value: dec!(1000),
651 useful_life_months: 36,
652 depreciation_method: DepreciationMethod::StraightLine,
653 in_service_date: None,
654 location_id: None,
655 asset_account_id: None,
656 accumulated_depreciation_account_id: None,
657 depreciation_expense_account_id: None,
658 currency: None,
659 }
660 }
661
662 #[test]
663 fn create_get_defaults_to_draft() {
664 let repo = test_repo();
665 let a = repo.create(create_input()).expect("create");
666 assert_eq!(a.status, FixedAssetStatus::Draft);
667 assert!(a.asset_number.starts_with("FA-"));
668 let fetched = repo.get(a.id).expect("get").expect("found");
669 assert_eq!(fetched.acquisition_cost, dec!(10000));
670 assert_eq!(fetched.book_value(), dec!(10000));
671 assert_eq!(fetched.depreciation_method, DepreciationMethod::StraightLine);
672 }
673
674 #[test]
675 fn create_with_in_service_date_is_in_service() {
676 let repo = test_repo();
677 let mut input = create_input();
678 input.in_service_date = NaiveDate::from_ymd_opt(2026, 2, 1);
679 let a = repo.create(input).expect("create");
680 assert_eq!(a.status, FixedAssetStatus::InService);
681 }
682
683 #[test]
684 fn create_rejects_invalid_input() {
685 let repo = test_repo();
686 let mut input = create_input();
687 input.salvage_value = dec!(20000);
688 assert!(repo.create(input).is_err());
689 }
690
691 #[test]
692 fn full_lifecycle_with_schedule_totals_exact() {
693 let repo = test_repo();
694 let a = repo.create(create_input()).expect("create");
695 let a = repo
696 .place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 2, 1).unwrap())
697 .expect("place in service");
698 assert_eq!(a.status, FixedAssetStatus::InService);
699
700 let schedule = repo.generate_schedule(a.id).expect("generate schedule");
701 assert_eq!(schedule.entries.len(), 36);
702 assert_eq!(schedule.total_depreciation, dec!(9000));
703 let total: Decimal = schedule.entries.iter().map(|e| e.amount).sum();
704 assert_eq!(total, dec!(9000));
705 assert_eq!(schedule.entries.last().unwrap().book_value, dec!(1000));
706
707 let a = repo.post_depreciation(a.id, 3).expect("post depreciation");
709 assert_eq!(a.accumulated_depreciation, dec!(750));
710 assert_eq!(a.book_value(), dec!(9250));
711 let persisted = repo.get_schedule(a.id).expect("get schedule").expect("some");
712 let posted = persisted
713 .entries
714 .iter()
715 .filter(|e| e.status == DepreciationEntryStatus::Posted)
716 .count();
717 assert_eq!(posted, 3);
718
719 let a = repo
721 .dispose(a.id, NaiveDate::from_ymd_opt(2026, 6, 30).unwrap(), dec!(9500), None)
722 .expect("dispose");
723 assert_eq!(a.status, FixedAssetStatus::Disposed);
724 let d = a.disposal.expect("disposal recorded");
725 assert_eq!(d.book_value_at_disposal, dec!(9250));
726 assert_eq!(d.gain_loss, dec!(250));
727 }
728
729 #[test]
730 fn post_all_periods_fully_depreciates() {
731 let repo = test_repo();
732 let mut input = create_input();
733 input.useful_life_months = 3;
734 let a = repo.create(input).expect("create");
735 repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
736 .expect("in service");
737 repo.generate_schedule(a.id).expect("schedule");
738 let a = repo.post_depreciation(a.id, 3).expect("post");
739 assert_eq!(a.status, FixedAssetStatus::FullyDepreciated);
740 assert_eq!(a.accumulated_depreciation, dec!(9000));
741 assert!(a.is_fully_depreciated());
742 assert!(repo.post_depreciation(a.id, 1).is_err());
743 }
744
745 #[test]
746 fn regenerate_after_posting_conflicts() {
747 let repo = test_repo();
748 let a = repo.create(create_input()).expect("create");
749 repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
750 .expect("in service");
751 repo.generate_schedule(a.id).expect("schedule");
752 repo.post_depreciation(a.id, 1).expect("post");
753 assert!(repo.generate_schedule(a.id).is_err());
754 }
755
756 #[test]
757 fn write_off_records_zero_proceeds_loss() {
758 let repo = test_repo();
759 let a = repo.create(create_input()).expect("create");
760 repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
761 .expect("in service");
762 let a = repo
763 .write_off(a.id, NaiveDate::from_ymd_opt(2026, 3, 1).unwrap(), Some("stolen".into()))
764 .expect("write off");
765 assert_eq!(a.status, FixedAssetStatus::WrittenOff);
766 let d = a.disposal.expect("disposal");
767 assert_eq!(d.proceeds, dec!(0));
768 assert_eq!(d.gain_loss, dec!(-10000));
769 assert_eq!(d.notes.as_deref(), Some("stolen"));
770 }
771
772 #[test]
773 fn invalid_transitions_conflict() {
774 let repo = test_repo();
775 let a = repo.create(create_input()).expect("create");
776 assert!(
778 repo.dispose(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap(), dec!(1), None)
779 .is_err()
780 );
781 repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
782 .expect("in service");
783 assert!(repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()).is_err());
785 repo.write_off(a.id, NaiveDate::from_ymd_opt(2026, 2, 1).unwrap(), None)
787 .expect("write off");
788 assert!(repo.update(a.id, UpdateFixedAsset::default()).is_err());
789 }
790
791 #[test]
792 fn list_filters_by_status_and_search() {
793 let repo = test_repo();
794 let a = repo.create(create_input()).expect("create");
795 let mut other = create_input();
796 other.name = "Delivery Van".into();
797 other.category = FixedAssetCategory::Vehicle;
798 repo.create(other).expect("create other");
799 repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
800 .expect("in service");
801
802 let in_service = repo
803 .list(FixedAssetFilter {
804 status: Some(FixedAssetStatus::InService),
805 ..Default::default()
806 })
807 .expect("list");
808 assert_eq!(in_service.len(), 1);
809 assert_eq!(in_service[0].id, a.id);
810
811 let vans = repo
812 .list(FixedAssetFilter { search: Some("van".into()), ..Default::default() })
813 .expect("list search");
814 assert_eq!(vans.len(), 1);
815 assert_eq!(vans[0].name, "Delivery Van");
816 }
817
818 mod gl_auto_posting {
819 use super::*;
820 use crate::sqlite::general_ledger::SqliteGeneralLedgerRepository;
821 use stateset_core::{
822 AccountSubType, AccountType, CreateAutoPostingConfig, CreateGlAccount, CreateGlPeriod,
823 GeneralLedgerRepository, JournalEntryFilter, JournalEntryStatus,
824 };
825
826 fn test_repos() -> (SqliteFixedAssetRepository, SqliteGeneralLedgerRepository) {
827 let db = SqliteDatabase::new(&DatabaseConfig::in_memory()).expect("in-memory db");
828 (
829 SqliteFixedAssetRepository::new(db.pool().clone()),
830 SqliteGeneralLedgerRepository::new(db.pool().clone()),
831 )
832 }
833
834 fn create_sub_account(
835 gl: &SqliteGeneralLedgerRepository,
836 number: &str,
837 name: &str,
838 account_type: AccountType,
839 sub_type: AccountSubType,
840 ) -> Uuid {
841 gl.create_account(CreateGlAccount {
842 account_number: number.into(),
843 name: name.into(),
844 description: None,
845 account_type,
846 account_sub_type: Some(sub_type),
847 parent_account_id: None,
848 is_header: Some(false),
849 is_posting: Some(true),
850 currency: None,
851 })
852 .expect("create account")
853 .id
854 }
855
856 fn setup_gl(gl: &SqliteGeneralLedgerRepository, auto_post_depreciation: bool) {
857 gl.initialize_chart_of_accounts().expect("init chart");
858 let period = gl
859 .create_period(CreateGlPeriod {
860 period_name: "All".into(),
861 fiscal_year: 2026,
862 period_number: 1,
863 start_date: NaiveDate::from_ymd_opt(2020, 1, 1).unwrap(),
864 end_date: NaiveDate::from_ymd_opt(2030, 12, 31).unwrap(),
865 })
866 .expect("create period");
867 gl.open_period(period.id).expect("open period");
868 let by_number = |n: &str| {
869 gl.get_account_by_number(n).expect("get account").expect("account exists").id
870 };
871 gl.set_auto_posting_config(CreateAutoPostingConfig {
872 config_name: "Test".into(),
873 cash_account_id: by_number("1010"),
874 accounts_receivable_account_id: by_number("1100"),
875 inventory_account_id: by_number("1200"),
876 accounts_payable_account_id: by_number("2010"),
877 unearned_revenue_account_id: None,
878 sales_revenue_account_id: by_number("4010"),
879 shipping_revenue_account_id: None,
880 cogs_account_id: by_number("5010"),
881 bad_debt_expense_account_id: None,
882 fx_gain_loss_account_id: None,
883 auto_post_depreciation,
884 auto_post_revenue_recognition: false,
885 })
886 .expect("set config");
887 }
888
889 fn in_service_asset(repo: &SqliteFixedAssetRepository) -> FixedAsset {
890 let a = repo.create(create_input()).expect("create");
891 let a = repo
892 .place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
893 .expect("in service");
894 repo.generate_schedule(a.id).expect("schedule");
895 a
896 }
897
898 #[test]
899 fn post_depreciation_auto_posts_balanced_journal_entry_when_enabled() {
900 let (repo, gl) = test_repos();
901 setup_gl(&gl, true);
902 let expense_id = create_sub_account(
903 &gl,
904 "5300",
905 "Depreciation Expense",
906 AccountType::Expense,
907 AccountSubType::DepreciationExpense,
908 );
909 let accum_id = create_sub_account(
910 &gl,
911 "1510",
912 "Accumulated Depreciation",
913 AccountType::Asset,
914 AccountSubType::AccumulatedDepreciation,
915 );
916
917 let a = in_service_asset(&repo);
918 let a = repo.post_depreciation(a.id, 3).expect("post depreciation");
919 assert_eq!(a.accumulated_depreciation, dec!(750));
920
921 let entries = gl
922 .list_journal_entries(JournalEntryFilter {
923 source_document_id: Some(a.id),
924 ..Default::default()
925 })
926 .expect("list entries");
927 assert_eq!(entries.len(), 1);
928 let entry = &entries[0];
929 assert_eq!(entry.status, JournalEntryStatus::Posted);
930 assert!(entry.is_balanced);
931 assert_eq!(entry.total_debits, dec!(750));
932 assert_eq!(entry.total_credits, dec!(750));
933 assert_eq!(entry.source_document_type.as_deref(), Some("fixed_asset_depreciation"));
934 assert_eq!(entry.lines.len(), 2);
935 let debit = entry.lines.iter().find(|l| l.debit_amount > dec!(0)).expect("debit");
936 let credit = entry.lines.iter().find(|l| l.credit_amount > dec!(0)).expect("credit");
937 assert_eq!(debit.account_id, expense_id);
938 assert_eq!(debit.debit_amount, dec!(750));
939 assert_eq!(credit.account_id, accum_id);
940 assert_eq!(credit.credit_amount, dec!(750));
941 }
942
943 #[test]
944 fn post_depreciation_prefers_asset_level_accounts() {
945 let (repo, gl) = test_repos();
946 setup_gl(&gl, true);
947 let expense_id = create_sub_account(
948 &gl,
949 "5301",
950 "Machinery Depreciation Expense",
951 AccountType::Expense,
952 AccountSubType::DepreciationExpense,
953 );
954 let accum_id = create_sub_account(
955 &gl,
956 "1511",
957 "Machinery Accumulated Depreciation",
958 AccountType::Asset,
959 AccountSubType::AccumulatedDepreciation,
960 );
961
962 let mut input = create_input();
963 input.depreciation_expense_account_id = Some(expense_id);
964 input.accumulated_depreciation_account_id = Some(accum_id);
965 let a = repo.create(input).expect("create");
966 let a = repo
967 .place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 1).unwrap())
968 .expect("in service");
969 repo.generate_schedule(a.id).expect("schedule");
970 repo.post_depreciation(a.id, 1).expect("post depreciation");
971
972 let entries = gl
973 .list_journal_entries(JournalEntryFilter {
974 source_document_id: Some(a.id),
975 ..Default::default()
976 })
977 .expect("list entries");
978 assert_eq!(entries.len(), 1);
979 let accounts: Vec<Uuid> = entries[0].lines.iter().map(|l| l.account_id).collect();
980 assert!(accounts.contains(&expense_id));
981 assert!(accounts.contains(&accum_id));
982 }
983
984 #[test]
985 fn post_depreciation_creates_no_journal_entry_when_disabled() {
986 let (repo, gl) = test_repos();
987 setup_gl(&gl, false);
988 let a = in_service_asset(&repo);
989 let a = repo.post_depreciation(a.id, 3).expect("post depreciation");
990 assert_eq!(a.accumulated_depreciation, dec!(750));
991
992 let entries = gl
993 .list_journal_entries(JournalEntryFilter {
994 source_document_id: Some(a.id),
995 ..Default::default()
996 })
997 .expect("list entries");
998 assert!(entries.is_empty());
999 }
1000
1001 #[test]
1002 fn post_depreciation_without_config_creates_no_journal_entry() {
1003 let (repo, gl) = test_repos();
1004 let a = in_service_asset(&repo);
1005 repo.post_depreciation(a.id, 1).expect("post depreciation");
1006 let entries = gl
1007 .list_journal_entries(JournalEntryFilter {
1008 source_document_id: Some(a.id),
1009 ..Default::default()
1010 })
1011 .expect("list entries");
1012 assert!(entries.is_empty());
1013 }
1014 }
1015
1016 #[test]
1017 fn declining_balance_schedule_round_trips() {
1018 let repo = test_repo();
1019 let mut input = create_input();
1020 input.depreciation_method = DepreciationMethod::DecliningBalance { rate: dec!(0.2) };
1021 input.useful_life_months = 12;
1022 input.salvage_value = dec!(500);
1023 let a = repo.create(input).expect("create");
1024 assert_eq!(a.depreciation_method, DepreciationMethod::DecliningBalance { rate: dec!(0.2) });
1025 let schedule = repo.generate_schedule(a.id).expect("schedule");
1026 let total: Decimal = schedule.entries.iter().map(|e| e.amount).sum();
1027 assert_eq!(total, dec!(9500));
1028 assert_eq!(schedule.entries[0].amount, dec!(2000));
1029 }
1030
1031 #[test]
1032 fn list_after_cursor_paginates_without_overlap() {
1033 let repo = test_repo();
1034 for _ in 0..3 {
1035 repo.create(create_input()).expect("create");
1036 }
1037 let all = repo.list(FixedAssetFilter::default()).expect("list all");
1038 assert_eq!(all.len(), 3);
1039
1040 let first_page =
1041 repo.list(FixedAssetFilter { limit: Some(2), ..Default::default() }).expect("page 1");
1042 assert_eq!(first_page.len(), 2);
1043 assert_eq!(first_page[0].id, all[0].id);
1044
1045 let last = &first_page[1];
1046 let second_page = repo
1047 .list(FixedAssetFilter {
1048 after_cursor: Some((last.created_at.to_rfc3339(), last.id.to_string())),
1049 ..Default::default()
1050 })
1051 .expect("page 2");
1052 assert_eq!(second_page.len(), 1);
1053 assert_eq!(second_page[0].id, all[2].id);
1054 }
1055}