Skip to main content

stateset_db/sqlite/
fixed_assets.rs

1//! SQLite implementation of the fixed asset repository
2
3use 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        // Keyset cursor: (created_at, id) for stable DESC ordering
326        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        // Offset pagination applies only in non-cursor mode.
334        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                // Accumulated depreciation through the last posted entry is exact.
518                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    /// Create and post a balanced depreciation journal entry
543    /// (debit depreciation expense, credit accumulated depreciation) when the
544    /// active GL auto-posting config has `auto_post_depreciation` enabled.
545    ///
546    /// Mirrors the invoice auto-posting pattern: config-gated, posted via the
547    /// existing general-ledger repository. Accounts come from the asset itself,
548    /// falling back to the first active posting account with the matching
549    /// account sub-type.
550    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
602/// Resolve a GL account for depreciation posting: prefer the account
603/// configured on the asset, then fall back to the first active posting
604/// account with the given sub-type in the chart of accounts.
605fn 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        // Post 3 periods of depreciation.
708        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        // Dispose for 9500 -> gain of 250 against book value 9250.
720        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        // Draft cannot be disposed.
777        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        // Cannot place in service twice.
784        assert!(repo.place_in_service(a.id, NaiveDate::from_ymd_opt(2026, 1, 2).unwrap()).is_err());
785        // Terminal assets cannot be updated.
786        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}